| 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 |