From 3874e62c34d72db8e2b09ffc12b088cbc1dc5332 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:47:47 -0700 Subject: [PATCH] fix(naming): keep the built-in branch-name prompt general The shipped auto-rename prompt no longer hard-codes style rules (word count, kebab-case, no prefixes). Users can already override naming via Source Control AI instructions and the branch-name command template; a prescriptive default fought those overrides. Git-safe sanitization still runs after generation. --- .../first-work-branch-rename.test.ts | 2 +- .../agent-hooks/first-work-branch-rename.ts | 5 +- .../commit-message-text-generation.test.ts | 3 +- src/shared/branch-name-from-work.test.ts | 18 ++++- src/shared/branch-name-from-work.ts | 66 +++++++++---------- 5 files changed, 53 insertions(+), 41 deletions(-) diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index c1f46a602fb..1df5dbba930 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -140,7 +140,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { }) it('strips a prefix the model leaked into the slug from both branch and display name', async () => { - // Model ignored "no prefixes" and echoed `you/worktree-spinner`, which the + // Model echoed `you/worktree-spinner`, which the // sanitizer folds to `you-worktree-spinner`; without stripping it would // double-prefix the branch (`you/you-...`) and show "You worktree spinner". generateBranchNameMock.mockResolvedValue({ success: true, slug: 'you-worktree-spinner' }) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index c6e7fb8d10e..d669192907f 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -283,9 +283,8 @@ async function runAutoRename( const username = provider ? (await getSshGitUsername(provider, repo.path)) || null : (await resolveLocalGitUsername(repo.path)) || null - // The model is told not to add a prefix, but sometimes echoes the configured - // one (e.g. `tmchow/...`); strip it so it doesn't double-prefix the branch or - // leak into the display name. + // The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip + // it so it doesn't double-prefix the branch or leak into the display name. const slug = stripConfiguredBranchPrefix( generated.slug, getConfiguredBranchPrefix(settings, username) diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 4464bb8f4ed..6bf37931a29 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -1879,8 +1879,9 @@ describe('generateBranchNameFromContext', () => { } ) - expect(prompt).toContain('Additional user prompt:') + expect(prompt.startsWith('Prefer auth terminology.')).toBe(true) expect(prompt).toContain('Prefer auth terminology.') + expect(prompt).not.toContain('Additional user prompt:') }) }) diff --git a/src/shared/branch-name-from-work.test.ts b/src/shared/branch-name-from-work.test.ts index a30c324cbfe..2c66923dd6c 100644 --- a/src/shared/branch-name-from-work.test.ts +++ b/src/shared/branch-name-from-work.test.ts @@ -100,6 +100,16 @@ describe('buildBranchNamePrompt', () => { expect(prompt).not.toContain("Agent's initial response") }) + it('keeps the default prompt general without style rules', () => { + const prompt = buildBranchNamePrompt({ firstPrompt: 'Add a logout button' }) + expect(prompt).toContain('Generate a short git branch name') + expect(prompt).toContain('Output ONLY the branch name on a single line') + expect(prompt).not.toContain('Rules:') + expect(prompt).not.toMatch(/kebab-case/i) + expect(prompt).not.toMatch(/between \d+ and \d+ words/i) + expect(prompt).not.toMatch(/no prefixes/i) + }) + it('includes the assistant response when present', () => { const prompt = buildBranchNamePrompt({ firstPrompt: 'Add a logout button', @@ -109,12 +119,14 @@ describe('buildBranchNamePrompt', () => { expect(prompt).toContain("I'll wire it into the header.") }) - it('appends a custom branch-name prompt when present', () => { + it('leads with a custom naming prompt so overrides can own style', () => { const prompt = buildBranchNamePrompt( { firstPrompt: 'Add a logout button' }, 'Prefer product nouns.' ) - expect(prompt).toContain('Additional user prompt:') - expect(prompt).toContain('Prefer product nouns.') + expect(prompt.startsWith('Prefer product nouns.')).toBe(true) + expect(prompt).not.toContain('Additional user prompt:') + expect(prompt).not.toContain('Rules:') + expect(prompt).toContain('Add a logout button') }) }) diff --git a/src/shared/branch-name-from-work.ts b/src/shared/branch-name-from-work.ts index abf30f38646..eef0d338955 100644 --- a/src/shared/branch-name-from-work.ts +++ b/src/shared/branch-name-from-work.ts @@ -1,10 +1,9 @@ import { MARINE_CREATURES } from './marine-creatures' -// Why: the work-derived branch name stays short on purpose — long, sentence-like -// branches read worse than the creature name they replace. Two-to-four words is -// the sweet spot the feature targets. +// Why: post-generation sanitization still bounds the leaf so a long model dump +// cannot become an unreadable branch. The *prompt* stays general — users can +// override naming style via Source Control AI instructions / templates. export const MAX_BRANCH_NAME_WORDS = 4 -const MIN_BRANCH_NAME_WORDS = 2 const CREATURE_NAMES_LOWER = new Set(MARINE_CREATURES.map((name) => name.toLowerCase())) @@ -46,16 +45,15 @@ export function sanitizeBranchSlug(raw: string, maxWords = MAX_BRANCH_NAME_WORDS } /** - * Drop a leading prefix segment the generation model prepended despite the - * "no prefixes" instruction — e.g. with a `tmchow/` branch prefix the model - * emits `tmchow/worktree-spinner`, which `sanitizeBranchSlug` folds to - * `tmchow-worktree-spinner`. Left alone, that leaks the prefix into both the - * branch leaf (yielding a doubled `tmchow/tmchow-...`) and the humanized - * display name. Strips only when the leading segment matches the *configured* - * prefix, so a work-derived name that merely starts with a real word survives. - * Prefix-only output (the model echoed just `tmchow`) yields an empty slug so - * the caller skips the rename — otherwise it would double-prefix to - * `tmchow/tmchow`. + * Drop a leading prefix segment the generation model sometimes prepends (e.g. + * with a `tmchow/` branch prefix it emits `tmchow/worktree-spinner`, which + * `sanitizeBranchSlug` folds to `tmchow-worktree-spinner`). Left alone, that + * leaks the prefix into both the branch leaf (yielding a doubled + * `tmchow/tmchow-...`) and the humanized display name. Strips only when the + * leading segment matches the *configured* prefix, so a work-derived name that + * merely starts with a real word survives. Prefix-only output (the model + * echoed just `tmchow`) yields an empty slug so the caller skips the rename — + * otherwise it would double-prefix to `tmchow/tmchow`. */ export function stripConfiguredBranchPrefix( slug: string, @@ -100,30 +98,32 @@ export type BranchNameWorkContext = { } /** - * Build the text-generation prompt that asks the configured agent to summarize - * the work into a branch name. Kept in shared so the prompt is identical across - * local and SSH generation targets. + * Build the text-generation prompt that asks the configured agent to name the + * work. Kept in shared so the prompt is identical across local and SSH targets. + * + * Why: the shipped default stays general. Naming style is left open so users + * who override via Source Control AI instructions / the branch-name command + * template are not fighting a rigid built-in rule list. Git-safety (kebab, + * length) is enforced after generation by sanitizeBranchSlug. */ export function buildBranchNamePrompt(context: BranchNameWorkContext, customPrompt = ''): string { - const sections = [ - 'Generate a git branch name that summarizes the coding task described below.', - 'Rules:', - `- Use between ${MIN_BRANCH_NAME_WORDS} and ${MAX_BRANCH_NAME_WORDS} words.`, - '- Lowercase kebab-case only (words joined by single hyphens).', - '- No slashes, no prefixes, no quotes, no trailing punctuation.', - '- Describe the work itself, not the agent or the repository.', - '- Output ONLY the branch name on a single line, nothing else.', - '', - 'User request:', - context.firstPrompt.trim() - ] + const sections: string[] = [] + const prompt = customPrompt.trim() + // Why: when the user supplies naming guidance, lead with it so their + // override owns style rather than sitting under a prescriptive default. + if (prompt) { + sections.push(prompt, '') + } else { + sections.push( + 'Generate a short git branch name that summarizes the coding task described below.', + 'Output ONLY the branch name on a single line, nothing else.', + '' + ) + } + sections.push('User request:', context.firstPrompt.trim()) const assistant = context.assistantMessage?.trim() if (assistant) { sections.push('', "Agent's initial response:", assistant) } - const prompt = customPrompt.trim() - if (prompt) { - sections.push('', 'Additional user prompt:', prompt) - } return sections.join('\n') }