feat: project-level prompts for metadata generation (#836)

* 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 `<user-instructions>` 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.
This commit is contained in:
Mohamed Boudra
2026-05-09 18:36:29 +08:00
committed by GitHub
parent 8b09846f98
commit 030e163fb8
16 changed files with 1240 additions and 39 deletions

View File

@@ -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<MetadataPromptKey, MetadataPromptField> = {
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({
</View>
</SettingsSection>
{METADATA_PROMPT_KEYS.map((key) => (
<MetadataPromptSection
key={key}
promptKey={key}
value={draft.metadataPrompts[key]}
onChange={handleMetadataPromptChange}
/>
))}
{isStale ? (
<View style={styles.calloutWrap}>
<Alert
@@ -767,6 +821,36 @@ function HostPickerItem({ host, isSelected, onSelectHost }: HostPickerItemProps)
);
}
interface MetadataPromptSectionProps {
promptKey: MetadataPromptKey;
value: string;
onChange: (key: MetadataPromptKey, text: string) => void;
}
function MetadataPromptSection({ promptKey, value, onChange }: MetadataPromptSectionProps) {
const meta = METADATA_PROMPT_FIELDS[promptKey];
const handleChange = useCallback(
(text: string) => onChange(promptKey, text),
[onChange, promptKey],
);
return (
<SettingsSection title={meta.title} testID={meta.sectionTestID}>
<View style={settingsStyles.card}>
<TextInput
testID={meta.inputTestID}
accessibilityLabel={meta.title}
multiline
value={value}
onChangeText={handleChange}
placeholder={meta.placeholder}
placeholderTextColor={styles.placeholderColor.color}
style={styles.lifecycleInput}
/>
</View>
</SettingsSection>
);
}
interface ScriptRowProps {
script: ProjectScriptDraft;
isFirst: boolean;

View File

@@ -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/<slug>" },
commitMessage: { instructions: "Conventional commits." },
pullRequest: { instructions: "Include risk notes." },
},
});
expect(draft.metadataPrompts).toEqual({
agentTitle: "Use mb/.",
branchName: "feat/<slug>",
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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: {

View File

@@ -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<MetadataPromptKey, string>;
metadataGenerationBase: PaseoMetadataGeneration | undefined;
}
interface LifecycleProjection {
@@ -88,6 +103,15 @@ function nextScriptDraftId(): string {
return `script-draft-${scriptDraftIdCounter}`;
}
function emptyMetadataPrompts(): Record<MetadataPromptKey, string> {
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<string, unknown> = {
...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<string, unknown> = { ...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<string, unknown> = { ...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;
}

View File

@@ -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<WorkspaceGitService, "resolveRepoRoot">;
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<WorkspaceGitService, "resolveRepoRoot">;
},
): Promise<string> {
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,

View File

@@ -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("<user-instructions>");
const userInstructionIndex = prompt.indexOf("Use the prefix mb/.");
const closeTagIndex = prompt.indexOf("</user-instructions>");
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),
};
}

View File

@@ -79,7 +79,10 @@ export interface AgentMcpServerOptions {
scheduleService?: ScheduleService | null;
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
github?: GitHubService;
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
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,

View File

@@ -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<typeof vi.fn>;
hasLocalBranch?: ReturnType<typeof vi.fn>;
resolveRepoRemoteUrl?: ReturnType<typeof vi.fn>;
resolveRepoRoot?: ReturnType<typeof vi.fn>;
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
};
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
@@ -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<string> {
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 <scope> text.",
},
},
});
const defaultRuleIndex = prompt.indexOf("Write a concise git commit message");
const openTagIndex = prompt.indexOf("<user-instructions>");
const noticeIndex = prompt.indexOf("override the guidelines above");
const userInstructionIndex = prompt.indexOf("Use conventional commits.");
const closeTagIndex = prompt.indexOf("</user-instructions>");
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<unknown> {
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<string> {
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 <ticket> text.",
},
},
});
const defaultRuleIndex = prompt.indexOf("Write a pull request title and body");
const openTagIndex = prompt.indexOf("<user-instructions>");
const noticeIndex = prompt.indexOf("override the guidelines above");
const userInstructionIndex = prompt.indexOf("Use a terse title.");
const closeTagIndex = prompt.indexOf("</user-instructions>");
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() };

View File

@@ -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,

View File

@@ -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("<user-instructions>");
const userInstructionIndex = prompt.indexOf("Use the prefix mb/.");
const closeTagIndex = prompt.indexOf("</user-instructions>");
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`);
}

View File

@@ -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<WorkspaceGitService, "resolveRepoRoot">;
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<WorkspaceGitService, "resolveRepoRoot">;
},
): Promise<string> {
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,

View File

@@ -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,
};

View File

@@ -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<string>;
}
export interface BuildMetadataPromptOptions {
cwd: string;
configKey: MetadataConfigKey;
before: string;
after: string;
trailing?: string;
workspaceGitService?: RepoRootResolver;
}
export async function buildMetadataPrompt(options: BuildMetadataPromptOptions): Promise<string> {
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<BuildMetadataPromptOptions, "cwd" | "configKey" | "workspaceGitService">,
): Promise<string | undefined> {
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() !== "";
}

View File

@@ -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: {},
},
});
});
});

View File

@@ -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<typeof PaseoScriptEntryRawSchema>;
export type PaseoMetadataGenerationEntry = z.infer<typeof PaseoMetadataGenerationEntrySchema>;
export type PaseoMetadataGeneration = z.infer<typeof PaseoMetadataGenerationSchema>;
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;

View File

@@ -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}
<user-instructions>
${overrideNotice}
Use conventional commits.
</user-instructions>
${afterBlock}`,
);
});
it("preserves multi-line instructions verbatim inside the block", () => {
const output = wrapWithUserInstructions(beforeBlock, "line1\nline2", afterBlock);
expect(output).toContain("line1\nline2");
});
});

View File

@@ -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>
${USER_INSTRUCTIONS_NOTICE}
${instructions}
</user-instructions>
${afterBlock}`;
}