diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e6c2f6f12..56d420353 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed garbled casing in auto-generated session titles. `normalizeGeneratedTitle` (`packages/coding-agent/src/tiny/text.ts`) used to force Title Case via a `\b\p{Ll}` regex, capitalizing function words ("for" → "For") and amplifying stray model capitals ("dAemon" → "DAemon"). It now reconciles each title token against the user's own message: tokens typed verbatim are kept; proper nouns the user cased distinctively are restored when the model flattened them ("tinyvmm" → "TinyVMM"); lowercase words carrying a stray interior capital the user never wrote are flattened ("dAemon" → "daemon"); and model-cased PascalCase proper nouns ("GitHub", "OAuth") are left untouched. Restoration is limited to distinctively cased source tokens so a message that merely starts with "For" can't force a mid-title "for" to "For". Applies to both the local tiny-model and online pi/smol title paths. + ## [16.1.23] - 2026-06-26 ### Added diff --git a/packages/coding-agent/src/tiny/text.ts b/packages/coding-agent/src/tiny/text.ts index 6d24b1aab..ebd10a704 100644 --- a/packages/coding-agent/src/tiny/text.ts +++ b/packages/coding-agent/src/tiny/text.ts @@ -153,7 +153,7 @@ export function isLowSignalTitleInput(message: string): boolean { */ export const NO_TITLE_SENTINEL = "none"; -export function normalizeGeneratedTitle(value: string | null | undefined): string | null { +export function normalizeGeneratedTitle(value: string | null | undefined, sourceText?: string): string | null { const firstLine = value?.trim().split(/\r?\n/, 1)[0]?.trim(); if (!firstLine) return null; const title = firstLine @@ -161,9 +161,54 @@ export function normalizeGeneratedTitle(value: string | null | undefined): strin .replace(/[.!?]$/, "") .trim(); if (!title || title.toLowerCase() === NO_TITLE_SENTINEL) return null; - return titleCase(title); + return sourceText === undefined ? title : reconcileTitleCasing(title, sourceText); } -function titleCase(value: string): string { - return value.replace(/\b\p{Ll}/gu, c => c.toUpperCase()); +/** + * Reconcile a generated title's casing against the user's own message. + * + * The title prompt asks for sentence case, but small title models still mangle + * casing two ways: they sprout stray interior capitals on ordinary words + * (`daemon` → `dAemon`) and they flatten proper nouns the user cares about + * (`TinyVMM` → `tinyvmm`). The user's message is the source of truth, so per + * title token: + * 1. typed verbatim in the message → keep it (the user established the casing); + * 2. else the message has the same word with *distinctive* casing + * (`TinyVMM`, `iOS`, `API`) → adopt the user's casing (restoration); + * 3. else it's a camelCase artifact (lowercase word + stray interior capital, + * `dAemon`) the user never wrote → lowercase it; + * 4. else leave it — preserves model-cased proper nouns like `GitHub`, `OAuth`. + * + * Restoration is limited to distinctively cased source tokens so a sentence that + * merely *starts* with `For` can't force a mid-title `for` to `For`. + */ +function reconcileTitleCasing(title: string, sourceText: string): string { + const verbatim = new Set(); + const distinctive = new Map(); + for (const [token] of sourceText.matchAll(TITLE_WORD)) { + verbatim.add(token); + if (isDistinctiveCasing(token)) { + const lower = token.toLowerCase(); + if (!distinctive.has(lower)) distinctive.set(lower, token); + } + } + return title.replace(TITLE_WORD, token => { + if (verbatim.has(token)) return token; + const restored = distinctive.get(token.toLowerCase()); + if (restored) return restored; + return isCamelArtifact(token) ? token.toLowerCase() : token; + }); +} + +/** Casing richer than a leading capital — interior or repeated uppercase + * (`TinyVMM`, `iOS`, `API`). Worth restoring from the user's message. */ +function isDistinctiveCasing(token: string): boolean { + return /\p{L}\p{Lu}/u.test(token); +} + +/** A lowercase word carrying a stray interior capital (`dAemon`, `cReate`): the + * model-mangled shape we flatten when the user never wrote it. PascalCase proper + * nouns (`GitHub`, `OAuth`) start uppercase and are left untouched. */ +function isCamelArtifact(token: string): boolean { + return /^\p{Ll}/u.test(token) && /\p{Lu}/u.test(token); } diff --git a/packages/coding-agent/src/tiny/worker.ts b/packages/coding-agent/src/tiny/worker.ts index e9ff06ed7..eb276bf05 100644 --- a/packages/coding-agent/src/tiny/worker.ts +++ b/packages/coding-agent/src/tiny/worker.ts @@ -217,14 +217,14 @@ function buildPrompt(generator: TextGenerationPipeline, message: string, systemP return `${generator.tokenizer.apply_chat_template(chat, chatTemplateOptions)}${TITLE_PREFILL}`; } -function extractTinyTitle(text: string): string | null { +function extractTinyTitle(text: string, sourceText: string): string | null { const titleStart = text.lastIndexOf(TITLE_PREFILL); const withoutPrefix = titleStart >= 0 ? text.slice(titleStart + TITLE_PREFILL.length) : text; const closeIndex = withoutPrefix.indexOf(TITLE_CLOSE); const withoutClose = closeIndex >= 0 ? withoutPrefix.slice(0, closeIndex) : withoutPrefix; const tagIndex = withoutClose.indexOf("<"); const withoutTag = tagIndex >= 0 ? withoutClose.slice(0, tagIndex) : withoutClose; - return normalizeGeneratedTitle(withoutTag); + return normalizeGeneratedTitle(withoutTag, sourceText); } async function generateTitle( @@ -249,7 +249,7 @@ async function generateTitle( return_full_text: false, stopping_criteria: createStopOnTextCriteria(transformers, generator.tokenizer, TITLE_CLOSE), })) as TextGenerationStringOutput; - return extractTinyTitle(output[0]?.generated_text ?? ""); + return extractTinyTitle(output[0]?.generated_text ?? "", message); } function buildCompletionPrompt(generator: TextGenerationPipeline, promptText: string): string { diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index 88a7370d6..3f97b3242 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -246,7 +246,7 @@ export async function generateTitleOnline( return null; } - const title = normalizeGeneratedTitle(extractGeneratedTitle(response.content)); + const title = normalizeGeneratedTitle(extractGeneratedTitle(response.content), firstMessage); if (!title) { logger.debug("title-generator: no title returned", { diff --git a/packages/coding-agent/test/tiny-text.test.ts b/packages/coding-agent/test/tiny-text.test.ts index d997e0a6d..95b8e962f 100644 --- a/packages/coding-agent/test/tiny-text.test.ts +++ b/packages/coding-agent/test/tiny-text.test.ts @@ -68,9 +68,21 @@ describe("formatTitleUserMessage", () => { }); describe("normalizeGeneratedTitle", () => { - it("returns the cleaned first line of a real title", () => { - expect(normalizeGeneratedTitle('"Investigate the resolver"')).toBe("Investigate The Resolver"); - expect(normalizeGeneratedTitle("Investigate the resolver.")).toBe("Investigate The Resolver"); + it("strips surrounding quotes and trailing punctuation but preserves casing", () => { + expect(normalizeGeneratedTitle('"Investigate the resolver"')).toBe("Investigate the resolver"); + expect(normalizeGeneratedTitle("Investigate the resolver.")).toBe("Investigate the resolver"); + }); + + it("preserves the model's sentence/proper-noun casing without title-casing", () => { + // Regression: the normalizer used to force Title Case, capitalizing function + // words ("for" → "For") and clobbering proper nouns the model cased right. + expect(normalizeGeneratedTitle("Docker client/daemon for TinyVMM")).toBe("Docker client/daemon for TinyVMM"); + }); + + it("preserves model casing verbatim when no source message is provided", () => { + // Without the user's message there is nothing to reconcile against, so the + // model's output is kept as-is (no title-casing, no flattening). + expect(normalizeGeneratedTitle("Docker client/dAemon for tinyvmm")).toBe("Docker client/dAemon for tinyvmm"); }); it("treats the bare none sentinel as no title (case/punctuation-insensitive)", () => { @@ -81,7 +93,7 @@ describe("normalizeGeneratedTitle", () => { }); it("keeps a title that merely contains the word none", () => { - expect(normalizeGeneratedTitle("Explain python None keyword")).toBe("Explain Python None Keyword"); + expect(normalizeGeneratedTitle("Explain Python None keyword")).toBe("Explain Python None keyword"); }); it("returns null for empty or whitespace-only output", () => { @@ -91,6 +103,46 @@ describe("normalizeGeneratedTitle", () => { }); }); +describe("normalizeGeneratedTitle source-aware casing", () => { + it("flattens a stray interior capital the user never typed", () => { + // "dAemon" is a model artifact; the user's message has no such token. + expect(normalizeGeneratedTitle("Docker client/dAemon for tinyvmm", "build a docker daemon for tinyvmm")).toBe( + "Docker client/daemon for tinyvmm", + ); + }); + + it("keeps odd casing the user typed verbatim", () => { + expect(normalizeGeneratedTitle("Use the dAemon API", "the dAemon name is intentional")).toBe( + "Use the dAemon API", + ); + }); + + it("restores a proper noun's casing from the user's message", () => { + // Tiny model flattened "TinyVMM" → "tinyvmm"; the user wrote it distinctively. + expect(normalizeGeneratedTitle("Set up tinyvmm daemon", "please configure TinyVMM")).toBe( + "Set up TinyVMM daemon", + ); + }); + + it("leaves PascalCase proper nouns the model produced even when absent from source", () => { + expect(normalizeGeneratedTitle("Fix GitHub OAuth flow", "fix the login redirect")).toBe("Fix GitHub OAuth flow"); + }); + + it("does not lowercase the model's correct casing when the user typed it lower", () => { + // Source "tinyvmm" is not distinctive, so it must not pull "TinyVMM" down. + expect(normalizeGeneratedTitle("Improve TinyVMM startup", "improve tinyvmm startup")).toBe( + "Improve TinyVMM startup", + ); + }); + + it("a source word that merely starts a sentence does not force mid-title casing", () => { + // Regression: leading "For" in the message must not capitalize "for" in the title. + expect(normalizeGeneratedTitle("Add retry to the for loop", "For reliability, add retries")).toBe( + "Add retry to the for loop", + ); + }); +}); + describe("isLowSignalTitleInput", () => { it("treats greetings and acknowledgements as low signal (defer)", () => { for (const msg of [