mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
222
packages/server/src/server/workspace-auto-name.ts
Normal file
222
packages/server/src/server/workspace-auto-name.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user