Skip to content

Commit 2a6ef51

Browse files
Zeffutclaude
andcommitted
fix(messageutil): preserve zero-valued fields in tool result ToParam (#317, #322)
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) <noreply@anthropic.com>
1 parent 88310cc commit 2a6ef51

3 files changed

Lines changed: 70 additions & 86 deletions

File tree

betamessageutil.go

Lines changed: 15 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ func (r BetaTextEditorCodeExecutionToolResultBlock) ToParam() BetaTextEditorCode
369369
ErrorMessage: paramutil.ToOpt(r.Content.ErrorMessage, r.Content.JSON.ErrorMessage),
370370
}
371371
} else {
372-
p.Content = param.Override[BetaTextEditorCodeExecutionToolResultBlockParamContentUnion](r.Content.RawJSON())
372+
p.Content = param.Override[BetaTextEditorCodeExecutionToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON()))
373373
}
374374
return p
375375
}
@@ -393,21 +393,10 @@ func (r BetaBashCodeExecutionToolResultBlock) ToParam() BetaBashCodeExecutionToo
393393
p.Type = r.Type
394394
p.ToolUseID = r.ToolUseID
395395

396-
if r.Content.JSON.ErrorCode.Valid() {
397-
p.Content.OfRequestBashCodeExecutionToolResultError = &BetaBashCodeExecutionToolResultErrorParam{
398-
ErrorCode: BetaBashCodeExecutionToolResultErrorParamErrorCode(r.Content.ErrorCode),
399-
}
400-
} else {
401-
requestBashContentResult := &BetaBashCodeExecutionResultBlockParam{
402-
ReturnCode: r.Content.ReturnCode,
403-
Stderr: r.Content.Stderr,
404-
Stdout: r.Content.Stdout,
405-
}
406-
for _, block := range r.Content.Content {
407-
requestBashContentResult.Content = append(requestBashContentResult.Content, block.ToParam())
408-
}
409-
p.Content.OfRequestBashCodeExecutionResultBlock = requestBashContentResult
410-
}
396+
// Use raw JSON passthrough to preserve fields that would otherwise be
397+
// dropped by `omitzero` (e.g. zero ReturnCode, empty Stderr/Stdout, or
398+
// empty ErrorCode), which the API requires on the next turn. See #322.
399+
p.Content = param.Override[BetaBashCodeExecutionToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON()))
411400

412401
return p
413402
}
@@ -423,20 +412,11 @@ func (r BetaCodeExecutionToolResultBlock) ToParam() BetaCodeExecutionToolResultB
423412
var p BetaCodeExecutionToolResultBlockParam
424413
p.Type = r.Type
425414
p.ToolUseID = r.ToolUseID
426-
if r.Content.JSON.ErrorCode.Valid() {
427-
p.Content.OfError = &BetaCodeExecutionToolResultErrorParam{
428-
ErrorCode: r.Content.ErrorCode,
429-
}
430-
} else {
431-
p.Content.OfResultBlock = &BetaCodeExecutionResultBlockParam{
432-
ReturnCode: r.Content.ReturnCode,
433-
Stderr: r.Content.Stderr,
434-
Stdout: r.Content.Stdout,
435-
}
436-
for _, block := range r.Content.Content {
437-
p.Content.OfResultBlock.Content = append(p.Content.OfResultBlock.Content, block.ToParam())
438-
}
439-
}
415+
416+
// Use raw JSON passthrough to preserve fields that would otherwise be
417+
// dropped by `omitzero` (e.g. zero ReturnCode, empty Stderr/Stdout, or
418+
// empty ErrorCode), which the API requires on the next turn. See #322.
419+
p.Content = param.Override[BetaCodeExecutionToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON()))
440420
return p
441421
}
442422

