From 030e163fb8ccb08efd4ae793a355a9a60432cef6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 9 May 2026 18:36:29 +0800 Subject: [PATCH] feat: project-level prompts for metadata generation (#836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(server): project-level prompts for metadata generation Add `metadataGeneration` config to `paseo.json` so projects can customize the four LLM-generated metadata strings — agent title, worktree branch name, commit message, and pull request title+body. Each entry takes an `instructions` string that gets injected as a `` block between the default rules and the load-bearing JSON format contract, with wrapper text saying user instructions override defaults. Missing/empty/invalid config keeps every prompt byte-identical to today. * refactor(server): centralize project metadata prompt building Three near-identical readers and four gate-then-wrap blocks collapsed into one buildMetadataPrompt helper. wrapWithUserInstructions tightened to require a non-empty string; its misleading empty fallback is gone. * feat(app): edit metadata generation prompts in project settings Adds four textareas (agent title, branch name, commit message, pull request) under project settings, modeled on the existing setup/teardown pattern. Round-trips through paseo.json preserving unknown sibling fields at both the metadataGeneration and entry level. --- .../src/screens/project-settings-screen.tsx | 84 ++++ .../app/src/utils/project-config-form.test.ts | 101 +++++ packages/app/src/utils/project-config-form.ts | 67 +++- .../server/agent/agent-metadata-generator.ts | 32 +- .../agent-metadata-generator.unit.test.ts | 101 ++++- .../server/src/server/agent/mcp-server.ts | 6 +- packages/server/src/server/session.test.ts | 375 ++++++++++++++++++ packages/server/src/server/session.ts | 46 ++- .../worktree-branch-name-generator.test.ts | 154 ++++++- .../server/worktree-branch-name-generator.ts | 37 +- packages/server/src/shared/messages.ts | 8 + .../server/src/utils/build-metadata-prompt.ts | 46 +++ .../src/utils/paseo-config-schema.test.ts | 154 +++++++ .../server/src/utils/paseo-config-schema.ts | 21 + .../src/utils/wrap-user-instructions.test.ts | 29 ++ .../src/utils/wrap-user-instructions.ts | 18 + 16 files changed, 1240 insertions(+), 39 deletions(-) create mode 100644 packages/server/src/utils/build-metadata-prompt.ts create mode 100644 packages/server/src/utils/paseo-config-schema.test.ts create mode 100644 packages/server/src/utils/wrap-user-instructions.test.ts create mode 100644 packages/server/src/utils/wrap-user-instructions.ts diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 1a2253cab..bd2ee2570 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -31,7 +31,9 @@ import { confirmDialog } from "@/utils/confirm-dialog"; import { applyDraftToConfig, configToDraft, + METADATA_PROMPT_KEYS, type LifecycleOriginalKind, + type MetadataPromptKey, type ProjectConfigDraft, type ProjectScriptDraft, } from "@/utils/project-config-form"; @@ -42,6 +44,40 @@ const SCRIPT_SERVICE_TYPE = "service"; const ICON_SIZE = 14; +interface MetadataPromptField { + title: string; + placeholder: string; + sectionTestID: string; + inputTestID: string; +} + +const METADATA_PROMPT_FIELDS: Record = { + agentTitle: { + title: "Agent title prompt", + placeholder: "Keep titles short and action-oriented.", + sectionTestID: "metadata-prompt-agent-title-section", + inputTestID: "metadata-prompt-agent-title-input", + }, + branchName: { + title: "Branch name prompt", + placeholder: "Prefix branches with feat/ or fix/.", + sectionTestID: "metadata-prompt-branch-name-section", + inputTestID: "metadata-prompt-branch-name-input", + }, + commitMessage: { + title: "Commit message prompt", + placeholder: "Use Conventional Commits.", + sectionTestID: "metadata-prompt-commit-message-section", + inputTestID: "metadata-prompt-commit-message-input", + }, + pullRequest: { + title: "Pull request prompt", + placeholder: "Include risk notes and a test plan.", + sectionTestID: "metadata-prompt-pull-request-section", + inputTestID: "metadata-prompt-pull-request-input", + }, +}; + const NO_TARGET_MESSAGE = "We don't have an editable copy of this project on any connected host."; const HOST_SWITCHER_LABEL = "Switch host"; @@ -432,6 +468,15 @@ function ProjectConfigForm({ [updateDraft], ); + const handleMetadataPromptChange = useCallback( + (key: MetadataPromptKey, text: string) => + updateDraft((d) => ({ + ...d, + metadataPrompts: { ...d.metadataPrompts, [key]: text }, + })), + [updateDraft], + ); + const handleRemoveScript = useCallback( async (script: ProjectScriptDraft) => { const ok = await confirmDialog({ @@ -585,6 +630,15 @@ function ProjectConfigForm({ + {METADATA_PROMPT_KEYS.map((key) => ( + + ))} + {isStale ? ( void; +} + +function MetadataPromptSection({ promptKey, value, onChange }: MetadataPromptSectionProps) { + const meta = METADATA_PROMPT_FIELDS[promptKey]; + const handleChange = useCallback( + (text: string) => onChange(promptKey, text), + [onChange, promptKey], + ); + return ( + + + + + + ); +} + interface ScriptRowProps { script: ProjectScriptDraft; isFirst: boolean; diff --git a/packages/app/src/utils/project-config-form.test.ts b/packages/app/src/utils/project-config-form.test.ts index a1365a3c6..29c12dc08 100644 --- a/packages/app/src/utils/project-config-form.test.ts +++ b/packages/app/src/utils/project-config-form.test.ts @@ -10,6 +10,13 @@ function emptyDraft(): ProjectConfigDraft { teardownText: "", teardownOriginalKind: "missing", scripts: [], + metadataPrompts: { + agentTitle: "", + branchName: "", + commitMessage: "", + pullRequest: "", + }, + metadataGenerationBase: undefined, }; } @@ -210,6 +217,100 @@ describe("applyDraftToConfig", () => { expect(tunnel.port).toBe("auto"); }); + it("reads metadata prompt instructions for all four keys", () => { + const draft = configToDraft({ + metadataGeneration: { + agentTitle: { instructions: "Use mb/." }, + branchName: { instructions: "feat/" }, + commitMessage: { instructions: "Conventional commits." }, + pullRequest: { instructions: "Include risk notes." }, + }, + }); + expect(draft.metadataPrompts).toEqual({ + agentTitle: "Use mb/.", + branchName: "feat/", + commitMessage: "Conventional commits.", + pullRequest: "Include risk notes.", + }); + }); + + it("defaults metadata prompts to empty strings when not present", () => { + const draft = configToDraft({ + metadataGeneration: { agentTitle: { instructions: "Use mb/." } }, + }); + expect(draft.metadataPrompts).toEqual({ + agentTitle: "Use mb/.", + branchName: "", + commitMessage: "", + pullRequest: "", + }); + }); + + it("writes only metadata prompt entries with non-empty text", () => { + const base: PaseoConfigRaw = {}; + const draft = configToDraft(base); + draft.metadataPrompts.agentTitle = "Use mb/."; + draft.metadataPrompts.commitMessage = "Conventional commits."; + const next = applyDraftToConfig({ draft, base }); + expect(next.metadataGeneration).toEqual({ + agentTitle: { instructions: "Use mb/." }, + commitMessage: { instructions: "Conventional commits." }, + }); + }); + + it("drops the metadataGeneration field when all prompts are empty", () => { + const base = PaseoConfigRawSchema.parse({ + metadataGeneration: { + agentTitle: { instructions: "Use mb/." }, + }, + }); + const draft = configToDraft(base); + draft.metadataPrompts.agentTitle = ""; + const next = applyDraftToConfig({ draft, base }); + expect(next.metadataGeneration).toBeUndefined(); + }); + + it("preserves unknown sibling fields inside metadataGeneration on round-trip", () => { + const base = PaseoConfigRawSchema.parse({ + metadataGeneration: { + agentTitle: { instructions: "Use mb/." }, + futureField: 42, + }, + }); + const draft = configToDraft(base); + draft.metadataPrompts.agentTitle = "Use prefix mb/ on titles."; + const next = applyDraftToConfig({ draft, base }); + const metadata = next.metadataGeneration as Record; + expect(metadata.agentTitle).toEqual({ instructions: "Use prefix mb/ on titles." }); + expect(metadata.futureField).toBe(42); + }); + + it("preserves unknown fields inside a metadata prompt entry on round-trip", () => { + const base = PaseoConfigRawSchema.parse({ + metadataGeneration: { + agentTitle: { instructions: "Use mb/.", model: "haiku" }, + }, + }); + const draft = configToDraft(base); + draft.metadataPrompts.agentTitle = "Updated."; + const next = applyDraftToConfig({ draft, base }); + const metadata = next.metadataGeneration as Record; + expect(metadata.agentTitle).toEqual({ instructions: "Updated.", model: "haiku" }); + }); + + it("clears instructions but preserves unknown sibling fields when text becomes empty", () => { + const base = PaseoConfigRawSchema.parse({ + metadataGeneration: { + agentTitle: { instructions: "Use mb/.", model: "haiku" }, + }, + }); + const draft = configToDraft(base); + draft.metadataPrompts.agentTitle = ""; + const next = applyDraftToConfig({ draft, base }); + const metadata = next.metadataGeneration as Record; + expect(metadata.agentTitle).toEqual({ model: "haiku" }); + }); + it("drops scripts with an empty name and removes scripts no longer present in the draft", () => { const base = PaseoConfigRawSchema.parse({ scripts: { diff --git a/packages/app/src/utils/project-config-form.ts b/packages/app/src/utils/project-config-form.ts index a8b13c8e0..99115d4ee 100644 --- a/packages/app/src/utils/project-config-form.ts +++ b/packages/app/src/utils/project-config-form.ts @@ -1,7 +1,20 @@ -import type { PaseoConfigRaw, PaseoScriptEntryRaw } from "@server/shared/messages"; +import type { + PaseoConfigRaw, + PaseoMetadataGeneration, + PaseoMetadataGenerationEntry, + PaseoScriptEntryRaw, +} from "@server/shared/messages"; export type LifecycleOriginalKind = "string" | "array" | "missing"; +export const METADATA_PROMPT_KEYS = [ + "agentTitle", + "branchName", + "commitMessage", + "pullRequest", +] as const; +export type MetadataPromptKey = (typeof METADATA_PROMPT_KEYS)[number]; + export interface ProjectScriptDraft { id: string; name: string; @@ -18,6 +31,8 @@ export interface ProjectConfigDraft { teardownText: string; teardownOriginalKind: LifecycleOriginalKind; scripts: ProjectScriptDraft[]; + metadataPrompts: Record; + metadataGenerationBase: PaseoMetadataGeneration | undefined; } interface LifecycleProjection { @@ -88,6 +103,15 @@ function nextScriptDraftId(): string { return `script-draft-${scriptDraftIdCounter}`; } +function emptyMetadataPrompts(): Record { + return { + agentTitle: "", + branchName: "", + commitMessage: "", + pullRequest: "", + }; +} + export function configToDraft(config: PaseoConfigRaw | null | undefined): ProjectConfigDraft { const worktree = config?.worktree ?? {}; const setup = projectLifecycle(worktree.setup); @@ -108,12 +132,23 @@ export function configToDraft(config: PaseoConfigRaw | null | undefined): Projec }); } + const metadataGeneration = config?.metadataGeneration; + const metadataPrompts = emptyMetadataPrompts(); + for (const key of METADATA_PROMPT_KEYS) { + const instructions = metadataGeneration?.[key]?.instructions; + if (typeof instructions === "string") { + metadataPrompts[key] = instructions; + } + } + return { setupText: setup.text, setupOriginalKind: setup.kind, teardownText: teardown.text, teardownOriginalKind: teardown.kind, scripts, + metadataPrompts, + metadataGenerationBase: metadataGeneration, }; } @@ -172,6 +207,31 @@ export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw { nextScripts[trimmedName] = nextEntry as PaseoScriptEntryRaw; } + const nextMetadataGeneration: Record = { + ...input.draft.metadataGenerationBase, + }; + for (const key of METADATA_PROMPT_KEYS) { + const text = input.draft.metadataPrompts[key]; + const baseEntry = input.draft.metadataGenerationBase?.[key] as + | PaseoMetadataGenerationEntry + | undefined; + if (text.trim().length === 0) { + if (baseEntry) { + const nextEntry: Record = { ...baseEntry }; + delete nextEntry.instructions; + if (Object.keys(nextEntry).length === 0) { + delete nextMetadataGeneration[key]; + } else { + nextMetadataGeneration[key] = nextEntry; + } + } else { + delete nextMetadataGeneration[key]; + } + } else { + nextMetadataGeneration[key] = { ...baseEntry, instructions: text }; + } + } + const result: Record = { ...baseConfig }; if (Object.keys(nextWorktree).length === 0) { delete result.worktree; @@ -183,5 +243,10 @@ export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw { } else { result.scripts = nextScripts; } + if (Object.keys(nextMetadataGeneration).length === 0) { + delete result.metadataGeneration; + } else { + result.metadataGeneration = nextMetadataGeneration; + } return result as PaseoConfigRaw; } diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index b4ed8a67e..016c2e821 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -9,6 +9,8 @@ import { generateStructuredAgentResponseWithFallback, } from "./agent-response-loop.js"; import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; +import { buildMetadataPrompt } from "../../utils/build-metadata-prompt.js"; +import type { WorkspaceGitService } from "../workspace-git-service.js"; export interface AgentMetadataGeneratorDeps { generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback; @@ -18,6 +20,7 @@ export interface AgentMetadataGenerationOptions { agentManager: AgentManager; agentId: string; cwd: string; + workspaceGitService?: Pick; initialPrompt?: string | null; explicitTitle?: string | null; paseoHome?: string; @@ -75,16 +78,26 @@ function buildMetadataSchema( return z.object(shape); } -function buildPrompt(needs: AgentMetadataNeeds): string { - const instructions: string[] = ["Generate metadata for a coding agent based on the user prompt."]; - +async function buildPrompt( + needs: AgentMetadataNeeds, + options: { + cwd: string; + workspaceGitService?: Pick; + }, +): Promise { + const beforeLines: string[] = ["Generate metadata for a coding agent based on the user prompt."]; if (needs.needsTitle) { - instructions.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`); + beforeLines.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`); } - instructions.push("Return JSON only with a single field 'title'."); - instructions.push("", "User prompt:", needs.prompt ?? ""); - return instructions.join("\n"); + return buildMetadataPrompt({ + cwd: options.cwd, + workspaceGitService: options.workspaceGitService, + configKey: "agentTitle", + before: beforeLines.join("\n"), + after: "Return JSON only with a single field 'title'.", + trailing: `User prompt:\n${needs.prompt ?? ""}`, + }); } export async function generateAndApplyAgentMetadata( @@ -110,7 +123,10 @@ export async function generateAndApplyAgentMetadata( result = await generator({ manager: options.agentManager, cwd: options.cwd, - prompt: buildPrompt(needs), + prompt: await buildPrompt(needs, { + cwd: options.cwd, + workspaceGitService: options.workspaceGitService, + }), schema, schemaName: "AgentMetadata", maxRetries: 2, diff --git a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts index 4c3b603e4..1959510c3 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; +import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js"; import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; import { generateAndApplyAgentMetadata, @@ -9,6 +13,19 @@ import { import type { AgentManager } from "./agent-manager.js"; const logger = createTestLogger(); +const cleanupPaths: string[] = []; +const PRE_CHANGE_TITLE_PROMPT = `Generate metadata for a coding agent based on the user prompt. +Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars). +Return JSON only with a single field 'title'. + +User prompt: +Implement this feature`; + +afterEach(() => { + for (const target of cleanupPaths.splice(0)) { + rmSync(target, { recursive: true, force: true }); + } +}); function createDeps( generateStructuredAgentResponseWithFallback: NonNullable< @@ -93,4 +110,86 @@ describe("agent metadata generator auto-title", () => { ); expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title"); }); + + it.each([ + ["paseo.json missing", undefined], + ["paseo.json exists but invalid JSON", "{ nope"], + ["paseo.json valid but missing metadataGeneration", {}], + [ + "metadataGeneration exists but missing agentTitle", + { metadataGeneration: { branchName: { instructions: "Use mb/." } } }, + ], + ["agentTitle exists but instructions is undefined", { metadataGeneration: { agentTitle: {} } }], + [ + "agentTitle exists but instructions is empty", + { metadataGeneration: { agentTitle: { instructions: "" } } }, + ], + [ + "agentTitle exists but instructions is whitespace-only", + { metadataGeneration: { agentTitle: { instructions: " \n\t " } } }, + ], + ])("keeps the pre-change prompt byte-identical when %s", async (_name, config) => { + const { prompt } = await generateTitlePromptWithConfig(config); + + expect(prompt).toBe(PRE_CHANGE_TITLE_PROMPT); + }); + + it("injects project instructions between the default rules and JSON contract", async () => { + const { prompt } = await generateTitlePromptWithConfig({ + metadataGeneration: { + agentTitle: { + instructions: "Use the prefix mb/.", + }, + }, + }); + + const defaultRuleIndex = prompt.indexOf("Title: short descriptive label"); + const noticeIndex = prompt.indexOf("override the guidelines above"); + const openTagIndex = prompt.indexOf(""); + const userInstructionIndex = prompt.indexOf("Use the prefix mb/."); + const closeTagIndex = prompt.indexOf(""); + const jsonContractIndex = prompt.indexOf("Return JSON only"); + const payloadIndex = prompt.indexOf("User prompt:"); + + expect(defaultRuleIndex).toBeGreaterThanOrEqual(0); + expect(defaultRuleIndex).toBeLessThan(openTagIndex); + expect(openTagIndex).toBeLessThan(noticeIndex); + expect(noticeIndex).toBeLessThan(userInstructionIndex); + expect(userInstructionIndex).toBeLessThan(closeTagIndex); + expect(closeTagIndex).toBeLessThan(jsonContractIndex); + expect(jsonContractIndex).toBeLessThan(payloadIndex); + }); }); + +async function generateTitlePromptWithConfig(config: unknown): Promise<{ prompt: string }> { + const repoRoot = mkdtempSync(path.join(tmpdir(), "paseo-title-config-")); + cleanupPaths.push(repoRoot); + if (typeof config === "string") { + writeFileSync(path.join(repoRoot, "paseo.json"), config); + } else if (config !== undefined) { + writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`); + } + + const setTitle = vi.fn().mockResolvedValue(undefined); + const manager = { setTitle } as unknown as AgentManager; + const generateStructured = vi.fn().mockResolvedValue({ title: "Generated title" }) as NonNullable< + AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] + >; + + await generateAndApplyAgentMetadata({ + agentManager: manager, + agentId: "agent-config", + cwd: path.join(repoRoot, "nested"), + workspaceGitService: createNoopWorkspaceGitService({ + resolveRepoRoot: async () => repoRoot, + }), + initialPrompt: "Implement this feature", + explicitTitle: null, + logger, + deps: createDeps(generateStructured), + }); + + return { + prompt: String(generateStructured.mock.calls[0]?.[0].prompt), + }; +} diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index b1337f1ca..0f8f91bb7 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -79,7 +79,10 @@ export interface AgentMcpServerOptions { scheduleService?: ScheduleService | null; providerRegistry?: Record | null; github?: GitHubService; - workspaceGitService?: Pick; + workspaceGitService?: Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" | "resolveRepoRoot" + >; archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; @@ -847,6 +850,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom agentManager, agentId: snapshot.id, cwd: snapshot.cwd, + workspaceGitService: options.workspaceGitService, initialPrompt: trimmedPrompt, explicitTitle: snapshot.config.title, paseoHome: options.paseoHome, diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 05aef2f53..35cec7020 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -10,6 +10,7 @@ import { CheckoutPrStatusSchema } from "../shared/messages.js"; import type { WorkspaceDescriptorPayload } from "../shared/messages.js"; import { decodeFileTransferFrame, FileTransferOpcode } from "../shared/binary-frames/index.js"; import { normalizeCheckoutPrStatusPayload, Session } from "./session.js"; +import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; import type { AgentClient, AgentMode, @@ -269,6 +270,7 @@ interface SessionForTestOptions { validateBranchRef?: ReturnType; hasLocalBranch?: ReturnType; resolveRepoRemoteUrl?: ReturnType; + resolveRepoRoot?: ReturnType; getWorkspaceGitMetadata?: ReturnType; }; workspaceRegistry?: { get: ReturnType }; @@ -305,6 +307,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { validateBranchRef: vi.fn(), hasLocalBranch: vi.fn(), resolveRepoRemoteUrl: vi.fn(), + resolveRepoRoot: vi.fn(), getWorkspaceGitMetadata: vi.fn(), }; const messages = options.messages ?? []; @@ -1638,6 +1641,78 @@ describe("session checkout merge handling", () => { }); describe("session checkout commit handling", () => { + const tempDirs: string[] = []; + const PRE_CHANGE_COMMIT_PROMPT = `Write a concise git commit message for the changes below. +Return JSON only with a single field 'message'. + +Files changed: +M\tfile.txt\t(+1 -0) + +diff --git a/file.txt b/file.txt ++hello +`; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function makeRoot(): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), "commit-metadata-session-test-"))); + tempDirs.push(root); + return root; + } + + function writeConfig(repoRoot: string, config: unknown): void { + writeFileSync(join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`); + } + + async function generateCommitPromptWithConfig(config: unknown): Promise { + const repoRoot = makeRoot(); + if (typeof config === "string") { + writeFileSync(join(repoRoot, "paseo.json"), config); + } else if (config !== undefined) { + writeConfig(repoRoot, config); + } + + const workspaceGitService = { + getCheckoutDiff: vi.fn().mockResolvedValue({ + diff: "diff --git a/file.txt b/file.txt\n+hello\n", + structured: [ + { + path: "file.txt", + additions: 1, + deletions: 0, + isNew: false, + isDeleted: false, + hunks: [], + status: "ok", + }, + ], + }), + getSnapshot: vi.fn().mockResolvedValue({}), + resolveRepoRoot: vi.fn().mockResolvedValue(repoRoot), + }; + agentResponseMocks.generateStructuredAgentResponseWithFallback.mockResolvedValue({ + message: "Update file", + }); + checkoutGitMocks.commitChanges.mockResolvedValue(undefined); + const session = createSessionForTest({ workspaceGitService }); + + await asSessionInternals(session).handleCheckoutCommitRequest({ + type: "checkout_commit_request", + cwd: join(repoRoot, "nested"), + message: "", + addAll: true, + requestId: "request-generated-commit", + }); + + return String( + agentResponseMocks.generateStructuredAgentResponseWithFallback.mock.calls[0]?.[0].prompt, + ); + } + test("forces a workspace git snapshot refresh after committing", async () => { const messages: unknown[] = []; const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() }; @@ -1737,6 +1812,100 @@ describe("session checkout commit handling", () => { }); }); + test.each([ + ["paseo.json missing", undefined], + ["paseo.json exists but invalid JSON", "{ nope"], + ["paseo.json valid but missing metadataGeneration", {}], + ["metadataGeneration is schema-invalid", { metadataGeneration: "not an object" }], + [ + "metadataGeneration exists but missing commitMessage", + { metadataGeneration: { pullRequest: { instructions: "Write a punchy PR." } } }, + ], + [ + "commitMessage exists but instructions is undefined", + { metadataGeneration: { commitMessage: {} } }, + ], + [ + "commitMessage exists but instructions is empty", + { metadataGeneration: { commitMessage: { instructions: "" } } }, + ], + [ + "commitMessage exists but instructions is whitespace-only", + { metadataGeneration: { commitMessage: { instructions: " \n\t " } } }, + ], + ])("keeps the pre-change commit prompt byte-identical when %s", async (_name, config) => { + const prompt = await generateCommitPromptWithConfig(config); + + expect(prompt).toBe(PRE_CHANGE_COMMIT_PROMPT); + }); + + test("injects commit instructions between the default rules and JSON contract", async () => { + const prompt = await generateCommitPromptWithConfig({ + metadataGeneration: { + commitMessage: { + instructions: "Use conventional commits.\nAccept XML-ish text.", + }, + }, + }); + + const defaultRuleIndex = prompt.indexOf("Write a concise git commit message"); + const openTagIndex = prompt.indexOf(""); + const noticeIndex = prompt.indexOf("override the guidelines above"); + const userInstructionIndex = prompt.indexOf("Use conventional commits."); + const closeTagIndex = prompt.indexOf(""); + const jsonContractIndex = prompt.indexOf("Return JSON only"); + const fileListIndex = prompt.indexOf("Files changed:"); + const patchIndex = prompt.indexOf("diff --git"); + + expect(defaultRuleIndex).toBeGreaterThanOrEqual(0); + expect(defaultRuleIndex).toBeLessThan(openTagIndex); + expect(openTagIndex).toBeLessThan(noticeIndex); + expect(noticeIndex).toBeLessThan(userInstructionIndex); + expect(userInstructionIndex).toBeLessThan(closeTagIndex); + expect(closeTagIndex).toBeLessThan(jsonContractIndex); + expect(jsonContractIndex).toBeLessThan(fileListIndex); + expect(fileListIndex).toBeLessThan(patchIndex); + }); + + test("keeps the commit fallback when structured generation fails", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getCheckoutDiff: vi.fn().mockResolvedValue({ + diff: "diff --git a/file.txt b/file.txt\n+hello\n", + structured: [], + }), + getSnapshot: vi.fn().mockResolvedValue({}), + resolveRepoRoot: vi.fn().mockResolvedValue(makeRoot()), + }; + agentResponseMocks.generateStructuredAgentResponseWithFallback.mockRejectedValue( + new StructuredAgentFallbackError([]), + ); + checkoutGitMocks.commitChanges.mockResolvedValue(undefined); + const session = createSessionForTest({ workspaceGitService, messages }); + + await asSessionInternals(session).handleCheckoutCommitRequest({ + type: "checkout_commit_request", + cwd: "/tmp/request-worktree", + message: "", + addAll: true, + requestId: "request-generated-commit-fallback", + }); + + expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", { + message: "Update files", + addAll: true, + }); + expect(messages).toContainEqual({ + type: "checkout_commit_response", + payload: { + cwd: "/tmp/request-worktree", + success: true, + error: null, + requestId: "request-generated-commit-fallback", + }, + }); + }); + test("does not force a workspace git snapshot refresh when commit fails", async () => { const messages: unknown[] = []; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; @@ -1768,6 +1937,85 @@ describe("session checkout commit handling", () => { }); describe("session checkout pull request creation", () => { + const tempDirs: string[] = []; + const PRE_CHANGE_PULL_REQUEST_PROMPT = `Write a pull request title and body for the changes below. +Return JSON only with fields 'title' and 'body'. + +Files changed: +M\tfile.txt\t(+1 -0) + +diff --git a/file.txt b/file.txt ++hello +`; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function makeRoot(): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), "pr-metadata-session-test-"))); + tempDirs.push(root); + return root; + } + + function writeConfig(repoRoot: string, config: unknown): void { + writeFileSync(join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`); + } + + async function generatePullRequestCallWithConfig(config: unknown): Promise { + const repoRoot = makeRoot(); + if (typeof config === "string") { + writeFileSync(join(repoRoot, "paseo.json"), config); + } else if (config !== undefined) { + writeConfig(repoRoot, config); + } + + const workspaceGitService = { + getCheckoutDiff: vi.fn().mockResolvedValue({ + diff: "diff --git a/file.txt b/file.txt\n+hello\n", + structured: [ + { + path: "file.txt", + additions: 1, + deletions: 0, + isNew: false, + isDeleted: false, + hunks: [], + status: "ok", + }, + ], + }), + resolveRepoRoot: vi.fn().mockResolvedValue(repoRoot), + }; + agentResponseMocks.generateStructuredAgentResponseWithFallback.mockResolvedValue({ + title: "Update file", + body: "Updates file.", + }); + checkoutGitMocks.createPullRequest.mockResolvedValue({ + url: "https://github.com/getpaseo/paseo/pull/1", + number: 1, + }); + const session = createSessionForTest({ workspaceGitService }); + + await asSessionInternals(session).handleCheckoutPrCreateRequest({ + type: "checkout_pr_create_request", + cwd: join(repoRoot, "nested"), + baseRef: "main", + title: "", + body: "", + requestId: "request-generated-pr", + }); + + return agentResponseMocks.generateStructuredAgentResponseWithFallback.mock.calls[0]?.[0]; + } + + async function generatePullRequestPromptWithConfig(config: unknown): Promise { + const call = await generatePullRequestCallWithConfig(config); + return String((call as { prompt?: unknown } | undefined)?.prompt); + } + test("generates PR text from checkout diffs read through the workspace git service", async () => { const messages: unknown[] = []; const workspaceGitService = { @@ -1841,6 +2089,133 @@ describe("session checkout pull request creation", () => { }); }); + test.each([ + ["paseo.json missing", undefined], + ["paseo.json exists but invalid JSON", "{ nope"], + ["paseo.json valid but missing metadataGeneration", {}], + ["metadataGeneration is schema-invalid", { metadataGeneration: "not an object" }], + [ + "metadataGeneration exists but missing pullRequest", + { metadataGeneration: { commitMessage: { instructions: "Use conventional commits." } } }, + ], + [ + "pullRequest exists but instructions is undefined", + { metadataGeneration: { pullRequest: {} } }, + ], + [ + "pullRequest exists but instructions is empty", + { metadataGeneration: { pullRequest: { instructions: "" } } }, + ], + [ + "pullRequest exists but instructions is whitespace-only", + { metadataGeneration: { pullRequest: { instructions: " \n\t " } } }, + ], + ])("keeps the pre-change PR prompt byte-identical when %s", async (_name, config) => { + const prompt = await generatePullRequestPromptWithConfig(config); + + expect(prompt).toBe(PRE_CHANGE_PULL_REQUEST_PROMPT); + }); + + test("injects PR instructions between the default rules and JSON contract", async () => { + const prompt = await generatePullRequestPromptWithConfig({ + metadataGeneration: { + pullRequest: { + instructions: "Use a terse title.\nKeep literal text.", + }, + }, + }); + + const defaultRuleIndex = prompt.indexOf("Write a pull request title and body"); + const openTagIndex = prompt.indexOf(""); + const noticeIndex = prompt.indexOf("override the guidelines above"); + const userInstructionIndex = prompt.indexOf("Use a terse title."); + const closeTagIndex = prompt.indexOf(""); + const jsonContractIndex = prompt.indexOf("Return JSON only"); + const fileListIndex = prompt.indexOf("Files changed:"); + const patchIndex = prompt.indexOf("diff --git"); + + expect(defaultRuleIndex).toBeGreaterThanOrEqual(0); + expect(defaultRuleIndex).toBeLessThan(openTagIndex); + expect(openTagIndex).toBeLessThan(noticeIndex); + expect(noticeIndex).toBeLessThan(userInstructionIndex); + expect(userInstructionIndex).toBeLessThan(closeTagIndex); + expect(closeTagIndex).toBeLessThan(jsonContractIndex); + expect(jsonContractIndex).toBeLessThan(fileListIndex); + expect(fileListIndex).toBeLessThan(patchIndex); + }); + + test("keeps PR generation as one structured call with title and body schema", async () => { + const call = await generatePullRequestCallWithConfig({ + metadataGeneration: { + pullRequest: { + instructions: "Use release-note style.", + }, + }, + }); + const schema = (call as { schema?: { safeParse?: (value: unknown) => { success: boolean } } }) + .schema; + + expect(agentResponseMocks.generateStructuredAgentResponseWithFallback).toHaveBeenCalledTimes(1); + expect(call).toMatchObject({ + schemaName: "PullRequest", + persistSession: false, + agentConfigOverrides: { + title: "PR generator", + internal: true, + }, + }); + expect(schema?.safeParse?.({ title: "Update file", body: "Updates file." }).success).toBe(true); + expect(schema?.safeParse?.({ title: "Update file" }).success).toBe(false); + }); + + test("keeps the PR fallback when structured generation fails", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getCheckoutDiff: vi.fn().mockResolvedValue({ + diff: "diff --git a/file.txt b/file.txt\n+hello\n", + structured: [], + }), + resolveRepoRoot: vi.fn().mockResolvedValue(makeRoot()), + }; + agentResponseMocks.generateStructuredAgentResponseWithFallback.mockRejectedValue( + new StructuredAgentFallbackError([]), + ); + checkoutGitMocks.createPullRequest.mockResolvedValue({ + url: "https://github.com/getpaseo/paseo/pull/9", + number: 9, + }); + const session = createSessionForTest({ workspaceGitService, messages }); + + await asSessionInternals(session).handleCheckoutPrCreateRequest({ + type: "checkout_pr_create_request", + cwd: "/tmp/request-worktree", + baseRef: "main", + title: "", + body: "", + requestId: "request-generated-pr-fallback", + }); + + expect(checkoutGitMocks.createPullRequest).toHaveBeenCalledWith( + "/tmp/request-worktree", + { + title: "Update changes", + body: "Automated PR generated by Paseo.", + base: "main", + }, + expect.anything(), + ); + expect(messages).toContainEqual({ + type: "checkout_pr_create_response", + payload: { + cwd: "/tmp/request-worktree", + url: "https://github.com/getpaseo/paseo/pull/9", + number: 9, + error: null, + requestId: "request-generated-pr-fallback", + }, + }); + }); + test("forces workspace git and GitHub refresh after creating a pull request", async () => { const messages: unknown[] = []; const github = { invalidate: vi.fn() }; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d8db06f5c..4e0a81784 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -173,6 +173,7 @@ import { writePaseoConfigForEdit, type ProjectConfigRpcError, } from "../utils/paseo-config-file.js"; +import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js"; import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import type { ScriptRouteStore } from "./script-proxy.js"; @@ -3110,6 +3111,7 @@ export class Session { agentManager: this.agentManager, agentId: snapshot.id, cwd: snapshot.cwd, + workspaceGitService: this.workspaceGitService, initialPrompt: trimmedPrompt, explicitTitle: params.explicitTitle, paseoHome: this.paseoHome, @@ -3284,6 +3286,7 @@ export class Session { agentManager: this.agentManager, agentId: snapshot.id, cwd: snapshot.cwd, + workspaceGitService: this.workspaceGitService, initialPrompt, explicitTitle, paseoHome: this.paseoHome, @@ -3445,6 +3448,7 @@ export class Session { return generateBranchNameFromFirstAgentContext({ agentManager: this.agentManager, cwd, + workspaceGitService: this.workspaceGitService, firstAgentContext, logger: this.sessionLogger, }); @@ -3906,14 +3910,19 @@ export class Session { diff.diff.length > maxPatchChars ? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n` : diff.diff; - const prompt = [ - "Write a concise git commit message for the changes below.", - "Return JSON only with a single field 'message'.", - "", - fileList, - "", - patch.length > 0 ? patch : "(No diff available)", - ].join("\n"); + const prompt = await buildMetadataPrompt({ + cwd, + workspaceGitService: this.workspaceGitService, + configKey: "commitMessage", + before: "Write a concise git commit message for the changes below.", + after: [ + "Return JSON only with a single field 'message'.", + "", + fileList, + "", + patch.length > 0 ? patch : "(No diff available)", + ].join("\n"), + }); try { const result = await generateStructuredAgentResponseWithFallback({ manager: this.agentManager, @@ -3973,14 +3982,19 @@ export class Session { diff.diff.length > maxPatchChars ? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n` : diff.diff; - const prompt = [ - "Write a pull request title and body for the changes below.", - "Return JSON only with fields 'title' and 'body'.", - "", - fileList, - "", - patch.length > 0 ? patch : "(No diff available)", - ].join("\n"); + const prompt = await buildMetadataPrompt({ + cwd, + workspaceGitService: this.workspaceGitService, + configKey: "pullRequest", + before: "Write a pull request title and body for the changes below.", + after: [ + "Return JSON only with fields 'title' and 'body'.", + "", + fileList, + "", + patch.length > 0 ? patch : "(No diff available)", + ].join("\n"), + }); try { return await generateStructuredAgentResponseWithFallback({ manager: this.agentManager, diff --git a/packages/server/src/server/worktree-branch-name-generator.test.ts b/packages/server/src/server/worktree-branch-name-generator.test.ts index dedd7f09d..b874c6f45 100644 --- a/packages/server/src/server/worktree-branch-name-generator.test.ts +++ b/packages/server/src/server/worktree-branch-name-generator.test.ts @@ -1,7 +1,34 @@ -import { describe, expect, test, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; import type { AgentManager } from "./agent/agent-manager.js"; +import { + attemptFirstAgentBranchAutoName, + type AttemptFirstAgentBranchAutoNameResult, +} from "./paseo-worktree-service.js"; +import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js"; +import { + writePaseoWorktreeFirstAgentBranchAutoNameMetadata, + writePaseoWorktreeMetadata, +} from "../utils/worktree-metadata.js"; + +const cleanupPaths: string[] = []; +const PRE_CHANGE_BRANCH_PROMPT = `Generate a git branch name for a coding agent based on the user prompt and attachments. +Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only. +No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens. +Return JSON only with a single field 'branch'. + +User context: +Fix the login flow`; + +afterEach(() => { + for (const target of cleanupPaths.splice(0)) { + rmSync(target, { recursive: true, force: true }); + } +}); function createLogger() { return { @@ -60,4 +87,129 @@ describe("generateBranchNameFromFirstAgentContext", () => { expect(branch).toBe("review-flaky-checkout"); expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Review flaky checkout"); }); + + test.each([ + ["paseo.json missing", undefined], + ["paseo.json exists but invalid JSON", "{ nope"], + ["paseo.json valid but missing metadataGeneration", {}], + [ + "metadataGeneration exists but missing branchName", + { metadataGeneration: { agentTitle: { instructions: "Use mb/." } } }, + ], + ["branchName exists but instructions is undefined", { metadataGeneration: { branchName: {} } }], + [ + "branchName exists but instructions is empty", + { metadataGeneration: { branchName: { instructions: "" } } }, + ], + [ + "branchName exists but instructions is whitespace-only", + { metadataGeneration: { branchName: { instructions: " \n\t " } } }, + ], + ])("keeps the pre-change prompt byte-identical when %s", async (_name, config) => { + const { prompt } = await generateBranchPromptWithConfig(config); + + expect(prompt).toBe(PRE_CHANGE_BRANCH_PROMPT); + }); + + test("injects project instructions between the default rules and JSON contract", async () => { + const { prompt } = await generateBranchPromptWithConfig({ + metadataGeneration: { + branchName: { + instructions: "Use the prefix mb/.", + }, + }, + }); + + const defaultRuleIndex = prompt.indexOf("No spaces, no uppercase"); + const noticeIndex = prompt.indexOf("override the guidelines above"); + const openTagIndex = prompt.indexOf(""); + const userInstructionIndex = prompt.indexOf("Use the prefix mb/."); + const closeTagIndex = prompt.indexOf(""); + const jsonContractIndex = prompt.indexOf("Return JSON only"); + const payloadIndex = prompt.indexOf("User context:"); + + expect(defaultRuleIndex).toBeGreaterThanOrEqual(0); + expect(defaultRuleIndex).toBeLessThan(openTagIndex); + expect(openTagIndex).toBeLessThan(noticeIndex); + expect(noticeIndex).toBeLessThan(userInstructionIndex); + expect(userInstructionIndex).toBeLessThan(closeTagIndex); + expect(closeTagIndex).toBeLessThan(jsonContractIndex); + expect(jsonContractIndex).toBeLessThan(payloadIndex); + }); + + test("keeps the branch slug validator fallback when instructions are present", async () => { + const repoRoot = createTempDir("paseo-branch-config-"); + const worktreeRoot = createTempDir("paseo-branch-worktree-"); + mkdirSync(path.join(worktreeRoot, ".git")); + writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" }); + writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, { + placeholderBranchName: "dazzling-yak", + }); + writeConfig(repoRoot, { + metadataGeneration: { + branchName: { + instructions: "Use the prefix mb/.", + }, + }, + }); + const generateStructured = vi.fn(async () => ({ branch: "Invalid Branch Name" })); + const renameCurrentBranch = vi.fn(async () => ({ currentBranch: "Invalid Branch Name" })); + + const result: AttemptFirstAgentBranchAutoNameResult = await attemptFirstAgentBranchAutoName({ + cwd: worktreeRoot, + firstAgentContext: { prompt: "Fix the login flow" }, + generateBranchNameFromContext: ({ cwd, firstAgentContext }) => + generateBranchNameFromFirstAgentContext({ + agentManager: {} as AgentManager, + cwd, + workspaceGitService: createNoopWorkspaceGitService({ + resolveRepoRoot: async () => repoRoot, + }), + firstAgentContext, + logger: createLogger(), + deps: { generateStructuredAgentResponseWithFallback: generateStructured }, + }), + getCurrentBranch: async () => "dazzling-yak", + renameCurrentBranch, + }); + + expect(result).toEqual({ attempted: true, renamed: false, branchName: null }); + expect(renameCurrentBranch).not.toHaveBeenCalled(); + }); }); + +async function generateBranchPromptWithConfig(config: unknown): Promise<{ prompt: string }> { + const repoRoot = createTempDir("paseo-branch-config-"); + if (typeof config === "string") { + writeFileSync(path.join(repoRoot, "paseo.json"), config); + } else if (config !== undefined) { + writeConfig(repoRoot, config); + } + + const generateStructured = vi.fn(async () => ({ branch: "fix-login-flow" })); + + await generateBranchNameFromFirstAgentContext({ + agentManager: {} as AgentManager, + cwd: path.join(repoRoot, "nested"), + workspaceGitService: createNoopWorkspaceGitService({ + resolveRepoRoot: async () => repoRoot, + }), + firstAgentContext: { prompt: "Fix the login flow" }, + logger: createLogger(), + deps: { generateStructuredAgentResponseWithFallback: generateStructured }, + }); + + return { + prompt: String(generateStructured.mock.calls[0]?.[0].prompt), + }; +} + +function createTempDir(prefix: string): string { + const tempDir = mkdtempSync(path.join(tmpdir(), prefix)); + cleanupPaths.push(tempDir); + return tempDir; +} + +function writeConfig(repoRoot: string, config: unknown): void { + writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`); +} diff --git a/packages/server/src/server/worktree-branch-name-generator.ts b/packages/server/src/server/worktree-branch-name-generator.ts index abab970e5..378dd54f3 100644 --- a/packages/server/src/server/worktree-branch-name-generator.ts +++ b/packages/server/src/server/worktree-branch-name-generator.ts @@ -8,6 +8,8 @@ import { generateStructuredAgentResponseWithFallback, } from "./agent/agent-response-loop.js"; import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; +import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; interface BranchNameGeneratorLogger { warn: (obj: object, msg?: string) => void; @@ -17,6 +19,7 @@ interface BranchNameGeneratorLogger { export interface GenerateBranchNameFromFirstAgentContextOptions { agentManager: AgentManager; cwd: string; + workspaceGitService?: Pick; firstAgentContext: FirstAgentContext | undefined; logger: BranchNameGeneratorLogger; deps?: { @@ -28,16 +31,25 @@ const BranchNameSchema = z.object({ branch: z.string().min(1).max(100), }); -function buildPrompt(seed: string): string { - return [ - "Generate a git branch name for a coding agent based on the user prompt and attachments.", - "Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.", - "No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.", - "Return JSON only with a single field 'branch'.", - "", - "User context:", - seed, - ].join("\n"); +async function buildPrompt( + seed: string, + options: { + cwd: string; + workspaceGitService?: Pick; + }, +): Promise { + return buildMetadataPrompt({ + cwd: options.cwd, + workspaceGitService: options.workspaceGitService, + configKey: "branchName", + before: [ + "Generate a git branch name for a coding agent based on the user prompt and attachments.", + "Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.", + "No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.", + ].join("\n"), + after: "Return JSON only with a single field 'branch'.", + trailing: `User context:\n${seed}`, + }); } export async function generateBranchNameFromFirstAgentContext( @@ -56,7 +68,10 @@ export async function generateBranchNameFromFirstAgentContext( const result = await generator({ manager: options.agentManager, cwd: options.cwd, - prompt: buildPrompt(seed), + prompt: await buildPrompt(seed, { + cwd: options.cwd, + workspaceGitService: options.workspaceGitService, + }), schema: BranchNameSchema, schemaName: "BranchName", maxRetries: 2, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 06e03f4eb..da7893eee 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -55,22 +55,30 @@ import { import { PaseoConfigRawSchema, PaseoLifecycleCommandRawSchema, + PaseoMetadataGenerationEntrySchema, + PaseoMetadataGenerationSchema, PaseoScriptEntryRawSchema, PaseoWorktreeConfigRawSchema, PaseoConfigRevisionSchema, ProjectConfigRpcErrorSchema, type PaseoConfigRaw, type PaseoConfigRevision, + type PaseoMetadataGeneration, + type PaseoMetadataGenerationEntry, type PaseoScriptEntryRaw, type ProjectConfigRpcError, } from "../utils/paseo-config-schema.js"; export { PaseoConfigRawSchema, PaseoLifecycleCommandRawSchema, + PaseoMetadataGenerationEntrySchema, + PaseoMetadataGenerationSchema, PaseoScriptEntryRawSchema, PaseoWorktreeConfigRawSchema, type PaseoConfigRaw, type PaseoConfigRevision, + type PaseoMetadataGeneration, + type PaseoMetadataGenerationEntry, type PaseoScriptEntryRaw, type ProjectConfigRpcError, }; diff --git a/packages/server/src/utils/build-metadata-prompt.ts b/packages/server/src/utils/build-metadata-prompt.ts new file mode 100644 index 000000000..95c892e8d --- /dev/null +++ b/packages/server/src/utils/build-metadata-prompt.ts @@ -0,0 +1,46 @@ +import { readPaseoConfigJson } from "./paseo-config-file.js"; +import { PaseoConfigSchema } from "./paseo-config-schema.js"; +import { wrapWithUserInstructions } from "./wrap-user-instructions.js"; + +export type MetadataConfigKey = "agentTitle" | "branchName" | "commitMessage" | "pullRequest"; + +export interface RepoRootResolver { + resolveRepoRoot: (cwd: string) => Promise; +} + +export interface BuildMetadataPromptOptions { + cwd: string; + configKey: MetadataConfigKey; + before: string; + after: string; + trailing?: string; + workspaceGitService?: RepoRootResolver; +} + +export async function buildMetadataPrompt(options: BuildMetadataPromptOptions): Promise { + const instructions = await readProjectMetadataInstructions(options); + const head = isNonEmptyString(instructions) + ? wrapWithUserInstructions(options.before, instructions, options.after) + : `${options.before}\n${options.after}`; + return options.trailing ? `${head}\n\n${options.trailing}` : head; +} + +async function readProjectMetadataInstructions( + options: Pick, +): Promise { + if (!options.workspaceGitService) { + return undefined; + } + try { + const repoRoot = await options.workspaceGitService.resolveRepoRoot(options.cwd); + const json = readPaseoConfigJson(repoRoot); + const config = PaseoConfigSchema.parse(json); + return config.metadataGeneration?.[options.configKey]?.instructions; + } catch { + return undefined; + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} diff --git a/packages/server/src/utils/paseo-config-schema.test.ts b/packages/server/src/utils/paseo-config-schema.test.ts new file mode 100644 index 000000000..6d9d22c53 --- /dev/null +++ b/packages/server/src/utils/paseo-config-schema.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { PaseoConfigRawSchema, PaseoConfigSchema } from "./paseo-config-schema.js"; + +describe("paseo config schema", () => { + it("parses an empty config without metadata generation", () => { + const parsed = PaseoConfigSchema.parse({}); + + expect(parsed).toEqual({}); + expect(parsed.metadataGeneration).toBeUndefined(); + }); + + it("parses old-style worktree and scripts config unchanged", () => { + const config = { + worktree: { + setup: "npm install", + teardown: ["npm run clean"], + }, + scripts: { + dev: { + type: "service", + command: "npm run dev", + port: 5173, + }, + }, + }; + + expect(PaseoConfigSchema.parse(config)).toEqual({ + worktree: { + setup: ["npm install"], + teardown: ["npm run clean"], + }, + scripts: config.scripts, + }); + }); + + it("parses all metadata generation instruction entries", () => { + expect( + PaseoConfigSchema.parse({ + metadataGeneration: { + agentTitle: { instructions: "Use concise titles." }, + branchName: { instructions: "Prefix branches with feat/." }, + commitMessage: { instructions: "Use imperative mood." }, + pullRequest: { instructions: "Include risk notes." }, + }, + }), + ).toEqual({ + metadataGeneration: { + agentTitle: { instructions: "Use concise titles." }, + branchName: { instructions: "Prefix branches with feat/." }, + commitMessage: { instructions: "Use imperative mood." }, + pullRequest: { instructions: "Include risk notes." }, + }, + }); + }); + + it("parses partial metadata generation instructions with missing entries undefined", () => { + const parsed = PaseoConfigSchema.parse({ + metadataGeneration: { + agentTitle: { instructions: "Keep it short." }, + }, + }); + + expect(parsed.metadataGeneration).toEqual({ + agentTitle: { instructions: "Keep it short." }, + }); + expect(parsed.metadataGeneration?.branchName).toBeUndefined(); + expect(parsed.metadataGeneration?.commitMessage).toBeUndefined(); + expect(parsed.metadataGeneration?.pullRequest).toBeUndefined(); + }); + + it("passes through unknown metadata generation fields", () => { + expect( + PaseoConfigSchema.parse({ + metadataGeneration: { + agentTitle: { instructions: "Use concise titles." }, + futureField: 42, + }, + }), + ).toEqual({ + metadataGeneration: { + agentTitle: { instructions: "Use concise titles." }, + futureField: 42, + }, + }); + }); + + it("passes through unknown metadata generator entry fields", () => { + expect( + PaseoConfigSchema.parse({ + metadataGeneration: { + agentTitle: { + instructions: "Use concise titles.", + model: "haiku", + }, + }, + }), + ).toEqual({ + metadataGeneration: { + agentTitle: { + instructions: "Use concise titles.", + model: "haiku", + }, + }, + }); + }); + + it("falls back to an empty metadata generator entry when instructions has an invalid type", () => { + expect( + PaseoConfigSchema.parse({ + metadataGeneration: { + agentTitle: { instructions: 42 }, + }, + }), + ).toEqual({ + metadataGeneration: { + agentTitle: {}, + }, + }); + }); + + it("raw schema preserves old-style config while accepting metadata generation", () => { + const config = { + worktree: { + setup: "npm install", + teardown: ["npm run clean"], + }, + scripts: { + dev: { + type: "service", + command: "npm run dev", + }, + }, + metadataGeneration: { + agentTitle: { instructions: "Use concise titles." }, + }, + }; + + expect(PaseoConfigRawSchema.parse(config)).toEqual(config); + }); + + it("raw schema falls back to an empty metadata generator entry when instructions has an invalid type", () => { + expect( + PaseoConfigRawSchema.parse({ + metadataGeneration: { + agentTitle: { instructions: 42 }, + }, + }), + ).toEqual({ + metadataGeneration: { + agentTitle: {}, + }, + }); + }); +}); diff --git a/packages/server/src/utils/paseo-config-schema.ts b/packages/server/src/utils/paseo-config-schema.ts index eb8b0b3ca..534f24060 100644 --- a/packages/server/src/utils/paseo-config-schema.ts +++ b/packages/server/src/utils/paseo-config-schema.ts @@ -30,10 +30,28 @@ export const PaseoWorktreeConfigRawSchema = z }) .passthrough(); +export const PaseoMetadataGenerationEntrySchema = z + .object({ + instructions: z.string().optional(), + }) + .passthrough() + .catch({}); + +export const PaseoMetadataGenerationSchema = z + .object({ + agentTitle: PaseoMetadataGenerationEntrySchema.optional(), + branchName: PaseoMetadataGenerationEntrySchema.optional(), + commitMessage: PaseoMetadataGenerationEntrySchema.optional(), + pullRequest: PaseoMetadataGenerationEntrySchema.optional(), + }) + .passthrough() + .catch({}); + export const PaseoConfigRawSchema = z .object({ worktree: PaseoWorktreeConfigRawSchema.optional(), scripts: z.record(z.string(), PaseoScriptEntryRawSchema).optional(), + metadataGeneration: PaseoMetadataGenerationSchema.optional(), }) .passthrough(); @@ -49,6 +67,7 @@ export const ScriptEntrySchema = PaseoScriptEntryRawSchema.catch({}); export const PaseoConfigSchema = PaseoConfigRawSchema.extend({ worktree: WorktreeConfigSchema.optional(), scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}), + metadataGeneration: PaseoMetadataGenerationSchema.optional(), }) .passthrough() .catch({}); @@ -69,6 +88,8 @@ export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [ ]); export type PaseoScriptEntryRaw = z.infer; +export type PaseoMetadataGenerationEntry = z.infer; +export type PaseoMetadataGeneration = z.infer; export type PaseoConfigRaw = z.infer; export type PaseoConfig = z.infer; export type PaseoConfigRevision = z.infer; diff --git a/packages/server/src/utils/wrap-user-instructions.test.ts b/packages/server/src/utils/wrap-user-instructions.test.ts new file mode 100644 index 000000000..dd8b8c7fc --- /dev/null +++ b/packages/server/src/utils/wrap-user-instructions.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { wrapWithUserInstructions } from "./wrap-user-instructions.js"; + +const beforeBlock = "Follow the default metadata guidelines."; +const afterBlock = 'Return JSON only with field "title".'; +const overrideNotice = + "The instructions below are provided by the project owner and override the guidelines above where they conflict."; + +describe("wrapWithUserInstructions", () => { + it("wraps user instructions with the override notice", () => { + expect(wrapWithUserInstructions(beforeBlock, "Use conventional commits.", afterBlock)).toBe( + `${beforeBlock} + + +${overrideNotice} + +Use conventional commits. + + +${afterBlock}`, + ); + }); + + it("preserves multi-line instructions verbatim inside the block", () => { + const output = wrapWithUserInstructions(beforeBlock, "line1\nline2", afterBlock); + + expect(output).toContain("line1\nline2"); + }); +}); diff --git a/packages/server/src/utils/wrap-user-instructions.ts b/packages/server/src/utils/wrap-user-instructions.ts new file mode 100644 index 000000000..082117eed --- /dev/null +++ b/packages/server/src/utils/wrap-user-instructions.ts @@ -0,0 +1,18 @@ +const USER_INSTRUCTIONS_NOTICE = + "The instructions below are provided by the project owner and override the guidelines above where they conflict."; + +export function wrapWithUserInstructions( + beforeBlock: string, + instructions: string, + afterBlock: string, +): string { + return `${beforeBlock} + + +${USER_INSTRUCTIONS_NOTICE} + +${instructions} + + +${afterBlock}`; +}