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
@@ -47,7 +47,10 @@ interface CreateAgentCommandDependencies {
providerSnapshotManager: ProviderSnapshotManager; providerSnapshotManager: ProviderSnapshotManager;
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh directory workspace for a cwd and returns its id. // 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 { export interface CreateAgentFromSessionInput {
@@ -253,7 +256,7 @@ async function resolveMcpCreateAgent(
? createdWorkspaceId ? createdWorkspaceId
: (input.workspaceId ?? : (input.workspaceId ??
parentAgent?.workspaceId ?? parentAgent?.workspaceId ??
(await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd))); (await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt)));
const { modeId: resolvedMode, featureValues: resolvedFeatures } = const { modeId: resolvedMode, featureValues: resolvedFeatures } =
await dependencies.providerSnapshotManager.resolveCreateConfig({ await dependencies.providerSnapshotManager.resolveCreateConfig({
@@ -300,11 +303,12 @@ async function resolveMcpCreateAgent(
async function ensureWorkspaceForMcpCreate( async function ensureWorkspaceForMcpCreate(
dependencies: CreateAgentCommandDependencies, dependencies: CreateAgentCommandDependencies,
cwd: string, cwd: string,
initialPrompt: string,
): Promise<string | undefined> { ): Promise<string | undefined> {
if (!dependencies.ensureWorkspaceForCreate) { if (!dependencies.ensureWorkspaceForCreate) {
return undefined; return undefined;
} }
return dependencies.ensureWorkspaceForCreate(cwd); return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt });
} }
async function sendInitialPrompt( async function sendInitialPrompt(
@@ -424,7 +428,7 @@ async function resolveMcpCwd(params: {
refName: worktree.refName, refName: worktree.refName,
action: worktree.action, action: worktree.action,
githubPrNumber: worktree.githubPrNumber, githubPrNumber: worktree.githubPrNumber,
...(params.initialPrompt ? { firstAgentContext: { prompt: params.initialPrompt } } : {}), firstAgentContext: { prompt: params.initialPrompt },
runSetup: false, runSetup: false,
paseoHome: dependencies.paseoHome, paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot, worktreesRoot: dependencies.worktreesRoot,
@@ -39,13 +39,20 @@ import {
createPaseoWorktree as createPaseoWorktreeService, createPaseoWorktree as createPaseoWorktreeService,
type CreatePaseoWorktreeInput, type CreatePaseoWorktreeInput,
} from "../paseo-worktree-service.js"; } 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 { 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 { GitHubService } from "../../services/github-service.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js"; import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js"; import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
const REPO_CWD = resolvePath("/tmp/repo"); const REPO_CWD = resolvePath("/tmp/repo");
const TARGET_CWD = resolvePath("/tmp/target"); 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); 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 AgentManagerSpies = ReturnType<typeof buildAgentManagerSpies>;
type AgentStorageSpies = ReturnType<typeof buildAgentStorageSpies>; type AgentStorageSpies = ReturnType<typeof buildAgentStorageSpies>;
@@ -616,40 +651,88 @@ function createPaseoWorktreeForMcpTest(options: {
paseoHome: string; paseoHome: string;
broadcasts: string[]; broadcasts: string[];
createdWorkspaceIds?: string[]; createdWorkspaceIds?: string[];
workspaceRecords?: Map<string, PersistedWorkspaceRecord>;
generateWorkspaceName?: () => Promise<GeneratedWorkspaceName | null>;
setupContinuations?: Array<"workspace" | "agent" | undefined>; setupContinuations?: Array<"workspace" | "agent" | undefined>;
startedAgentSetupIds?: string[]; startedAgentSetupIds?: string[];
}): CreatePaseoWorktreeWorkflowFn { }): CreatePaseoWorktreeWorkflowFn {
const projects = new Map<string, PersistedProjectRecord>(); const projects = new Map<string, PersistedProjectRecord>();
const workspaces = new Map<string, PersistedWorkspaceRecord>(); const workspaces = options.workspaceRecords ?? new Map<string, PersistedWorkspaceRecord>();
const github = createGitHubServiceStub(); const github = createGitHubServiceStub();
const workspaceGitService = new WorkspaceGitServiceImpl({ const workspaceGitService = new WorkspaceGitServiceImpl({
logger: createTestLogger(), logger: createTestLogger(),
paseoHome: options.paseoHome, paseoHome: options.paseoHome,
deps: { github }, 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) => { return async (input, serviceOptions) => {
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind); options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
const result = await createPaseoWorktreeService(input, { const result = await createPaseoWorktreeWorkflow(
github, {
...(serviceOptions?.resolveDefaultBranch paseoHome: options.paseoHome,
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch } createPaseoWorktree: (workflowInput, workflowOptions) =>
: {}), createPaseoWorktreeService(workflowInput, {
projectRegistry: { github,
get: async (projectId) => projects.get(projectId) ?? null, ...(workflowOptions?.resolveDefaultBranch
upsert: async (record) => { ? { resolveDefaultBranch: workflowOptions.resolveDefaultBranch }
projects.set(record.projectId, record); : {}),
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: { input,
get: async (workspaceId) => workspaces.get(workspaceId) ?? null, serviceOptions,
list: async () => Array.from(workspaces.values()), );
upsert: async (record) => {
workspaces.set(record.workspaceId, record);
},
},
workspaceGitService,
});
options.broadcasts.push(result.workspace.workspaceId); options.broadcasts.push(result.workspace.workspaceId);
options.createdWorkspaceIds?.push(result.workspace.workspaceId); options.createdWorkspaceIds?.push(result.workspace.workspaceId);
if (serviceOptions?.setupContinuation?.kind === "agent") { if (serviceOptions?.setupContinuation?.kind === "agent") {
@@ -1629,7 +1712,7 @@ describe("create_agent MCP tool", () => {
{ workspaceId: createdWorkspaceIds[0] }, { workspaceId: createdWorkspaceIds[0] },
); );
} finally { } finally {
await rm(tempDir, { recursive: true, force: true }); await removeTempDir(tempDir);
} }
}); });
@@ -1703,20 +1786,406 @@ describe("create_agent MCP tool", () => {
.trim(); .trim();
expect(initialBranch).not.toBe(""); expect(initialBranch).not.toBe("");
expect(initialBranch).not.toBe("main"); expect(initialBranch).not.toBe("main");
await new Promise((resolve) => setTimeout(resolve, 0)); await waitForUnexpectedWorkspaceNamingSideEffects();
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1); expect(broadcasts).toHaveLength(1);
} finally { } 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 { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-checkout-name-context-")); const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-checkout-name-context-"));
const repoDir = join(tempDir, "repo"); const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo"); const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = []; const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
let generateCalls = 0;
const workspaceGitService = { const workspaceGitService = {
getSnapshot: vi.fn(async () => { getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run"); throw new Error("agent metadata branch rename should not run");
@@ -1761,7 +2230,19 @@ describe("create_agent MCP tool", () => {
agentStorage, agentStorage,
providerSnapshotManager: createOpenCodeManager().manager, providerSnapshotManager: createOpenCodeManager().manager,
paseoHome, 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: workspaceGitService as unknown as Pick<
WorkspaceGitService, WorkspaceGitService,
"getSnapshot" | "listWorktrees" "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 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( expect(
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" }) execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
.toString() .toString()
.trim(), .trim(),
).toBe("existing-feature"); ).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(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1); expect(broadcasts).toEqual([workspaceId, workspaceId]);
} finally { } finally {
await rm(tempDir, { recursive: true, force: true }); await removeTempDir(tempDir);
} }
}); });
@@ -1888,7 +2383,7 @@ describe("create_agent MCP tool", () => {
undefined, undefined,
{ workspaceId: "ws-pr-123" }, { workspaceId: "ws-pr-123" },
); );
await new Promise((resolve) => setTimeout(resolve, 0)); await waitForUnexpectedWorkspaceNamingSideEffects();
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
}); });
@@ -1960,7 +2455,7 @@ describe("create_agent MCP tool", () => {
expect(broadcasts).toHaveLength(1); expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/); expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
} finally { } finally {
await rm(tempDir, { recursive: true, force: true }); await removeTempDir(tempDir);
} }
}); });
@@ -2056,7 +2551,7 @@ describe("create_agent MCP tool", () => {
"ws-archive-tool-worktree", "ws-archive-tool-worktree",
]); ]);
} finally { } 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"); expect(archivedWorkspaceIds).toContain("ws-mcp-B");
await expect(access(worktreePath)).rejects.toThrow(); await expect(access(worktreePath)).rejects.toThrow();
} finally { } 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)), access(z.string().parse(created.structuredContent.worktreePath)),
).rejects.toThrow(); ).rejects.toThrow();
} finally { } finally {
await rm(tempDir, { recursive: true, force: true }); await removeTempDir(tempDir);
} }
}); });
@@ -29,6 +29,7 @@ import {
import { WaitForAgentTracker } from "../wait-for-agent-tracker.js"; import { WaitForAgentTracker } from "../wait-for-agent-tracker.js";
import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js"; import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js";
import type { FirstAgentContext } from "../../messages.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js";
import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js"; import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js";
@@ -105,7 +106,10 @@ export interface PaseoToolHostDependencies {
clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"]; clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"];
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh directory workspace for a cwd and returns its id. // 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; browserToolsBroker?: BrowserToolsBroker | null;
paseoHome?: string; paseoHome?: string;
worktreesRoot?: string; worktreesRoot?: string;
@@ -1181,7 +1185,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
async function resolveCreateAgentToolArgs(args: unknown): Promise<ResolvedCreateAgentToolArgs> { async function resolveCreateAgentToolArgs(args: unknown): Promise<ResolvedCreateAgentToolArgs> {
if (callerAgentId) { if (callerAgentId) {
const parsed = agentToAgentCreateAgentArgsSchema.parse(args); 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 { return {
kind: "agent-scoped", kind: "agent-scoped",
parsedArgs: parsed, parsedArgs: parsed,
@@ -1195,7 +1201,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
if (parsedArgs.relationship.kind === "subagent") { if (parsedArgs.relationship.kind === "subagent") {
throw new Error("relationship subagent requires an agent-scoped tool session"); 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 { return {
kind: "top-level", kind: "top-level",
parsedArgs, parsedArgs,
@@ -1309,6 +1317,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
async function resolveCreateAgentWorkspace( async function resolveCreateAgentWorkspace(
workspace: AgentToAgentCreateAgentArgs["workspace"] | TopLevelCreateAgentArgs["workspace"], workspace: AgentToAgentCreateAgentArgs["workspace"] | TopLevelCreateAgentArgs["workspace"],
firstAgentContext: FirstAgentContext | undefined,
): Promise<{ ): Promise<{
cwd: string | undefined; cwd: string | undefined;
workspaceId: string | undefined; workspaceId: string | undefined;
@@ -1360,7 +1369,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
} }
return { return {
cwd, cwd,
workspaceId: await options.ensureWorkspaceForCreate(cwd), workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext),
worktree: undefined, worktree: undefined,
}; };
} }
+41 -3
View File
@@ -139,7 +139,7 @@ import type { PushNotificationSender } from "./push/notifications.js";
import { getOrCreateServerId } from "./server-id.js"; import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js"; import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.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 { import type {
AgentProviderRuntimeSettingsMap, AgentProviderRuntimeSettingsMap,
ProviderOverride, ProviderOverride,
@@ -162,6 +162,10 @@ import {
type DaemonAuthConfig, type DaemonAuthConfig,
} from "./auth.js"; } from "./auth.js";
import { createWebUiMiddleware } from "./web-ui.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 MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]"; const REDACTED_LOG_VALUE = "[redacted]";
@@ -854,11 +858,21 @@ export async function createPaseoDaemon(
const findWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => { const findWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
return resolveWorkspaceIdForPath(cwd, await workspaceRegistry.list()); 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( const workspace = await createLocalCheckoutWorkspace(
{ cwd }, { cwd, title: resolveFirstAgentPromptTitle(firstAgentContext) },
{ projectRegistry, workspaceRegistry, workspaceGitService }, { projectRegistry, workspaceRegistry, workspaceGitService },
); );
if (firstAgentContext) {
workspaceAutoName.scheduleForDirectory({
workspaceId: workspace.workspaceId,
cwd: workspace.cwd,
firstAgentContext,
});
}
return workspace.workspaceId; return workspace.workspaceId;
}; };
const listActiveWorkspacesExternal = async (): Promise<ActiveWorkspaceRef[]> => { 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) => { const emitExternalSessionMessage = (message: SessionOutboundMessage) => {
wsServer?.broadcast(wrapSessionMessage(message)); 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({ setupAutoArchiveOnMerge({
paseoHome: config.paseoHome, paseoHome: config.paseoHome,
@@ -941,6 +976,8 @@ export async function createPaseoDaemon(
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [], .map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
); );
}, },
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
workspaceAutoName.scheduleForWorktree(autoNameInput),
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
await emitWorkspaceUpdatesExternal([workspaceId]); await emitWorkspaceUpdatesExternal([workspaceId]);
}, },
@@ -1208,6 +1245,7 @@ export async function createPaseoDaemon(
daemonConfigStore, daemonConfigStore,
mcpBaseUrl, mcpBaseUrl,
{ allowedOrigins, hostnames: configuredHostnames }, { allowedOrigins, hostnames: configuredHostnames },
workspaceAutoName,
config.auth, config.auth,
speechService, speechService,
terminalManager, terminalManager,
+23 -172
View File
@@ -192,16 +192,12 @@ import {
} from "./workspace-directory.js"; } from "./workspace-directory.js";
import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js"; import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js";
import { import {
attemptFirstAgentBranchAutoName,
createLocalCheckoutWorkspace, createLocalCheckoutWorkspace,
createPaseoWorktree, createPaseoWorktree,
type CreatePaseoWorktreeInput, type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult, type CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js"; } from "./paseo-worktree-service.js";
import { import { WorkspaceAutoName } from "./workspace-auto-name.js";
generateBranchNameFromFirstAgentContext,
type GeneratedWorkspaceName,
} from "./worktree-branch-name-generator.js";
import { import {
buildAgentSessionConfig as buildWorktreeAgentSessionConfig, buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
createPaseoWorktreeWorkflow as createWorktreeWorkflow, createPaseoWorktreeWorkflow as createWorktreeWorkflow,
@@ -427,10 +423,8 @@ export interface SessionOptions {
// Injected so tests can substitute the git branch rename without module mocks; // Injected so tests can substitute the git branch rename without module mocks;
// defaults to the real checkout-git implementation. // defaults to the real checkout-git implementation.
renameCurrentBranch?: typeof renameCurrentBranchDefault; 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;
workspaceAutoName: WorkspaceAutoName;
daemonConfigStore: DaemonConfigStore; daemonConfigStore: DaemonConfigStore;
mcpBaseUrl?: string | null; mcpBaseUrl?: string | null;
stt: Resolvable<SpeechToTextProvider | null>; stt: Resolvable<SpeechToTextProvider | null>;
@@ -549,8 +543,8 @@ export class Session {
private readonly filesystem: SessionFileSystem; private readonly filesystem: SessionFileSystem;
private readonly github: GitHubService; private readonly github: GitHubService;
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext;
private readonly workspaceGitService: WorkspaceGitService; private readonly workspaceGitService: WorkspaceGitService;
private readonly workspaceAutoName: WorkspaceAutoName;
private readonly gitMutation: GitMutationService; private readonly gitMutation: GitMutationService;
private readonly workspaceProvisioning: WorkspaceProvisioningService; private readonly workspaceProvisioning: WorkspaceProvisioningService;
private readonly daemonConfigStore: DaemonConfigStore; private readonly daemonConfigStore: DaemonConfigStore;
@@ -617,8 +611,8 @@ export class Session {
checkoutDiffManager, checkoutDiffManager,
github, github,
renameCurrentBranch, renameCurrentBranch,
generateWorkspaceName,
workspaceGitService, workspaceGitService,
workspaceAutoName,
daemonConfigStore, daemonConfigStore,
stt, stt,
sttLanguage, sttLanguage,
@@ -675,13 +669,13 @@ export class Session {
this.filesystem = filesystem ?? nodeSessionFileSystem; this.filesystem = filesystem ?? nodeSessionFileSystem;
this.github = github ?? createGitHubService(); this.github = github ?? createGitHubService();
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
this.generateWorkspaceName = generateWorkspaceName ?? generateBranchNameFromFirstAgentContext;
this.workspaceGitService = workspaceGitService; this.workspaceGitService = workspaceGitService;
this.gitMutation = createGitMutationService({ this.gitMutation = createGitMutationService({
workspaceGitService: this.workspaceGitService, workspaceGitService: this.workspaceGitService,
github: this.github, github: this.github,
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
this.workspaceAutoName = workspaceAutoName;
this.workspaceProvisioning = createWorkspaceProvisioningService({ this.workspaceProvisioning = createWorkspaceProvisioningService({
workspaceRegistry: this.workspaceRegistry, workspaceRegistry: this.workspaceRegistry,
projectRegistry: this.projectRegistry, projectRegistry: this.projectRegistry,
@@ -2459,11 +2453,14 @@ export class Session {
createdAgentId = snapshot.id; createdAgentId = snapshot.id;
await this.agentUpdates.forwardLiveAgent(snapshot); await this.agentUpdates.forwardLiveAgent(snapshot);
if (createdDirectoryWorkspaceForAgent && trimmedPrompt) { if (createdDirectoryWorkspaceForAgent && trimmedPrompt) {
await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({ this.workspaceAutoName.scheduleForDirectory(
workspaceId, {
cwd: createAgentConfig.cwd, workspaceId,
firstAgentContext, cwd: createAgentConfig.cwd,
}); firstAgentContext,
},
{ currentSelection: this.getFocusedAgentSelectionForCwd(createAgentConfig.cwd) },
);
} }
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({ this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
autoArchive, 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 { private isPathWithinRoot(rootPath: string, candidatePath: string): boolean {
const resolvedRoot = resolve(rootPath); const resolvedRoot = resolve(rootPath);
const resolvedCandidate = resolve(candidatePath); const resolvedCandidate = resolve(candidatePath);
@@ -4567,31 +4430,17 @@ export class Session {
}); });
if (request.firstAgentContext) { if (request.firstAgentContext) {
const firstAgentContext = request.firstAgentContext; const firstAgentContext = request.firstAgentContext;
this.scheduleWorkspaceNaming( this.workspaceAutoName.scheduleForDirectory(
() => {
this.maybeAutoNameDirectoryWorkspaceTitle({ workspaceId: workspace.workspaceId,
workspaceId: workspace.workspaceId, cwd: workspace.cwd,
cwd: workspace.cwd, firstAgentContext,
firstAgentContext, },
}), { currentSelection: this.getFocusedAgentSelectionForCwd(workspace.cwd) },
{ cwd: workspace.cwd, message: "Failed to auto-name directory workspace title" },
); );
} }
} }
// 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( private async handleWorkspaceCreateWorktree(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>, request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> { ): Promise<void> {
@@ -4896,7 +4745,9 @@ export class Session {
this.createPaseoWorktree(workflowInput, serviceOptions), this.createPaseoWorktree(workflowInput, serviceOptions),
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace), warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) => autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput), this.workspaceAutoName.scheduleForWorktree(autoNameInput, {
currentSelection: this.getFocusedAgentSelectionForCwd(autoNameInput.workspace.cwd),
}),
emitWorkspaceUpdateForWorkspaceId: (workspaceId) => emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId), this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => { cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
@@ -13,6 +13,7 @@ import path from "node:path";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import { z } from "zod"; import { z } from "zod";
import { createTestLogger } from "../test-utils/test-logger.js";
import { Session } from "./session.js"; import { Session } from "./session.js";
import type { SessionOptions } from "./session.js"; import type { SessionOptions } from "./session.js";
import type { AgentUpdatesService } from "./session/agent-updates/agent-updates-service.js"; import type { AgentUpdatesService } from "./session/agent-updates/agent-updates-service.js";
@@ -32,9 +33,15 @@ import type {
AgentStreamEvent, AgentStreamEvent,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
import { createWorktree, UnknownBranchError } from "../utils/worktree.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 { WorktreeRequestError, toWorktreeRequestError } from "./worktree-errors.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 type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
import { WorkspaceAutoName } from "./workspace-auto-name.js";
import type { GitHubService } from "../services/github-service.js"; import type { GitHubService } from "../services/github-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import { import {
@@ -146,10 +153,6 @@ 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; promptTitle?: string | null },
): Promise<void>;
emit(message: unknown): void; emit(message: unknown): void;
onMessage(message: unknown): void; onMessage(message: unknown): void;
paseoHome: string; paseoHome: string;
@@ -545,6 +548,48 @@ function createSessionForWorkspaceTests(
warn: vi.fn(), warn: vi.fn(),
error: 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( const session = asTestSession(
new Session({ new Session({
@@ -556,16 +601,7 @@ function createSessionForWorkspaceTests(
pushTokenStore: asPushTokenStore(), pushTokenStore: asPushTokenStore(),
paseoHome: options.paseoHome ?? "/tmp/paseo-test", paseoHome: options.paseoHome ?? "/tmp/paseo-test",
worktreesRoot: options.worktreesRoot, worktreesRoot: options.worktreesRoot,
agentManager: asAgentManager({ agentManager,
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
archiveAgent: async () => ({ archivedAt: new Date().toISOString() }),
archiveSnapshot: async () => ({}),
unarchiveSnapshot: async () => true,
clearAgentAttention: async () => {},
notifyAgentState: () => {},
}),
agentStorage: asAgentStorage({ agentStorage: asAgentStorage({
list: async () => [ list: async () => [
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
@@ -601,36 +637,7 @@ function createSessionForWorkspaceTests(
archive: async () => {}, archive: async () => {},
remove: async () => {}, remove: async () => {},
}, },
workspaceRegistry: options.workspaceRegistry ?? { 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 () => {},
},
filesystem: { isDirectory: async () => true }, filesystem: { isDirectory: async () => true },
chatService: asChatService(), chatService: asChatService(),
scheduleService: asScheduleService(), scheduleService: asScheduleService(),
@@ -651,9 +658,20 @@ function createSessionForWorkspaceTests(
dispose: () => {}, dispose: () => {},
}), }),
github: options.github, 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, renameCurrentBranch: options.renameCurrentBranch,
generateWorkspaceName: options.generateWorkspaceName,
daemonConfigStore: asDaemonConfigStore({ daemonConfigStore: asDaemonConfigStore({
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
onChange: () => () => {}, onChange: () => () => {},
@@ -661,7 +679,7 @@ function createSessionForWorkspaceTests(
mcpBaseUrl: null, mcpBaseUrl: null,
stt: null, stt: null,
tts: null, tts: null,
providerSnapshotManager: createProviderSnapshotManagerStub().manager, providerSnapshotManager,
terminalManager: options.terminalManager ?? null, 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 test("workspace auto-name keeps a manual title written before the scheduled title lands", async () => {
// concurrent upsert that happened between workspace creation and the async name vi.useFakeTimers();
// write is not clobbered. const workspace = createPersistedWorkspaceRecord({
test("applyGeneratedWorkspaceTitle writes branch metadata and does not clobber concurrent title writes", async () => { workspaceId: "ws-manual-title",
const session = createSessionForWorkspaceTests(); projectId: "proj-manual-title",
cwd: REPO_CWD,
// The record at create-time: no title override. kind: "directory",
const recordAtCreateTime = createPersistedWorkspaceRecord({ displayName: "repo",
workspaceId: "ws-worktree-1", title: "Fix login bug",
projectId: "proj-1",
cwd: `${REPO_CWD}/worktrees/task-branch`,
kind: "worktree",
displayName: "task-branch",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}); });
const stored = new Map([[workspace.workspaceId, workspace]]);
// Simulate a concurrent write that happened AFTER the workspace was created const emittedWorkspaceIds: string[] = [];
// but BEFORE the async name generation completes — e.g. the user set a title. const workspaceAutoName = new WorkspaceAutoName({
const recordAfterConcurrentWrite = { agentManager: asAgentManager({}),
...recordAtCreateTime, workspaceRegistry: {
title: "User-set title", get: async (workspaceId) => stored.get(workspaceId) ?? null,
updatedAt: "2026-03-01T12:01:00.000Z", upsert: async (record) => {
}; stored.set(record.workspaceId, record);
},
const stored = new Map([[recordAfterConcurrentWrite.workspaceId, recordAfterConcurrentWrite]]); },
session.workspaceRegistry.get = async (id: string) => stored.get(id) ?? null; workspaceGitService: createNoopWorkspaceGitService(),
session.workspaceRegistry.upsert = async (record: unknown) => { providerSnapshotManager: createProviderSnapshotManagerStub().manager,
const parsed = record as typeof recordAtCreateTime; readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
stored.set(parsed.workspaceId, parsed); gitMutation: { notifyGitMutation: async () => {} },
}; emitWorkspaceUpdateForCwd: async () => {},
// Silence notification side-effects. emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
session.emitWorkspaceUpdateForCwd = async () => {}; emittedWorkspaceIds.push(workspaceId);
session.emitWorkspaceUpdatesForWorkspaceIds = async () => {}; },
logger: asSessionLogger(createTestLogger()),
await session.applyGeneratedWorkspaceTitle("ws-worktree-1", { generateWorkspaceName: async () => ({ title: "Generated login fix", branch: null }),
title: "Generated Task Title",
branch: "task-branch-renamed",
}); });
const saved = stored.get("ws-worktree-1"); try {
// The branch-shaped display name stays branch-shaped. workspaceAutoName.scheduleForDirectory({
expect(saved?.displayName).toBe("task-branch"); workspaceId: workspace.workspaceId,
// The renamed branch is persisted into the dedicated branch field. cwd: workspace.cwd,
expect(saved?.branch).toBe("task-branch-renamed"); firstAgentContext: { prompt: "Fix login bug" },
// The concurrent user-set title is NOT clobbered. });
expect(saved?.title).toBe("User-set title"); 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 () => { test("workspace auto-name replaces the unchanged prompt title", async () => {
const session = createSessionForWorkspaceTests(); vi.useFakeTimers();
const workspace = createPersistedWorkspaceRecord({ const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-prompt-title", workspaceId: "ws-prompt-title",
projectId: "proj-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", updatedAt: "2026-03-01T12:00:00.000Z",
}); });
const stored = new Map([[workspace.workspaceId, workspace]]); const stored = new Map([[workspace.workspaceId, workspace]]);
session.workspaceRegistry.get = async (id: string) => stored.get(id) ?? null; const workspaceAutoName = new WorkspaceAutoName({
session.workspaceRegistry.upsert = async (record: unknown) => { agentManager: asAgentManager({}),
const parsed = record as typeof workspace; workspaceRegistry: {
stored.set(parsed.workspaceId, parsed); get: async (workspaceId) => stored.get(workspaceId) ?? null,
}; upsert: async (record) => {
stored.set(record.workspaceId, record);
await session.applyGeneratedWorkspaceTitle(workspace.workspaceId, { },
title: "Generated login fix", },
promptTitle: "Fix login bug", 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, { expect(stored.get(workspace.workspaceId)?.title).toBe("Generated login fix");
...workspace, } finally {
title: "User rename", vi.useRealTimers();
updatedAt: "2026-03-01T12:01:00.000Z", }
});
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, { const workspace = createPersistedWorkspaceRecord({
title: "Generated login fix", workspaceId: "ws-rejected-branch-title",
promptTitle: "Fix login bug", 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 // Phase 7: branch is a git fact derived per-descriptor from each workspace's own
@@ -25,6 +25,7 @@ import { createStub } from "./test-utils/class-mocks.js";
import { DaemonClient } from "./test-utils/daemon-client.js"; import { DaemonClient } from "./test-utils/daemon-client.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
interface BrowserToolsDaemonHarness { interface BrowserToolsDaemonHarness {
broker: BrowserToolsBroker; broker: BrowserToolsBroker;
@@ -70,6 +71,13 @@ function browserHostCapabilities(
}; };
} }
function createWorkspaceAutoNameStub(): WorkspaceAutoName {
return createStub<WorkspaceAutoName>({
scheduleForWorktree: () => {},
scheduleForDirectory: () => {},
});
}
describe("WebSocketServer browser tools wiring", () => { describe("WebSocketServer browser tools wiring", () => {
it("registers capable clients and dispatches broker requests over the real WebSocket path", async () => { it("registers capable clients and dispatches broker requests over the real WebSocket path", async () => {
const harness = await startBrowserToolsDaemonHarness(); const harness = await startBrowserToolsDaemonHarness();
@@ -292,6 +300,7 @@ function createVoiceAssistantWebSocketServer(params: {
createStub<DaemonConfigStore>(daemonConfigStore), createStub<DaemonConfigStore>(daemonConfigStore),
null, null,
{ allowedOrigins: new Set(["*"]) }, { allowedOrigins: new Set(["*"]) },
createWorkspaceAutoNameStub(),
undefined, undefined,
undefined, undefined,
undefined, undefined,
@@ -12,6 +12,7 @@ import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import type { PushNotificationSender, PushPayload } from "./push/notifications.js"; import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
const wsModuleMock = vi.hoisted(() => { const wsModuleMock = vi.hoisted(() => {
class MockWebSocketServer { class MockWebSocketServer {
@@ -65,6 +66,13 @@ function createLogger() {
return logger; return logger;
} }
function createWorkspaceAutoNameStub(): WorkspaceAutoName {
return createStub<WorkspaceAutoName>({
scheduleForWorktree: () => {},
scheduleForDirectory: () => {},
});
}
class RecordingPushNotificationSender implements PushNotificationSender { class RecordingPushNotificationSender implements PushNotificationSender {
readonly sent: PushPayload[] = []; readonly sent: PushPayload[] = [];
@@ -106,6 +114,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
createStub<DaemonConfigStore>(daemonConfigStore), createStub<DaemonConfigStore>(daemonConfigStore),
null, null,
{ allowedOrigins: new Set() }, { allowedOrigins: new Set() },
createWorkspaceAutoNameStub(),
undefined, undefined,
undefined, undefined,
undefined, undefined,
@@ -9,6 +9,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js"; import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js"; import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.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 { asInternals, createStub } from "./test-utils/class-mocks.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import { import {
@@ -215,6 +216,13 @@ function createLogger() {
return logger; return logger;
} }
function createWorkspaceAutoNameStub(): WorkspaceAutoName {
return createStub<WorkspaceAutoName>({
scheduleForWorktree: () => {},
scheduleForDirectory: () => {},
});
}
function createServer(options?: { function createServer(options?: {
speechReadiness?: SpeechReadinessSnapshot | null; speechReadiness?: SpeechReadinessSnapshot | null;
logger?: ReturnType<typeof createLogger>; logger?: ReturnType<typeof createLogger>;
@@ -246,6 +254,7 @@ function createServer(options?: {
createStub<DaemonConfigStore>(daemonConfigStore), createStub<DaemonConfigStore>(daemonConfigStore),
null, null,
{ allowedOrigins: new Set() }, { allowedOrigins: new Set() },
createWorkspaceAutoNameStub(),
undefined, undefined,
speechReadiness speechReadiness
? { ? {
@@ -18,6 +18,7 @@ import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-re
import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import type { PushNotificationSender, PushPayload } from "./push/notifications.js"; import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
const wsModuleMock = vi.hoisted(() => { const wsModuleMock = vi.hoisted(() => {
class MockWebSocketServer { class MockWebSocketServer {
@@ -68,6 +69,13 @@ function createLogger() {
return logger; return logger;
} }
function createWorkspaceAutoNameStub(): WorkspaceAutoName {
return createStub<WorkspaceAutoName>({
scheduleForWorktree: () => {},
scheduleForDirectory: () => {},
});
}
function createTerminalManager() { function createTerminalManager() {
let listener: TerminalActivityListener | null = null; let listener: TerminalActivityListener | null = null;
@@ -139,6 +147,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work
createStub<DaemonConfigStore>(daemonConfigStore), createStub<DaemonConfigStore>(daemonConfigStore),
null, null,
{ allowedOrigins: new Set() }, { allowedOrigins: new Set() },
createWorkspaceAutoNameStub(),
undefined, undefined,
undefined, undefined,
terminalManager, terminalManager,
@@ -34,6 +34,7 @@ import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from
import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.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 { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js"; import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
@@ -424,6 +425,7 @@ export class VoiceAssistantWebSocketServer {
private readonly checkoutDiffManager: CheckoutDiffManager; private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly github: GitHubService; private readonly github: GitHubService;
private readonly workspaceGitService: WorkspaceGitService; private readonly workspaceGitService: WorkspaceGitService;
private readonly workspaceAutoName: WorkspaceAutoName;
private readonly downloadTokenStore: DownloadTokenStore; private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string; private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined; private readonly worktreesRoot: string | undefined;
@@ -473,6 +475,7 @@ export class VoiceAssistantWebSocketServer {
daemonConfigStore: DaemonConfigStore, daemonConfigStore: DaemonConfigStore,
mcpBaseUrl: string | null, mcpBaseUrl: string | null,
wsConfig: WebSocketServerConfig, wsConfig: WebSocketServerConfig,
workspaceAutoName: WorkspaceAutoName,
auth?: DaemonAuthConfig, auth?: DaemonAuthConfig,
speech?: SpeechService | null, speech?: SpeechService | null,
terminalManager?: TerminalManager | null, terminalManager?: TerminalManager | null,
@@ -540,6 +543,7 @@ export class VoiceAssistantWebSocketServer {
this.checkoutDiffManager = requiredServices.checkoutDiffManager; this.checkoutDiffManager = requiredServices.checkoutDiffManager;
this.github = github ?? createGitHubService(); this.github = github ?? createGitHubService();
this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService(); this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
this.workspaceAutoName = workspaceAutoName;
this.downloadTokenStore = downloadTokenStore; this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome; this.paseoHome = paseoHome;
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
@@ -1019,6 +1023,7 @@ export class VoiceAssistantWebSocketServer {
checkoutDiffManager: this.checkoutDiffManager, checkoutDiffManager: this.checkoutDiffManager,
github: this.github, github: this.github,
workspaceGitService: this.workspaceGitService, workspaceGitService: this.workspaceGitService,
workspaceAutoName: this.workspaceAutoName,
daemonConfigStore: this.daemonConfigStore, daemonConfigStore: this.daemonConfigStore,
mcpBaseUrl: this.mcpBaseUrl, mcpBaseUrl: this.mcpBaseUrl,
stt: () => this.speech?.resolveStt() ?? null, stt: () => this.speech?.resolveStt() ?? null,
@@ -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);
}
}
@@ -99,6 +99,7 @@ function createWorkflowForRequestTest(options: {
paseoHome: options.paseoHome, paseoHome: options.paseoHome,
createPaseoWorktree, createPaseoWorktree,
warmWorkspaceGitData: options.warmWorkspaceGitData ?? (async () => {}), warmWorkspaceGitData: options.warmWorkspaceGitData ?? (async () => {}),
autoNameWorkspaceBranchForFirstAgent: () => {},
emitWorkspaceUpdateForWorkspaceId: async () => {}, emitWorkspaceUpdateForWorkspaceId: async () => {},
cacheWorkspaceSetupSnapshot: () => {}, cacheWorkspaceSetupSnapshot: () => {},
emit: () => {}, emit: () => {},
@@ -382,6 +383,7 @@ describe("create-agent worktree setup boundary", () => {
paseoHome, paseoHome,
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
warmWorkspaceGitData: async () => {}, warmWorkspaceGitData: async () => {},
autoNameWorkspaceBranchForFirstAgent: () => {},
emitWorkspaceUpdateForWorkspaceId: async () => {}, emitWorkspaceUpdateForWorkspaceId: async () => {},
cacheWorkspaceSetupSnapshot: () => {}, cacheWorkspaceSetupSnapshot: () => {},
emit: (message) => workspaceSetupEvents.push(message), emit: (message) => workspaceSetupEvents.push(message),
@@ -119,7 +119,7 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB
}, },
) => Promise<CreatePaseoWorktreeResult>; ) => Promise<CreatePaseoWorktreeResult>;
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>; warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
autoNameWorkspaceBranchForFirstAgent?: (input: { autoNameWorkspaceBranchForFirstAgent: (input: {
workspace: PersistedWorkspaceRecord; workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext; firstAgentContext: FirstAgentContext;
}) => void; }) => void;
@@ -596,7 +596,7 @@ export async function createPaseoWorktreeWorkflow(
setTimeout(() => { setTimeout(() => {
if (input.firstAgentContext) { if (input.firstAgentContext) {
dependencies.autoNameWorkspaceBranchForFirstAgent?.({ dependencies.autoNameWorkspaceBranchForFirstAgent({
workspace, workspace,
firstAgentContext: input.firstAgentContext, firstAgentContext: input.firstAgentContext,
}); });