mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 06:17:41 +00:00
fix: filter streamed thinking tags (#1300)
Co-authored-by: GoClaw Operator <operator@goclaw>
This commit is contained in:
1 parent
85b17f698f
commit
22efff8c0d
13 files changed
+453
-28
No files matched your search
+16
-13
@@ -98,10 +98,10 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
|
||||
currentStream := rc.stream
|
||||
rc.stream = nil
|
||||
rc.inToolPhase = true
|
||||
rc.thinkingDone = false // allow new thinking in next iteration
|
||||
rc.thinkingBuffer = "" // reset thinking buffer for new iteration
|
||||
rc.hasThinking = false // new iteration starts fresh
|
||||
rc.tagParseSkipped = false // re-enable tag parsing for next iteration
|
||||
rc.thinkingDone = false // allow new thinking in next iteration
|
||||
rc.thinkingBuffer = "" // reset thinking buffer for new iteration
|
||||
rc.hasThinking = false // new iteration starts fresh
|
||||
rc.tagParsePending = "" // discard any incomplete tag from the previous iteration
|
||||
rc.mu.Unlock()
|
||||
if currentStream != nil {
|
||||
if err := currentStream.Stop(ctx); err != nil {
|
||||
@@ -135,19 +135,22 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
|
||||
// Fallback <think> tag parsing: for providers that embed thinking
|
||||
// in the content stream (DeepSeek-via-OpenRouter, Qwen, some Ollama models).
|
||||
// Only activates when no native ChatEventThinking was received.
|
||||
if !rc.hasThinking && !rc.thinkingDone && !rc.tagParseSkipped {
|
||||
candidate := rc.streamBuffer + content
|
||||
if !rc.hasThinking && !rc.thinkingDone {
|
||||
previousAnswer := rc.streamBuffer
|
||||
candidate := rc.streamBuffer + rc.tagParsePending + content
|
||||
split := SplitThinkTags(candidate)
|
||||
if split.Thinking != "" {
|
||||
if split.Found || split.Pending != "" {
|
||||
// Found think tags — commit to buffer and route or suppress reasoning
|
||||
// before any tagged content can leak into the answer lane.
|
||||
displayReasoningInStream := rc.ReasoningDelivery.ShowInChannel && !rc.ReasoningDelivery.BubbleDelivery && sc.ReasoningStreamEnabled()
|
||||
previousThinking := rc.thinkingBuffer
|
||||
rc.streamBuffer = candidate
|
||||
rc.thinkingBuffer = split.Thinking
|
||||
thinkText := rc.thinkingBuffer
|
||||
currentStream := rc.stream
|
||||
if split.Partial {
|
||||
if split.Partial || split.Pending != "" {
|
||||
rc.streamBuffer = split.Answer
|
||||
rc.tagParsePending = split.Pending
|
||||
answerText := rc.streamBuffer
|
||||
// Still inside <think> — wait for the close tag before streaming
|
||||
// answer content. Native thinking uses hasThinking; tag parsing
|
||||
// keeps it false until close so later chunks continue parsing.
|
||||
@@ -158,9 +161,12 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
|
||||
}
|
||||
} else if displayReasoningInStream && currentStream != nil {
|
||||
currentStream.Update(ctx, formatReasoningPreview(thinkText))
|
||||
} else if currentStream != nil {
|
||||
currentStream.Update(ctx, answerText)
|
||||
}
|
||||
break
|
||||
}
|
||||
rc.tagParsePending = ""
|
||||
// Tag closed — transition to answer, or strip reasoning entirely
|
||||
// when Show Reasoning is off.
|
||||
answerText := split.Answer
|
||||
@@ -177,7 +183,7 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
|
||||
m.flushReasoningBubbles(runID)
|
||||
}
|
||||
|
||||
if !displayReasoningInStream {
|
||||
if previousAnswer != "" || !displayReasoningInStream {
|
||||
if reasoningStream != nil && answerText != "" {
|
||||
reasoningStream.Update(ctx, answerText)
|
||||
}
|
||||
@@ -209,9 +215,6 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
|
||||
}
|
||||
break
|
||||
}
|
||||
// No think tags found — mark as skipped so we don't re-parse.
|
||||
// Don't commit to streamBuffer here — the normal flow below appends content.
|
||||
rc.tagParseSkipped = true
|
||||
}
|
||||
|
||||
// Reasoning→answer transition: first chunk after native thinking events.
|
||||
|
||||
@@ -54,7 +54,7 @@ type RunContext struct {
|
||||
thinkingBuffer string // accumulated thinking/reasoning text
|
||||
hasThinking bool // true if any thinking events received this iteration
|
||||
thinkingDone bool // true after first chunk arrives (reasoning→answer transition complete)
|
||||
tagParseSkipped bool // true after first chunk with no <think> tags (skip re-parsing)
|
||||
tagParsePending string // raw trailing text withheld because it may be a split <think> tag
|
||||
reasoningBubbles *reasoningBubbleBuffer
|
||||
reasoningBubbleTimer *time.Timer
|
||||
}
|
||||
|
||||
@@ -105,25 +105,36 @@ func (m *Manager) appendReasoningBubbleFromThinkTagChunk(runID string, rc *RunCo
|
||||
var done bool
|
||||
|
||||
rc.mu.Lock()
|
||||
if rc.thinkingDone || rc.tagParseSkipped {
|
||||
if rc.thinkingDone {
|
||||
rc.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
candidate := rc.streamBuffer + content
|
||||
candidate := rc.streamBuffer + rc.tagParsePending + content
|
||||
split := SplitThinkTags(candidate)
|
||||
if split.Thinking == "" {
|
||||
rc.tagParseSkipped = true
|
||||
if !split.Found && split.Pending == "" {
|
||||
rc.streamBuffer = split.Answer
|
||||
rc.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !split.Found && split.Pending != "" {
|
||||
rc.streamBuffer = split.Answer
|
||||
rc.tagParsePending = split.Pending
|
||||
rc.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if split.Pending != "" {
|
||||
rc.streamBuffer = split.Answer
|
||||
rc.tagParsePending = split.Pending
|
||||
}
|
||||
|
||||
previousThinking := rc.thinkingBuffer
|
||||
rc.streamBuffer = candidate
|
||||
rc.streamBuffer = split.Answer
|
||||
rc.tagParsePending = split.Pending
|
||||
rc.thinkingBuffer = split.Thinking
|
||||
if !split.Partial {
|
||||
rc.thinkingDone = true
|
||||
rc.streamBuffer = split.Answer
|
||||
rc.tagParsePending = ""
|
||||
done = true
|
||||
}
|
||||
delta = reasoningDelta(previousThinking, split.Thinking)
|
||||
|
||||
@@ -203,6 +203,57 @@ func TestHandleAgentEvent_ReasoningOffStripsThinkTagsFromStreamingAnswer(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentEvent_ReasoningOffStripsLateThinkTagsFromStreamingAnswer(t *testing.T) {
|
||||
mb := bus.New()
|
||||
mgr := NewManager(mb)
|
||||
ch := &reasoningStreamingTestChannel{name: "test", reasoningEnabled: true}
|
||||
mgr.RegisterChannel("test", ch)
|
||||
delivery := ResolveReasoningDelivery(ReasoningDeliveryOff, nil)
|
||||
mgr.RegisterRunWithBehavior("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, true, false, true, ResolvedChatBehavior{}, delivery)
|
||||
|
||||
mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil)
|
||||
mgr.HandleAgentEvent(protocol.ChatEventChunk, "run-1", map[string]string{"content": "Visible prefix "})
|
||||
mgr.HandleAgentEvent(protocol.ChatEventChunk, "run-1", map[string]string{"content": "<thinking>hidden reasoning</thinking> visible answer"})
|
||||
|
||||
if len(ch.streams) != 1 {
|
||||
t.Fatalf("streams = %d, want answer stream only", len(ch.streams))
|
||||
}
|
||||
got := ch.streams[0].lastUpdate()
|
||||
if strings.Contains(got, "<thinking>") || strings.Contains(got, "hidden reasoning") {
|
||||
t.Fatalf("stream leaked thinking tag content: %q", got)
|
||||
}
|
||||
if got != "Visible prefix visible answer" {
|
||||
t.Fatalf("stream updates = %q, want clean answer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentEvent_ReasoningOffStripsSplitThinkTagsFromStreamingAnswer(t *testing.T) {
|
||||
mb := bus.New()
|
||||
mgr := NewManager(mb)
|
||||
ch := &reasoningStreamingTestChannel{name: "test", reasoningEnabled: true}
|
||||
mgr.RegisterChannel("test", ch)
|
||||
delivery := ResolveReasoningDelivery(ReasoningDeliveryOff, nil)
|
||||
mgr.RegisterRunWithBehavior("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, true, false, true, ResolvedChatBehavior{}, delivery)
|
||||
|
||||
mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil)
|
||||
mgr.HandleAgentEvent(protocol.ChatEventChunk, "run-1", map[string]string{"content": "Visible "})
|
||||
mgr.HandleAgentEvent(protocol.ChatEventChunk, "run-1", map[string]string{"content": "<think"})
|
||||
|
||||
if got := ch.streams[0].lastUpdate(); got != "Visible " {
|
||||
t.Fatalf("partial tag name leaked before completion: %q", got)
|
||||
}
|
||||
|
||||
mgr.HandleAgentEvent(protocol.ChatEventChunk, "run-1", map[string]string{"content": "ing>hidden reasoning</thinking> answer"})
|
||||
|
||||
got := ch.streams[0].lastUpdate()
|
||||
if strings.Contains(got, "<thinking>") || strings.Contains(got, "hidden reasoning") {
|
||||
t.Fatalf("stream leaked split thinking tag content: %q", got)
|
||||
}
|
||||
if got != "Visible answer" {
|
||||
t.Fatalf("stream updates = %q, want clean answer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentEvent_AlwaysBubblesExtractsThinkTagsFromStreamingChunk(t *testing.T) {
|
||||
mb := bus.New()
|
||||
mgr := NewManager(mb)
|
||||
@@ -267,3 +318,10 @@ func (s *reasoningRecordingStream) Stop(context.Context) error {
|
||||
}
|
||||
|
||||
func (s *reasoningRecordingStream) MessageID() int { return 1 }
|
||||
|
||||
func (s *reasoningRecordingStream) lastUpdate() string {
|
||||
if len(s.updates) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s.updates[len(s.updates)-1]
|
||||
}
|
||||
@@ -17,6 +17,8 @@ type ThinkTagSplit struct {
|
||||
Thinking string // content inside <think> tags (empty if no tags found)
|
||||
Answer string // content outside <think> tags
|
||||
Partial bool // true if an unclosed <think> tag is present (buffer for more)
|
||||
Found bool // true if an opening think tag was found
|
||||
Pending string // trailing text withheld because it may be a split opening tag
|
||||
}
|
||||
|
||||
// SplitThinkTags extracts thinking content from <think>...</think> tags.
|
||||
@@ -28,11 +30,13 @@ func SplitThinkTags(text string) ThinkTagSplit {
|
||||
if !strings.Contains(lower, "<think") &&
|
||||
!strings.Contains(lower, "<thought") &&
|
||||
!strings.Contains(lower, "<antthinking") {
|
||||
return ThinkTagSplit{Answer: text}
|
||||
answer, pending := splitTrailingThinkTagPrefix(text)
|
||||
return ThinkTagSplit{Answer: answer, Pending: pending}
|
||||
}
|
||||
|
||||
var thinking, answer strings.Builder
|
||||
remaining := text
|
||||
found := false
|
||||
|
||||
for remaining != "" {
|
||||
// Find opening tag
|
||||
@@ -47,6 +51,7 @@ func SplitThinkTags(text string) ThinkTagSplit {
|
||||
if openLoc[0] > 0 {
|
||||
answer.WriteString(remaining[:openLoc[0]])
|
||||
}
|
||||
found = true
|
||||
|
||||
// Find closing tag after the opening tag
|
||||
afterOpen := remaining[openLoc[1]:]
|
||||
@@ -58,6 +63,8 @@ func SplitThinkTags(text string) ThinkTagSplit {
|
||||
Thinking: thinking.String(),
|
||||
Answer: answer.String(),
|
||||
Partial: true,
|
||||
Found: true,
|
||||
Pending: remaining[openLoc[0]:],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +73,46 @@ func SplitThinkTags(text string) ThinkTagSplit {
|
||||
remaining = afterOpen[closeLoc[1]:]
|
||||
}
|
||||
|
||||
answerText := answer.String()
|
||||
pending := ""
|
||||
if !found {
|
||||
answerText, pending = splitTrailingThinkTagPrefix(answerText)
|
||||
}
|
||||
|
||||
return ThinkTagSplit{
|
||||
Thinking: thinking.String(),
|
||||
Answer: answer.String(),
|
||||
Answer: answerText,
|
||||
Found: found,
|
||||
Pending: pending,
|
||||
}
|
||||
}
|
||||
|
||||
func splitTrailingThinkTagPrefix(text string) (answer, pending string) {
|
||||
idx := strings.LastIndex(text, "<")
|
||||
if idx < 0 {
|
||||
return text, ""
|
||||
}
|
||||
suffix := text[idx:]
|
||||
if strings.Contains(suffix, ">") || !isPossibleThinkOpenPrefix(suffix) {
|
||||
return text, ""
|
||||
}
|
||||
return text[:idx], suffix
|
||||
}
|
||||
|
||||
func isPossibleThinkOpenPrefix(suffix string) bool {
|
||||
lower := strings.ToLower(suffix)
|
||||
if !strings.HasPrefix(lower, "<") {
|
||||
return false
|
||||
}
|
||||
rest := strings.TrimLeft(lower[1:], " \t\r\n")
|
||||
normalized := "<" + rest
|
||||
if normalized == "<" {
|
||||
return true
|
||||
}
|
||||
for _, tag := range []string{"<think", "<thinking", "<thought", "<antthinking"} {
|
||||
if strings.HasPrefix(tag, normalized) || strings.HasPrefix(normalized, tag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -55,16 +55,17 @@ func (s *ObserveStage) drainInjectedMessages() []providers.Message {
|
||||
}
|
||||
|
||||
func (s *ObserveStage) observeFinalResponse(state *RunState, resp *providers.ChatResponse, injected []providers.Message) {
|
||||
content, thinking := splitTaggedThinkingContent(resp.Content, resp.Thinking)
|
||||
if len(injected) == 0 {
|
||||
state.Observe.FinalContent = resp.Content
|
||||
state.Observe.FinalThinking = resp.Thinking
|
||||
state.Observe.FinalContent = content
|
||||
state.Observe.FinalThinking = thinking
|
||||
return
|
||||
}
|
||||
|
||||
state.Messages.AppendPending(providers.Message{
|
||||
Role: "assistant",
|
||||
Content: resp.Content,
|
||||
Thinking: resp.Thinking,
|
||||
Content: content,
|
||||
Thinking: thinking,
|
||||
Transient: true,
|
||||
})
|
||||
appendPendingMessages(state, injected)
|
||||
|
||||
@@ -1981,6 +1981,27 @@ func TestObserveStage_LateInjectionAfterFinalContinuesRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveStage_MovesTaggedThinkingIntoFinalThinking(t *testing.T) {
|
||||
t.Parallel()
|
||||
stage := NewObserveStage(&PipelineDeps{})
|
||||
state := defaultState()
|
||||
state.Think.LastResponse = &providers.ChatResponse{
|
||||
Content: "Visible <thinking>hidden reasoning</thinking> answer",
|
||||
Thinking: "native",
|
||||
}
|
||||
|
||||
err := stage.Execute(context.Background(), state)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error: %v", err)
|
||||
}
|
||||
if state.Observe.FinalContent != "Visible answer" {
|
||||
t.Errorf("FinalContent = %q, want %q", state.Observe.FinalContent, "Visible answer")
|
||||
}
|
||||
if state.Observe.FinalThinking != "native\nhidden reasoning" {
|
||||
t.Errorf("FinalThinking = %q, want %q", state.Observe.FinalThinking, "native\nhidden reasoning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveStage_FinalContent_SetWhenNoToolCalls(t *testing.T) {
|
||||
t.Parallel()
|
||||
deps := &PipelineDeps{}
|
||||
@@ -2440,6 +2461,35 @@ func TestFinalizeStage_SanitizesContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeStage_SuppressesSilentReplyAfterFlush(t *testing.T) {
|
||||
t.Parallel()
|
||||
finalContent := "This is directed at Bảo Ly Content, not me. I should stay silent.NO_REPLY"
|
||||
var flushed []providers.Message
|
||||
deps := &PipelineDeps{
|
||||
IsSilentReply: func(content string) bool {
|
||||
return content == finalContent
|
||||
},
|
||||
FlushMessages: func(_ context.Context, _ string, messages []providers.Message) error {
|
||||
flushed = append(flushed, messages...)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
stage := NewFinalizeStage(deps)
|
||||
state := defaultState()
|
||||
state.Observe.FinalContent = finalContent
|
||||
|
||||
err := stage.Execute(context.Background(), state)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error: %v", err)
|
||||
}
|
||||
if state.Observe.FinalContent != "" {
|
||||
t.Fatalf("FinalContent = %q, want empty after silent reply suppression", state.Observe.FinalContent)
|
||||
}
|
||||
if len(flushed) == 0 || flushed[len(flushed)-1].Content != finalContent {
|
||||
t.Fatalf("flushed final content = %#v, want original silent reply persisted before suppression", flushed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeStage_DeduplicatesMediaByPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
deps := &PipelineDeps{}
|
||||
@@ -3332,4 +3382,3 @@ func TestToolStage_Parallel_DefersNonToolMessages(t *testing.T) {
|
||||
t.Errorf("pending[3].Content = %q, want nudge", pending[3].Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var taggedThinkingOpenRe = regexp.MustCompile(`(?is)<\s*(?:redacted_thinking|think(?:ing)?|thought|antthinking)\b[^>]*>`)
|
||||
var taggedThinkingCloseRe = regexp.MustCompile(`(?is)</\s*(?:redacted_thinking|think(?:ing)?|thought|antthinking)\s*>`)
|
||||
|
||||
func splitTaggedThinkingContent(content, existingThinking string) (string, string) {
|
||||
lower := strings.ToLower(content)
|
||||
if !strings.Contains(lower, "<think") &&
|
||||
!strings.Contains(lower, "<thought") &&
|
||||
!strings.Contains(lower, "<antthinking") &&
|
||||
!strings.Contains(lower, "<redacted_thinking") {
|
||||
return content, existingThinking
|
||||
}
|
||||
|
||||
var answer strings.Builder
|
||||
var taggedThinking strings.Builder
|
||||
remaining := content
|
||||
|
||||
for remaining != "" {
|
||||
openLoc := taggedThinkingOpenRe.FindStringIndex(remaining)
|
||||
if openLoc == nil {
|
||||
answer.WriteString(remaining)
|
||||
break
|
||||
}
|
||||
|
||||
answer.WriteString(remaining[:openLoc[0]])
|
||||
afterOpen := remaining[openLoc[1]:]
|
||||
closeLoc := taggedThinkingCloseRe.FindStringIndex(afterOpen)
|
||||
if closeLoc == nil {
|
||||
taggedThinking.WriteString(afterOpen)
|
||||
break
|
||||
}
|
||||
|
||||
taggedThinking.WriteString(afterOpen[:closeLoc[0]])
|
||||
remaining = afterOpen[closeLoc[1]:]
|
||||
}
|
||||
|
||||
return answer.String(), appendTaggedThinking(existingThinking, taggedThinking.String())
|
||||
}
|
||||
|
||||
func appendTaggedThinking(existing, addition string) string {
|
||||
existing = strings.TrimSpace(existing)
|
||||
addition = strings.TrimSpace(addition)
|
||||
if existing == "" {
|
||||
return addition
|
||||
}
|
||||
if addition == "" {
|
||||
return existing
|
||||
}
|
||||
return existing + "\n" + addition
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transformHistoryMessages } from "./chat-message.adapter";
|
||||
import type { Message } from "@/types/session";
|
||||
|
||||
describe("transformHistoryMessages", () => {
|
||||
it("moves legacy tagged thinking from assistant content into thinking", () => {
|
||||
const messages: Message[] = [
|
||||
{ role: "assistant", content: "Visible <thinking>hidden reasoning</thinking> answer" },
|
||||
];
|
||||
|
||||
const [msg] = transformHistoryMessages(messages);
|
||||
|
||||
expect(msg?.content).toBe("Visible answer");
|
||||
expect(msg?.thinking).toBe("hidden reasoning");
|
||||
});
|
||||
|
||||
it("preserves existing thinking and appends tagged thinking", () => {
|
||||
const messages: Message[] = [
|
||||
{ role: "assistant", content: "Visible <think>tagged</think> answer", thinking: "native" },
|
||||
];
|
||||
|
||||
const [msg] = transformHistoryMessages(messages);
|
||||
|
||||
expect(msg?.content).toBe("Visible answer");
|
||||
expect(msg?.thinking).toBe("native\ntagged");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { Message } from "@/types/session";
|
||||
import type { ChatMessage, ToolStreamEntry, MediaItem } from "@/types/chat";
|
||||
import { toFileUrl } from "@/lib/file-helpers";
|
||||
import { messageToTimestamp } from "@/lib/message-utils";
|
||||
import { splitThinkingTagsFromText } from "@/lib/think-tag-stream";
|
||||
|
||||
/**
|
||||
* Transform raw Message[] from history RPC into ChatMessage[] for display.
|
||||
@@ -21,8 +22,13 @@ export function transformHistoryMessages(
|
||||
}
|
||||
|
||||
const msgs: ChatMessage[] = allMsgs.map((m: Message, i: number) => {
|
||||
const normalized = m.role === "assistant"
|
||||
? splitThinkingTagsFromText(m.content ?? "", m.thinking ?? "")
|
||||
: { content: m.content, thinking: m.thinking };
|
||||
const chatMsg: ChatMessage = {
|
||||
...m,
|
||||
content: normalized.content,
|
||||
thinking: normalized.thinking,
|
||||
timestamp: messageToTimestamp(m, i, allMsgs.length),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendFilteredThinkingChunk, createThinkTagStreamFilterState } from "./think-tag-stream";
|
||||
|
||||
describe("think tag stream filter", () => {
|
||||
it("strips late thinking tags from streamed answer text", () => {
|
||||
let state = createThinkTagStreamFilterState();
|
||||
|
||||
state = appendFilteredThinkingChunk(state, "Visible prefix ");
|
||||
state = appendFilteredThinkingChunk(state, "<thinking>hidden reasoning</thinking> visible answer");
|
||||
|
||||
expect(state.text).toBe("Visible prefix visible answer");
|
||||
expect(state.thinking).toBe("hidden reasoning");
|
||||
expect(state.thinkingDelta).toBe("hidden reasoning");
|
||||
});
|
||||
|
||||
it("strips thinking tags split across stream chunks", () => {
|
||||
let state = createThinkTagStreamFilterState();
|
||||
|
||||
state = appendFilteredThinkingChunk(state, "Visible ");
|
||||
state = appendFilteredThinkingChunk(state, "<");
|
||||
state = appendFilteredThinkingChunk(state, "thinking>hidden reasoning");
|
||||
state = appendFilteredThinkingChunk(state, "</thinking> answer");
|
||||
|
||||
expect(state.text).toBe("Visible answer");
|
||||
expect(state.thinking).toBe("hidden reasoning");
|
||||
});
|
||||
|
||||
it("streams thinking text while the closing tag is still pending", () => {
|
||||
let state = createThinkTagStreamFilterState();
|
||||
|
||||
state = appendFilteredThinkingChunk(state, "Visible <thinking>hidden ");
|
||||
expect(state.text).toBe("Visible ");
|
||||
expect(state.thinking).toBe("hidden ");
|
||||
expect(state.thinkingDelta).toBe("hidden ");
|
||||
|
||||
state = appendFilteredThinkingChunk(state, "reasoning</think");
|
||||
expect(state.text).toBe("Visible ");
|
||||
expect(state.thinking).toBe("hidden reasoning");
|
||||
expect(state.thinkingDelta).toBe("reasoning");
|
||||
|
||||
state = appendFilteredThinkingChunk(state, "ing> answer");
|
||||
expect(state.text).toBe("Visible answer");
|
||||
expect(state.thinking).toBe("hidden reasoning");
|
||||
});
|
||||
|
||||
it("holds a partial think tag name until the tag resolves", () => {
|
||||
let state = createThinkTagStreamFilterState();
|
||||
|
||||
state = appendFilteredThinkingChunk(state, "Visible ");
|
||||
state = appendFilteredThinkingChunk(state, "<think");
|
||||
|
||||
expect(state.text).toBe("Visible ");
|
||||
|
||||
state = appendFilteredThinkingChunk(state, "ing>hidden reasoning</thinking> answer");
|
||||
|
||||
expect(state.text).toBe("Visible answer");
|
||||
expect(state.thinking).toBe("hidden reasoning");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
export type ThinkTagStreamFilterState = {
|
||||
text: string;
|
||||
thinking: string;
|
||||
thinkingDelta: string;
|
||||
pending: string;
|
||||
inThinking: boolean;
|
||||
};
|
||||
|
||||
const openThinkTagRe = /<\s*(?:redacted_thinking|think(?:ing)?|thought|antthinking)\b[^>]*>/i;
|
||||
const closeThinkTagRe = /<\/\s*(?:redacted_thinking|think(?:ing)?|thought|antthinking)\s*>/i;
|
||||
const thinkOpenPrefixes = ["<redacted_thinking", "<think", "<thinking", "<thought", "<antthinking"];
|
||||
const thinkClosePrefixes = ["</redacted_thinking", "</think", "</thinking", "</thought", "</antthinking"];
|
||||
|
||||
export function createThinkTagStreamFilterState(): ThinkTagStreamFilterState {
|
||||
return { text: "", thinking: "", thinkingDelta: "", pending: "", inThinking: false };
|
||||
}
|
||||
|
||||
export function appendFilteredThinkingChunk(
|
||||
state: ThinkTagStreamFilterState,
|
||||
chunk: string,
|
||||
): ThinkTagStreamFilterState {
|
||||
let input = state.pending + chunk;
|
||||
let text = state.text;
|
||||
let thinking = state.thinking;
|
||||
let thinkingDelta = "";
|
||||
let inThinking = state.inThinking;
|
||||
let pending = "";
|
||||
|
||||
while (input.length > 0) {
|
||||
if (inThinking) {
|
||||
const close = closeThinkTagRe.exec(input);
|
||||
if (!close) {
|
||||
const split = splitTrailingThinkTagPrefix(input, thinkClosePrefixes);
|
||||
thinking += split.answer;
|
||||
thinkingDelta += split.answer;
|
||||
pending = split.pending;
|
||||
return { text, thinking, thinkingDelta, pending, inThinking: true };
|
||||
}
|
||||
|
||||
const thinkingPart = input.slice(0, close.index);
|
||||
thinking += thinkingPart;
|
||||
thinkingDelta += thinkingPart;
|
||||
input = input.slice(close.index + close[0].length);
|
||||
inThinking = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const open = openThinkTagRe.exec(input);
|
||||
if (!open) {
|
||||
const split = splitTrailingThinkTagPrefix(input, thinkOpenPrefixes);
|
||||
text += split.answer;
|
||||
pending = split.pending;
|
||||
break;
|
||||
}
|
||||
|
||||
text += input.slice(0, open.index);
|
||||
input = input.slice(open.index + open[0].length);
|
||||
inThinking = true;
|
||||
}
|
||||
|
||||
return { text, thinking, thinkingDelta, pending, inThinking };
|
||||
}
|
||||
|
||||
export function splitThinkingTagsFromText(content: string, existingThinking = ""): { content: string; thinking?: string } {
|
||||
const state = appendFilteredThinkingChunk(createThinkTagStreamFilterState(), content);
|
||||
const thinking = appendThinking(existingThinking, state.thinking);
|
||||
return { content: state.text, thinking: thinking || undefined };
|
||||
}
|
||||
|
||||
function appendThinking(existing: string, addition: string): string {
|
||||
const cleanExisting = existing.trim();
|
||||
const cleanAddition = addition.trim();
|
||||
if (!cleanExisting) return cleanAddition;
|
||||
if (!cleanAddition) return cleanExisting;
|
||||
return `${cleanExisting}\n${cleanAddition}`;
|
||||
}
|
||||
|
||||
function splitTrailingThinkTagPrefix(input: string, prefixes: string[]): { answer: string; pending: string } {
|
||||
const idx = input.lastIndexOf("<");
|
||||
if (idx < 0) return { answer: input, pending: "" };
|
||||
|
||||
const suffix = input.slice(idx);
|
||||
if (suffix.includes(">") || !isPossibleThinkTagPrefix(suffix, prefixes)) {
|
||||
return { answer: input, pending: "" };
|
||||
}
|
||||
return { answer: input.slice(0, idx), pending: suffix };
|
||||
}
|
||||
|
||||
function isPossibleThinkTagPrefix(suffix: string, prefixes: string[]): boolean {
|
||||
const lower = suffix.toLowerCase();
|
||||
if (!lower.startsWith("<")) return false;
|
||||
|
||||
const normalized = lower.startsWith("</")
|
||||
? `</${lower.slice(2).trimStart()}`
|
||||
: `<${lower.slice(1).trimStart()}`;
|
||||
|
||||
if (normalized === "<" || normalized === "</") return true;
|
||||
return prefixes.some((tag) => tag.startsWith(normalized) || normalized.startsWith(tag));
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { toFileUrl, mediaKindFromMime } from "@/lib/file-helpers";
|
||||
import { transformHistoryMessages } from "@/adapters/chat-message.adapter";
|
||||
import { useChatTeamTasks } from "./use-chat-team-tasks";
|
||||
import { useChatMessagesStore } from "@/stores/use-chat-messages-store";
|
||||
import { appendFilteredThinkingChunk, createThinkTagStreamFilterState } from "@/lib/think-tag-stream";
|
||||
|
||||
// Stable empty array — avoids creating a new reference on every render inside
|
||||
// Zustand selectors, which would trigger an infinite re-render loop (React #185).
|
||||
@@ -41,6 +42,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
const expectingRunRef = useRef(false);
|
||||
const streamRef = useRef("");
|
||||
const thinkingRef = useRef("");
|
||||
const streamFilterRef = useRef(createThinkTagStreamFilterState());
|
||||
const toolStreamRef = useRef<ToolStreamEntry[]>([]);
|
||||
const agentIdRef = useRef(agentId);
|
||||
agentIdRef.current = agentId;
|
||||
@@ -96,6 +98,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
expectingRunRef.current = false;
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
streamFilterRef.current = createThinkTagStreamFilterState();
|
||||
toolStreamRef.current = [];
|
||||
activityRef.current = null;
|
||||
blockRepliesRef.current = [];
|
||||
@@ -158,6 +161,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
setToolStream([]);
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
streamFilterRef.current = createThinkTagStreamFilterState();
|
||||
toolStreamRef.current = [];
|
||||
}
|
||||
return;
|
||||
@@ -179,7 +183,11 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
break;
|
||||
}
|
||||
case "chunk": {
|
||||
streamRef.current += event.payload?.content ?? "";
|
||||
streamFilterRef.current = appendFilteredThinkingChunk(streamFilterRef.current, event.payload?.content ?? "");
|
||||
if (streamFilterRef.current.thinkingDelta) {
|
||||
thinkingRef.current += streamFilterRef.current.thinkingDelta;
|
||||
}
|
||||
streamRef.current = streamFilterRef.current.text;
|
||||
if (!rafPendingRef.current) {
|
||||
rafPendingRef.current = true;
|
||||
rafHandleRef.current = requestAnimationFrame(() => {
|
||||
@@ -246,6 +254,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
setToolStream([]);
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
streamFilterRef.current = createThinkTagStreamFilterState();
|
||||
toolStreamRef.current = [];
|
||||
activityRef.current = null;
|
||||
setActivity(null);
|
||||
@@ -269,6 +278,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
setToolStream([]);
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
streamFilterRef.current = createThinkTagStreamFilterState();
|
||||
activityRef.current = null;
|
||||
setActivity(null);
|
||||
blockRepliesRef.current = [];
|
||||
@@ -286,6 +296,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
setToolStream([]);
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
streamFilterRef.current = createThinkTagStreamFilterState();
|
||||
toolStreamRef.current = [];
|
||||
activityRef.current = null;
|
||||
setActivity(null);
|
||||
|
||||
Reference in new issue
Block a user