Skip to content

fix(core): strip inline reasoning blocks from generated session titles - #57071

Open
Veld101 wants to merge 2 commits into
anomalyco:devfrom
Veld101:title-inline-reasoning
Open

Veld101 wants to merge 2 commits into
anomalyco:devfrom
Veld101:title-inline-reasoning

Conversation

@Veld101

@Veld101 Veld101 commented Oct 19, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #57043

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Some OpenAI-compatible gateways (LiteLLM in the report) stream the title model's reasoning inline in the text channel instead of as a separate reasoning part. The title code keeps the first non-empty line of that text, so a title model that writes <think>…</think> followed by the title produced this as the session title:

<think>The user message is "/mcps". Need a brief title.</think>

and the real title on the next line was never used.

This strips <think>…</think> blocks before selecting the line, which is what v1 did before the session rewrite (ad8a4bc744, #1325). The strip runs after the deltas join, so a block split across stream deltas still matches:

return chunks
  .join("")
  .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
  .split("\n")
  .map((line) => line.trim())
  .find((line) => line.length > 0)

If the model emits nothing but reasoning, the result is empty and no rename happens, so a session keeps its fallback title instead of gaining a reasoning-only one.

Relationship to #52006

#52006 also touches this function, but it addresses a different input: it filters chunks whose providerMetadata.phase is commentary, which is structured OpenAI Responses commentary. Inline <think> text inside an ordinary text chunk carries no phase, so it is not covered by that change. The two are complementary; whichever lands second will need a trivial rebase.

How did you verify your code works?

Two cases in packages/core/test/session-title.test.ts, both confirmed to fail without the change (reverting only title.ts turns both red, and the first reproduces the reported title verbatim):

  • drops inline reasoning blocks from the generated title — the reported shape, <think>…</think> then the title on the next line.
  • drops inline reasoning blocks split across stream deltas — pins that the strip happens after the join, which a per-delta strip would miss.
bun test test/session-title.test.ts   # 17 pass, 0 fail
bun typecheck                         # packages/core, clean
bunx prettier --check                 # clean
bun run lint                          # exit 0, no new findings

Screenshots / recordings

Not applicable; no UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #54401, #54185

Some OpenAI-compatible gateways stream the title model's reasoning inline in
the text channel rather than as a separate reasoning part. The title code keeps
the first non-empty line of that text, so a title model that writes
`<think>…</think>` followed by the title produced `<think>…` as the session
title for every new session.

Strip `<think>…</think>` blocks before selecting the line, which is what v1 did
before the session rewrite (ad8a4bc, anomalyco#1325). Stripping happens after the
deltas join, so a block split across stream deltas still matches.

Adds cases for a single block and for one split across deltas; both fail
without the change.
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. I ran the #57043 reproduction (a simulated title model that streams <think>…</think> and then the title) against the base and this branch. On the base the tab title is the raw reasoning; with this change it's MCP servers list. session-title.test.ts passes (17/17) and packages/core typechecks.

Public API: unchanged.

One thing worth handling: a reasoning block that is never closed (or uses uppercase tags) still ends up in the title. See the inline comment.

Related: #54401, #54185

Before

After

Comment thread packages/core/src/session/title.ts Outdated
// separate part, and its first line would otherwise become the title.
return chunks
.join("")
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only matches a closed, lowercase block. If the title output is cut off mid-reasoning (more likely once #54411 adds an output limit), the text is just <think>… and that becomes the title, which is the same symptom as #57043. A stray </think> or <THINK> also gets through. Could you make the match case-insensitive and also drop a trailing unclosed <think>… up to the end of the output, plus any leftover lone tags? For example:

.replace(/<think>[\s\S]*?(<\/think>|$)\s*/gi, "")
.replace(/<\/?think>/gi, "")

Then reasoning-only or truncated output falls through to undefined and keeps the fallback title.

Follow-up to the previous commit: a title stream truncated mid-reasoning
leaves `<think>…` with no closing tag, which the closed-block pattern did not
match, so the tag still became the title. Uppercase tags and a stray closing
tag without an opening block leaked the same way.

Match case-insensitively, treat end of output as closing an unterminated block,
and drop any leftover tag. Reasoning-only output then yields no title at all,
so the session keeps its fallback instead of gaining a reasoning-only one.

Adds cases for a truncated uppercase block and for a stray closing tag.
@Veld101

Veld101 commented Oct 19, 2026

Copy link
Copy Markdown
Author

Applied both parts of the suggestion in 0d0704d:

.replace(/<think>[\s\S]*?(<\/think>|$)\s*/gi, "")
.replace(/<\/?think>/gi, "")
  • Case-insensitive, so <THINK> and mixed case match.
  • |$ closes an unterminated block, so a stream truncated mid-reasoning is dropped to the end of the output instead of becoming the title.
  • The second pass drops a leftover lone tag, e.g. a leading </think> with no opening block.

Reasoning-only or truncated output now yields no title, so the session keeps its fallback rather than storing a tag.

Two cases added, both red against the previous single-pattern version:

  • drops an unclosed or uppercase reasoning block — <THINK>Need a brief title. with no close; asserts the title stays undefined.
  • strips a stray closing tag that has no opening block — </think>MCP servers list; asserts MCP servers list.

session-title.test.ts is 19 pass / 0 fail, packages/core typechecks, prettier is clean.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tui: new sessions retain Thinking, User, or Asking as their title

1 participant