返回 DeepSeek-Reasonix
retry_e2e_test.go
根目录 / internal / agent / retry_e2e_test.go
1 package agent
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "reflect"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "testing"
16 "time"
17
18 "reasonix/internal/event"
19 "reasonix/internal/provider"
20 "reasonix/internal/provider/anthropic"
21 "reasonix/internal/provider/openai"
22 "reasonix/internal/tool"
23 )
24
25 type recordSink struct {
26 mu sync.Mutex
27 evs []event.Event
28 recovery []event.ProtocolRecoveryAudit
29 }
30
31 type textSignalSink struct {
32 *recordSink
33 textSeen chan struct{}
34 once sync.Once
35 }
36
37 func (s *textSignalSink) Emit(e event.Event) {
38 s.recordSink.Emit(e)
39 if e.Kind == event.Text {
40 s.once.Do(func() { close(s.textSeen) })
41 }
42 }
43
44 func (s *recordSink) Emit(e event.Event) {
45 s.mu.Lock()
46 defer s.mu.Unlock()
47 s.evs = append(s.evs, e)
48 }
49
50 func (s *recordSink) kinds(k event.Kind) []event.Event {
51 s.mu.Lock()
52 defer s.mu.Unlock()
53 var out []event.Event
54 for _, e := range s.evs {
55 if e.Kind == k {
56 out = append(out, e)
57 }
58 }
59 return out
60 }
61
62 func (s *recordSink) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) {
63 s.mu.Lock()
64 defer s.mu.Unlock()
65 s.recovery = append(s.recovery, a)
66 }
67
68 func (s *recordSink) recoveryCount(kind event.ProtocolRecoveryKind) int {
69 s.mu.Lock()
70 defer s.mu.Unlock()
71 var count int
72 for _, audit := range s.recovery {
73 if audit.Kind == kind {
74 count++
75 }
76 }
77 return count
78 }
79
80 // TestAgentEmitsRetryingThenStreams drives the whole chain end-to-end: a real
81 // OpenAI-compatible provider hits an httptest server that returns 503 twice then
82 // a valid SSE stream. The agent must emit a Retrying event per backoff (so the
83 // composer can show "retrying n/m") and still deliver the streamed answer.
84 func TestAgentEmitsRetryingThenStreams(t *testing.T) {
85 var reqs int
86 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87 reqs++
88 if reqs <= 2 {
89 w.WriteHeader(http.StatusServiceUnavailable)
90 _, _ = w.Write([]byte(`{"error":"overloaded"}`))
91 return
92 }
93 w.Header().Set("Content-Type", "text/event-stream")
94 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hi there\"}}]}\n\ndata: [DONE]\n\n")
95 }))
96 defer srv.Close()
97
98 prov, err := openai.New(provider.Config{Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4", APIKey: "k"})
99 if err != nil {
100 t.Fatalf("New provider: %v", err)
101 }
102
103 sink := &recordSink{}
104 a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, sink)
105 if err := a.Run(context.Background(), "hi"); err != nil {
106 t.Fatalf("Run: %v", err)
107 }
108
109 retries := sink.kinds(event.Retrying)
110 if len(retries) != 2 || retries[0].RetryAttempt != 1 || retries[1].RetryAttempt != 2 {
111 t.Fatalf("want two Retrying events (1,2), got %+v", retries)
112 }
113 if retries[0].RetryMax != maxStreamRecoveries {
114 t.Errorf("RetryMax = %d, want %d", retries[0].RetryMax, maxStreamRecoveries)
115 }
116
117 var answer strings.Builder
118 for _, e := range sink.kinds(event.Text) {
119 answer.WriteString(e.Text)
120 }
121 if !strings.Contains(answer.String(), "hi there") {
122 t.Errorf("streamed answer = %q, want it to contain %q", answer.String(), "hi there")
123 }
124 }
125
126 // TestDeepSeekFlashMissingReasoningRecoveryWithRealSSE exercises the actual
127 // OpenAI-compatible decoder shape used by the official Flash endpoint. The
128 // first response emits a tool call without reasoning_content; the second exact
129 // request includes it; only the adopted call reaches the session and UI.
130 func TestDeepSeekFlashMissingReasoningRecoveryWithRealSSE(t *testing.T) {
131 var mu sync.Mutex
132 var bodies [][]byte
133 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134 body, _ := io.ReadAll(r.Body)
135 mu.Lock()
136 bodies = append(bodies, append([]byte(nil), body...))
137 requestNo := len(bodies)
138 mu.Unlock()
139
140 w.Header().Set("Content-Type", "text/event-stream")
141
142 if requestNo == 1 {
143 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"echo","arguments":"{\"text\":\"hi\"}"}}]},"finish_reason":"tool_calls"}]}`+"\n\n")
144 _, _ = io.WriteString(w, `data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12,"prompt_cache_hit_tokens":10,"prompt_cache_miss_tokens":0}}`+"\n\n")
145 _, _ = io.WriteString(w, "data: [DONE]\n\n")
146 return
147 }
148 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"done"},"finish_reason":"stop"}]}`+"\n\n")
149 _, _ = io.WriteString(w, "data: [DONE]\n\n")
150 }))
151 defer srv.Close()
152
153 prov, err := openai.New(provider.Config{
154 Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4-flash", APIKey: "k",
155 Extra: map[string]any{"reasoning_protocol": "deepseek", "thinking": "enabled"},
156 })
157 if err != nil {
158 t.Fatalf("New provider: %v", err)
159 }
160 sink := &recordSink{}
161 a := New(prov, echoRegistry(), NewSession(""), Options{}, sink)
162 if err := a.Run(context.Background(), "go"); err != nil {
163 t.Fatalf("Run: %v", err)
164 }
165
166 mu.Lock()
167 gotBodies := append([][]byte(nil), bodies...)
168 mu.Unlock()
169 if len(gotBodies) != 2 {
170 t.Fatalf("HTTP requests = %d, want malformed + recovery + final", len(gotBodies))
171 }
172 if !bytes.Contains(gotBodies[1], []byte(`"role":"tool"`)) {
173 t.Fatalf("recovery request changed bytes:\nfirst=%s\nretry=%s", gotBodies[0], gotBodies[1])
174 }
175 var toolTurns int
176 for _, message := range a.Session().Messages {
177 if message.Role == provider.RoleAssistant && len(message.ToolCalls) > 0 {
178 toolTurns++
179 if message.ReasoningContent != "" {
180 t.Fatalf("adopted reasoning = %q", message.ReasoningContent)
181 }
182 }
183 }
184 if toolTurns != 1 {
185 t.Fatalf("saved tool turns = %d, want 1", toolTurns)
186 }
187 // One partial dispatch from the adopted SSE plus one full execution
188 // dispatch. The discarded malformed stream must not add a third card.
189 if got := len(sink.kinds(event.ToolDispatch)); got != 2 {
190 t.Fatalf("tool dispatch events = %d, want adopted partial + full", got)
191 }
192 for _, notice := range sink.kinds(event.Notice) {
193 if strings.Contains(notice.Text, "reasoning_content") || strings.Contains(notice.Detail, "reasoning_content") {
194 t.Fatalf("protocol warning leaked to UI: %+v", notice)
195 }
196 }
197 }
198
199 // TestDeepSeekOpenAIReasoningReplay400RepairsOldHistory drives the OpenAI
200 // adapter through the shared stale-history recovery path. The first request
201 // replays an old assistant reasoning turn and is rejected; the repair retry
202 // strips only provider-visible reasoning while preserving canonical history.
203 func TestDeepSeekOpenAIReasoningReplay400RepairsOldHistory(t *testing.T) {
204 var mu sync.Mutex
205 var bodies [][]byte
206 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
207 body, _ := io.ReadAll(r.Body)
208 mu.Lock()
209 bodies = append(bodies, append([]byte(nil), body...))
210 requestNo := len(bodies)
211 mu.Unlock()
212
213 if requestNo == 1 {
214 w.WriteHeader(http.StatusBadRequest)
215 _, _ = io.WriteString(w, `{"error":{"message":"The reasoning_content in the thinking mode must be passed back to the API"}}`)
216 return
217 }
218 w.Header().Set("Content-Type", "text/event-stream")
219 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"recovered"},"finish_reason":"stop"}]}`+"\n\n")
220 _, _ = io.WriteString(w, "data: [DONE]\n\n")
221 }))
222 defer srv.Close()
223
224 prov, err := openai.New(provider.Config{
225 Name: "deepseek-openai", BaseURL: srv.URL, Model: "deepseek-v4-pro", APIKey: "k",
226 Extra: map[string]any{"reasoning_protocol": "deepseek", "thinking": "enabled"},
227 })
228 if err != nil {
229 t.Fatalf("New provider: %v", err)
230 }
231 session := NewSession("system")
232 session.Add(provider.Message{Role: provider.RoleUser, Content: "earlier"})
233 session.Add(provider.Message{Role: provider.RoleAssistant, Content: "old answer", ReasoningContent: "stale thinking"})
234 sink := &recordSink{}
235 a := New(prov, echoRegistry(), session, Options{}, sink)
236
237 if err := a.Run(withNoClosedLoop(context.Background()), "next"); err != nil {
238 t.Fatalf("Run: %v", err)
239 }
240
241 mu.Lock()
242 gotBodies := append([][]byte(nil), bodies...)
243 mu.Unlock()
244 if len(gotBodies) != 2 {
245 t.Fatalf("HTTP requests = %d, want rejected attempt plus one repair retry", len(gotBodies))
246 }
247 if !bytes.Contains(gotBodies[0], []byte(`"reasoning_content":"stale thinking"`)) {
248 t.Fatalf("first request did not replay old reasoning: %s", gotBodies[0])
249 }
250 if bytes.Contains(gotBodies[1], []byte("stale thinking")) || bytes.Contains(gotBodies[1], []byte("reasoning_content")) {
251 t.Fatalf("repair retry still carries old reasoning: %s", gotBodies[1])
252 }
253 if !bytes.Contains(gotBodies[1], []byte("old answer")) {
254 t.Fatalf("repair retry lost visible assistant text: %s", gotBodies[1])
255 }
256 var first, second map[string]json.RawMessage
257 if err := json.Unmarshal(gotBodies[0], &first); err != nil {
258 t.Fatalf("decode first request: %v", err)
259 }
260 if err := json.Unmarshal(gotBodies[1], &second); err != nil {
261 t.Fatalf("decode repair request: %v", err)
262 }
263 delete(first, "messages")
264 delete(second, "messages")
265 if !reflect.DeepEqual(first, second) {
266 t.Fatalf("repair retry changed non-message fields:\nfirst=%s\nretry=%s", gotBodies[0], gotBodies[1])
267 }
268 for _, message := range session.Snapshot() {
269 if message.Role == provider.RoleAssistant && message.Content == "old answer" && message.ReasoningContent != "stale thinking" {
270 t.Fatalf("canonical history lost old reasoning: %+v", message)
271 }
272 }
273 if got := sink.recoveryCount(event.ProtocolRecoveryReasoningReplay400Detected); got != 1 {
274 t.Fatalf("reasoning_replay_400_detected audits = %d, want 1", got)
275 }
276 if got := sink.recoveryCount(event.ProtocolRecoveryReasoningReplay400Recovered); got != 1 {
277 t.Fatalf("reasoning_replay_400_recovered audits = %d, want 1", got)
278 }
279 }
280
281 func TestDeepSeekOpenAIReasoningReplay400StripsOldToolHistory(t *testing.T) {
282 var mu sync.Mutex
283 var bodies [][]byte
284 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
285 body, _ := io.ReadAll(r.Body)
286 mu.Lock()
287 bodies = append(bodies, append([]byte(nil), body...))
288 requestNo := len(bodies)
289 mu.Unlock()
290 if requestNo == 1 {
291 w.WriteHeader(http.StatusBadRequest)
292 _, _ = io.WriteString(w, `{"error":{"message":"The reasoning_content in the thinking mode must be passed back to the API"}}`)
293 return
294 }
295 w.Header().Set("Content-Type", "text/event-stream")
296 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"recovered"},"finish_reason":"stop"}]}`+"\n\n")
297 _, _ = io.WriteString(w, "data: [DONE]\n\n")
298 }))
299 defer srv.Close()
300
301 prov, err := openai.New(provider.Config{
302 Name: "deepseek-openai", BaseURL: srv.URL, Model: "deepseek-v4-pro", APIKey: "k",
303 Extra: map[string]any{"reasoning_protocol": "deepseek", "thinking": "enabled"},
304 })
305 if err != nil {
306 t.Fatalf("New provider: %v", err)
307 }
308 session := NewSession("system")
309 session.Add(provider.Message{Role: provider.RoleUser, Content: "earlier"})
310 session.Add(provider.Message{
311 Role: provider.RoleAssistant, Content: "I will inspect the file", ReasoningContent: "stale thinking",
312 ToolCalls: []provider.ToolCall{{ID: "old-call", Name: "read_file", Arguments: `{"path":"old.go"}`}},
313 })
314 session.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "old-call", Name: "read_file", Content: "old result"})
315 a := New(prov, echoRegistry(), session, Options{}, &recordSink{})
316 if err := a.Run(withNoClosedLoop(context.Background()), "next"); err != nil {
317 t.Fatalf("Run: %v", err)
318 }
319
320 mu.Lock()
321 gotBodies := append([][]byte(nil), bodies...)
322 mu.Unlock()
323 if len(gotBodies) != 2 {
324 t.Fatalf("HTTP requests = %d, want rejected attempt plus one repair retry", len(gotBodies))
325 }
326 if !bytes.Contains(gotBodies[0], []byte("old-call")) || !bytes.Contains(gotBodies[0], []byte("stale thinking")) {
327 t.Fatalf("first request did not contain old tool history: %s", gotBodies[0])
328 }
329 if bytes.Contains(gotBodies[1], []byte(`"tool_calls"`)) || bytes.Contains(gotBodies[1], []byte(`"role":"tool"`)) || bytes.Contains(gotBodies[1], []byte("stale thinking")) {
330 t.Fatalf("repair retry retained stale tool history: %s", gotBodies[1])
331 }
332 if !bytes.Contains(gotBodies[1], []byte("old result")) {
333 t.Fatal("repair lost the completed tool output")
334 }
335 if !bytes.Contains(gotBodies[1], []byte("I will inspect the file")) {
336 t.Fatalf("repair retry lost visible old assistant text: %s", gotBodies[1])
337 }
338 }
339
340 func TestGLMToolTurnWithoutReasoningContinuesWithoutRecovery(t *testing.T) {
341 var mu sync.Mutex
342 var bodies [][]byte
343 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
344 body, _ := io.ReadAll(r.Body)
345 mu.Lock()
346 bodies = append(bodies, append([]byte(nil), body...))
347 requestNo := len(bodies)
348 mu.Unlock()
349
350 w.Header().Set("Content-Type", "text/event-stream")
351 if requestNo == 1 {
352 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"echo","arguments":"{\"text\":\"hi\"}"}}]},"finish_reason":"tool_calls"}]}`+"\n\n")
353 } else {
354 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"done"},"finish_reason":"stop"}]}`+"\n\n")
355 }
356 _, _ = io.WriteString(w, "data: [DONE]\n\n")
357 }))
358 defer srv.Close()
359
360 prov, err := openai.New(provider.Config{
361 Name: "glm", BaseURL: srv.URL, Model: "glm-5.2", APIKey: "k",
362 Extra: map[string]any{"reasoning_protocol": "glm"},
363 })
364 if err != nil {
365 t.Fatalf("New provider: %v", err)
366 }
367 sink := &recordSink{}
368 a := New(prov, echoRegistry(), NewSession(""), Options{}, sink)
369 if err := a.Run(context.Background(), "go"); err != nil {
370 t.Fatalf("Run: %v", err)
371 }
372
373 mu.Lock()
374 requestBodies := append([][]byte(nil), bodies...)
375 mu.Unlock()
376 if len(requestBodies) != 2 {
377 t.Fatalf("HTTP requests = %d, want tool turn and final turn without recovery", len(requestBodies))
378 }
379 if !bytes.Contains(requestBodies[1], []byte(`"reasoning_content":""`)) {
380 t.Fatal("GLM replay did not preserve the empty reasoning_content field required for tool history")
381 }
382 if got := sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted); got != 0 {
383 t.Fatalf("missing-reasoning retries = %d, want 0", got)
384 }
385 if got := len(sink.kinds(event.ToolResult)); got != 1 {
386 t.Fatalf("tool results = %d, want 1", got)
387 }
388 }
389
390 func TestGLMTextWithoutReasoningStreamsBeforeResponseCompletes(t *testing.T) {
391 responseStarted := make(chan struct{})
392 releaseResponse := make(chan struct{})
393 var releaseOnce sync.Once
394
395 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
396 w.Header().Set("Content-Type", "text/event-stream")
397 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"streamed"}}]}`+"\n\n")
398 w.(http.Flusher).Flush()
399 close(responseStarted)
400 <-releaseResponse
401 _, _ = io.WriteString(w, "data: [DONE]\n\n")
402 }))
403 t.Cleanup(srv.Close)
404 t.Cleanup(func() { releaseOnce.Do(func() { close(releaseResponse) }) })
405
406 prov, err := openai.New(provider.Config{
407 Name: "glm", BaseURL: srv.URL, Model: "glm-5.2", APIKey: "k",
408 Extra: map[string]any{"reasoning_protocol": "glm"},
409 })
410 if err != nil {
411 t.Fatalf("New provider: %v", err)
412 }
413 sink := &textSignalSink{recordSink: &recordSink{}, textSeen: make(chan struct{})}
414 a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, sink)
415 done := make(chan error, 1)
416 go func() { done <- a.Run(withNoClosedLoop(context.Background()), "reply with streamed") }()
417
418 <-responseStarted
419 select {
420 case <-sink.textSeen:
421 case err := <-done:
422 t.Fatalf("Run completed before the held response was released: %v", err)
423 case <-time.After(2 * time.Second):
424 t.Fatal("GLM text stayed buffered until the response completed")
425 }
426 releaseOnce.Do(func() { close(releaseResponse) })
427 if err := <-done; err != nil {
428 t.Fatalf("Run: %v", err)
429 }
430 }
431
432 func TestGLMReasoningOverflowFailsBeforeToolExecution(t *testing.T) {
433 var requests atomic.Int32
434 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
435 requestNo := requests.Add(1)
436 w.Header().Set("Content-Type", "text/event-stream")
437 if requestNo == 1 {
438 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"reasoning_content":"`+strings.Repeat("reason", 16)+`"}}]}`+"\n\n")
439 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"echo","arguments":"{\"text\":\"must not run\"}"}}]},"finish_reason":"tool_calls"}]}`+"\n\n")
440 } else {
441 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"unexpected continuation"},"finish_reason":"stop"}]}`+"\n\n")
442 }
443 _, _ = io.WriteString(w, "data: [DONE]\n\n")
444 }))
445 defer srv.Close()
446
447 prov, err := openai.New(provider.Config{
448 Name: "glm", BaseURL: srv.URL, Model: "glm-5.2", APIKey: "k",
449 Extra: map[string]any{"reasoning_protocol": "glm"},
450 })
451 if err != nil {
452 t.Fatalf("New provider: %v", err)
453 }
454 sink := &recordSink{}
455 a := New(prov, echoRegistry(), NewSession(""), Options{ReasoningByteLimit: 16}, sink)
456 var replayErr *ReasoningReplayError
457 if err := a.Run(withNoClosedLoop(context.Background()), "go"); !errors.As(err, &replayErr) || replayErr.Kind != ReasoningReplayOverflow {
458 t.Fatalf("Run error = %v, want ReasoningReplayOverflow", err)
459 }
460 if got := len(sink.kinds(event.ToolResult)); got != 0 {
461 t.Fatalf("tool results = %d, want 0 after incomplete GLM reasoning", got)
462 }
463 if got := requests.Load(); got != 1 {
464 t.Fatalf("HTTP requests = %d, want no continuation after incomplete GLM reasoning", got)
465 }
466 }
467
468 // TestDeepSeekAnthropicThinking400CatchAndRepair drives the full self-heal
469 // against the real Anthropic-adapter wire shape: the first request replays the
470 // stored thinking block and the server rejects it with DeepSeek's documented
471 // 400; the agent must repair the projection once (stripping all reasoning),
472 // retry, and keep the strong projection for the following run.
473 func TestDeepSeekAnthropicThinking400CatchAndRepair(t *testing.T) {
474 var mu sync.Mutex
475 var bodies [][]byte
476 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
477 body, _ := io.ReadAll(r.Body)
478 mu.Lock()
479 bodies = append(bodies, append([]byte(nil), body...))
480 requestNo := len(bodies)
481 mu.Unlock()
482
483 if requestNo == 1 {
484 w.WriteHeader(http.StatusBadRequest)
485 _, _ = w.Write([]byte(`{"error":{"message":"The ` + "`content[].thinking`" + ` in the thinking mode must be passed back to the API","type":"invalid_request_error"}}`))
486 return
487 }
488 w.Header().Set("Content-Type", "text/event-stream")
489 _, _ = io.WriteString(w, "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":20,\"output_tokens\":1}}}\n\n")
490 if requestNo == 2 {
491 _, _ = io.WriteString(w, "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n")
492 _, _ = io.WriteString(w, "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"fresh thinking\"}}\n\n")
493 _, _ = io.WriteString(w, "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n")
494 }
495 _, _ = io.WriteString(w, "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n")
496 _, _ = io.WriteString(w, "data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"repaired answer\"}}\n\n")
497 _, _ = io.WriteString(w, "data: {\"type\":\"content_block_stop\",\"index\":1}\n\n")
498 _, _ = io.WriteString(w, "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n")
499 _, _ = io.WriteString(w, "data: {\"type\":\"message_stop\"}\n\n")
500 }))
501 defer srv.Close()
502
503 prov, err := anthropic.New(provider.Config{
504 Name: "deepseek-anthropic", BaseURL: srv.URL, Model: "deepseek-v4-flash", APIKey: "k",
505 Extra: map[string]any{"reasoning_protocol": "deepseek", "thinking": "enabled"},
506 })
507 if err != nil {
508 t.Fatalf("New provider: %v", err)
509 }
510 session := NewSession("system")
511 session.Add(provider.Message{Role: provider.RoleUser, Content: "earlier"})
512 session.Add(provider.Message{Role: provider.RoleAssistant, Content: "old answer", ReasoningContent: "stale thinking"})
513 sink := &recordSink{}
514 a := New(prov, echoRegistry(), session, Options{}, sink)
515
516 if err := a.Run(withNoClosedLoop(context.Background()), "next"); err != nil {
517 t.Fatalf("Run: %v", err)
518 }
519
520 mu.Lock()
521 gotBodies := append([][]byte(nil), bodies...)
522 mu.Unlock()
523 if len(gotBodies) != 2 {
524 t.Fatalf("HTTP requests = %d, want rejected attempt plus one repair retry", len(gotBodies))
525 }
526 if !bytes.Contains(gotBodies[0], []byte(`"type":"thinking"`)) || !bytes.Contains(gotBodies[0], []byte("stale thinking")) {
527 t.Fatalf("first request did not replay the stored thinking block: %s", gotBodies[0])
528 }
529 if bytes.Contains(gotBodies[1], []byte(`"type":"thinking"`)) || bytes.Contains(gotBodies[1], []byte("stale thinking")) {
530 t.Fatalf("repair retry still carries thinking blocks: %s", gotBodies[1])
531 }
532 if !bytes.Contains(gotBodies[1], []byte("old answer")) {
533 t.Fatalf("repair retry lost the visible assistant text: %s", gotBodies[1])
534 }
535 // Only Messages may change between the rejected request and its repair.
536 var first, second map[string]json.RawMessage
537 if err := json.Unmarshal(gotBodies[0], &first); err != nil || json.Unmarshal(gotBodies[1], &second) != nil {
538 t.Fatalf("decode request bodies: %v", err)
539 }
540 delete(first, "messages")
541 delete(second, "messages")
542 if !reflect.DeepEqual(first, second) {
543 t.Fatalf("repair retry changed non-message fields:\nfirst=%s\nretry=%s", gotBodies[0], gotBodies[1])
544 }
545 if got := sink.recoveryCount(event.ProtocolRecoveryReasoningReplay400Detected); got != 1 {
546 t.Fatalf("reasoning_replay_400_detected audits = %d, want 1", got)
547 }
548 if got := sink.recoveryCount(event.ProtocolRecoveryReasoningReplay400Recovered); got != 1 {
549 t.Fatalf("reasoning_replay_400_recovered audits = %d, want 1", got)
550 }
551 var repairNotices int
552 for _, e := range sink.kinds(event.Notice) {
553 if e.Code == event.NoticeCodeReasoningReplayRepair && e.Level == event.LevelWarn {
554 repairNotices++
555 }
556 }
557 if repairNotices != 1 {
558 t.Fatalf("repair notices = %d, want 1", repairNotices)
559 }
560 // The adopted answer streamed through and the canonical history keeps both
561 // turns' reasoning untouched by the provider-visible repair.
562 var answer strings.Builder
563 for _, e := range sink.kinds(event.Text) {
564 answer.WriteString(e.Text)
565 }
566 if answer.String() != "repaired answer" {
567 t.Fatalf("streamed answer = %q, want the repaired response", answer.String())
568 }
569
570 // The next run keeps the repaired prefix stripped while replaying reasoning
571 // from the newly committed assistant turn normally.
572 if err := a.Run(withNoClosedLoop(context.Background()), "again"); err != nil {
573 t.Fatalf("second Run: %v", err)
574 }
575 mu.Lock()
576 third := append([]byte(nil), bodies[len(bodies)-1]...)
577 total := len(bodies)
578 mu.Unlock()
579 if total != 3 {
580 t.Fatalf("HTTP requests = %d, want one more for the follow-up run", total)
581 }
582 if bytes.Contains(third, []byte("stale thinking")) {
583 t.Fatalf("strong projection retained stale reasoning in the next run: %s", third)
584 }
585 if !bytes.Contains(third, []byte("fresh thinking")) {
586 t.Fatalf("strong projection dropped new-turn reasoning in the next run: %s", third)
587 }
588 for _, m := range session.Snapshot() {
589 if m.Role == provider.RoleAssistant && m.Content == "old answer" && m.ReasoningContent != "stale thinking" {
590 t.Fatalf("canonical history lost its reasoning: %+v", m)
591 }
592 }
593 }
594
594 lines GO