feat(coding-agent): improved session title casing logic

- Updated `normalizeGeneratedTitle` to reconcile model-generated titles against the user's input instead of forcing title-case.
- Added logic to restore distinctive proper-noun casing (e.g., `TinyVMM`) and flatten model-generated camelCase artifacts (e.g., `dAemon`) that do not appear in the user's message.
- Ensured model-cased proper nouns that are not in the source message (e.g., `GitHub`) are preserved.
This commit is contained in:
can1357
2026-06-26 20:27:40 +02:00
parent 51779aeefd
commit 52b8fb1565
5 changed files with 113 additions and 12 deletions
+4
View File
@@ -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
+49 -4
View File
@@ -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<string>();
const distinctive = new Map<string, string>();
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);
}
+3 -3
View File
@@ -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 {
@@ -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", {
+56 -4
View File
@@ -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 [