FN-7180: add configurable PR metadata prompts

Add project-scoped guidance fields for AI-generated Create PR metadata.

- Store PR title and description prompt guidance as project settings only.
- Surface editable guidance textareas in Project Models settings.
- Append trimmed guidance to the PR metadata system prompt without changing defaults for blank values.
- Document the settings and add release notes/tests for persistence, UI, and prompt generation.

Files changed:
 .changeset/fn-7180-pr-prompt-settings.md           |  7 ++
 docs/dashboard-guide.md                            |  1 +
 docs/settings-reference.md                         |  2 +
 .../core/src/__tests__/settings-defaults.test.ts   | 11 ++-
 packages/core/src/__tests__/store-settings.test.ts | 19 +++++
 packages/core/src/settings-schema.ts               |  2 +
 packages/core/src/types.ts                         | 12 ++++
 .../app/__tests__/settings-sections.test.tsx       | 54 ++++++++++++++
 .../dashboard/app/components/SettingsModal.tsx     |  4 ++
 .../settings/sections/ProjectModelsSection.tsx     | 12 ++++
 .../src/__tests__/pr-metadata-generator.test.ts    | 84 ++++++++++++++++++++++
 packages/dashboard/src/pr-metadata-generator.ts    | 23 ++++--
 12 files changed, 225 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-7180

Fusion-Task-Lineage: 9bdc42d5-ffd3-4208-b64a-96d142cc1dc8

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-28 00:28:13 -07:00
parent 01fe47d50f
commit 9c207fd286
12 changed files with 225 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add project settings to customize the AI prompts for PR title and description generation.
category: feature
dev: New project settings `prTitlePromptInstructions` / `prDescriptionPromptInstructions` (default undefined) are appended to the Create PR dialog's metadata-generation system prompt in generatePrMetadata.

View File

@@ -1004,6 +1004,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
- The **Create Pull Request** modal is a floating pop-out like Plan Mission, New Task, and Automations: drag its header or resize from desktop edges/corners, while mobile keeps the full-screen dialog layout. Close it with **X**, **Cancel**, or **Escape**; stray clicks inside or outside the floating shell do not dismiss it.
- The modal shell renders immediately: preflight checks and PR options load independently of AI-generated title/body metadata, so slow AI suggestions no longer block base-branch selection, diagnostics, or manual PR authoring. The **Diff & commit preview** section starts collapsed and can be expanded on demand.
- AI title/body generation in the dialog is bounded to 15 seconds and is canceled if the request disconnects; on timeout/cancel, Fusion falls back to deterministic task-based PR title/body content instead of leaving the spinner stuck forever.
- Project Settings → Project Models includes optional **PR title prompt guidance** and **PR description prompt guidance** fields. Blank fields preserve the default Create PR metadata prompt; populated fields append guidance for the generated title or body sections.
- The **Artifacts** tab combines task documents written by agents or users with task-scoped registered media artifacts. The gallery uses thumbnail-first image/video cards, image and video previews can expand into a dismissible full-size lightbox, video and audio use native controls, document artifacts show text previews, and generic artifacts open through their media URL.
- The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread.
- Review comments hide GitHub template HTML comments in both Markdown and Plain modes, show author avatars or User/Bot fallbacks, label Human vs Bot/agent authors, and include All/Human/Bot filtering.

View File

@@ -584,6 +584,8 @@ Default notes:
| `titleSummarizerModelId` | `string` | `undefined` | Model ID for title summarization. |
| `titleSummarizerFallbackProvider` | `string` | `undefined` | Fallback provider for title summarization. |
| `titleSummarizerFallbackModelId` | `string` | `undefined` | Fallback model ID for title summarization. |
| `prTitlePromptInstructions` | `string` | `undefined` | Optional project guidance appended to the Create PR dialog's AI metadata system prompt for the generated PR title. Blank or whitespace-only values are treated as unset and keep the default prompt behavior. |
| `prDescriptionPromptInstructions` | `string` | `undefined` | Optional project guidance appended to the Create PR dialog's AI metadata system prompt for generated PR body fields (`summary`, `changes`, `testing`). Blank or whitespace-only values are treated as unset and keep the default prompt behavior. |
| `scripts` | `Record<string, string>` | `undefined` | Named script map used by script-mode workflow steps and setup hooks. |
| `setupScript` | `string` | `undefined` | Script key from `scripts` to run before task execution. |
| `insightExtractionEnabled` | `boolean` | `false` | Enable scheduled memory insight extraction. |

