| 1 | package anthropic |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "testing" |
| 6 | |
| 7 | "reasonix/internal/provider" |
| 8 | ) |
| 9 | |
| 10 | func TestBuildRequestDeepSeekDropsUnreplayableToolActivity(t *testing.T) { |
| 11 | c := &client{model: "deepseek-v4-flash", deepseek: true, thinking: "enabled"} |
| 12 | r := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{ |
| 13 | {Role: provider.RoleUser, Content: "inspect the change"}, |
| 14 | { |
| 15 | Role: provider.RoleAssistant, Content: "I checked the file.", |
| 16 | ToolCalls: []provider.ToolCall{{ID: "read-1", Name: "read_file", Arguments: `{"path":"main.go"}`}}, |
| 17 | }, |
| 18 | {Role: provider.RoleTool, ToolCallID: "read-1", Name: "read_file", Content: "package main"}, |
| 19 | {Role: provider.RoleUser, Content: "continue"}, |
| 20 | }}) |
| 21 | |
| 22 | if len(r.Messages) != 3 { |
| 23 | t.Fatalf("messages = %#v, want user/plain assistant/user", r.Messages) |
| 24 | } |
| 25 | if got := r.Messages[1].Content; len(got) != 1 || got[0].Type != "text" || got[0].Text != "I checked the file." { |
| 26 | t.Fatalf("projected assistant = %#v, want visible text only", got) |
| 27 | } |
| 28 | for _, message := range r.Messages { |
| 29 | for _, block := range message.Content { |
| 30 | if block.Type == "tool_use" || block.Type == "tool_result" || block.Type == "thinking" { |
| 31 | t.Fatalf("unreplayable activity reached DeepSeek wire: %#v", r.Messages) |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func TestDeepSeekReplayProjectionKeepsHealthyHistoryBacking(t *testing.T) { |
| 38 | c := &client{model: "deepseek-v4-flash", deepseek: true, thinking: "enabled"} |
| 39 | msgs := []provider.Message{{ |
| 40 | Role: provider.RoleAssistant, ReasoningContent: "read it first", |
| 41 | ToolCalls: []provider.ToolCall{{ID: "read-1", Name: "read_file", Arguments: `{}`}}, |
| 42 | }} |
| 43 | got, changed := provider.ProjectReplaySafeMessages(c, msgs) |
| 44 | if changed || len(got) != 1 || &got[0] != &msgs[0] { |
| 45 | t.Fatal("healthy replay history allocated or changed") |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func TestBuildRequestDeepSeekMergedAssistantTurnKeepsThinkingFirst(t *testing.T) { |
| 50 | c := &client{model: "deepseek-v4-flash", deepseek: true, thinking: "enabled"} |
| 51 | r := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{ |
| 52 | {Role: provider.RoleUser, Content: "start"}, |
| 53 | { |
| 54 | Role: provider.RoleAssistant, Content: "first answer", |
| 55 | ToolCalls: []provider.ToolCall{{ID: "t1", Name: "read_file", Arguments: `{}`}}, |
| 56 | }, // reasoning lost: projection degrades this turn to plain text |
| 57 | {Role: provider.RoleTool, ToolCallID: "t1", Name: "read_file", Content: "data"}, |
| 58 | { |
| 59 | Role: provider.RoleAssistant, Content: "second answer", ReasoningContent: "fresh thinking", |
| 60 | ToolCalls: []provider.ToolCall{{ID: "t2", Name: "read_file", Arguments: `{}`}}, |
| 61 | }, |
| 62 | {Role: provider.RoleTool, ToolCallID: "t2", Name: "read_file", Content: "more data"}, |
| 63 | {Role: provider.RoleUser, Content: "continue"}, |
| 64 | }}) |
| 65 | |
| 66 | // The degraded first turn and the healthy second turn merge into one |
| 67 | // assistant message; its thinking block must lead. |
| 68 | if len(r.Messages) != 3 { |
| 69 | t.Fatalf("messages = %#v, want user/merged-assistant/user", r.Messages) |
| 70 | } |
| 71 | blocks := r.Messages[1].Content |
| 72 | if len(blocks) != 4 || |
| 73 | blocks[0].Type != "thinking" || blocks[0].Thinking != "fresh thinking" || |
| 74 | blocks[1].Type != "text" || blocks[1].Text != "first answer" || |
| 75 | blocks[2].Type != "text" || blocks[2].Text != "second answer" || |
| 76 | blocks[3].Type != "tool_use" || blocks[3].ID != "t2" { |
| 77 | t.Fatalf("merged assistant blocks = %#v, want [thinking, text, text, tool_use]", blocks) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | func TestBuildRequestNativeAnthropicKeepsItsExistingValidationPath(t *testing.T) { |
| 82 | c := &client{model: "claude-sonnet", thinking: "adaptive"} |
| 83 | r := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{ |
| 84 | Role: provider.RoleAssistant, |
| 85 | ToolCalls: []provider.ToolCall{{ID: "read-1", Name: "read_file", Arguments: `{}`}}, |
| 86 | }}}) |
| 87 | if len(r.Messages) != 2 || len(r.Messages[0].Content) != 1 || r.Messages[0].Content[0].Type != "tool_use" || |
| 88 | len(r.Messages[1].Content) != 1 || r.Messages[1].Content[0].Type != "tool_result" { |
| 89 | t.Fatalf("native Anthropic history unexpectedly projected: %#v", r.Messages) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | func TestBuildRequestDeepSeekReplaysReasoningFromHistory(t *testing.T) { |
| 94 | toolTurn := []provider.Message{ |
| 95 | {Role: provider.RoleUser, Content: "weather?"}, |
| 96 | {Role: provider.RoleAssistant, ReasoningContent: "I should call the tool.", |
| 97 | ToolCalls: []provider.ToolCall{{ID: "t1", Name: "get_weather", Arguments: `{"city":"Paris"}`}}}, |
| 98 | {Role: provider.RoleTool, ToolCallID: "t1", Content: "sunny"}, |
| 99 | } |
| 100 | for _, tc := range []struct { |
| 101 | name string |
| 102 | thinking string |
| 103 | effort string |
| 104 | }{ |
| 105 | {name: "current request has no tools", thinking: "enabled", effort: "high"}, |
| 106 | {name: "thinking disabled after tool call", thinking: "enabled", effort: "disabled"}, |
| 107 | } { |
| 108 | t.Run(tc.name, func(t *testing.T) { |
| 109 | c := &client{model: "deepseek-v4-flash", deepseek: true, thinking: tc.thinking, effort: tc.effort} |
| 110 | r := c.buildRequest(context.Background(), provider.Request{Messages: toolTurn}) |
| 111 | if len(r.Tools) != 0 { |
| 112 | t.Fatalf("current request tools = %+v, want none", r.Tools) |
| 113 | } |
| 114 | if len(r.Messages) != 3 { |
| 115 | t.Fatalf("messages = %+v, want user/assistant/user", r.Messages) |
| 116 | } |
| 117 | blocks := r.Messages[1].Content |
| 118 | if len(blocks) != 2 || blocks[0].Type != "thinking" || blocks[0].Thinking != "I should call the tool." || blocks[1].Type != "tool_use" { |
| 119 | t.Fatalf("assistant blocks = %+v, want historical thinking before tool_use", blocks) |
| 120 | } |
| 121 | }) |
| 122 | } |
| 123 | |
| 124 | t.Run("reasoning without a tool call is replayed", func(t *testing.T) { |
| 125 | // DeepSeek's thinking mode requires every historical assistant turn's |
| 126 | // thinking block back when the request declares tools, even for plain |
| 127 | // question-answer turns (api-docs.deepseek.com/guides/thinking_mode). |
| 128 | c := &client{model: "deepseek-v4-flash", deepseek: true, thinking: "enabled", effort: "high"} |
| 129 | r := c.buildRequest(context.Background(), provider.Request{ |
| 130 | Messages: []provider.Message{ |
| 131 | {Role: provider.RoleUser, Content: "hello"}, |
| 132 | {Role: provider.RoleAssistant, Content: "hi", ReasoningContent: "scratchpad"}, |
| 133 | }, |
| 134 | Tools: []provider.ToolSchema{{Name: "get_weather"}}, |
| 135 | }) |
| 136 | blocks := r.Messages[1].Content |
| 137 | if len(r.Messages) != 2 || len(blocks) != 2 || blocks[0].Type != "thinking" || blocks[0].Thinking != "scratchpad" || blocks[1].Type != "text" { |
| 138 | t.Fatalf("non-tool assistant blocks = %+v, want thinking before text", r.Messages) |
| 139 | } |
| 140 | }) |
| 141 | } |
| 142 |