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;
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user