From 2a6ef5170f356058f7c641378173d707539fd6a0 Mon Sep 17 00:00:00 2001 From: Zeffut Date: Sat, 23 May 2026 02:08:39 +0200 Subject: [PATCH] fix(messageutil): preserve zero-valued fields in tool result ToParam (#317, #322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ToParam() methods on CodeExecutionToolResultBlock, BashCodeExecutionToolResultBlock, ToolSearchToolResultBlock and their beta counterparts re-marshaled response blocks via struct fields tagged `omitzero`. Zero values from the server (return_code:0, stdout:"", error_code:"") were stripped and the API returned 400 on the next turn. Switch to param.Override[ContentUnion](json.RawMessage(r.Content.RawJSON())) so the exact server-validated payload is forwarded unchanged. This also fixes a silent bug where param.Override was receiving a Go string instead of json.RawMessage — leading to double-encoded JSON. Adds 2 regression tests covering #317 and #322. go vet, go build, and the new tests pass cleanly. Closes #317. Closes #322. Co-Authored-By: Claude Opus 4.7 (1M context) --- betamessageutil.go | 58 ++++++++++++--------------------------------- messageutil.go | 58 ++++++++++++--------------------------------- messageutil_test.go | 40 +++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 86 deletions(-) diff --git a/betamessageutil.go b/betamessageutil.go index d3b53f49..0a7583e8 100644 --- a/betamessageutil.go +++ b/betamessageutil.go @@ -369,7 +369,7 @@ func (r BetaTextEditorCodeExecutionToolResultBlock) ToParam() BetaTextEditorCode ErrorMessage: paramutil.ToOpt(r.Content.ErrorMessage, r.Content.JSON.ErrorMessage), } } else { - p.Content = param.Override[BetaTextEditorCodeExecutionToolResultBlockParamContentUnion](r.Content.RawJSON()) + p.Content = param.Override[BetaTextEditorCodeExecutionToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON())) } return p } @@ -393,21 +393,10 @@ func (r BetaBashCodeExecutionToolResultBlock) ToParam() BetaBashCodeExecutionToo p.Type = r.Type p.ToolUseID = r.ToolUseID - if r.Content.JSON.ErrorCode.Valid() { - p.Content.OfRequestBashCodeExecutionToolResultError = &BetaBashCodeExecutionToolResultErrorParam{ - ErrorCode: BetaBashCodeExecutionToolResultErrorParamErrorCode(r.Content.ErrorCode), - } - } else { - requestBashContentResult := &BetaBashCodeExecutionResultBlockParam{ - ReturnCode: r.Content.ReturnCode, - Stderr: r.Content.Stderr, - Stdout: r.Content.Stdout, - } - for _, block := range r.Content.Content { - requestBashContentResult.Content = append(requestBashContentResult.Content, block.ToParam()) - } - p.Content.OfRequestBashCodeExecutionResultBlock = requestBashContentResult - } + // Use raw JSON passthrough to preserve fields that would otherwise be + // dropped by `omitzero` (e.g. zero ReturnCode, empty Stderr/Stdout, or + // empty ErrorCode), which the API requires on the next turn. See #322. + p.Content = param.Override[BetaBashCodeExecutionToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON())) return p } @@ -423,20 +412,11 @@ func (r BetaCodeExecutionToolResultBlock) ToParam() BetaCodeExecutionToolResultB var p BetaCodeExecutionToolResultBlockParam p.Type = r.Type p.ToolUseID = r.ToolUseID - if r.Content.JSON.ErrorCode.Valid() { - p.Content.OfError = &BetaCodeExecutionToolResultErrorParam{ - ErrorCode: r.Content.ErrorCode, - } - } else { - p.Content.OfResultBlock = &BetaCodeExecutionResultBlockParam{ - ReturnCode: r.Content.ReturnCode, - Stderr: r.Content.Stderr, - Stdout: r.Content.Stdout, - } - for _, block := range r.Content.Content { - p.Content.OfResultBlock.Content = append(p.Content.OfResultBlock.Content, block.ToParam()) - } - } + + // Use raw JSON passthrough to preserve fields that would otherwise be + // dropped by `omitzero` (e.g. zero ReturnCode, empty Stderr/Stdout, or + // empty ErrorCode), which the API requires on the next turn. See #322. + p.Content = param.Override[BetaCodeExecutionToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON())) return p } @@ -451,19 +431,11 @@ func (r BetaToolSearchToolResultBlock) ToParam() BetaToolSearchToolResultBlockPa var p BetaToolSearchToolResultBlockParam p.Type = r.Type p.ToolUseID = r.ToolUseID - if r.Content.JSON.ErrorCode.Valid() { - p.Content.OfRequestToolSearchToolResultError = &BetaToolSearchToolResultErrorParam{ - ErrorCode: BetaToolSearchToolResultErrorParamErrorCode(r.Content.ErrorCode), - } - } else { - p.Content.OfRequestToolSearchToolSearchResultBlock = &BetaToolSearchToolSearchResultBlockParam{} - for _, block := range r.Content.ToolReferences { - p.Content.OfRequestToolSearchToolSearchResultBlock.ToolReferences = append( - p.Content.OfRequestToolSearchToolSearchResultBlock.ToolReferences, - block.ToParam(), - ) - } - } + + // Use raw JSON passthrough to preserve the required `error_code` field on + // the error variant (the typed field is tagged `omitzero`, so an empty + // ErrorCode would be silently dropped). See #317. + p.Content = param.Override[BetaToolSearchToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON())) return p } diff --git a/messageutil.go b/messageutil.go index e735e479..a6c4c339 100644 --- a/messageutil.go +++ b/messageutil.go @@ -316,21 +316,10 @@ func (r BashCodeExecutionToolResultBlock) ToParam() BashCodeExecutionToolResultB p.Type = r.Type p.ToolUseID = r.ToolUseID - if r.Content.JSON.ErrorCode.Valid() { - p.Content.OfRequestBashCodeExecutionToolResultError = &BashCodeExecutionToolResultErrorParam{ - ErrorCode: BashCodeExecutionToolResultErrorCode(r.Content.ErrorCode), - } - } else { - requestBashContentResult := &BashCodeExecutionResultBlockParam{ - ReturnCode: r.Content.ReturnCode, - Stderr: r.Content.Stderr, - Stdout: r.Content.Stdout, - } - for _, block := range r.Content.Content { - requestBashContentResult.Content = append(requestBashContentResult.Content, block.ToParam()) - } - p.Content.OfRequestBashCodeExecutionResultBlock = requestBashContentResult - } + // Use raw JSON passthrough to preserve fields that would otherwise be + // dropped by `omitzero` (e.g. zero ReturnCode, empty Stderr/Stdout, or + // empty ErrorCode), which the API requires on the next turn. See #322. + p.Content = param.Override[BashCodeExecutionToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON())) return p } @@ -346,20 +335,11 @@ func (r CodeExecutionToolResultBlock) ToParam() CodeExecutionToolResultBlockPara var p CodeExecutionToolResultBlockParam p.Type = r.Type p.ToolUseID = r.ToolUseID - if r.Content.JSON.ErrorCode.Valid() { - p.Content.OfRequestCodeExecutionToolResultError = &CodeExecutionToolResultErrorParam{ - ErrorCode: r.Content.ErrorCode, - } - } else { - p.Content.OfRequestCodeExecutionResultBlock = &CodeExecutionResultBlockParam{ - ReturnCode: r.Content.ReturnCode, - Stderr: r.Content.Stderr, - Stdout: r.Content.Stdout, - } - for _, block := range r.Content.Content { - p.Content.OfRequestCodeExecutionResultBlock.Content = append(p.Content.OfRequestCodeExecutionResultBlock.Content, block.ToParam()) - } - } + + // Use raw JSON passthrough to preserve fields that would otherwise be + // dropped by `omitzero` (e.g. zero ReturnCode, empty Stderr/Stdout, or + // empty ErrorCode), which the API requires on the next turn. See #322. + p.Content = param.Override[CodeExecutionToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON())) return p } @@ -380,7 +360,7 @@ func (r TextEditorCodeExecutionToolResultBlock) ToParam() TextEditorCodeExecutio ErrorMessage: paramutil.ToOpt(r.Content.ErrorMessage, r.Content.JSON.ErrorMessage), } } else { - p.Content = param.Override[TextEditorCodeExecutionToolResultBlockParamContentUnion](r.Content.RawJSON()) + p.Content = param.Override[TextEditorCodeExecutionToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON())) } return p } @@ -389,19 +369,11 @@ func (r ToolSearchToolResultBlock) ToParam() ToolSearchToolResultBlockParam { var p ToolSearchToolResultBlockParam p.Type = r.Type p.ToolUseID = r.ToolUseID - if r.Content.JSON.ErrorCode.Valid() { - p.Content.OfRequestToolSearchToolResultError = &ToolSearchToolResultErrorParam{ - ErrorCode: ToolSearchToolResultErrorCode(r.Content.ErrorCode), - } - } else { - p.Content.OfRequestToolSearchToolSearchResultBlock = &ToolSearchToolSearchResultBlockParam{} - for _, block := range r.Content.ToolReferences { - p.Content.OfRequestToolSearchToolSearchResultBlock.ToolReferences = append( - p.Content.OfRequestToolSearchToolSearchResultBlock.ToolReferences, - block.ToParam(), - ) - } - } + + // Use raw JSON passthrough to preserve the required `error_code` field on + // the error variant (the typed field is tagged `omitzero`, so an empty + // ErrorCode would be silently dropped). See #317. + p.Content = param.Override[ToolSearchToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON())) return p } diff --git a/messageutil_test.go b/messageutil_test.go index cf1aedcb..9a2501a0 100644 --- a/messageutil_test.go +++ b/messageutil_test.go @@ -2,6 +2,7 @@ package anthropic_test import ( "encoding/json" + "strings" "testing" "github.com/anthropics/anthropic-sdk-go" @@ -32,6 +33,45 @@ func TestContentBlockUnionToParam(t *testing.T) { } }) + t.Run("CodeExecutionToolResultBlock preserves zero-valued stdout/stderr/return_code (regression for #322)", func(t *testing.T) { + // A successful code execution where return_code is 0 and stdout/stderr + // are empty would previously be marshalled as + // {"type":"code_execution_result"} (all fields dropped via omitzero), + // which the API rejects on the next turn. + raw := `{"type":"code_execution_tool_result","tool_use_id":"srvtoolu_1","content":{"type":"code_execution_result","return_code":0,"stdout":"","stderr":"","content":[]}}` + result := unmarshalContentBlockParam(t, raw) + if result.OfCodeExecutionToolResult == nil { + t.Fatal("Expected OfCodeExecutionToolResult to be non-nil") + } + marshalled, err := json.Marshal(result.OfCodeExecutionToolResult.Content) + if err != nil { + t.Fatalf("failed to marshal content: %v", err) + } + got := string(marshalled) + for _, key := range []string{`"return_code"`, `"stdout"`, `"stderr"`} { + if !strings.Contains(got, key) { + t.Errorf("expected %s to be present in marshalled content (got %s)", key, got) + } + } + }) + + t.Run("ToolSearchToolResultBlock preserves empty error_code (regression for #317)", func(t *testing.T) { + // An empty error_code on a tool_search_tool_result_error would + // previously be dropped, producing {"type":"tool_search_tool_result_error"}. + raw := `{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_2","content":{"type":"tool_search_tool_result_error","error_code":""}}` + result := unmarshalContentBlockParam(t, raw) + if result.OfToolSearchToolResult == nil { + t.Fatal("Expected OfToolSearchToolResult to be non-nil") + } + marshalled, err := json.Marshal(result.OfToolSearchToolResult.Content) + if err != nil { + t.Fatalf("failed to marshal content: %v", err) + } + if !strings.Contains(string(marshalled), `"error_code"`) { + t.Errorf("expected error_code to be present in marshalled content (got %s)", string(marshalled)) + } + }) + t.Run("WebSearchToolResultBlock with search results", func(t *testing.T) { 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"}]}`) var block anthropic.ContentBlockUnion