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
This commit is contained in:
roboomp
2026-07-10 23:12:42 +00:00
parent 0420d44d3f
commit 65b0f05326
2 changed files with 48 additions and 3 deletions
@@ -35,6 +35,8 @@ const TITLE_MAX_TOKENS = 1024;
/** Matches the title the model wraps in `<title>...</title>`. */
const TITLE_MARKER_GLOBAL_RE = /<title>([\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();
@@ -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({