返回 DeepSeek-Reasonix
live_deepseek_recovery_test.go
根目录 / internal / agent / live_deepseek_recovery_test.go
1 //go:build live
2
3 package agent
4
5 import (
6 "bytes"
7 "context"
8 "encoding/json"
9 "fmt"
10 "io"
11 "net/http"
12 "net/http/httptest"
13 "os"
14 "strings"
15 "sync"
16 "sync/atomic"
17 "testing"
18 "time"
19
20 "reasonix/internal/event"
21 "reasonix/internal/provider"
22 "reasonix/internal/provider/openai"
23 "reasonix/internal/tool"
24 )
25
26 // TestLiveDeepSeekFlashMissingReasoningRecovery exercises the production agent
27 // against DeepSeek's official API while a local proxy removes reasoning_content
28 // from one or two real tool-call responses. It is credential-gated and excluded
29 // from ordinary CI; response text, tool arguments, and credentials are never
30 // logged or written to disk.
31 func TestLiveDeepSeekFlashMissingReasoningRecovery(t *testing.T) {
32 key := os.Getenv("DEEPSEEK_API_KEY")
33 if key == "" {
34 t.Skip("DEEPSEEK_API_KEY not set")
35 }
36
37 for _, tc := range []struct {
38 name string
39 stripResponses int32
40 }{
41 {name: "transient", stripResponses: 1},
42 {name: "persistent", stripResponses: 2},
43 } {
44 t.Run(tc.name, func(t *testing.T) {
45 result := runLiveDeepSeekRecoveryScenario(t, key, tc.stripResponses, 1)
46 t.Logf("mode=%s upstream_requests=%d stripped_fields=%d executions=%d tool_turns=%d warnings=%d retry_attempts=%d recovered=%d replaced=%d fallbacks=%d",
47 tc.name, result.requests, result.strippedFields, result.executions, result.toolTurns,
48 result.warnings, result.retryAttempts, result.recovered, result.replaced, result.fallbacks)
49 if result.strippedFields == 0 {
50 t.Fatal("official response contained no reasoning field to strip")
51 }
52 if result.executions != 1 || result.toolTurns != 1 || result.requests != 2 {
53 t.Fatalf("executions/tool turns/requests = %d/%d/%d, want 1/1/2", result.executions, result.toolTurns, result.requests)
54 }
55 if result.warnings != 0 || result.retryAttempts != 0 || result.recovered != 0 || result.replaced != 0 || result.fallbacks != 0 {
56 t.Fatal("compatible empty reasoning must not trigger a strict recovery incident")
57 }
58 })
59 }
60 }
61
62 type liveRecoveryResult struct {
63 requests, strippedFields int
64 executions, toolTurns, warnings int
65 retryAttempts, recovered, replaced int
66 fallbacks int
67 identicalRetry bool
68 }
69
70 func runLiveDeepSeekRecoveryScenario(t *testing.T, key string, stripResponses int32, attempt int) liveRecoveryResult {
71 t.Helper()
72 proxy := &liveReasoningStripProxy{stripResponses: stripResponses}
73 server := httptest.NewServer(proxy)
74 defer server.Close()
75
76 prov, err := openai.New(provider.Config{
77 Name: "deepseek-live-recovery",
78 BaseURL: server.URL,
79 Model: "deepseek-v4-flash",
80 APIKey: key,
81 Extra: map[string]any{
82 "api_key_env": "DEEPSEEK_API_KEY",
83 "reasoning_protocol": "deepseek",
84 "thinking": "enabled",
85 "effort": "low",
86 },
87 })
88 if err != nil {
89 t.Fatalf("create live provider: %v", err)
90 }
91
92 var executions atomic.Int32
93 registry := tool.NewRegistry()
94 registry.Add(liveRecoveryEchoTool{executions: &executions})
95 sink := &recordSink{}
96 a := New(prov, registry, NewSession("You are a concise tool-using assistant."), Options{
97 MaxSteps: 4,
98 MissingReasoningWarnStateDir: t.TempDir(),
99 }, sink)
100 ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
101 defer cancel()
102 if err := a.Run(ctx, fmt.Sprintf("Live recovery probe %d: call echo exactly once, then report that it completed.", attempt)); err != nil {
103 t.Fatalf("live agent run: %v", err)
104 }
105
106 result := liveRecoveryResult{executions: int(executions.Load())}
107 for _, msg := range a.Session().Snapshot() {
108 if msg.Role == provider.RoleAssistant && len(msg.ToolCalls) > 0 {
109 result.toolTurns++
110 }
111 }
112 for _, notice := range sink.kinds(event.Notice) {
113 text := strings.ToLower(notice.Text + " " + notice.Detail)
114 if strings.Contains(text, "reasoning_content") || strings.Contains(text, "replayable thinking") {
115 result.warnings++
116 }
117 }
118 result.retryAttempts = sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted)
119 result.recovered = sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryRecovered)
120 result.replaced = sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryReplaced)
121 result.fallbacks = sink.recoveryCount(event.ProtocolRecoveryMissingReasoningFallback)
122 result.requests = int(proxy.requests.Load())
123 result.strippedFields = int(proxy.strippedFields.Load())
124 proxy.mu.Lock()
125 result.identicalRetry = len(proxy.firstBody) > 0 && bytes.Equal(proxy.firstBody, proxy.retryBody)
126 proxy.mu.Unlock()
127 return result
128 }
129
130 type liveRecoveryEchoTool struct{ executions *atomic.Int32 }
131
132 func (t liveRecoveryEchoTool) Name() string { return "echo" }
133 func (t liveRecoveryEchoTool) Description() string { return "Return a fixed live-test marker." }
134 func (t liveRecoveryEchoTool) Schema() json.RawMessage {
135 return json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`)
136 }
137 func (t liveRecoveryEchoTool) ReadOnly() bool { return true }
138 func (t liveRecoveryEchoTool) Execute(context.Context, json.RawMessage) (string, error) {
139 t.executions.Add(1)
140 return "live recovery marker", nil
141 }
142
143 type liveReasoningStripProxy struct {
144 stripResponses int32
145 requests atomic.Int32
146 toolResponses atomic.Int32
147 strippedFields atomic.Int32
148 mu sync.Mutex
149 firstRequestNo int32
150 firstBody []byte
151 retryBody []byte
152 }
153
154 func (p *liveReasoningStripProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
155 body, err := io.ReadAll(r.Body)
156 if err != nil {
157 http.Error(w, "read request", http.StatusBadRequest)
158 return
159 }
160 requestNo := p.requests.Add(1)
161 p.mu.Lock()
162 if p.firstRequestNo != 0 && requestNo == p.firstRequestNo+1 {
163 p.retryBody = append([]byte(nil), body...)
164 }
165 p.mu.Unlock()
166
167 upstream, err := http.NewRequestWithContext(r.Context(), http.MethodPost,
168 "https://api.deepseek.com/chat/completions", bytes.NewReader(body))
169 if err != nil {
170 http.Error(w, "create upstream request", http.StatusInternalServerError)
171 return
172 }
173 upstream.Header.Set("Authorization", r.Header.Get("Authorization"))
174 upstream.Header.Set("Content-Type", "application/json")
175 resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(upstream)
176 if err != nil {
177 http.Error(w, "upstream request failed", http.StatusBadGateway)
178 return
179 }
180 defer resp.Body.Close()
181 responseBody, err := io.ReadAll(resp.Body)
182 if err != nil {
183 http.Error(w, "read upstream response", http.StatusBadGateway)
184 return
185 }
186 if resp.StatusCode == http.StatusOK && bytes.Contains(responseBody, []byte(`"tool_calls"`)) {
187 toolResponse := p.toolResponses.Add(1)
188 if toolResponse <= p.stripResponses {
189 if toolResponse == 1 {
190 p.mu.Lock()
191 p.firstRequestNo = requestNo
192 p.firstBody = append([]byte(nil), body...)
193 p.mu.Unlock()
194 }
195 responseBody = p.stripReasoning(responseBody)
196 }
197 }
198 w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
199 w.WriteHeader(resp.StatusCode)
200 _, _ = w.Write(responseBody)
201 }
202
203 func (p *liveReasoningStripProxy) stripReasoning(body []byte) []byte {
204 lines := bytes.Split(body, []byte("\n"))
205 for i, line := range lines {
206 if !bytes.HasPrefix(line, []byte("data: ")) {
207 continue
208 }
209 data := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data: ")))
210 if bytes.Equal(data, []byte("[DONE]")) {
211 continue
212 }
213 var payload map[string]any
214 if json.Unmarshal(data, &payload) != nil {
215 continue
216 }
217 choices, _ := payload["choices"].([]any)
218 changed := false
219 for _, rawChoice := range choices {
220 choice, _ := rawChoice.(map[string]any)
221 delta, _ := choice["delta"].(map[string]any)
222 for _, field := range []string{"reasoning_content", "reasoning"} {
223 if _, ok := delta[field]; ok {
224 delete(delta, field)
225 p.strippedFields.Add(1)
226 changed = true
227 }
228 }
229 }
230 if changed {
231 encoded, err := json.Marshal(payload)
232 if err == nil {
233 lines[i] = append([]byte("data: "), encoded...)
234 }
235 }
236 }
237 return bytes.Join(lines, []byte("\n"))
238 }
239
239 lines GO