diff --git a/betamessage.go b/betamessage.go index dc167ebb..b57c3fea 100644 --- a/betamessage.go +++ b/betamessage.go @@ -18,6 +18,7 @@ import ( "github.com/anthropics/anthropic-sdk-go/packages/respjson" "github.com/anthropics/anthropic-sdk-go/packages/ssestream" "github.com/anthropics/anthropic-sdk-go/shared/constant" + "github.com/tidwall/gjson" ) // BetaMessageService contains methods and other services that help with @@ -7943,16 +7944,61 @@ type BetaToolResultBlockParam struct { // Create a cache control breakpoint at this content block. CacheControl BetaCacheControlEphemeralParam `json:"cache_control,omitzero"` Content []BetaToolResultBlockParamContentUnion `json:"content,omitzero"` + // ContentString is an alternative to Content that allows setting a plain string + // value for the content field. Per the API docs, tool_result content can be + // either a string (e.g. "content": "15 degrees") or an array of content blocks. + // When ContentString is non-empty and Content is empty, it will be serialized as + // a plain string. This field is not serialized directly via struct tags. + ContentString string `json:"-"` // This field can be elided, and will marshal its zero value as "tool_result". Type constant.ToolResult `json:"type" api:"required"` paramObj } func (r BetaToolResultBlockParam) MarshalJSON() (data []byte, err error) { + if r.ContentString != "" && len(r.Content) == 0 { + type shadow struct { + ToolUseID string `json:"tool_use_id"` + IsError param.Opt[bool] `json:"is_error,omitzero"` + CacheControl BetaCacheControlEphemeralParam `json:"cache_control,omitzero"` + Content string `json:"content"` + Type constant.ToolResult `json:"type"` + } + return json.Marshal(shadow{ + ToolUseID: r.ToolUseID, + IsError: r.IsError, + CacheControl: r.CacheControl, + Content: r.ContentString, + Type: r.Type, + }) + } type shadow BetaToolResultBlockParam return param.MarshalObject(r, (*shadow)(&r)) } func (r *BetaToolResultBlockParam) UnmarshalJSON(data []byte) error { + // Check if the content field is a string (the API supports both string and array). + contentField := gjson.GetBytes(data, "content") + if contentField.Exists() && contentField.Type == gjson.String { + type stringContent struct { + ToolUseID string `json:"tool_use_id"` + IsError param.Opt[bool] `json:"is_error,omitzero"` + CacheControl BetaCacheControlEphemeralParam `json:"cache_control,omitzero"` + Content string `json:"content"` + Type constant.ToolResult `json:"type"` + } + var sc stringContent + if err := json.Unmarshal(data, &sc); err != nil { + return err + } + r.ToolUseID = sc.ToolUseID + r.IsError = sc.IsError + r.CacheControl = sc.CacheControl + r.Content = []BetaToolResultBlockParamContentUnion{ + {OfText: &BetaTextBlockParam{Text: sc.Content}}, + } + r.Type = sc.Type + return nil + } return apijson.UnmarshalRoot(data, r) } @@ -7966,6 +8012,16 @@ func NewBetaToolResultTextBlockParam(toolUseID string, text string, isError bool return p } +// NewBetaToolResultStringBlockParam creates a tool result block with content as a plain string. +// Per the API docs, tool_result content can be either a string or an array of content blocks. +func NewBetaToolResultStringBlockParam(toolUseID string, text string, isError bool) BetaToolResultBlockParam { + var p BetaToolResultBlockParam + p.ToolUseID = toolUseID + p.IsError = param.Opt[bool]{Value: isError} + p.ContentString = text + return p +} + // Only one field can be non-zero. // // Use [param.IsOmitted] to confirm if a field is set. diff --git a/message.go b/message.go index 182e22e1..ecb1446a 100644 --- a/message.go +++ b/message.go @@ -1830,12 +1830,21 @@ func NewToolUseBlock(id string, input any, name string) ContentBlockParamUnion { } func NewToolResultBlock(toolUseID string, content string, isError bool) ContentBlockParamUnion { + toolBlock := ToolResultBlockParam{ + ToolUseID: toolUseID, + ContentString: content, + IsError: Bool(isError), + } + return ContentBlockParamUnion{OfToolResult: &toolBlock} +} + +// NewToolResultBlockFromArray creates a tool result block with content as an array of content +// blocks. Use this when you need to pass structured content (e.g., text + images). +func NewToolResultBlockFromArray(toolUseID string, content []ToolResultBlockParamContentUnion, isError bool) ContentBlockParamUnion { toolBlock := ToolResultBlockParam{ ToolUseID: toolUseID, - Content: []ToolResultBlockParamContentUnion{ - {OfText: &TextBlockParam{Text: content}}, - }, - IsError: Bool(isError), + Content: content, + IsError: Bool(isError), } return ContentBlockParamUnion{OfToolResult: &toolBlock} } @@ -6725,16 +6734,65 @@ type ToolResultBlockParam struct { // Create a cache control breakpoint at this content block. CacheControl CacheControlEphemeralParam `json:"cache_control,omitzero"` Content []ToolResultBlockParamContentUnion `json:"content,omitzero"` + // ContentString is an alternative to Content that allows setting a plain string + // value for the content field. Per the API docs, tool_result content can be + // either a string (e.g. "content": "15 degrees") or an array of content blocks. + // When ContentString is non-empty and Content is empty, it will be serialized as + // a plain string. This field is not serialized directly via struct tags. + ContentString string `json:"-"` // This field can be elided, and will marshal its zero value as "tool_result". Type constant.ToolResult `json:"type" api:"required"` paramObj } func (r ToolResultBlockParam) MarshalJSON() (data []byte, err error) { + // If ContentString is set and Content array is empty, serialize content as a + // plain string instead of an array, matching the API's support for both formats. + if r.ContentString != "" && len(r.Content) == 0 { + type shadow struct { + ToolUseID string `json:"tool_use_id"` + IsError param.Opt[bool] `json:"is_error,omitzero"` + CacheControl CacheControlEphemeralParam `json:"cache_control,omitzero"` + Content string `json:"content"` + Type constant.ToolResult `json:"type"` + } + return json.Marshal(shadow{ + ToolUseID: r.ToolUseID, + IsError: r.IsError, + CacheControl: r.CacheControl, + Content: r.ContentString, + Type: r.Type, + }) + } type shadow ToolResultBlockParam return param.MarshalObject(r, (*shadow)(&r)) } func (r *ToolResultBlockParam) UnmarshalJSON(data []byte) error { + // Check if the content field is a string (the API supports both string and array). + contentField := gjson.GetBytes(data, "content") + if contentField.Exists() && contentField.Type == gjson.String { + // Content is a plain string — deserialize other fields normally, then + // normalize the string into a Content array with a single TextBlock. + type stringContent struct { + ToolUseID string `json:"tool_use_id"` + IsError param.Opt[bool] `json:"is_error,omitzero"` + CacheControl CacheControlEphemeralParam `json:"cache_control,omitzero"` + Content string `json:"content"` + Type constant.ToolResult `json:"type"` + } + var sc stringContent + if err := json.Unmarshal(data, &sc); err != nil { + return err + } + r.ToolUseID = sc.ToolUseID + r.IsError = sc.IsError + r.CacheControl = sc.CacheControl + r.Content = []ToolResultBlockParamContentUnion{ + {OfText: &TextBlockParam{Text: sc.Content}}, + } + r.Type = sc.Type + return nil + } return apijson.UnmarshalRoot(data, r) } diff --git a/toolresultblock_test.go b/toolresultblock_test.go new file mode 100644 index 00000000..f51c3abc --- /dev/null +++ b/toolresultblock_test.go @@ -0,0 +1,248 @@ +package anthropic_test + +import ( + "encoding/json" + "testing" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/packages/param" + "github.com/tidwall/gjson" +) + +func TestToolResultBlockParam_MarshalJSON_StringContent(t *testing.T) { + // When ContentString is set and Content is empty, content should serialize as a string. + block := anthropic.ToolResultBlockParam{ + ToolUseID: "call_123", + ContentString: "15 degrees", + IsError: param.Opt[bool]{Value: false}, + } + + data, err := json.Marshal(block) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + result := gjson.ParseBytes(data) + + if result.Get("tool_use_id").String() != "call_123" { + t.Errorf("Expected tool_use_id 'call_123', got '%s'", result.Get("tool_use_id").String()) + } + if result.Get("type").String() != "tool_result" { + t.Errorf("Expected type 'tool_result', got '%s'", result.Get("type").String()) + } + // Content should be a string, not an array + contentResult := result.Get("content") + if contentResult.Type != gjson.String { + t.Fatalf("Expected content to be a string, got type %v: %s", contentResult.Type, contentResult.Raw) + } + if contentResult.String() != "15 degrees" { + t.Errorf("Expected content '15 degrees', got '%s'", contentResult.String()) + } +} + +func TestToolResultBlockParam_MarshalJSON_ArrayContent(t *testing.T) { + // When Content array is set, content should serialize as an array. + block := anthropic.ToolResultBlockParam{ + ToolUseID: "call_456", + Content: []anthropic.ToolResultBlockParamContentUnion{ + {OfText: &anthropic.TextBlockParam{Text: "some result"}}, + }, + IsError: param.Opt[bool]{Value: false}, + } + + data, err := json.Marshal(block) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + result := gjson.ParseBytes(data) + contentResult := result.Get("content") + if !contentResult.IsArray() { + t.Fatalf("Expected content to be an array, got: %s", contentResult.Raw) + } + items := contentResult.Array() + if len(items) != 1 { + t.Fatalf("Expected 1 content item, got %d", len(items)) + } + if items[0].Get("text").String() != "some result" { + t.Errorf("Expected text 'some result', got '%s'", items[0].Get("text").String()) + } +} + +func TestToolResultBlockParam_UnmarshalJSON_StringContent(t *testing.T) { + // The API docs say content can be a string. When deserializing, a string content + // should be normalized into a Content array with a single TextBlock. + jsonStr := `{ + "tool_use_id": "call_789", + "type": "tool_result", + "content": "15 degrees", + "is_error": false + }` + + var block anthropic.ToolResultBlockParam + err := json.Unmarshal([]byte(jsonStr), &block) + if err != nil { + t.Fatalf("Failed to unmarshal string content: %v", err) + } + + if block.ToolUseID != "call_789" { + t.Errorf("Expected tool_use_id 'call_789', got '%s'", block.ToolUseID) + } + if len(block.Content) != 1 { + t.Fatalf("Expected 1 content item after string normalization, got %d", len(block.Content)) + } + if block.Content[0].OfText == nil { + t.Fatal("Expected OfText to be non-nil") + } + if block.Content[0].OfText.Text != "15 degrees" { + t.Errorf("Expected text '15 degrees', got '%s'", block.Content[0].OfText.Text) + } +} + +func TestToolResultBlockParam_UnmarshalJSON_ArrayContent(t *testing.T) { + // Standard array content format should still work. + jsonStr := `{ + "tool_use_id": "call_abc", + "type": "tool_result", + "content": [{"type": "text", "text": "hello world"}], + "is_error": false + }` + + var block anthropic.ToolResultBlockParam + err := json.Unmarshal([]byte(jsonStr), &block) + if err != nil { + t.Fatalf("Failed to unmarshal array content: %v", err) + } + + if block.ToolUseID != "call_abc" { + t.Errorf("Expected tool_use_id 'call_abc', got '%s'", block.ToolUseID) + } + if len(block.Content) != 1 { + t.Fatalf("Expected 1 content item, got %d", len(block.Content)) + } + if block.Content[0].OfText == nil { + t.Fatal("Expected OfText to be non-nil") + } + if block.Content[0].OfText.Text != "hello world" { + t.Errorf("Expected text 'hello world', got '%s'", block.Content[0].OfText.Text) + } +} + +func TestNewToolResultBlock_SerializesAsString(t *testing.T) { + // NewToolResultBlock should produce content as a string in JSON. + block := anthropic.NewToolResultBlock("call_123", "sunny 72F", false) + + data, err := json.Marshal(block) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + result := gjson.ParseBytes(data) + contentResult := result.Get("content") + if contentResult.Type != gjson.String { + t.Fatalf("Expected content to be a string, got type %v: %s", contentResult.Type, contentResult.Raw) + } + if contentResult.String() != "sunny 72F" { + t.Errorf("Expected content 'sunny 72F', got '%s'", contentResult.String()) + } +} + +func TestNewToolResultBlockFromArray_SerializesAsArray(t *testing.T) { + // NewToolResultBlockFromArray should produce content as an array in JSON. + content := []anthropic.ToolResultBlockParamContentUnion{ + {OfText: &anthropic.TextBlockParam{Text: "result text"}}, + } + block := anthropic.NewToolResultBlockFromArray("call_456", content, false) + + data, err := json.Marshal(block) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + result := gjson.ParseBytes(data) + contentResult := result.Get("content") + if !contentResult.IsArray() { + t.Fatalf("Expected content to be an array, got: %s", contentResult.Raw) + } +} + +func TestToolResultBlockParam_RoundTrip_StringContent(t *testing.T) { + // Marshal with string content, then unmarshal should normalize to Content array. + original := anthropic.ToolResultBlockParam{ + ToolUseID: "call_rt", + ContentString: "round trip test", + IsError: param.Opt[bool]{Value: true}, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + // Verify JSON has string content + result := gjson.ParseBytes(data) + if result.Get("content").Type != gjson.String { + t.Fatalf("Expected marshaled content to be a string, got: %s", result.Get("content").Raw) + } + + var restored anthropic.ToolResultBlockParam + err = json.Unmarshal(data, &restored) + if err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if restored.ToolUseID != original.ToolUseID { + t.Errorf("ToolUseID mismatch: got '%s', want '%s'", restored.ToolUseID, original.ToolUseID) + } + // After unmarshaling string content, it should be in Content array as a TextBlock. + if len(restored.Content) != 1 || restored.Content[0].OfText == nil { + t.Fatal("Expected Content to have 1 TextBlock after round-trip") + } + if restored.Content[0].OfText.Text != "round trip test" { + t.Errorf("Content text mismatch: got '%s', want 'round trip test'", restored.Content[0].OfText.Text) + } +} + +func TestBetaToolResultBlockParam_MarshalJSON_StringContent(t *testing.T) { + block := anthropic.BetaToolResultBlockParam{ + ToolUseID: "call_beta_123", + ContentString: "beta string content", + IsError: param.Opt[bool]{Value: false}, + } + + data, err := json.Marshal(block) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + result := gjson.ParseBytes(data) + contentResult := result.Get("content") + if contentResult.Type != gjson.String { + t.Fatalf("Expected content to be a string, got type %v: %s", contentResult.Type, contentResult.Raw) + } + if contentResult.String() != "beta string content" { + t.Errorf("Expected content 'beta string content', got '%s'", contentResult.String()) + } +} + +func TestBetaToolResultBlockParam_UnmarshalJSON_StringContent(t *testing.T) { + jsonStr := `{ + "tool_use_id": "call_beta_789", + "type": "tool_result", + "content": "beta 15 degrees", + "is_error": false + }` + + var block anthropic.BetaToolResultBlockParam + err := json.Unmarshal([]byte(jsonStr), &block) + if err != nil { + t.Fatalf("Failed to unmarshal string content: %v", err) + } + + if len(block.Content) != 1 || block.Content[0].OfText == nil { + t.Fatal("Expected Content to have 1 TextBlock after string normalization") + } + if block.Content[0].OfText.Text != "beta 15 degrees" { + t.Errorf("Expected text 'beta 15 degrees', got '%s'", block.Content[0].OfText.Text) + } +}