Files
groqqer/stream_filters_test.go
2026-09-15 21:37:33 +03:00

146 lines
5.8 KiB
Go

// 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 := "<tool_response>\n{\"name\": \"web_search\", \"content\": \"tokyo=14M\"}\n</tool_response>\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, "<tool_response>") || !strings.Contains(content, "</tool_response>") {
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, "<tool_response>") || !strings.Contains(c, "</tool_response>") || !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 := "<tool_response>\n{\"name\": \"web_search\", \"content\": \"cached\"}\n</tool_response>\n"
call := "<tool_call>\n{\"name\": \"web_search\", \"arguments\": {\"q\": \"more\"}}\n</tool_call>"
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, "<tool_response>") || !strings.Contains(combined, "</tool_response>") {
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, "<tool_call>") {
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 := "<tool_call>\n{\"name\": \"weather\", \"arguments\": {}}\n</tool_call>"
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)
}
}