From 81d324bea491e50b6a2d30f5c7316f4ce3a2d465 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Fri, 24 Jul 2026 09:11:45 +0530 Subject: [PATCH] Propagate message.AuthorName to the OpenAI chat request name field buildMessageParam dropped msg.AuthorName, so participant attribution set in multi-agent and group-chat flows never reached the Chat Completions API. Build the system, user, and assistant params as struct literals and set the optional name field via a sanitizeAuthorName helper that mirrors .NET OpenAIChatClient.SanitizeAuthorName (keep alphanumerics, cap at 64 characters, empty for blank input). Tool messages are unchanged as they have no name field. --- provider/openaiprovider/chat.go | 60 ++++++++++++--- provider/openaiprovider/chat_test.go | 111 +++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 10 deletions(-) diff --git a/provider/openaiprovider/chat.go b/provider/openaiprovider/chat.go index 6cf702b2..d43ddd92 100644 --- a/provider/openaiprovider/chat.go +++ b/provider/openaiprovider/chat.go @@ -13,6 +13,7 @@ import ( "slices" "strings" "time" + "unicode" "github.com/microsoft/agent-framework-go/agent" "github.com/microsoft/agent-framework-go/agent/format/jsonformat" @@ -368,10 +369,16 @@ func buildMessageParam(msg *message.Message) ([]openai.ChatCompletionMessagePara if len(contents) == 0 { return nil, nil } + sys := openai.ChatCompletionSystemMessageParam{} if len(contents) == 1 { - return []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(contents[0].Text)}, nil + sys.Content.OfString = openai.String(contents[0].Text) + } else { + sys.Content.OfArrayOfContentParts = contents + } + if name := sanitizeAuthorName(msg.AuthorName); name != "" { + sys.Name = openai.String(name) } - return []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(contents)}, nil + return []openai.ChatCompletionMessageParamUnion{{OfSystem: &sys}}, nil case message.RoleUser: var contents []openai.ChatCompletionContentPartUnionParam @@ -434,10 +441,16 @@ func buildMessageParam(msg *message.Message) ([]openai.ChatCompletionMessagePara if len(contents) == 0 { return nil, nil } + usr := openai.ChatCompletionUserMessageParam{} if len(contents) == 1 && contents[0].OfText != nil { - return []openai.ChatCompletionMessageParamUnion{openai.UserMessage(contents[0].OfText.Text)}, nil + usr.Content.OfString = openai.String(contents[0].OfText.Text) + } else { + usr.Content.OfArrayOfContentParts = contents } - return []openai.ChatCompletionMessageParamUnion{openai.UserMessage(contents)}, nil + if name := sanitizeAuthorName(msg.AuthorName); name != "" { + usr.Name = openai.String(name) + } + return []openai.ChatCompletionMessageParamUnion{{OfUser: &usr}}, nil case message.RoleAssistant: var contents []openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion @@ -477,12 +490,14 @@ func buildMessageParam(msg *message.Message) ([]openai.ChatCompletionMessagePara } else { content = openai.ChatCompletionAssistantMessageParamContentUnion{OfArrayOfContentParts: contents} } - return []openai.ChatCompletionMessageParamUnion{{ - OfAssistant: &openai.ChatCompletionAssistantMessageParam{ - Content: content, - ToolCalls: toolCalls, - }, - }}, nil + asst := openai.ChatCompletionAssistantMessageParam{ + Content: content, + ToolCalls: toolCalls, + } + if name := sanitizeAuthorName(msg.AuthorName); name != "" { + asst.Name = openai.String(name) + } + return []openai.ChatCompletionMessageParamUnion{{OfAssistant: &asst}}, nil case message.RoleTool: // Each tool result needs its own separate message for OpenAI API compliance @@ -505,6 +520,31 @@ func buildMessageParam(msg *message.Message) ([]openai.ChatCompletionMessagePara } } +// sanitizeAuthorName mirrors the .NET OpenAIChatClient.SanitizeAuthorName used +// for ChatMessage.AuthorName. The Chat Completions API only accepts a limited +// character set for the participant "name" field, so it keeps only alphanumeric +// characters and caps the result at 64 characters. It returns an empty string +// when the input is empty, whitespace-only, or entirely disallowed characters, +// in which case the caller leaves the name field unset. +func sanitizeAuthorName(name string) string { + if strings.TrimSpace(name) == "" { + return "" + } + const maxLen = 64 + var b strings.Builder + n := 0 + for _, r := range name { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + n++ + if n >= maxLen { + break + } + } + } + return b.String() +} + func addUsage(contents []message.Content, usage openai.CompletionUsage) []message.Content { details := message.UsageDetails{ InputTokenCount: usage.PromptTokens, diff --git a/provider/openaiprovider/chat_test.go b/provider/openaiprovider/chat_test.go index 840ef585..8b67b01a 100644 --- a/provider/openaiprovider/chat_test.go +++ b/provider/openaiprovider/chat_test.go @@ -1595,3 +1595,114 @@ func TestChatEmptyChoices_NonStreaming(t *testing.T) { t.Errorf("expected usage input=12 total=12 to be surfaced, got %+v", usage) } } + +func TestChatAuthorNamePropagation_NonStreaming(t *testing.T) { + const input = ` + { + "messages": [ + {"role": "system", "content": "You are helpful.", "name": "AgentOne"}, + {"role": "user", "content": "hi", "name": "AgentOne"}, + {"role": "assistant", "content": "hello", "name": "AgentOne"} + ], + "model": "gpt-4o-mini" + } + ` + const output = ` + { + "id": "chatcmpl-author", + "object": "chat.completion", + "created": 1727894187, + "model": "gpt-4o-mini", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"} + ] + } + ` + server := newTestServer(t, input, output) + defer server.Close() + + a := newTestClient(server) + + messages := []*message.Message{ + {Role: message.RoleSystem, AuthorName: "Agent One", Contents: []message.Content{&message.TextContent{Text: "You are helpful."}}}, + {Role: message.RoleUser, AuthorName: "Agent One", Contents: []message.Content{&message.TextContent{Text: "hi"}}}, + {Role: message.RoleAssistant, AuthorName: "Agent One", Contents: []message.Content{&message.TextContent{Text: "hello"}}}, + } + if _, err := a.Run(t.Context(), messages).Collect(); err != nil { + t.Fatalf("error = %v", err) + } +} + +func TestChatAuthorNameSanitizationAndTruncation_NonStreaming(t *testing.T) { + // Disallowed characters are stripped and the result is capped at 64 runes. + const input = ` + { + "messages": [ + {"role": "user", "content": "hi", "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + ], + "model": "gpt-4o-mini" + } + ` + const output = ` + { + "id": "chatcmpl-author", + "object": "chat.completion", + "created": 1727894187, + "model": "gpt-4o-mini", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"} + ] + } + ` + server := newTestServer(t, input, output) + defer server.Close() + + a := newTestClient(server) + + // "!" is stripped, then 70 alphanumerics are truncated to 64. + authorName := "!" + strings.Repeat("a", 70) + messages := []*message.Message{ + {Role: message.RoleUser, AuthorName: authorName, Contents: []message.Content{&message.TextContent{Text: "hi"}}}, + } + if _, err := a.Run(t.Context(), messages).Collect(); err != nil { + t.Fatalf("error = %v", err) + } +} + +func TestChatAuthorNameEmpty_NonStreaming(t *testing.T) { + // Empty or whitespace-only author names leave the name field unset. + const input = ` + { + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"} + ], + "model": "gpt-4o-mini" + } + ` + const output = ` + { + "id": "chatcmpl-author", + "object": "chat.completion", + "created": 1727894187, + "model": "gpt-4o-mini", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"} + ] + } + ` + server := newTestServer(t, input, output) + defer server.Close() + + a := newTestClient(server) + + messages := []*message.Message{ + {Role: message.RoleSystem, AuthorName: "", Contents: []message.Content{&message.TextContent{Text: "You are helpful."}}}, + {Role: message.RoleUser, AuthorName: " ", Contents: []message.Content{&message.TextContent{Text: "hi"}}}, + {Role: message.RoleAssistant, AuthorName: " ", Contents: []message.Content{&message.TextContent{Text: "hello"}}}, + } + if _, err := a.Run(t.Context(), messages).Collect(); err != nil { + t.Fatalf("error = %v", err) + } +}