From fb8496fd216a5daadde144638fbb093e022868a7 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 30 May 2026 23:12:18 +0200 Subject: [PATCH] feat(tiny/text): added code block stripping for session title generation - Added `stripCodeBlocks` to remove fenced code blocks before titling, preventing literal noise (e.g. version strings in UI mockups) from becoming the session title. - Added `prepareTitleInput` composing strip and truncate steps, updated `formatTitleUserMessage` to use it. - Added unit tests for stripping logic and an integration test verifying the model never receives code block contents. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/tiny/text.ts | 37 +++++++++++- packages/coding-agent/test/tiny-text.test.ts | 60 +++++++++++++++++++ .../coding-agent/test/title-generator.test.ts | 20 +++++++ 4 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/tiny-text.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c2d744cc8..7ea89d2d9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Fixed the `read` tool description advertising `inspect_image` ("for visual analysis, call `inspect_image`") even when the `inspect_image` tool was disabled, which left the model hunting for a tool absent from its function list. The image section is now gated on `inspect_image.enabled`: when disabled it instead states that reading an image path returns the decoded image inline. +- Fixed session-title generation latching onto literal text inside fenced code blocks — a pasted UI mockup containing "Welcome to Claude Code v2.1.158" titled the session "Setup Screen for Claude Code v2.1.158" instead of capturing the actual request. The first user message now has fenced code blocks stripped before titling (both the online `pi/smol` and local CPU model paths share the same preprocessing), with a fallback to the original message when stripping would leave too little to title from (e.g. a message that is essentially just a code block). ### Fixed diff --git a/packages/coding-agent/src/tiny/text.ts b/packages/coding-agent/src/tiny/text.ts index 6f7ee728e..fc35d1c9c 100644 --- a/packages/coding-agent/src/tiny/text.ts +++ b/packages/coding-agent/src/tiny/text.ts @@ -1,11 +1,46 @@ export const MAX_TITLE_INPUT_CHARS = 2000; +/** + * Minimum length of code-stripped input below which we fall back to the + * original message. Guards against messages that are (almost) entirely a code + * block — stripping would otherwise leave the model nothing to title from. + */ +const MIN_STRIPPED_TITLE_CHARS = 12; +/** Matches a fenced code block (3+ backticks), including an unterminated trailing fence. */ +const FENCED_CODE_BLOCK = /```+[\s\S]*?(?:```+|$)/g; + export function truncateTitleInput(message: string): string { return message.length > MAX_TITLE_INPUT_CHARS ? `${message.slice(0, MAX_TITLE_INPUT_CHARS)}…` : message; } +/** + * Strip fenced code blocks from a message before titling. + * + * Small title models latch onto literal text inside code blocks — e.g. a pasted + * UI mockup containing "Welcome to Claude Code v2.1.158" yields that string as + * the title instead of the surrounding intent. Removing fenced blocks leaves the + * prose that actually describes the task. Inline code (single backticks) is kept + * — it is short, high-signal context like `/login`. + * + * Falls back to the original message when stripping leaves too little to title + * (a message that is essentially just a code block). + */ +export function stripCodeBlocks(message: string): string { + const cleaned = message + .replace(FENCED_CODE_BLOCK, " ") + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return cleaned.length >= MIN_STRIPPED_TITLE_CHARS ? cleaned : message; +} + +/** Prepare a raw user message for titling: drop code blocks, then bound length. */ +export function prepareTitleInput(message: string): string { + return truncateTitleInput(stripCodeBlocks(message)); +} + export function formatTitleUserMessage(message: string): string { - return `\n${truncateTitleInput(message)}\n`; + return `\n${prepareTitleInput(message)}\n`; } export function normalizeGeneratedTitle(value: string | null | undefined): string | null { diff --git a/packages/coding-agent/test/tiny-text.test.ts b/packages/coding-agent/test/tiny-text.test.ts new file mode 100644 index 000000000..e623fc7a0 --- /dev/null +++ b/packages/coding-agent/test/tiny-text.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "bun:test"; +import { formatTitleUserMessage, MAX_TITLE_INPUT_CHARS, prepareTitleInput, stripCodeBlocks } from "../src/tiny/text"; + +describe("stripCodeBlocks", () => { + it("drops fenced code blocks but keeps the surrounding prose", () => { + const message = "lets plan a setup screen together.\n```\nsome mockup\n```\nit should only show once."; + const stripped = stripCodeBlocks(message); + expect(stripped).not.toContain("some mockup"); + expect(stripped).toContain("plan a setup screen"); + expect(stripped).toContain("it should only show once."); + }); + + it("removes literal noise inside a pasted mockup (the reported regression)", () => { + // A small title model titled this session "Setup Screen for Claude Code v2.1.158" + // because the version string lived inside the fenced mockup. + const message = + "lets plan a setup screen together.\nSomething like\n```\nWelcome to Claude Code v2.1.158\n[splash]\n1. Auto\n2. Dark mode\n```\nsteps: pick provider, pick theme"; + const stripped = stripCodeBlocks(message); + expect(stripped).not.toContain("Claude Code v2.1.158"); + expect(stripped).toContain("pick provider, pick theme"); + }); + + it("handles an unterminated fence by stripping to end of message", () => { + const stripped = stripCodeBlocks("describe the bug\n```\nthrows here and never closes"); + expect(stripped).toBe("describe the bug"); + }); + + it("keeps inline code (single backticks) as high-signal context", () => { + const stripped = stripCodeBlocks("wire up the `/login` provider step"); + expect(stripped).toContain("`/login`"); + }); + + it("falls back to the original when the message is essentially only a code block", () => { + const message = "```python\ndef merge_sort(a):\n return a\n```"; + expect(stripCodeBlocks(message)).toBe(message); + }); + + it("returns prose unchanged when there is no code block", () => { + expect(stripCodeBlocks("Investigate the resolver")).toBe("Investigate the resolver"); + }); +}); + +describe("prepareTitleInput", () => { + it("strips code blocks before bounding length", () => { + const message = `intro prose ${"x".repeat(MAX_TITLE_INPUT_CHARS)}\n\`\`\`\n${"y".repeat(5000)}\n\`\`\``; + const prepared = prepareTitleInput(message); + expect(prepared).not.toContain("yyyy"); + expect(prepared.length).toBeLessThanOrEqual(MAX_TITLE_INPUT_CHARS + 1); // +1 for the ellipsis + }); +}); + +describe("formatTitleUserMessage", () => { + it("wraps stripped content in user-message tags", () => { + const formatted = formatTitleUserMessage("plan a thing\n```\nnoise\n```"); + expect(formatted.startsWith("\n")).toBe(true); + expect(formatted.endsWith("\n")).toBe(true); + expect(formatted).toContain("plan a thing"); + expect(formatted).not.toContain("noise"); + }); +}); diff --git a/packages/coding-agent/test/title-generator.test.ts b/packages/coding-agent/test/title-generator.test.ts index fd2f2350a..ca4c3724f 100644 --- a/packages/coding-agent/test/title-generator.test.ts +++ b/packages/coding-agent/test/title-generator.test.ts @@ -106,4 +106,24 @@ describe("title generator", () => { expect(title).toBe("Budget Title"); expect(maxTokens).toBeGreaterThanOrEqual(1024); }); + + it("strips code blocks from the message sent to the model", async () => { + const model = getModelOrThrow("claude-sonnet-4-5"); + const completeSimpleMock = vi.spyOn(ai, "completeSimple").mockResolvedValue({ + stopReason: "stop", + content: [{ type: "toolCall", id: "call-title", name: "set_title", arguments: { title: "Setup Screen" } }], + } as never); + + await generateSessionTitle( + "plan a setup screen\n```\nWelcome to Claude Code v2.1.158\n```\npick provider then theme", + createRegistry(model), + createSettings(model), + ); + + const sentMessages = (completeSimpleMock.mock.calls[0]?.[1] as { messages?: Array<{ content?: string }> }) + ?.messages; + const userContent = sentMessages?.[0]?.content ?? ""; + expect(userContent).not.toContain("Claude Code v2.1.158"); + expect(userContent).toContain("pick provider then theme"); + }); });