返回 DeepSeek-Reasonix
stall_recover_test.go
根目录 / internal / provider / openai / stall_recover_test.go
1 package openai
2
3 import (
4 "context"
5 "errors"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "reasonix/internal/provider"
15 )
16
17 // TestStreamStallSurfacesAsInterrupt exercises the watchdog's primary target:
18 // a proxy that drops a long-lived SSE connection silently mid-stream (no FIN,
19 // no RST) during a reasoner's first-token gap. Body-phase cuts surface as
20 // StreamInterruptedError so the Agent can replay the frozen request — providers
21 // no longer stack a second reconnect budget.
22 func TestStreamStallSurfacesAsInterrupt(t *testing.T) {
23 release := make(chan struct{})
24 var reqs atomic.Int32
25 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
26 reqs.Add(1)
27 // One keep-alive comment (resets the watchdog once) then total silence —
28 // a half-open connection that never delivers data and never closes.
29 w.Header().Set("Content-Type", "text/event-stream")
30 w.WriteHeader(http.StatusOK)
31 flush(w)
32 _, _ = io.WriteString(w, ": keep-alive\n\n")
33 flush(w)
34 <-release
35 }))
36 defer srv.Close()
37 defer close(release)
38
39 p, err := New(provider.Config{Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4", APIKey: "k"})
40 if err != nil {
41 t.Fatalf("New: %v", err)
42 }
43 p.(*client).idleTimeout = 150 * time.Millisecond
44
45 ch, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
46 if err != nil {
47 t.Fatalf("Stream: %v", err)
48 }
49 var gotInterrupted bool
50 for chunk := range ch {
51 if chunk.Type == provider.ChunkError {
52 var interrupted *provider.StreamInterruptedError
53 gotInterrupted = errors.As(chunk.Err, &interrupted)
54 }
55 }
56 if !gotInterrupted {
57 t.Error("half-open stall must surface as StreamInterruptedError for Agent replay")
58 }
59 if got := reqs.Load(); got != 1 {
60 t.Errorf("server saw %d requests, want 1 (no provider body replay)", got)
61 }
62 }
63
64 // TestStreamToleratesEmptyDataLine: an OpenAI-compatible gateway may emit an
65 // empty or heartbeat `data:` line. Before the fix the empty payload failed
66 // json.Unmarshal ("unexpected end of JSON input") and fatally aborted the whole
67 // turn; the sibling Anthropic/Responses transports tolerate it.
68 func TestStreamToleratesEmptyDataLine(t *testing.T) {
69 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
70 w.Header().Set("Content-Type", "text/event-stream")
71 _, _ = io.WriteString(w, "data:\n\n") // heartbeat / empty payload
72 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n")
73 _, _ = io.WriteString(w, "data: [DONE]\n\n")
74 }))
75 defer srv.Close()
76
77 p, err := New(provider.Config{Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4", APIKey: "k"})
78 if err != nil {
79 t.Fatalf("New: %v", err)
80 }
81 ch, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
82 if err != nil {
83 t.Fatalf("Stream: %v", err)
84 }
85 var text strings.Builder
86 for chunk := range ch {
87 if chunk.Type == provider.ChunkError {
88 t.Fatalf("empty data: line should be tolerated, not abort the stream: %v", chunk.Err)
89 }
90 if chunk.Type == provider.ChunkText {
91 text.WriteString(chunk.Text)
92 }
93 }
94 if got := text.String(); got != "ok" {
95 t.Errorf("text = %q, want %q", got, "ok")
96 }
97 }
98
98 lines GO