feat(protocol): add Name field to providers.Message

Adds an optional Name field on providers.Message so per-message sender
attribution can be carried through the persistence layer and onto the
wire. The field is JSON-tagged `name,omitempty`, so:

- the OpenAI Chat Completions wire format will be able to emit the
  native `name` field that disambiguates participants in multi-user
  conversations;
- session stores that round-trip messages via json.Marshal preserve
  the field with no extra plumbing;
- existing session files load cleanly with Name = "".

This is the schema-only change. Adapters and the agent pipeline are
updated in subsequent commits.

Refs #2702.
This commit is contained in:
maxiaoyang 2026-04-29 20:54:11 +08:00
parent 62d0e34ec9
commit 72005ae06c
2 changed files with 76 additions and 2 deletions

View file

@ -79,8 +79,16 @@ type Attachment struct {
} }
type Message struct { type Message struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
// Name carries per-message sender attribution for multi-user sessions
// (Discord/Telegram/Slack groups, etc.). It is the sanitized SenderID
// from the inbound channel and is OpenAI-Chat-Completions wire-safe
// (matches `^[a-zA-Z0-9_-]{1,64}$`). Adapters that natively support a
// per-message name field (OpenAI) emit it directly; adapters that do
// not (Anthropic, Bedrock) prefix it onto user content at marshal
// time without mutating the persisted message.
Name string `json:"name,omitempty"`
Media []string `json:"media,omitempty"` Media []string `json:"media,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"` Attachments []Attachment `json:"attachments,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"`

View file

@ -0,0 +1,66 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package protocoltypes
import (
"encoding/json"
"strings"
"testing"
)
// TestMessage_NameJSONRoundtrip verifies that the Name field — added for
// multi-user sender attribution (issue #2702) — round-trips through
// json.Marshal/Unmarshal. The session persistence layer (JSONL append +
// in-memory snapshot via json.MarshalIndent) relies on this transparency
// to carry sender attribution into stored history without bespoke code.
func TestMessage_NameJSONRoundtrip(t *testing.T) {
in := Message{Role: "user", Content: "hi", Name: "U_alice"}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
if !strings.Contains(string(data), `"name":"U_alice"`) {
t.Errorf("expected name in marshaled JSON, got: %s", string(data))
}
var out Message
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
if out.Name != "U_alice" {
t.Errorf("Name after roundtrip = %q, want U_alice", out.Name)
}
}
// TestMessage_NameOmittedWhenEmpty preserves wire/persistence backward
// compatibility for direct-channel turns and pre-2702 session files: an
// empty Name must not appear as `"name":""` in either marshaled output.
func TestMessage_NameOmittedWhenEmpty(t *testing.T) {
in := Message{Role: "user", Content: "hi"}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
if strings.Contains(string(data), `"name"`) {
t.Errorf("empty Name should be omitted; got: %s", string(data))
}
}
// TestMessage_LegacyHistoryUnmarshalsToEmptyName verifies that historical
// session files written before this field existed still load cleanly: the
// Name field is simply zero-valued.
func TestMessage_LegacyHistoryUnmarshalsToEmptyName(t *testing.T) {
legacy := []byte(`{"role":"user","content":"old message"}`)
var msg Message
if err := json.Unmarshal(legacy, &msg); err != nil {
t.Fatalf("json.Unmarshal legacy: %v", err)
}
if msg.Name != "" {
t.Errorf("legacy Name = %q, want empty", msg.Name)
}
if msg.Content != "old message" {
t.Errorf("legacy Content = %q, want preserved", msg.Content)
}
}