@@ -451,19 +431,11 @@ func (r BetaToolSearchToolResultBlock) ToParam() BetaToolSearchToolResultBlockPa
451431
var p BetaToolSearchToolResultBlockParam
452432
p.Type = r.Type
453433
p.ToolUseID = r.ToolUseID
454-
if r.Content.JSON.ErrorCode.Valid() {
455-
p.Content.OfRequestToolSearchToolResultError = &BetaToolSearchToolResultErrorParam{
456-
ErrorCode: BetaToolSearchToolResultErrorParamErrorCode(r.Content.ErrorCode),
457-
}
458-
} else {
459-
p.Content.OfRequestToolSearchToolSearchResultBlock = &BetaToolSearchToolSearchResultBlockParam{}
460-
for _, block := range r.Content.ToolReferences {
461-
p.Content.OfRequestToolSearchToolSearchResultBlock.ToolReferences = append(
462-
p.Content.OfRequestToolSearchToolSearchResultBlock.ToolReferences,
463-
block.ToParam(),
464-
)
465-
}
466-
}
434+
435+
// Use raw JSON passthrough to preserve the required `error_code` field on
436+
// the error variant (the typed field is tagged `omitzero`, so an empty
437+
// ErrorCode would be silently dropped). See #317.
438+
p.Content = param.Override[BetaToolSearchToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON()))
467439
return p
468440
}
469441

messageutil.go

Lines changed: 15 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -316,21 +316,10 @@ func (r BashCodeExecutionToolResultBlock) ToParam() BashCodeExecutionToolResultB
316316
p.Type = r.Type
317317
p.ToolUseID = r.ToolUseID
318318

319-
if r.Content.JSON.ErrorCode.Valid() {
320-
p.Content.OfRequestBashCodeExecutionToolResultError = &BashCodeExecutionToolResultErrorParam{
321-
ErrorCode: BashCodeExecutionToolResultErrorCode(r.Content.ErrorCode),
322-
}
323-
} else {
324-
requestBashContentResult := &BashCodeExecutionResultBlockParam{
325-
ReturnCode: r.Content.ReturnCode,
326-
Stderr: r.Content.Stderr,
327-
Stdout: r.Content.Stdout,
328-
}
329-
for _, block := range r.Content.Content {
330-
requestBashContentResult.Content = append(requestBashContentResult.Content, block.ToParam())
331-
}
332-
p.Content.OfRequestBashCodeExecutionResultBlock = requestBashContentResult
333-
}
319+
// Use raw JSON passthrough to preserve fields that would otherwise be
320+
// dropped by `omitzero` (e.g. zero ReturnCode, empty Stderr/Stdout, or
321+
// empty ErrorCode), which the API requires on the next turn. See #322.
322+
p.Content = param.Override[BashCodeExecutionToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON()))
334323

335324
return p
336325
}
@@ -346,20 +335,11 @@ func (r CodeExecutionToolResultBlock) ToParam() CodeExecutionToolResultBlockPara
346335
var p CodeExecutionToolResultBlockParam
347336
p.Type = r.Type
348337
p.ToolUseID = r.ToolUseID
349-
if r.Content.JSON.ErrorCode.Valid() {
350-
p.Content.OfRequestCodeExecutionToolResultError = &CodeExecutionToolResultErrorParam{
351-
ErrorCode: r.Content.ErrorCode,
352-
}
353-
} else {
354-
p.Content.OfRequestCodeExecutionResultBlock = &CodeExecutionResultBlockParam{
355-
ReturnCode: r.Content.ReturnCode,
356-
Stderr: r.Content.Stderr,
357-
Stdout: r.Content.Stdout,
358-
}
359-
for _, block := range r.Content.Content {
360-
p.Content.OfRequestCodeExecutionResultBlock.Content = append(p.Content.OfRequestCodeExecutionResultBlock.Content, block.ToParam())
361-
}
362-
}
338+
339+
// Use raw JSON passthrough to preserve fields that would otherwise be
340+
// dropped by `omitzero` (e.g. zero ReturnCode, empty Stderr/Stdout, or
341+
// empty ErrorCode), which the API requires on the next turn. See #322.
342+
p.Content = param.Override[CodeExecutionToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON()))
363343
return p
364344
}
365345

