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): Model | 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 tag", async () => {
+ const model = getModelFor("deepseek", "deepseek-v4-pro");
+ vi.spyOn(ai, "completeSimple").mockResolvedValue({
+ stopReason: "stop",
+ content: [{ type: "text", text: "Fix tag parsing" }],
+ } as never);
+
+ const title = await generateSessionTitle(
+ "fix title generation for tag parsing",
+ createRegistry(model),
+ createSettings(model),
+ );
+
+ expect(title).toBe("Fix 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 tag from a truncated response", async () => {
const model = getModelFor("deepseek", "deepseek-v4-pro");
vi.spyOn(ai, "completeSimple").mockResolvedValue({