Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions internal/thinking/text.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package thinking

import (
"regexp"
"strings"

"github.com/tidwall/gjson"
)

Expand Down Expand Up @@ -39,3 +42,217 @@ func GetThinkingText(part gjson.Result) string {

return ""
}

// thoughtTagPattern matches <thought>...</thought> and <think>...</think> tags, including
// multiline content. It is used to extract thinking content that providers like MiniMax
// embed inside XML tags within the regular content field.
var thoughtTagPattern = regexp.MustCompile(`<thought>([\s\S]*?)</thought>|<think>([\s\S]*?)</think>`)

// StripThoughtTags removes <thought>...</thought> and <think>...</think> tags from text,
// returning the extracted thinking content and the cleaned visible text.
//
// Some OpenAI-compatible providers (e.g., MiniMax M3 via OpenCode Go) embed reasoning
// content inside XML tags within the regular content field instead of using the standard
// reasoning_content field. This function separates the tagged thinking content so it can
// be placed into proper Anthropic thinking blocks.
//
// If no tags are found, thinking returns empty string and clean is the original text.
func StripThoughtTags(text string) (thinking, clean string) {
if !strings.Contains(text, "<thought") && !strings.Contains(text, "<think") {
return "", text
}

var thinkingParts []string
clean = thoughtTagPattern.ReplaceAllStringFunc(text, func(match string) string {
// Extract content between tags
inner := match
if strings.HasPrefix(match, "<thought>") {
inner = match[len("<thought>") : len(match)-len("</thought>")]
} else if strings.HasPrefix(match, "<think>") {
inner = match[len("<think>") : len(match)-len("</think>")]
}
if trimmed := strings.TrimSpace(inner); trimmed != "" {
thinkingParts = append(thinkingParts, trimmed)
}
return " "
})

if len(thinkingParts) > 0 {
thinking = strings.Join(thinkingParts, "\n")
}
// Collapse multiple spaces introduced by tag removal.
clean = strings.TrimSpace(clean)
clean = multiSpace.ReplaceAllString(clean, " ")
return thinking, clean
}

// multiSpace matches two or more consecutive whitespace characters.
var multiSpace = regexp.MustCompile(`\s{2,}`)

// ThoughtTagStripper maintains state for stripping thought tags across streaming chunks.
// Some providers emit <thought> tags that span multiple SSE delta chunks, requiring
// stateful processing.
type ThoughtTagStripper struct {
buf strings.Builder
inThought bool
currentTag string // "thought" or "think"
thinkingBuf strings.Builder
}

// NewThoughtTagStripper creates a new ThoughtTagStripper instance.
func NewThoughtTagStripper() *ThoughtTagStripper {
return &ThoughtTagStripper{}
}

