ituoke/internal/agent/stream_test.go

149 lines
5.4 KiB
Go

package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestStreamDeliversFirstChunkBeforeCompletion(t *testing.T) {
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
if body["stream"] != true {
t.Error("stream=true was not sent")
}
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Error("missing stream credential")
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
fmt.Fprint(w, ": heartbeat\r\nevent: message\r\ndata: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"你好\"}}]}\r\n\r\n")
w.(http.Flusher).Flush()
select {
case <-release:
case <-r.Context().Done():
return
}
fmt.Fprint(w, "data: {\"choices\":[\ndata: {\"index\":0,\"delta\":{\"content\":\",世界🙂\"}}]}\n\n")
fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]")
}))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
chunks := make(chan string, 4)
finished := make(chan error, 1)
go func() {
text, reason, err := completeStream(ctx, testConfig(server.URL), "test-key", nil, func(delta string) { chunks <- delta })
if err == nil && (text != "你好,世界🙂" || reason != "stop") {
err = fmt.Errorf("wrong completion: %q %q", text, reason)
}
finished <- err
}()
select {
case first := <-chunks:
if first != "你好" {
t.Fatalf("first chunk: %q", first)
}
case err := <-finished:
t.Fatalf("returned before releasing stream: %v", err)
case <-ctx.Done():
t.Fatal("first chunk was buffered until stream ended")
}
close(release)
if err := <-finished; err != nil {
t.Fatal(err)
}
if second := <-chunks; second != ",世界🙂" {
t.Fatalf("Unicode/multiline chunk damaged: %q", second)
}
}
func TestStreamCancellationClosesHTTPAndDoesNotCommitHistory(t *testing.T) {
disconnected := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"}}]}\n\n")
w.(http.Flusher).Flush()
<-r.Context().Done()
close(disconnected)
}))
defer server.Close()
s, _ := testService(t)
if _, err := s.SaveModelConfig(ModelConfigInput{Config: testConfig(server.URL), APIKey: "key"}); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s.emitDelta = func(delta ChatDelta) {
if delta.RequestID != "request-1" || delta.Delta != "partial" {
t.Errorf("wrong event: %+v", delta)
}
cancel()
}
_, err := s.ChatStream(ctx, "request-1", "phone", "hello")
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation lost: %v", err)
}
if len(s.history["phone"]) != 0 {
t.Fatal("cancelled answer committed to model context")
}
select {
case <-disconnected:
case <-time.After(2 * time.Second):
t.Fatal("cancel did not close HTTP request")
}
}
func TestStreamCompletionAndFailureMarkers(t *testing.T) {
cases := []struct {
name, input, text, reason string
wantError bool
}{
{"done", "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n", "ok", "", false},
{"length", "data: {\"choices\":[{\"delta\":{\"content\":\"part\"},\"finish_reason\":\"length\"}]}\n\n", "part", "length", false},
{"interrupted", "data: {\"choices\":[{\"delta\":{\"content\":\"part\"}}]}\n\n", "part", "", true},
{"empty", "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\ndata: [DONE]\n\n", "", "", true},
{"malformed", "data: {oops}\n\n", "", "", true},
{"provider-error", "data: {\"error\":{\"message\":\"rejected secret-key\"}}\n\n", "", "", true},
{"filtered", "data: {\"choices\":[{\"delta\":{\"content\":\"part\"},\"finish_reason\":\"content_filter\"}]}\n\n", "part", "content_filter", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var received strings.Builder
text, reason, err := readCompletionStream(strings.NewReader(tc.input), "secret-key", func(delta string) { received.WriteString(delta) })
if (err != nil) != tc.wantError || text != tc.text || reason != tc.reason || received.String() != text {
t.Fatalf("got %q %q %v, callback %q", text, reason, err, received.String())
}
if err != nil && strings.Contains(err.Error(), "secret-key") {
t.Fatal("provider error exposed credential")
}
})
}
}
func TestSuccessfulStreamCommitsHistory(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\ndata: {\"choices\":[],\"usage\":{}}\n\ndata: [DONE]\n\n")
}))
defer server.Close()
s, _ := testService(t)
if _, err := s.SaveModelConfig(ModelConfigInput{Config: testConfig(server.URL), APIKey: "key"}); err != nil {
t.Fatal(err)
}
result, err := s.ChatStream(context.Background(), "request-1", "phone", "question")
if err != nil || result["content"] != "hello" {
t.Fatalf("completion: %v %v", result, err)
}
if len(s.history["phone"]) != 2 || s.history["phone"][1].Content != "hello" {
t.Fatal("completed stream did not enter history")
}
}