From 2489240cb960259589d72db6867176254abfa2ea Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Fri, 24 Jul 2026 08:28:15 +0530 Subject: [PATCH 1/2] Preserve large-integer precision in FunctionResultContent.Result Decode the JSON result with a number-preserving decoder so numeric leaves become json.Number instead of float64. Unmarshaling into an any previously corrupted integers above 2^53 (9007199254740993 became 9007199254740992), so a Marshal followed by Unmarshal was not idempotent for large-integer tool results, biting checkpoint save/restore and provider re-serialization. --- message/content.go | 24 ++++++++++++++++++++++-- message/content_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/message/content.go b/message/content.go index e535c292..3af9c073 100644 --- a/message/content.go +++ b/message/content.go @@ -374,6 +374,14 @@ type serializedFunctionResultContent struct { Type contentKind } +type serializedFunctionResultContentForUnmarshal struct { + ContentHeader + + CallID string + Error string `json:",omitempty"` + Result json.RawMessage `json:",omitempty"` +} + // FunctionResultContent represents the result of a function call. type FunctionResultContent struct { ContentHeader @@ -397,13 +405,25 @@ func (t *FunctionResultContent) MarshalJSON() ([]byte, error) { } func (t *FunctionResultContent) UnmarshalJSON(data []byte) error { - var tmp serializedFunctionResultContent + var tmp serializedFunctionResultContentForUnmarshal if err := json.Unmarshal(data, &tmp); err != nil { return err } t.ContentHeader = tmp.ContentHeader t.CallID = tmp.CallID - t.Result = tmp.Result + // Decode the result with a number-preserving decoder so numeric leaves become + // json.Number rather than float64. This keeps large integers exact (values + // above 2^53 would otherwise be corrupted) and makes Marshal/Unmarshal + // idempotent, matching .NET's JsonElement preservation. + if len(tmp.Result) > 0 { + dec := json.NewDecoder(bytes.NewReader(tmp.Result)) + dec.UseNumber() + var r any + if err := dec.Decode(&r); err != nil { + return err + } + t.Result = r + } if tmp.Error != "" { t.Error = errors.New(tmp.Error) } diff --git a/message/content_test.go b/message/content_test.go index 5255208a..4429d476 100644 --- a/message/content_test.go +++ b/message/content_test.go @@ -3,9 +3,11 @@ package message_test import ( + "bytes" "encoding/base64" "encoding/json" "errors" + "fmt" "reflect" "testing" @@ -183,6 +185,43 @@ func TestContentEncoding_Roundtrip(t *testing.T) { } } +func TestFunctionResultContentRoundtripPreservesResult(t *testing.T) { + cases := []struct { + name string + result any + want string // fmt.Sprint of the round-tripped result + }{ + {"large-int", int64(9007199254740993), "9007199254740993"}, + {"small-int", int64(42), "42"}, + {"string", "hello", "hello"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + original := &message.FunctionResultContent{CallID: "call-1", Result: tc.result} + first, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + var decoded message.FunctionResultContent + if err := json.Unmarshal(first, &decoded); err != nil { + t.Fatal(err) + } + if got := fmt.Sprint(decoded.Result); got != tc.want { + t.Fatalf("round-tripped result = %q, want %q", got, tc.want) + } + // Re-marshaling the decoded value must produce identical bytes, + // i.e. Marshal/Unmarshal is idempotent for the result payload. + second, err := json.Marshal(&decoded) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, second) { + t.Fatalf("re-marshaled JSON differs:\n first = %s\nsecond = %s", first, second) + } + }) + } +} + func TestDataContentUnmarshalDefaultsMissingMediaType(t *testing.T) { var content message.DataContent if err := json.Unmarshal([]byte(`{"Type":"data","URI":"data:,hello%20world+literal"}`), &content); err != nil { From e14efb5a5bb85b6d8d179b56191fd7ccd135dab7 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Fri, 24 Jul 2026 10:38:16 +0530 Subject: [PATCH 2/2] Reset FunctionResultContent Result and Error on unmarshal into non-zero receiver --- message/content.go | 4 ++++ message/content_test.go | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/message/content.go b/message/content.go index 3af9c073..a8931bc1 100644 --- a/message/content.go +++ b/message/content.go @@ -423,9 +423,13 @@ func (t *FunctionResultContent) UnmarshalJSON(data []byte) error { return err } t.Result = r + } else { + t.Result = nil } if tmp.Error != "" { t.Error = errors.New(tmp.Error) + } else { + t.Error = nil } return nil } diff --git a/message/content_test.go b/message/content_test.go index 4429d476..8825faa8 100644 --- a/message/content_test.go +++ b/message/content_test.go @@ -222,6 +222,29 @@ func TestFunctionResultContentRoundtripPreservesResult(t *testing.T) { } } +func TestFunctionResultContentUnmarshalResetsStaleFields(t *testing.T) { + // Unmarshaling into a non-zero receiver must clear previously set Result + // and Error when the incoming JSON omits them, matching standard JSON + // unmarshal semantics. + decoded := message.FunctionResultContent{ + CallID: "old", + Result: "stale", + Error: errors.New("stale error"), + } + if err := json.Unmarshal([]byte(`{"Type":"functionResult","CallID":"new"}`), &decoded); err != nil { + t.Fatal(err) + } + if decoded.Result != nil { + t.Fatalf("Result = %v, want nil", decoded.Result) + } + if decoded.Error != nil { + t.Fatalf("Error = %v, want nil", decoded.Error) + } + if decoded.CallID != "new" { + t.Fatalf("CallID = %q, want %q", decoded.CallID, "new") + } +} + func TestDataContentUnmarshalDefaultsMissingMediaType(t *testing.T) { var content message.DataContent if err := json.Unmarshal([]byte(`{"Type":"data","URI":"data:,hello%20world+literal"}`), &content); err != nil {