// Feed processes a streaming content chunk and returns any extracted thinking text
// and the cleaned visible text. The thinking and visible outputs may each be empty
// if the chunk is entirely consumed by tag processing.
func (s *ThoughtTagStripper) Feed(chunk string) (thinking, visible string) {
s.buf.WriteString(chunk)
full := s.buf.String()
s.buf.Reset()

var thinkingOut strings.Builder
var visibleOut strings.Builder

emitVisible := func(text string) {
text = strings.TrimSpace(text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve whitespace in streaming text deltas

When normal streaming content is passed through Feed, this TrimSpace strips leading/trailing whitespace from every delta even when there are no thought tags. OpenAI-style streams commonly send tokens like "Hello" followed by " world"; after this change the emitted Anthropic text deltas become "Hello" and "world", so clients reconstruct "Helloworld" instead of the original text. The stripper should preserve visible bytes outside tags rather than trimming each chunk.

Useful? React with 👍 / 👎.

if text == "" {
return
}
if visibleOut.Len() > 0 {
visibleOut.WriteByte(' ')
}
visibleOut.WriteString(text)
}

emitThinking := func(text string) {
text = strings.TrimSpace(text)
if text == "" {
return
}
if thinkingOut.Len() > 0 {
thinkingOut.WriteByte('\n')
}
thinkingOut.WriteString(text)
}

i := 0
for i < len(full) {
if s.inThought {
closeTag := "</" + s.currentTag + ">"
closeIdx := strings.Index(full[i:], closeTag)
if closeIdx >= 0 {
s.thinkingBuf.WriteString(full[i : i+closeIdx])
emitThinking(s.thinkingBuf.String())
s.thinkingBuf.Reset()
i += closeIdx + len(closeTag)
s.inThought = false
s.currentTag = ""
} else {
s.thinkingBuf.WriteString(full[i:])
i = len(full)
Comment on lines +152 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Buffer partial closing tags inside thought blocks

When the closing tag is split across SSE chunks, for example "<thought>why</thou" followed by "ght>answer", this branch appends the partial </thou to thinkingBuf and discards the chunk boundary. The next feed can no longer match </thought>, so ght>answer is treated as thinking and the visible answer is swallowed until flush. This is the same split-chunk case the stateful stripper is meant to handle, but it only buffers partial opening tags outside a thought block.

Useful? React with 👍 / 👎.

}
continue
}

// Not in a thought — look for an opening tag.
thoughtIdx := strings.Index(full[i:], "<thought>")
thinkIdx := strings.Index(full[i:], "<think>")

openIdx := -1
tagName := ""
if thoughtIdx >= 0 && (thinkIdx < 0 || thoughtIdx <= thinkIdx) {
openIdx = thoughtIdx
tagName = "thought"
} else if thinkIdx >= 0 {
openIdx = thinkIdx
tagName = "think"
}

if openIdx < 0 {
// No opening tag in buffer. Check for a partial tag at the very end
// that could complete on the next chunk.
remaining := full[i:]
partialStart := lastPotentialTagPrefix(remaining)
if partialStart >= 0 {
emitVisible(remaining[:partialStart])
// Keep the partial prefix in buffer for next chunk.
s.buf.WriteString(remaining[partialStart:])
} else {
emitVisible(remaining)
}
i = len(full)
continue
}

// Emit text before the tag.
emitVisible(full[i : i+openIdx])

// Enter thought.
i += openIdx + len("<"+tagName+">")
s.inThought = true
s.currentTag = tagName

// Check if closing tag is in the same buffer.
closeTag := "</" + tagName + ">"
if closeIdx := strings.Index(full[i:], closeTag); closeIdx >= 0 {
emitThinking(full[i : i+closeIdx])
i += closeIdx + len(closeTag)
s.inThought = false
s.currentTag = ""
} else {
s.thinkingBuf.WriteString(full[i:])
i = len(full)
}
}

return thinkingOut.String(), visibleOut.String()
}
Comment on lines +110 to +211

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.

critical

There are two critical issues in the streaming Feed implementation:

  1. Whitespace and Newline Destruction: Calling strings.TrimSpace(text) on every chunk and joining them with spaces completely destroys formatting, newlines, and spaces in the streaming output. For example, if a chunk is just "\n", it is ignored. If a chunk ends with a space, that space is lost, causing words to run together (e.g., "hello " and "world" becomes "helloworld").
  2. Split Closing Tags: If a closing tag (e.g., </thought>) is split across streaming chunks (e.g., Chunk 1 ends with "</th" and Chunk 2 starts with "ought>"), the stripper fails to detect it. It writes "</th" to s.thinkingBuf and doesn't buffer it in s.buf. In the next chunk, it doesn't find "</thought>" because the prefix is missing. As a result, the tag is never closed, and the entire rest of the stream is incorrectly treated as thinking content.

We should rewrite Feed to write directly to visibleOut and thinkingOut without trimming or joining with spaces, and check for partial closing tag prefixes at the end of the remaining string using lastPotentialTagPrefix when s.inThought is true. Note that the tests in internal/thinking/text_test.go (specifically TestThoughtTagStripper_ComplexStream and TestThoughtTagStripper_VisibleBeforeTag) will also need to be updated to expect the correct, non-destructive streaming behavior.

func (s *ThoughtTagStripper) Feed(chunk string) (thinking, visible string) {
	s.buf.WriteString(chunk)
	full := s.buf.String()
	s.buf.Reset()

	var thinkingOut strings.Builder
	var visibleOut strings.Builder

	emitVisible := func(text string) {
		visibleOut.WriteString(text)
	}

	emitThinking := func(text string) {
		thinkingOut.WriteString(text)
	}

	i := 0
	for i < len(full) {
		if s.inThought {
			closeTag := "</" + s.currentTag + ">"
			closeIdx := strings.Index(full[i:], closeTag)
			if closeIdx >= 0 {
				s.thinkingBuf.WriteString(full[i : i+closeIdx])
				emitThinking(s.thinkingBuf.String())
				s.thinkingBuf.Reset()
				i += closeIdx + len(closeTag)
				s.inThought = false
				s.currentTag = ""
			} else {
				remaining := full[i:]
				partialStart := lastPotentialTagPrefix(remaining)
				if partialStart >= 0 {
					s.thinkingBuf.WriteString(remaining[:partialStart])
					s.buf.WriteString(remaining[partialStart:])
				} else {
					s.thinkingBuf.WriteString(remaining)
				}
				i = len(full)
			}
			continue
		}

		// Not in a thought — look for an opening tag.
		thoughtIdx := strings.Index(full[i:], "<thought>")
		thinkIdx := strings.Index(full[i:], "<think>")

		openIdx := -1
		tagName := ""
		if thoughtIdx >= 0 && (thinkIdx < 0 || thoughtIdx <= thinkIdx) {
			openIdx = thoughtIdx
			tagName = "thought"
		} else if thinkIdx >= 0 {
			openIdx = thinkIdx
			tagName = "think"
		}

		if openIdx < 0 {
			// No opening tag in buffer. Check for a partial tag at the very end
			// that could complete on the next chunk.
			remaining := full[i:]
			partialStart := lastPotentialTagPrefix(remaining)
			if partialStart >= 0 {
				emitVisible(remaining[:partialStart])
				// Keep the partial prefix in buffer for next chunk.
				s.buf.WriteString(remaining[partialStart:])
			} else {
				emitVisible(remaining)
			}
			i = len(full)
			continue
		}

		// Emit text before the tag.
		emitVisible(full[i : i+openIdx])

		// Enter thought.
		i += openIdx + len("<"+tagName+">")
		s.inThought = true
		s.currentTag = tagName

		// Check if closing tag is in the same buffer.
		closeTag := "</" + tagName + ">"
		if closeIdx := strings.Index(full[i:], closeTag); closeIdx >= 0 {
			emitThinking(full[i : i+closeIdx])
			i += closeIdx + len(closeTag)
			s.inThought = false
			s.currentTag = ""
		} else {
			remaining := full[i:]
			partialStart := lastPotentialTagPrefix(remaining)
			if partialStart >= 0 {
				s.thinkingBuf.WriteString(remaining[:partialStart])
				s.buf.WriteString(remaining[partialStart:])
			} else {
				s.thinkingBuf.WriteString(remaining)
			}
			i = len(full)
		}
	}

	return thinkingOut.String(), visibleOut.String()
}


// lastPotentialTagPrefix checks if the trailing portion of s looks like it could be
// the start of <thought>, </thought>, <think>, or </think>. Returns the index within s
// where the potential tag starts, or -1 if no partial tag prefix is detected.
func lastPotentialTagPrefix(s string) int {
lastLT := strings.LastIndex(s, "<")
if lastLT < 0 {
return -1
}
candidate := s[lastLT:]

for _, tag := range []string{
"<thought>", "</thought>", "<think>", "</think>",
} {
if strings.HasPrefix(tag, candidate) && len(candidate) < len(tag) {
return lastLT
}
}
return -1
}

// Flush returns any remaining buffered thinking text. Call this when the stream
// ends to ensure no thinking content is lost. Also returns any remaining visible
// text in the buffer.
func (s *ThoughtTagStripper) Flush() (thinking, visible string) {
if s.inThought {
// Unterminated thought tag — treat buffered content as thinking
thinking = strings.TrimSpace(s.thinkingBuf.String())
}
// Any remaining non-tag content
if !s.inThought {
visible = strings.TrimSpace(s.buf.String())
}
s.buf.Reset()
s.thinkingBuf.Reset()
s.inThought = false
s.currentTag = ""
return thinking, visible
}
Comment on lines +236 to +250

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.

medium

In Flush(), if s.inThought is true, any partial closing tag buffered in s.buf is discarded instead of being appended to the thinking content. We should append s.buf.String() to s.thinkingBuf.String() when treating unterminated thought tags as thinking, and avoid trimming visible to preserve trailing whitespace of the stream.

Suggested change
func (s *ThoughtTagStripper) Flush() (thinking, visible string) {
if s.inThought {
// Unterminated thought tag — treat buffered content as thinking
thinking = strings.TrimSpace(s.thinkingBuf.String())
}
// Any remaining non-tag content
if !s.inThought {
visible = strings.TrimSpace(s.buf.String())
}
s.buf.Reset()
s.thinkingBuf.Reset()
s.inThought = false
s.currentTag = ""
return thinking, visible
}
func (s *ThoughtTagStripper) Flush() (thinking, visible string) {
if s.inThought {
// Unterminated thought tag — treat buffered content as thinking
thinking = s.thinkingBuf.String() + s.buf.String()
}
// Any remaining non-tag content
if !s.inThought {
visible = s.buf.String()
}
s.buf.Reset()
s.thinkingBuf.Reset()
s.inThought = false
s.currentTag = ""
return thinking, visible
}


// Reset clears all internal state for reuse.
func (s *ThoughtTagStripper) Reset() {
s.buf.Reset()
s.thinkingBuf.Reset()
s.inThought = false
s.currentTag = ""
}
Loading
Loading