View File

@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_MAX_AUTO_MERGE_RETRIES, resolveMaxAutoMergeRetries } from "../in-review-stall.js";
import { isExperimentalFeatureEnabled } from "../experimental-features.js";
import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS } from "../settings-schema.js";
import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "../settings-schema.js";
import { isWorkflowColumnsEnabled } from "../workflow-columns-settings.js";
import {
__resetLegacyCwdMainWarningForTests,
@@ -99,6 +99,15 @@ describe("settings defaults invariants", () => {
expect("githubCloseSourceIssueOnDone" in DEFAULT_GLOBAL_SETTINGS).toBe(false);
});
it("defaults PR metadata prompt guidance to project-scoped unset strings", () => {
expect(DEFAULT_PROJECT_SETTINGS.prTitlePromptInstructions).toBeUndefined();
expect(DEFAULT_PROJECT_SETTINGS.prDescriptionPromptInstructions).toBeUndefined();
expect(PROJECT_SETTINGS_KEYS).toContain("prTitlePromptInstructions");
expect(PROJECT_SETTINGS_KEYS).toContain("prDescriptionPromptInstructions");
expect(GLOBAL_SETTINGS_KEYS).not.toContain("prTitlePromptInstructions");
expect(GLOBAL_SETTINGS_KEYS).not.toContain("prDescriptionPromptInstructions");
});
it("defaults AI merge commit summaries to enabled", () => {
// FN-5642/FN-5644 intentionally default this on for subject + body summary coverage.
expect(DEFAULT_PROJECT_SETTINGS.useAiMergeCommitSummary).toBe(true);

View File

@@ -48,6 +48,25 @@ describe("TaskStore", () => {
});
});
describe("PR metadata prompt guidance settings", () => {
it("round-trips title and description prompt guidance via project settings", async () => {
await harness.store().updateSettings({
prTitlePromptInstructions: "Use release-note titles.",
prDescriptionPromptInstructions: "Group body bullets by operator impact.",
});
const settings = await harness.store().getSettings();
expect(settings.prTitlePromptInstructions).toBe("Use release-note titles.");
expect(settings.prDescriptionPromptInstructions).toBe("Group body bullets by operator impact.");
const { project, global } = await harness.store().getSettingsByScope();
expect(project.prTitlePromptInstructions).toBe("Use release-note titles.");
expect(project.prDescriptionPromptInstructions).toBe("Group body bullets by operator impact.");
expect("prTitlePromptInstructions" in global).toBe(false);
expect("prDescriptionPromptInstructions" in global).toBe(false);
});
});
describe("worktreeCopyFiles setting", () => {
it("round-trips populated copy-file paths via getSettings and project serialization", async () => {
await harness.store().updateSettings({ worktreeCopyFiles: [".env", "config/local.env", "packages/api/.env.test"] });

View File

@@ -483,6 +483,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
titleSummarizerModelId: undefined,
titleSummarizerFallbackProvider: undefined,
titleSummarizerFallbackModelId: undefined,
prTitlePromptInstructions: undefined,
prDescriptionPromptInstructions: undefined,
scripts: undefined,
setupScript: undefined,
insightExtractionEnabled: false,

View File

@@ -4229,6 +4229,18 @@ export interface ProjectSettings {
* planning fallback, then global fallback. Must be set together with
* `titleSummarizerFallbackProvider`. */
titleSummarizerFallbackModelId?: string;
/**
* FNXC:PrMetadataGeneration 2026-06-27-00:00:
* Project operators can add title-specific guidance to the Create PR metadata prompt without replacing the strict JSON schema contract. Blank or whitespace-only values are treated as unset so the default prompt remains byte-for-byte unchanged.
* Optional project-scoped guidance appended to the PR metadata system prompt for the generated `title` field. Default: undefined.
*/
prTitlePromptInstructions?: string;
/**
* FNXC:PrMetadataGeneration 2026-06-27-00:00:
* Project operators can add body-specific guidance to the Create PR metadata prompt without replacing the strict JSON schema contract. Blank or whitespace-only values are treated as unset so the default prompt remains byte-for-byte unchanged.
* Optional project-scoped guidance appended to the PR metadata system prompt for the generated `summary`, `changes`, and `testing` fields. Default: undefined.
*/
prDescriptionPromptInstructions?: string;
/** Named scripts that can be referenced by setupScript or other automation.
* A map of script name to shell command. */
scripts?: Record<string, string>;

View File

@@ -17,6 +17,7 @@ import { AppearanceSection } from "../components/settings/sections/AppearanceSec
import { NotificationsSection } from "../components/settings/sections/NotificationsSection";
import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection";
import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub";
import { ProjectModelsSection } from "../components/settings/sections/ProjectModelsSection";
import { PromptsSection } from "../components/settings/sections/PromptsSection";
import { SecretsSection } from "../components/settings/sections/SecretsSection";
import { WorktreesSection } from "../components/settings/sections/WorktreesSection";
@@ -218,6 +219,59 @@ describe("WorktreesSection", () => {
});
});
describe("ProjectModelsSection", () => {
const models = {
modelLanes: [],
getLaneStatus: () => "inherited" as const,
getLaneValue: () => "",
updateLaneValue: vi.fn(),
resetLaneValue: vi.fn(),
availableModels: [],
modelsLoading: false,
favoriteProviders: [],
favoriteModels: [],
onToggleFavorite: vi.fn(),
onToggleModelFavorite: vi.fn(),
editingPresetId: null,
setEditingPresetId: vi.fn(),
presetDraft: null,
setPresetDraft: vi.fn(),
onSavePresetDraft: vi.fn(),
confirmDelete: vi.fn(),
};
it("renders PR prompt guidance textareas and emits edits through setForm", () => {
function ProjectModelsHost() {
const [form, setFormState] = useState<SettingsFormState>({
prTitlePromptInstructions: "Keep it short.",
prDescriptionPromptInstructions: "Mention testing.",
} as SettingsFormState);
return (
<ProjectModelsSection
scopeBanner={null}
form={form}
setForm={setFormState as never}
models={models}
addToast={vi.fn()}
/>
);
}
render(<ProjectModelsHost />);
const titleField = screen.getByLabelText("PR title prompt guidance") as HTMLTextAreaElement;
const descriptionField = screen.getByLabelText("PR description prompt guidance") as HTMLTextAreaElement;
expect(titleField.value).toBe("Keep it short.");
expect(descriptionField.value).toBe("Mention testing.");
fireEvent.change(titleField, { target: { value: "Use release style." } });
fireEvent.change(descriptionField, { target: { value: "Group by impact." } });
expect(titleField.value).toBe("Use release style.");
expect(descriptionField.value).toBe("Group by impact.");
});
});
describe("PromptsSection", () => {
it("renders the title and mounts AgentPromptsManager", () => {
render(

View File

@@ -753,6 +753,8 @@ export function SettingsModal({
webhookUrl: undefined,
webhookFormat: "generic",
webhookEvents: undefined,
prTitlePromptInstructions: "",
prDescriptionPromptInstructions: "",
});
const [loading, setLoading] = useState(true);
// Guards the Save action against double-submit (rapid clicks / Enter) while the
@@ -2444,6 +2446,8 @@ export function SettingsModal({
taskPrefix: form.taskPrefix?.trim() || undefined,
githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined,
githubAuthToken: form.githubAuthToken?.trim() || undefined,
prTitlePromptInstructions: form.prTitlePromptInstructions?.trim() || undefined,
prDescriptionPromptInstructions: form.prDescriptionPromptInstructions?.trim() || undefined,
overlapIgnorePaths: (form.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0),
worktreeCopyFiles: normalizedWorktreeCopyFiles.length > 0 || initialScopedValues?.project?.worktreeCopyFiles !== undefined
? normalizedWorktreeCopyFiles

View File

@@ -475,6 +475,18 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
{(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) && (<p className="settings-description">
{t("settings.movedStub.summarizerModelInline", "The summarization model lane above controls title auto-summarization, merge commit summaries, GitHub tracking titles, and PR metadata generation.")}
</p>)}
<div className="form-group">
<label htmlFor="prTitlePromptInstructions">{t("settings.projectModels.prTitlePromptInstructions", "PR title prompt guidance")}</label>
<textarea id="prTitlePromptInstructions" value={form.prTitlePromptInstructions || ""} onChange={(e) => setForm((f) => ({ ...f, prTitlePromptInstructions: e.target.value }))} rows={3} placeholder={t("settings.projectModels.prTitlePromptInstructionsPlaceholder", "Example: Use conventional-commit style and keep titles under 72 characters.")}/>
<small>{t("settings.projectModels.prTitlePromptInstructionsHelp", "Guides the AI-generated Create PR title. Leave blank to use the default PR metadata prompt.")}</small>
</div>
<div className="form-group">
<label htmlFor="prDescriptionPromptInstructions">{t("settings.projectModels.prDescriptionPromptInstructions", "PR description prompt guidance")}</label>
<textarea id="prDescriptionPromptInstructions" value={form.prDescriptionPromptInstructions || ""} onChange={(e) => setForm((f) => ({ ...f, prDescriptionPromptInstructions: e.target.value }))} rows={4} placeholder={t("settings.projectModels.prDescriptionPromptInstructionsPlaceholder", "Example: Emphasize operator-facing behavior and list verification commands exactly.")}/>
<small>{t("settings.projectModels.prDescriptionPromptInstructionsHelp", "Guides the AI-generated Create PR summary, changes, and testing sections. Leave blank to use the default PR metadata prompt.")}</small>
</div>
</>);
}
export default ProjectModelsSection;

View File

@@ -66,6 +66,17 @@ function expectFallbackBody(body: string) {
expect(body).toContain("Closes FN-4991");
}
function capturedSystemPrompt(): string {
const call = vi.mocked(createFnAgent).mock.calls.at(-1)?.[0] as { systemPrompt?: string } | undefined;
return call?.systemPrompt ?? "";
}
const BASE_PR_METADATA_SYSTEM_PROMPT = [
"Generate GitHub PR metadata.",
"Respond with strict JSON only.",
"Schema: {title, summary, changes, testing, linkedTask}",
].join("\n");
describe("generatePrMetadata", () => {
let repoRoot: string;
@@ -129,6 +140,79 @@ describe("generatePrMetadata", () => {
expect(promptMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }));
});
it("keeps the base PR metadata system prompt unchanged when guidance is unset", async () => {
const result = await generatePrMetadata({
task: createTask(),
repoRoot,
settings: {} as never,
});
expect(capturedSystemPrompt()).toBe(BASE_PR_METADATA_SYSTEM_PROMPT);
expect(capturedSystemPrompt()).toContain("Schema: {title, summary, changes, testing, linkedTask}");
expect(result.title).toBe("feat: add routes");
expect(result.body).toContain("## Summary");
});
it("adds only title guidance when title instructions are set", async () => {
const result = await generatePrMetadata({
task: createTask(),
repoRoot,
settings: { prTitlePromptInstructions: " Use conventional commit style. " } as never,
});
expect(capturedSystemPrompt()).toBe(`${BASE_PR_METADATA_SYSTEM_PROMPT}\nTitle guidance: Use conventional commit style.`);
expect(capturedSystemPrompt()).not.toContain("Description guidance:");
expect(result.title).toBe("feat: add routes");
expect(result.body).toContain("- pnpm test");
});
it("adds only description guidance when description instructions are set", async () => {
const result = await generatePrMetadata({
task: createTask(),
repoRoot,
settings: { prDescriptionPromptInstructions: " Mention user-facing behavior. " } as never,
});
expect(capturedSystemPrompt()).toBe(`${BASE_PR_METADATA_SYSTEM_PROMPT}\nDescription guidance: Mention user-facing behavior.`);
expect(capturedSystemPrompt()).not.toContain("Title guidance:");
expect(result.title).toBe("feat: add routes");
expect(result.body).toContain("Summary text");
});
it("adds both title and description guidance when both instructions are set", async () => {
const result = await generatePrMetadata({
task: createTask(),
repoRoot,
settings: {
prTitlePromptInstructions: "Use release-note tone.",
prDescriptionPromptInstructions: "Group changes by operator impact.",
} as never,
});
expect(capturedSystemPrompt()).toBe([
BASE_PR_METADATA_SYSTEM_PROMPT,
"Title guidance: Use release-note tone.",
"Description guidance: Group changes by operator impact.",
].join("\n"));
expect(result.title).toBe("feat: add routes");
expect(result.body).toContain("## Changes");
});
it("treats whitespace-only PR prompt guidance as unset", async () => {
const result = await generatePrMetadata({
task: createTask(),
repoRoot,
settings: {
prTitlePromptInstructions: " \n\t ",
prDescriptionPromptInstructions: " ",
} as never,
});
expect(capturedSystemPrompt()).toBe(BASE_PR_METADATA_SYSTEM_PROMPT);
expect(result.title).toBe("feat: add routes");
expect(result.body).toContain("## Testing");
});
it("fills known sections when template exists and preserves unknown headings", async () => {
mkdirSync(join(repoRoot, ".github"), { recursive: true });
writeFileSync(

View File

@@ -270,6 +270,23 @@ export async function generatePrMetadata(input: {
const template = templateExists ? await raceWithAbort(readFile(templatePath, "utf8"), combinedSignal) : "";
const model = resolveTitleSummarizerSettingsModel(settings as Partial<Settings>);
const systemPrompt = [
"Generate GitHub PR metadata.",
"Respond with strict JSON only.",
"Schema: {title, summary, changes, testing, linkedTask}",
];
const titleGuidance = settings.prTitlePromptInstructions?.trim();
const descriptionGuidance = settings.prDescriptionPromptInstructions?.trim();
/*
* FNXC:PrMetadataGeneration 2026-06-27-00:00:
* Custom project guidance augments the Create PR metadata generator only after trimming; unset or whitespace-only values must leave the base three-line strict-JSON system prompt byte-for-byte unchanged so existing parse/fallback behavior remains stable.
*/
if (titleGuidance) {
systemPrompt.push(`Title guidance: ${titleGuidance}`);
}
if (descriptionGuidance) {
systemPrompt.push(`Description guidance: ${descriptionGuidance}`);
}
const mcpServers = (await raceWithAbort(resolveMcpServersForStore(store ?? {}), combinedSignal)).servers;
let aiText = "";
const { session } = await raceWithAbort(createFnAgent({
@@ -282,11 +299,7 @@ export async function generatePrMetadata(input: {
mcpServers,
defaultProvider: model.provider,
defaultModelId: model.modelId,
systemPrompt: [
"Generate GitHub PR metadata.",
"Respond with strict JSON only.",
"Schema: {title, summary, changes, testing, linkedTask}",
].join("\n"),
systemPrompt: systemPrompt.join("\n"),
onText: (delta: string) => {
aiText += delta;
},