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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user