返回 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 wantRecovered int
41 wantFallback int
42 }{
43 {name: "transient", stripResponses: 1, wantRecovered: 1},
44 {name: "persistent", stripResponses: 2, wantFallback: 1},
45 } {
46 t.Run(tc.name, func(t *testing.T) {
47 for attempt := 1; attempt <= 3; attempt++ {
48 result := runLiveDeepSeekRecoveryScenario(t, key, tc.stripResponses, attempt)
49 t.Logf("mode=%s attempt=%d upstream_requests=%d stripped_fields=%d retry_request_identical=%t executions=%d tool_turns=%d warnings=%d retry_attempts=%d recovered=%d replaced=%d fallbacks=%d",
50 tc.name, attempt, result.requests, result.strippedFields, result.identicalRetry,
51 result.executions, result.toolTurns, result.warnings, result.retryAttempts,
52 result.recovered, result.replaced, result.fallbacks)
53 if result.strippedFields == 0 || result.replaced != 0 {
54 continue // provider chose a different response shape; retry a bounded fresh scenario
55 }
56 if result.executions != 1 || result.toolTurns != 1 {
57 t.Fatalf("tool execution/session turns = %d/%d, want 1/1", result.executions, result.toolTurns)
58 }
59 if result.warnings != 0 {
60 t.Fatalf("user-visible protocol warnings = %d, want 0", result.warnings)
61 }
62 if result.retryAttempts == 0 {
63 if result.recovered != 0 || result.fallbacks != 1 {
64 t.Fatalf("no-retry fallback outcomes recovered/fallbacks = %d/%d, want 0/1",
65 result.recovered, result.fallbacks)
66 }
67 continue // visible text made retry unsafe; fallback was correct, seek the requested retry shape
68 }
69 if !result.identicalRetry {
70 t.Fatal("missing-reasoning recovery changed the provider request")
71 }
72 if result.retryAttempts != 1 || result.recovered != tc.wantRecovered || result.fallbacks != tc.wantFallback {
73 t.Fatalf("recovery outcomes attempts/recovered/fallbacks = %d/%d/%d, want 1/%d/%d",
74 result.retryAttempts, result.recovered, result.fallbacks, tc.wantRecovered, tc.wantFallback)
75 }
76 return
77 }
78 t.Fatal("official API did not produce the requested live recovery shape in three bounded attempts")
79 })
80 }
81 }
82
83 type liveRecoveryResult struct {
84 requests, strippedFields int
85 executions, toolTurns, warnings int
86 retryAttempts, recovered, replaced int
87 fallbacks int
88 identicalRetry bool
89 }
90
91 func runLiveDeepSeekRecoveryScenario(t *testing.T, key string, stripResponses int32, attempt int) liveRecoveryResult {
92 t.Helper()
93 proxy := &liveReasoningStripProxy{stripResponses: stripResponses}
94 server := httptest.NewServer(proxy)
95 defer server.Close()
96
97 prov, err := openai.New(provider.Config{
98 Name: "deepseek-live-recovery",
99 BaseURL: server.URL,
100 Model: "deepseek-v4-flash",
101 APIKey: key,
102 Extra: map[string]any{
103 "api_key_env": "DEEPSEEK_API_KEY",
104 "reasoning_protocol": "deepseek",
105 "thinking": "enabled",
106 "effort": "low",
107 },
108 })
109 if err != nil {
110 t.Fatalf("create live provider: %v", err)
111 }
112
113 var executions atomic.Int32
114 registry := tool.NewRegistry()
115 registry.Add(liveRecoveryEchoTool{executions: &executions})
116 sink := &recordSink{}
117 a := New(prov, registry, NewSession("You are a concise tool-using assistant."), Options{
118 MaxSteps: 4,
119 MissingReasoningWarnStateDir: t.TempDir(),
120 }, sink)
121 ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
122 defer cancel()
123 if err := a.Run(ctx, fmt.Sprintf("Live recovery probe %d: call echo exactly once, then report that it completed.", attempt)); err != nil {
124 t.Fatalf("live agent run: %v", err)
125 }
126
127 result := liveRecoveryResult{executions: int(executions.Load())}
128 for _, msg := range a.Session().Snapshot() {
129 if msg.Role == provider.RoleAssistant && len(msg.ToolCalls) > 0 {
130 result.toolTurns++
131 }
132 }
133 for _, notice := range sink.kinds(event.Notice) {
134 text := strings.ToLower(notice.Text + " " + notice.Detail)
135 if strings.Contains(text, "reasoning_content") || strings.Contains(text, "replayable thinking") {
136 result.warnings++
137 }
138 }
139 result.retryAttempts = sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted)
140 result.recovered = sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryRecovered)
141 result.replaced = sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryReplaced)
142 result.fallbacks = sink.recoveryCount(event.ProtocolRecoveryMissingReasoningFallback)
143 result.requests = int(proxy.requests.Load())
144 result.strippedFields = int(proxy.strippedFields.Load())
145 proxy.mu.Lock()
146 result.identicalRetry = len(proxy.firstBody) > 0 && bytes.Equal(proxy.firstBody, proxy.retryBody)
147 proxy.mu.Unlock()
148 return result
149 }
150
151 type liveRecoveryEchoTool struct{ executions *atomic.Int32 }
152
153 func (t liveRecoveryEchoTool) Name() string { return "echo" }
154 func (t liveRecoveryEchoTool) Description() string { return "Return a fixed live-test marker." }
155 func (t liveRecoveryEchoTool) Schema() json.RawMessage {
156 return json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`)
157 }
158 func (t liveRecoveryEchoTool) ReadOnly() bool { return true }
159 func (t liveRecoveryEchoTool) Execute(context.Context, json.RawMessage) (string, error) {
160 t.executions.Add(1)
161 return "live recovery marker", nil
162 }
163
164 type liveReasoningStripProxy struct {
165 stripResponses int32
166 requests atomic.Int32
167 toolResponses atomic.Int32
168 strippedFields atomic.Int32
169 mu sync.Mutex
170 firstRequestNo int32
171 firstBody []byte
172 retryBody []byte
173 }
174
175 func (p *liveReasoningStripProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
176 body, err := io.ReadAll(r.Body)
177 if err != nil {
178 http.Error(w, "read request", http.StatusBadRequest)
179 return
180 }
181 requestNo := p.requests.Add(1)
182 p.mu.Lock()
183 if p.firstRequestNo != 0 && requestNo == p.firstRequestNo+1 {
184 p.retryBody = append([]byte(nil), body...)
185 }
186 p.mu.Unlock()
187
188 upstream, err := http.NewRequestWithContext(r.Context(), http.MethodPost,
189 "https://api.deepseek.com/chat/completions", bytes.NewReader(body))
190 if err != nil {
191 http.Error(w, "create upstream request", http.StatusInternalServerError)
192 return
193 }
194 upstream.Header.Set("Authorization", r.Header.Get("Authorization"))
195 upstream.Header.Set("Content-Type", "application/json")
196 resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(upstream)
197 if err != nil {
198 http.Error(w, "upstream request failed", http.StatusBadGateway)
199 return
200 }
201 defer resp.Body.Close()
202 responseBody, err := io.ReadAll(resp.Body)
203 if err != nil {
204 http.Error(w, "read upstream response", http.StatusBadGateway)
205 return
206 }
207 if resp.StatusCode == http.StatusOK && bytes.Contains(responseBody, []byte(`"tool_calls"`)) {
208 toolResponse := p.toolResponses.Add(1)
209 if toolResponse <= p.stripResponses {
210 if toolResponse == 1 {
211 p.mu.Lock()
212 p.firstRequestNo = requestNo
213 p.firstBody = append([]byte(nil), body...)
214 p.mu.Unlock()
215 }
216 responseBody = p.stripReasoning(responseBody)
217 }
218 }
219 w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
220 w.WriteHeader(resp.StatusCode)
221 _, _ = w.Write(responseBody)
222 }
223
224 func (p *liveReasoningStripProxy) stripReasoning(body []byte) []byte {
225 lines := bytes.Split(body, []byte("\n"))
226 for i, line := range lines {
227 if !bytes.HasPrefix(line, []byte("data: ")) {
228 continue
229 }
230 data := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data: ")))
231 if bytes.Equal(data, []byte("[DONE]")) {
232 continue
233 }
234 var payload map[string]any
235 if json.Unmarshal(data, &payload) != nil {
236 continue
237 }
238 choices, _ := payload["choices"].([]any)
239 changed := false
240 for _, rawChoice := range choices {
241 choice, _ := rawChoice.(map[string]any)
242 delta, _ := choice["delta"].(map[string]any)
243 if _, ok := delta["reasoning_content"]; ok {
244 delete(delta, "reasoning_content")
245 p.strippedFields.Add(1)
246 changed = true
247 }
248 }
249 if changed {
250 encoded, err := json.Marshal(payload)
251 if err == nil {
252 lines[i] = append([]byte("data: "), encoded...)
253 }
254 }
255 }
256 return bytes.Join(lines, []byte("\n"))
257 }
258
258 lines GO