From 65b0f0532655782f67e26086a4baafafcdcaa3ba Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 10 Jul 2026 23:12:42 +0000 Subject: [PATCH] fix(coding-agent): preserved markerless thinking titles - Limited markerless fallback cleanup to leading leaked-thinking envelopes so literal reasoning syntax in plain titles survives. - Added markerless regression coverage for think tags and thinking-fence titles. Fixes #5122 --- .../coding-agent/src/utils/title-generator.ts | 18 ++++++++-- .../coding-agent/test/title-generator.test.ts | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index a4887afd7..69425e79f 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -35,6 +35,8 @@ const TITLE_MAX_TOKENS = 1024; /** Matches the title the model wraps in `...`. */ const TITLE_MARKER_GLOBAL_RE = /([\s\S]*?)<\/title>/gi; const TITLE_VISIBILITY_SENTINEL = "\uE000omp-title-visible\uE000"; +const LEADING_THINKING_TAG_RE = /^\s*<(think|thinking|reasoning)>\s*[\s\S]*?<\/\1>\s*/i; +const LEADING_THINKING_FENCE_RE = /^\s*```(?:thinking|reasoning)\b[\s\S]*?```\s*/i; function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel?: Model<Api>): Model<Api> | undefined { const availableModels = registry.getAvailable(); @@ -244,12 +246,12 @@ function extractGeneratedTitle(contentBlocks: AssistantMessage["content"]): stri } } // Stay lenient: prefer the first closed title marker in visible text, then - // fall back to a plain sentence after stripping leaked thinking plus any - // stray/unclosed title tag fragment (e.g. output truncated before closing). + // fall back to a plain sentence after stripping only known leading leaked + // thinking envelopes plus any stray/unclosed title tag fragment. const markedTitle = extractVisibleMarkedTitle(textTitle); const cleanedTextTitle = markedTitle ?? - stripLeakedThinkingMarkup(textTitle) + stripLeadingLeakedThinkingMarkup(textTitle) .replace(/<\/?title>/gi, "") .trim(); return unwrapJsonTitle(cleanedTextTitle); @@ -272,6 +274,16 @@ function isVisibleTitleMarker(text: string, markerIndex: number): boolean { ); } +function stripLeadingLeakedThinkingMarkup(text: string): string { + let current = text; + while (true) { + const withoutTag = current.replace(LEADING_THINKING_TAG_RE, ""); + const withoutFence = withoutTag.replace(LEADING_THINKING_FENCE_RE, ""); + if (withoutFence === current) return current; + current = withoutFence; + } +} + function stripLeakedThinkingMarkup(text: string): string { const healer = new StreamMarkupHealing({ pattern: "thinking" }); return healer.feed(text) + healer.flushPending(); diff --git a/packages/coding-agent/test/title-generator.test.ts b/packages/coding-agent/test/title-generator.test.ts index cc7cb29f9..fdb80cbc4 100644 --- a/packages/coding-agent/test/title-generator.test.ts +++ b/packages/coding-agent/test/title-generator.test.ts @@ -377,6 +377,39 @@ describe("title generator", () => { expect(title).toBe("Fix login button on mobile"); }); + it("preserves a markerless title that mentions a <think> tag", async () => { + const model = getModelFor("deepseek", "deepseek-v4-pro"); + vi.spyOn(ai, "completeSimple").mockResolvedValue({ + stopReason: "stop", + content: [{ type: "text", text: "Fix <think> tag parsing" }], + } as never); + + const title = await generateSessionTitle( + "fix title generation for <think> tag parsing", + createRegistry(model), + createSettings(model), + ); + + expect(title).toBe("Fix <think> tag parsing"); + }); + + it("preserves a markerless title that mentions a ```thinking fence", async () => { + const model = getModelFor("deepseek", "deepseek-v4-pro"); + vi.spyOn(ai, "completeSimple").mockResolvedValue({ + stopReason: "stop", + content: [{ type: "text", text: "Fix ```thinking fence parsing" }], + } as never); + + const title = await generateSessionTitle( + "fix title generation for a ```thinking fence", + createRegistry(model), + createSettings(model), + ); + + expect(title).toContain("```thinking"); + expect(title).toContain("fence"); + }); + it("strips an unclosed <title> tag from a truncated response", async () => { const model = getModelFor("deepseek", "deepseek-v4-pro"); vi.spyOn(ai, "completeSimple").mockResolvedValue({