返回 DeepSeek-Reasonix
replay_compatibility_test.go
根目录 / internal / provider / anthropic / replay_compatibility_test.go
1 package anthropic
2
3 import (
4 "context"
5 "encoding/json"
6 "reflect"
7 "strings"
8 "testing"
9
10 "reasonix/internal/provider"
11 )
12
13 func TestAnthropicCompatibilityPrecedesRecovery(t *testing.T) {
14 native := &client{nativeAnthropic: true, thinking: "adaptive"}
15 gateway := &client{model: "deepseek-v4-flash", thinking: "adaptive"}
16 deepseek := &client{deepseek: true, thinking: "enabled"}
17 plain := provider.Message{Role: provider.RoleAssistant, Content: "answer", ReasoningContent: "observed thought", ReasoningState: provider.ReasoningComplete}
18 toolTurn := plain
19 toolTurn.ToolCalls = []provider.ToolCall{{ID: "one", Name: "read_file", Arguments: `{}`}}
20 missing := toolTurn
21 missing.ReasoningContent = ""
22 missing.ReasoningState = provider.ReasoningEmpty
23 incomplete := plain
24 incomplete.ReasoningState = provider.ReasoningIncomplete
25 truncated := plain
26 truncated.ReasoningState = provider.ReasoningTruncated
27 for _, tc := range []struct {
28 name string
29 c *client
30 m provider.Message
31 complete bool
32 want provider.ReplayDecision
33 }{
34 {"native plain unsigned", native, plain, true, provider.ReplayCompatible},
35 {"native unsigned tool", native, toolTurn, true, provider.ReplayRecover},
36 {"native missing tool", native, missing, true, provider.ReplayRecover},
37 {"gateway unsigned tool", gateway, toolTurn, true, provider.ReplayDirect},
38 {"gateway missing tool", gateway, missing, true, provider.ReplayDirect},
39 {"deepseek unsigned tool", deepseek, toolTurn, true, provider.ReplayDirect},
40 {"deepseek missing tool", deepseek, missing, true, provider.ReplayRecover},
41 {"native incomplete", native, incomplete, true, provider.ReplayReject},
42 {"native truncated", native, truncated, true, provider.ReplayReject},
43 {"native unfinished response", native, plain, false, provider.ReplayReject},
44 {"gateway incomplete", gateway, incomplete, true, provider.ReplayReject},
45 } {
46 t.Run(tc.name, func(t *testing.T) {
47 if got := provider.DecideReasoningReplay(tc.c, tc.m, tc.complete); got != tc.want {
48 t.Fatalf("decision=%s want=%s", got, tc.want)
49 }
50 })
51 }
52 }
53
54 func TestNativeUnsignedTextConversionPreservesCanonicalHistory(t *testing.T) {
55 c := &client{nativeAnthropic: true, thinking: "adaptive"}
56 raw := []provider.Message{{Role: provider.RoleAssistant, Content: "answer", ReasoningContent: "one two", ReasoningState: provider.ReasoningComplete, ThinkingBlocks: []provider.ThinkingBlock{{Type: "thinking", Thinking: "one"}, {Type: "thinking", Thinking: "two"}}}}
57 before, _ := json.Marshal(raw)
58 projected, changed := provider.ProjectReplaySafeMessages(c, raw)
59 if !changed || len(projected) != 1 || projected[0].Content != "one\n\ntwo\n\nanswer" || projected[0].ReasoningContent != "" || len(projected[0].ThinkingBlocks) != 0 {
60 t.Fatalf("projection=%+v changed=%v", projected, changed)
61 }
62 after, _ := json.Marshal(raw)
63 if string(before) != string(after) {
64 t.Fatal("conversion rewrote canonical history")
65 }
66 again, changed := provider.ProjectReplaySafeMessages(c, projected)
67 if changed || &again[0] != &projected[0] {
68 t.Fatal("conversion is not idempotent")
69 }
70 req := c.buildRequest(context.Background(), provider.Request{Messages: raw})
71 encoded, _ := json.Marshal(req.Messages)
72 if strings.Contains(string(encoded), `"type":"thinking"`) || !strings.Contains(string(encoded), `one\n\ntwo\n\nanswer`) {
73 t.Fatalf("wire=%s", encoded)
74 }
75 }
76
77 func TestGatewayReplayPreservesActualBlocksWithoutFabricatingSignature(t *testing.T) {
78 for _, mode := range []string{"adaptive", "enabled"} {
79 c := &client{thinking: mode}
80 raw := []provider.Message{{Role: provider.RoleAssistant, ReasoningContent: "received", ThinkingBlocks: []provider.ThinkingBlock{{Type: "thinking", Thinking: "received"}}, ToolCalls: []provider.ToolCall{{ID: "c", Name: "read_file", Arguments: `{}`}}}}
81 if c.ReasoningReplayCapabilities().RequireSignature {
82 t.Fatal("gateway inferred Claude signature contract")
83 }
84 projected, changed := provider.ProjectReplaySafeMessages(c, raw)
85 if changed || &projected[0] != &raw[0] {
86 t.Fatal("healthy gateway history changed")
87 }
88 req := c.buildRequest(context.Background(), provider.Request{Messages: raw})
89 encoded, _ := json.Marshal(req.Messages)
90 if !strings.Contains(string(encoded), `"thinking":"received"`) || strings.Contains(string(encoded), `"signature"`) {
91 t.Fatalf("wire=%s", encoded)
92 }
93 }
94 }
95
96 func TestSignedAndRedactedHistoryNeverUsesTextConversion(t *testing.T) {
97 c := &client{nativeAnthropic: true, thinking: "adaptive"}
98 blocks := []provider.ThinkingBlock{{Type: "thinking", Signature: "proof"}, {Type: "redacted_thinking", Data: "opaque"}}
99 raw := []provider.Message{{Role: provider.RoleAssistant, ThinkingBlocks: blocks}}
100 projected, changed := provider.ProjectReplaySafeMessages(c, raw)
101 if changed || &projected[0] != &raw[0] {
102 t.Fatal("healthy signed prefix changed")
103 }
104 replayed := c.replayReasoningBlocks(raw[0])
105 if len(replayed) != 2 || replayed[0].Signature != "proof" || replayed[1].Data != "opaque" {
106 t.Fatalf("replay=%+v", replayed)
107 }
108 mixed := raw[0]
109 mixed.ThinkingBlocks = append(append([]provider.ThinkingBlock(nil), blocks...), provider.ThinkingBlock{Type: "thinking", Thinking: "unsigned"})
110 if _, ok := c.ConvertReasoningReplay(mixed); ok {
111 t.Fatal("mixed proof was flattened")
112 }
113 if !reflect.DeepEqual(raw[0].ThinkingBlocks, blocks) {
114 t.Fatal("raw blocks changed")
115 }
116 }
117
118 func TestNilCompatibilityAdapterKeepsHistory(t *testing.T) {
119 var c *client
120 raw := []provider.Message{{Role: provider.RoleAssistant, ReasoningContent: "legacy"}}
121 projected, changed := provider.ProjectReplaySafeMessages(c, raw)
122 if changed || &projected[0] != &raw[0] {
123 t.Fatal("nil adapter changed history")
124 }
125 }
126
126 lines GO