From e385a766208f35218dfbd570e60d60aea77c41c5 Mon Sep 17 00:00:00 2001 From: Luxferre Date: Tue, 15 Sep 2026 21:37:33 +0300 Subject: [PATCH] add --- stream_filters_test.go | 146 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 stream_filters_test.go 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