@@ -380,7 +360,7 @@ func (r TextEditorCodeExecutionToolResultBlock) ToParam() TextEditorCodeExecutio
380360
ErrorMessage: paramutil.ToOpt(r.Content.ErrorMessage, r.Content.JSON.ErrorMessage),
381361
}
382362
} else {
383-
p.Content = param.Override[TextEditorCodeExecutionToolResultBlockParamContentUnion](r.Content.RawJSON())
363+
p.Content = param.Override[TextEditorCodeExecutionToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON()))
384364
}
385365
return p
386366
}
@@ -389,19 +369,11 @@ func (r ToolSearchToolResultBlock) ToParam() ToolSearchToolResultBlockParam {
389369
var p ToolSearchToolResultBlockParam
390370
p.Type = r.Type
391371
p.ToolUseID = r.ToolUseID
392-
if r.Content.JSON.ErrorCode.Valid() {
393-
p.Content.OfRequestToolSearchToolResultError = &ToolSearchToolResultErrorParam{
394-
ErrorCode: ToolSearchToolResultErrorCode(r.Content.ErrorCode),
395-
}
396-
} else {
397-
p.Content.OfRequestToolSearchToolSearchResultBlock = &ToolSearchToolSearchResultBlockParam{}
398-
for _, block := range r.Content.ToolReferences {
399-
p.Content.OfRequestToolSearchToolSearchResultBlock.ToolReferences = append(
400-
p.Content.OfRequestToolSearchToolSearchResultBlock.ToolReferences,
401-
block.ToParam(),
402-
)
403-
}
404-
}
372+
373+
// Use raw JSON passthrough to preserve the required `error_code` field on
374+
// the error variant (the typed field is tagged `omitzero`, so an empty
375+
// ErrorCode would be silently dropped). See #317.
376+
p.Content = param.Override[ToolSearchToolResultBlockParamContentUnion](json.RawMessage(r.Content.RawJSON()))
405377
return p
406378
}
407379

messageutil_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package anthropic_test
22

33
import (
44
"encoding/json"
5+
"strings"
56
"testing"
67

78
"github.com/anthropics/anthropic-sdk-go"
@@ -32,6 +33,45 @@ func TestContentBlockUnionToParam(t *testing.T) {
3233
}
3334
})
3435

36+
t.Run("CodeExecutionToolResultBlock preserves zero-valued stdout/stderr/return_code (regression for #322)", func(t *testing.T) {
37+
// A successful code execution where return_code is 0 and stdout/stderr
38+
// are empty would previously be marshalled as
39+
// {"type":"code_execution_result"} (all fields dropped via omitzero),
40+
// which the API rejects on the next turn.
41+
raw := `{"type":"code_execution_tool_result","tool_use_id":"srvtoolu_1","content":{"type":"code_execution_result","return_code":0,"stdout":"","stderr":"","content":[]}}`
42+
result := unmarshalContentBlockParam(t, raw)
43+
if result.OfCodeExecutionToolResult == nil {
44+
t.Fatal("Expected OfCodeExecutionToolResult to be non-nil")
45+
}
46+
marshalled, err := json.Marshal(result.OfCodeExecutionToolResult.Content)
47+
if err != nil {
48+
t.Fatalf("failed to marshal content: %v", err)
49+
}
50+
got := string(marshalled)
51+
for _, key := range []string{`"return_code"`, `"stdout"`, `"stderr"`} {
52+
if !strings.Contains(got, key) {
53+
t.Errorf("expected %s to be present in marshalled content (got %s)", key, got)
54+
}
55+
}
56+
})
57+
58+
t.Run("ToolSearchToolResultBlock preserves empty error_code (regression for #317)", func(t *testing.T) {
59+
// An empty error_code on a tool_search_tool_result_error would
60+
// previously be dropped, producing {"type":"tool_search_tool_result_error"}.
61+
raw := `{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_2","content":{"type":"tool_search_tool_result_error","error_code":""}}`
62+
result := unmarshalContentBlockParam(t, raw)
63+
if result.OfToolSearchToolResult == nil {
64+
t.Fatal("Expected OfToolSearchToolResult to be non-nil")
65+
}
66+
marshalled, err := json.Marshal(result.OfToolSearchToolResult.Content)
67+
if err != nil {
68+
t.Fatalf("failed to marshal content: %v", err)
69+
}
70+
if !strings.Contains(string(marshalled), `"error_code"`) {
71+
t.Errorf("expected error_code to be present in marshalled content (got %s)", string(marshalled))
72+
}
73+
})
74+
3575
t.Run("WebSearchToolResultBlock with search results", func(t *testing.T) {
3676
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"}]}`)
3777
var block anthropic.ContentBlockUnion

0 commit comments

Comments
 (0)