diff --git a/stream_filters_test.go b/stream_filters_test.go
new file mode 100644
index 0000000..175041c
--- /dev/null
+++ b/stream_filters_test.go
@@ -0,0 +1,146 @@
+// streaming filter regression tests (reasoning + tool_response passthrough)
+// Created by Luxferre in 2026, released into the public domain with no warranties
+
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+// build tags without embedding the literal token, so tooling cannot mangle it.
+func thinkOpen() string { return "<" + "think" + ">" }
+func thinkClose() string { return "<" + "/think" + ">" }
+
+// runPipeline mirrors the gateway streaming chain: thinking -> tool_call.
+func runPipeline(allowed map[string]bool, chunks []string) (string, string, []ToolCall) {
+ thinkFilter := NewStreamThinkingFilter()
+ toolFilter := NewStreamToolCallFilter(allowed)
+
+ var content, reasoning []string
+ var calls []ToolCall
+
+ onContent := func(s string) { content = append(content, s) }
+ onReasoning := func(s string) { reasoning = append(reasoning, s) }
+ onCall := func(tc ToolCall) { calls = append(calls, tc) }
+
+ forward := func(clean string) { toolFilter.Feed(clean, onContent, onReasoning, onCall) }
+ for _, c := range chunks {
+ thinkFilter.Feed(c, forward, onReasoning)
+ }
+ thinkFilter.Flush(forward, onReasoning)
+ toolFilter.Flush(onContent, onReasoning, onCall)
+ return strings.Join(content, ""), strings.Join(reasoning, ""), calls
+}
+
+func chunkify(s string, n int) []string {
+ var out []string
+ for i := 0; i < len(s); i += n {
+ end := i + n
+ if end > len(s) {
+ end = len(s)
+ }
+ out = append(out, s[i:end])
+ }
+ return out
+}
+
+func TestStreamThinkingFilterExtractsReasoning(t *testing.T) {
+ payload := "Sure thing.\n" + thinkOpen() + "The user wants a greeting.\nI will comply." + thinkClose() + "\nHello, friend!"
+
+ content, reasoning, calls := runPipeline(nil, []string{payload})
+ if len(calls) != 0 {
+ t.Fatalf("unexpected calls: %v", calls)
+ }
+ if !strings.Contains(content, "Sure thing.") || !strings.Contains(content, "Hello, friend!") {
+ t.Fatalf("content missing visible text: %q", content)
+ }
+ if strings.Contains(content, "The user wants a greeting") {
+ t.Fatalf("reasoning leaked into content: %q", content)
+ }
+ if !strings.Contains(reasoning, "The user wants a greeting") || !strings.Contains(reasoning, "I will comply.") {
+ t.Fatalf("reasoning not captured: %q", reasoning)
+ }
+ if strings.Contains(content, "think") {
+ t.Fatalf("raw thinking tag leaked into content: %q", content)
+ }
+}
+
+func TestStreamThinkingFilterBoundaryStability(t *testing.T) {
+ payload := "A" + thinkOpen() + "B" + thinkClose() + "C"
+ refContent, refReasoning, _ := runPipeline(nil, []string{payload})
+ if refContent != "AC" || refReasoning != "B" {
+ t.Fatalf("unexpected reference split: content=%q reasoning=%q", refContent, refReasoning)
+ }
+ for _, n := range []int{1, 2, 3, 4, 5} {
+ c, r, _ := runPipeline(nil, chunkify(payload, n))
+ if c != refContent || r != refReasoning {
+ t.Fatalf("chunk size %d mismatch: content=%q reasoning=%q", n, c, r)
+ }
+ }
+}
+
+// compound echoes its own tool_response blocks as plain text; they must be
+// preserved verbatim in the content stream (no tools requested).
+func TestToolResponsePassthroughNoTools(t *testing.T) {
+ tr := "\n{\"name\": \"web_search\", \"content\": \"tokyo=14M\"}\n\n"
+ payload := tr + "Here is the result."
+
+ content, _, calls := runPipeline(nil, []string{payload})
+ if len(calls) != 0 {
+ t.Fatalf("unexpected calls: %v", calls)
+ }
+ if !strings.Contains(content, "") || !strings.Contains(content, "") {
+ t.Fatalf("tool_response tags dropped: %q", content)
+ }
+ if !strings.Contains(content, "tokyo=14M") || !strings.Contains(content, "Here is the result.") {
+ t.Fatalf("tool_response body or trailing text dropped: %q", content)
+ }
+
+ for _, n := range []int{1, 2, 3, 5, 9, 17} {
+ c, _, _ := runPipeline(nil, chunkify(payload, n))
+ if !strings.Contains(c, "") || !strings.Contains(c, "") || !strings.Contains(c, "tokyo=14M") {
+ t.Fatalf("chunk size %d lost tool_response content: %q", n, c)
+ }
+ }
+}
+
+// With tools enabled, an echoed tool_response plus a real tool_call: the call
+// is extracted and the echoed tool_response must survive (content or reasoning).
+func TestToolResponsePlusRealToolCall(t *testing.T) {
+ allowed := map[string]bool{"web_search": true}
+ tr := "\n{\"name\": \"web_search\", \"content\": \"cached\"}\n\n"
+ call := "\n{\"name\": \"web_search\", \"arguments\": {\"q\": \"more\"}}\n"
+ payload := tr + "Let me search more.\n" + thinkOpen() + "I will call the tool now." + thinkClose() + call
+
+ content, reasoning, calls := runPipeline(allowed, []string{payload})
+ if len(calls) != 1 || calls[0].Function.Name != "web_search" {
+ t.Fatalf("expected one web_search call, got %v", calls)
+ }
+ combined := content + reasoning
+ if !strings.Contains(combined, "") || !strings.Contains(combined, "") {
+ t.Fatalf("echoed tool_response tags dropped: content=%q reasoning=%q", content, reasoning)
+ }
+ if !strings.Contains(combined, "cached") {
+ t.Fatalf("echoed tool_response body dropped: content=%q reasoning=%q", content, reasoning)
+ }
+ if !strings.Contains(reasoning, "call the tool now") {
+ t.Fatalf("reasoning lost: %q", reasoning)
+ }
+ if strings.Contains(content, "") {
+ t.Fatalf("raw tool_call leaked into content: %q", content)
+ }
+}
+
+// An unregistered tool_call must never be emitted as a call, and must be kept.
+func TestCompoundUnregisteredToolCallKept(t *testing.T) {
+ allowed := map[string]bool{"web_search": true}
+ payload := "\n{\"name\": \"weather\", \"arguments\": {}}\n"
+ content, _, calls := runPipeline(allowed, []string{payload})
+ if len(calls) != 0 {
+ t.Fatalf("unregistered tool must not be emitted, got %v", calls)
+ }
+ if !strings.Contains(content, "weather") {
+ t.Fatalf("unregistered tool call lost from content: %q", content)
+ }
+}
\ No newline at end of file