github.com/anthropics/anthropic-sdk-go@v1.70.1/messageutil_test.go (about)

     1  package anthropic_test
     2  
     3  import (
     4  	"encoding/json"
     5  	"reflect"
     6  	"testing"
     7  
     8  	"github.com/tidwall/gjson"
     9  
    10  	"github.com/anthropics/anthropic-sdk-go"
    11  	"github.com/anthropics/anthropic-sdk-go/shared/constant"
    12  )
    13  
    14  func unmarshalContentBlockParam(t *testing.T, jsonData string) anthropic.ContentBlockParamUnion {
    15  	t.Helper()
    16  	var block anthropic.ContentBlockUnion
    17  	err := json.Unmarshal([]byte(jsonData), &block)
    18  	if err != nil {
    19  		t.Fatalf("Failed to unmarshal JSON: %v", err)
    20  	}
    21  	result := block.ToParam()
    22  	return result
    23  }
    24  
    25  func TestContentBlockUnionToParam(t *testing.T) {
    26  	t.Run("TextBlock with text only", func(t *testing.T) {
    27  		result := unmarshalContentBlockParam(t, `{"type":"text","text":"Hello, world!"}`)
    28  		if result.OfText == nil {
    29  			t.Fatal("Expected OfText to be non-nil")
    30  		}
    31  		if result.OfText.Text != "Hello, world!" {
    32  			t.Errorf("Expected text 'Hello, world!', got '%s'", result.OfText.Text)
    33  		}
    34  		if result.OfText.Type != constant.Text("text") {
    35  			t.Errorf("Expected type 'text', got '%s'", result.OfText.Type)
    36  		}
    37  	})
    38  
    39  	t.Run("WebSearchToolResultBlock with search results", func(t *testing.T) {
    40  		result := unmarshalContentBlockParam(t, `{"type":"web_search_tool_result","tool_use_id":"test123","content":[{"type":"web_search_result","title":"Test Web Title","url":"https://test.com","encrypted_content":"abc123","page_age":"1 day ago"}]}`)
    41  		var block anthropic.ContentBlockUnion
    42  		if err := json.Unmarshal([]byte(`{"type":"web_search_tool_result","tool_use_id":"test123","content":[{"type":"web_search_result","title":"Test Web Title","url":"https://test.com","encrypted_content":"abc123","page_age":"1 day ago"}]}`), &block); err != nil {
    43  			t.Fatalf("Failed to unmarshal: %v", err)
    44  		}
    45  
    46  		if len(block.Content.OfWebSearchResultBlockArray) != 1 {
    47  			t.Errorf("Expected Content.OfWebSearchResultBlockArray to have 1 result, got %d", len(block.Content.OfWebSearchResultBlockArray))
    48  		}
    49  		if len(block.Content.OfWebSearchResultBlockArray) > 0 && block.Content.OfWebSearchResultBlockArray[0].Title != "Test Web Title" {
    50  			t.Errorf("Expected title '', got '%s'", block.Content.OfWebSearchResultBlockArray[0].Title)
    51  		}
    52  
    53  		if result.OfWebSearchToolResult == nil {
    54  			t.Fatal("Expected OfWebSearchToolResult to be non-nil")
    55  		}
    56  		if len(result.OfWebSearchToolResult.Content.OfWebSearchToolResultBlockItem) != 1 {
    57  			t.Errorf("Expected 1 search result in param, got %d", len(result.OfWebSearchToolResult.Content.OfWebSearchToolResultBlockItem))
    58  		}
    59  	})
    60  
    61  	t.Run("ToolUseBlock keeps unknown caller", func(t *testing.T) {
    62  		result := unmarshalContentBlockParam(t, `{"type":"tool_use","id":"toolu_1","name":"f","input":{},"caller":{"type":"future_caller","x":1}}`)
    63  		b, err := json.Marshal(result)
    64  		if err != nil {
    65  			t.Fatalf("Failed to marshal: %v", err)
    66  		}
    67  		if got := gjson.GetBytes(b, "caller").Raw; got != `{"type":"future_caller","x":1}` {
    68  			t.Errorf("Expected unknown caller to survive ToParam, got %s", got)
    69  		}
    70  	})
    71  }
    72  
    73  func TestTextCitationToParamKeepsAllFields(t *testing.T) {
    74  	t.Run("page_location keeps cited_text", func(t *testing.T) {
    75  		result := unmarshalContentBlockParam(t, `{"type":"text","text":"x","citations":[{"type":"page_location","cited_text":"quoted","document_index":2,"document_title":"Doc","start_page_number":3,"end_page_number":4}]}`)
    76  		c := result.OfText.Citations[0].OfPageLocation
    77  		if c == nil {
    78  			t.Fatal("Expected OfPageLocation to be non-nil")
    79  		}
    80  		if c.CitedText != "quoted" {
    81  			t.Errorf("Expected cited_text to survive ToParam, got %q", c.CitedText)
    82  		}
    83  	})
    84  
    85  	t.Run("search_result_location keeps index fields and source", func(t *testing.T) {
    86  		result := unmarshalContentBlockParam(t, `{"type":"text","text":"x","citations":[{"type":"search_result_location","cited_text":"quoted","title":"T","source":"src-1","search_result_index":5,"start_block_index":1,"end_block_index":2}]}`)
    87  		c := result.OfText.Citations[0].OfSearchResultLocation
    88  		if c == nil {
    89  			t.Fatal("Expected OfSearchResultLocation to be non-nil")
    90  		}
    91  		if c.Source != "src-1" || c.SearchResultIndex != 5 || c.StartBlockIndex != 1 || c.EndBlockIndex != 2 {
    92  			t.Errorf("Expected source/index fields to survive ToParam, got source=%q search_result_index=%d start=%d end=%d",
    93  				c.Source, c.SearchResultIndex, c.StartBlockIndex, c.EndBlockIndex)
    94  		}
    95  	})
    96  
    97  	t.Run("web_search_result_location keeps url and encrypted_index", func(t *testing.T) {
    98  		result := unmarshalContentBlockParam(t, `{"type":"text","text":"x","citations":[{"type":"web_search_result_location","cited_text":"quoted","title":"T","url":"https://example.com","encrypted_index":"enc-1"}]}`)
    99  		c := result.OfText.Citations[0].OfWebSearchResultLocation
   100  		if c == nil {
   101  			t.Fatal("Expected OfWebSearchResultLocation to be non-nil")
   102  		}
   103  		if c.URL != "https://example.com" || c.EncryptedIndex != "enc-1" {
   104  			t.Errorf("Expected url/encrypted_index to survive ToParam, got url=%q encrypted_index=%q", c.URL, c.EncryptedIndex)
   105  		}
   106  	})
   107  }
   108  
   109  func TestToolUseToParamKeepsToolsetName(t *testing.T) {
   110  	t.Run("toolset member keeps toolset_name", func(t *testing.T) {
   111  		result := unmarshalContentBlockParam(t, `{"type":"tool_use","id":"toolu_1","name":"screenshot","input":{},"toolset_name":"computer","caller":{"type":"direct"}}`)
   112  		if result.OfToolUse == nil {
   113  			t.Fatal("Expected OfToolUse to be non-nil")
   114  		}
   115  		b, err := json.Marshal(result)
   116  		if err != nil {
   117  			t.Fatalf("Failed to marshal param: %v", err)
   118  		}
   119  		if got := gjson.GetBytes(b, "toolset_name").String(); got != "computer" {
   120  			t.Errorf("Expected toolset_name to survive ToParam, got %q in %s", got, b)
   121  		}
   122  	})
   123  
   124  	t.Run("non-member omits toolset_name", func(t *testing.T) {
   125  		result := unmarshalContentBlockParam(t, `{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{}}`)
   126  		b, err := json.Marshal(result)
   127  		if err != nil {
   128  			t.Fatalf("Failed to marshal param: %v", err)
   129  		}
   130  		if gjson.GetBytes(b, "toolset_name").Exists() {
   131  			t.Errorf("Expected toolset_name to be omitted, got %s", b)
   132  		}
   133  	})
   134  }
   135  
   136  // TestTextCitationToParamExhaustive guards against converter drift: every
   137  // citation variant round-trips fully-populated JSON, and every exported
   138  // field of the resulting param must be set — a spec-added field that the
   139  // converter forgets to copy fails here without a hand-written assertion.
   140  func TestTextCitationToParamExhaustive(t *testing.T) {
   141  	cases := map[string]string{
   142  		"char_location":              `{"type":"char_location","cited_text":"q","document_index":1,"document_title":"D","start_char_index":2,"end_char_index":3}`,
   143  		"page_location":              `{"type":"page_location","cited_text":"q","document_index":1,"document_title":"D","start_page_number":2,"end_page_number":3}`,
   144  		"content_block_location":     `{"type":"content_block_location","cited_text":"q","document_index":1,"document_title":"D","start_block_index":2,"end_block_index":3}`,
   145  		"search_result_location":     `{"type":"search_result_location","cited_text":"q","title":"T","source":"s","search_result_index":1,"start_block_index":2,"end_block_index":3}`,
   146  		"web_search_result_location": `{"type":"web_search_result_location","cited_text":"q","title":"T","url":"https://e.com","encrypted_index":"e"}`,
   147  	}
   148  	for name, citationJSON := range cases {
   149  		t.Run(name, func(t *testing.T) {
   150  			result := unmarshalContentBlockParam(t, `{"type":"text","text":"x","citations":[`+citationJSON+`]}`)
   151  			assertNoZeroExportedFields(t, result.OfText.Citations[0])
   152  		})
   153  	}
   154  }
   155  
   156  // assertNoZeroExportedFields fails for any exported zero-valued field in the
   157  // single set variant of a param union, walking one pointer level.
   158  func assertNoZeroExportedFields(t *testing.T, union any) {
   159  	t.Helper()
   160  	uv := reflect.ValueOf(union)
   161  	var variant reflect.Value
   162  	for i := 0; i < uv.NumField(); i++ {
   163  		f := uv.Field(i)
   164  		if f.Kind() == reflect.Pointer && !f.IsNil() {
   165  			variant = f.Elem()
   166  			break
   167  		}
   168  	}
   169  	if !variant.IsValid() {
   170  		t.Fatal("Expected exactly one non-nil variant in the citation param union")
   171  	}
   172  	vt := variant.Type()
   173  	for i := 0; i < vt.NumField(); i++ {
   174  		field := vt.Field(i)
   175  		if !field.IsExported() || field.Anonymous {
   176  			continue
   177  		}
   178  		if variant.Field(i).IsZero() {
   179  			t.Errorf("Expected %s.%s to be populated by toParamUnion, got the zero value", vt.Name(), field.Name)
   180  		}
   181  	}
   182  }
   183  
   184  // messageStartWithUsage seeds the fields message_delta never re-sends, so a
   185  // test can tell "the delta overwrote it" from "the delta left it alone".
   186  const messageStartWithUsage = `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"output_tokens":1,"cache_creation_input_tokens":2,"cache_read_input_tokens":3,"cache_creation":{"ephemeral_5m_input_tokens":2,"ephemeral_1h_input_tokens":0},"service_tier":"standard"}}}`
   187  
   188  // accumulateMessage folds raw stream events into a Message the way a caller of
   189  // Messages.NewStreaming does.
   190  func accumulateMessage(t *testing.T, events ...string) anthropic.Message {
   191  	t.Helper()
   192  	message := anthropic.Message{}
   193  	for _, raw := range events {
   194  		event := anthropic.MessageStreamEventUnion{}
   195  		if err := event.UnmarshalJSON([]byte(raw)); err != nil {
   196  			t.Fatalf("Failed to unmarshal event %s: %v", raw, err)
   197  		}
   198  		if err := message.Accumulate(event); err != nil {
   199  			t.Fatalf("Accumulate(%s): %v", raw, err)
   200  		}
   201  	}
   202  	return message
   203  }
   204  
   205  // TestAccumulateMessageDeltaAppliesEveryField pins the accumulated Message to
   206  // what the non-streaming Message would hold: every value the final
   207  // message_delta carries reaches it.
   208  func TestAccumulateMessageDeltaAppliesEveryField(t *testing.T) {
   209  	message := accumulateMessage(t,
   210  		messageStartWithUsage,
   211  		`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":{"id":"container_1","expires_at":"2025-01-01T00:00:00Z"}},"usage":{"input_tokens":12,"output_tokens":99,"cache_creation_input_tokens":4,"cache_read_input_tokens":5,"server_tool_use":{"web_search_requests":2,"web_fetch_requests":1},"output_tokens_details":{"thinking_tokens":7}}}`,
   212  		`{"type":"message_stop"}`,
   213  	)
   214  
   215  	if message.Container.ID != "container_1" {
   216  		t.Errorf("Expected container from the delta, got %q", message.Container.ID)
   217  	}
   218  	if got := gjson.Get(message.RawJSON(), "container.id").String(); got != "container_1" {
   219  		t.Errorf("Expected container in the accumulated JSON, got %q", got)
   220  	}
   221  	if message.StopReason != anthropic.StopReasonEndTurn {
   222  		t.Errorf("Expected stop_reason end_turn, got %q", message.StopReason)
   223  	}
   224  	if message.Usage.OutputTokensDetails.ThinkingTokens != 7 {
   225  		t.Errorf("Expected output_tokens_details from the delta, got %d", message.Usage.OutputTokensDetails.ThinkingTokens)
   226  	}
   227  	if message.Usage.ServerToolUse.WebSearchRequests != 2 || message.Usage.ServerToolUse.WebFetchRequests != 1 {
   228  		t.Errorf("Expected server_tool_use from the delta, got %+v", message.Usage.ServerToolUse)
   229  	}
   230  	// Cumulative totals: the delta's value replaces the message_start one.
   231  	if message.Usage.InputTokens != 12 || message.Usage.OutputTokens != 99 {
   232  		t.Errorf("Expected input/output tokens 12/99, got %d/%d", message.Usage.InputTokens, message.Usage.OutputTokens)
   233  	}
   234  	if message.Usage.CacheCreationInputTokens != 4 || message.Usage.CacheReadInputTokens != 5 {
   235  		t.Errorf("Expected cache tokens 4/5, got %d/%d", message.Usage.CacheCreationInputTokens, message.Usage.CacheReadInputTokens)
   236  	}
   237  }
   238  
   239  // TestAccumulateMessageDeltaKeepsOmittedUsage covers the other half of the
   240  // contract: message_delta omits the counters that do not apply, and it never
   241  // re-sends service_tier or the cache_creation breakdown at all.
   242  func TestAccumulateMessageDeltaKeepsOmittedUsage(t *testing.T) {
   243  	message := accumulateMessage(t,
   244  		messageStartWithUsage,
   245  		`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":25}}`,
   246  		`{"type":"message_stop"}`,
   247  	)
   248  
   249  	if message.Usage.OutputTokens != 25 {
   250  		t.Errorf("Expected output_tokens to be overwritten with 25, got %d", message.Usage.OutputTokens)
   251  	}
   252  	if message.Usage.InputTokens != 10 {
   253  		t.Errorf("Expected input_tokens to keep the message_start value 10, got %d", message.Usage.InputTokens)
   254  	}
   255  	if message.Usage.CacheCreationInputTokens != 2 || message.Usage.CacheReadInputTokens != 3 {
   256  		t.Errorf("Expected cache tokens to keep the message_start values 2/3, got %d/%d", message.Usage.CacheCreationInputTokens, message.Usage.CacheReadInputTokens)
   257  	}
   258  	if message.Usage.CacheCreation.Ephemeral5mInputTokens != 2 {
   259  		t.Errorf("Expected cache_creation to survive, got %+v", message.Usage.CacheCreation)
   260  	}
   261  	if message.Usage.ServiceTier != anthropic.UsageServiceTierStandard {
   262  		t.Errorf("Expected service_tier to survive, got %q", message.Usage.ServiceTier)
   263  	}
   264  	if got := gjson.Get(message.RawJSON(), "usage.service_tier").String(); got != "standard" {
   265  		t.Errorf("Expected service_tier in the accumulated JSON, got %q", got)
   266  	}
   267  	if message.ID != "msg_1" || message.Role != "assistant" {
   268  		t.Errorf("Expected id/role to survive, got %q/%q", message.ID, message.Role)
   269  	}
   270  }
   271  
   272  // TestAccumulateMessageDeltaStopDetails checks the refusal detail reaches the
   273  // message, and that the last delta wins even when its stop_details is null —
   274  // the key is always sent and null is a meaningful final value, as it is for
   275  // stop_reason and stop_sequence.
   276  func TestAccumulateMessageDeltaStopDetails(t *testing.T) {
   277  	message := accumulateMessage(t,
   278  		messageStartWithUsage,
   279  		`{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"bio","explanation":"nope"}},"usage":{"output_tokens":5}}`,
   280  	)
   281  	if message.StopDetails.Category != anthropic.RefusalStopDetailsCategoryBio {
   282  		t.Errorf("Expected stop_details from the delta, got %+v", message.StopDetails)
   283  	}
   284  
   285  	message = accumulateMessage(t,
   286  		messageStartWithUsage,
   287  		`{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"bio","explanation":"nope"}},"usage":{"output_tokens":5}}`,
   288  		`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":9}}`,
   289  	)
   290  	if message.StopDetails.Category != "" {
   291  		t.Errorf("Expected the last delta's null stop_details to win, got %+v", message.StopDetails)
   292  	}
   293  }