add
This commit is contained in:
@@ -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 := "<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user