feat(providers/anthropic_messages): prefix user content with sender name

Anthropic's Messages API has no per-message author identity, so the
attribution from Message.Name needs to be rendered into content for
the model to see it. This adapter handles the HTTP path used by
self-hosted and custom Anthropic-compatible endpoints.

User content is wrapped via messageutil.ApplyUserNamePrefix at marshal
time, giving wire payloads like

    {"role": "user", "content": "[U_alice] My name is Alice"}

The persisted Message is never mutated; the prefix exists only in the
outgoing request body.

Tool result blocks (msg.ToolCallID set) are explicitly not prefixed
because they are function outputs, not user utterances. Direct
single-user channels see no change because Name is empty there.

Tests cover the four relevant cases: prefix applied, no-name no-prefix,
tool result untouched, and a multi-user history scenario reproducing
the issue #2702 example.

Refs #2702.
This commit is contained in:
maxiaoyang 2026-04-29 21:04:19 +08:00
parent 1895fc69ae
commit f89b4cdf48
2 changed files with 98 additions and 2 deletions

View file

@ -17,6 +17,7 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/common"
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
) )
@ -213,10 +214,13 @@ func buildRequestBody(
"content": []map[string]any{toolResultBlock}, "content": []map[string]any{toolResultBlock},
}) })
} else { } else {
// Regular user message // Regular user message. Anthropic has no per-message author
// identity, so any sender attribution from msg.Name is rendered
// as a `[name] ` prefix on the content. The persisted message
// is not mutated — only the wire payload carries the prefix.
apiMessages = append(apiMessages, map[string]any{ apiMessages = append(apiMessages, map[string]any{
"role": "user", "role": "user",
"content": msg.Content, "content": messageutil.ApplyUserNamePrefix(msg),
}) })
} }

View file

@ -717,3 +717,95 @@ func TestProviderChatErrors(t *testing.T) {
}) })
} }
} }
// TestBuildRequestBody_UserNamePrefixed verifies that sender attribution
// (Message.Name) is rendered as a `[name] ` prefix on the user content
// sent to the Anthropic Messages API. Anthropic has no native per-message
// author identity, so prefixing is the wire-level fallback.
func TestBuildRequestBody_UserNamePrefixed(t *testing.T) {
messages := []Message{
{Role: "user", Content: "My name is Alice", Name: "U07AB12C3DEF"},
}
body, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 1024})
if err != nil {
t.Fatalf("buildRequestBody: %v", err)
}
apiMessages, ok := body["messages"].([]any)
if !ok || len(apiMessages) != 1 {
t.Fatalf("messages = %T (%d), want 1-element []any", body["messages"], len(apiMessages))
}
first, ok := apiMessages[0].(map[string]any)
if !ok {
t.Fatalf("messages[0] = %T, want map[string]any", apiMessages[0])
}
got := first["content"]
want := "[U07AB12C3DEF] My name is Alice"
if got != want {
t.Errorf("content = %q, want %q", got, want)
}
}
// TestBuildRequestBody_NoNameNoPrefix verifies that messages without
// sender attribution flow through the wire path unchanged, preserving
// backward compatibility for direct (single-user) channels.
func TestBuildRequestBody_NoNameNoPrefix(t *testing.T) {
messages := []Message{
{Role: "user", Content: "Hello, world!"},
}
body, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 1024})
if err != nil {
t.Fatalf("buildRequestBody: %v", err)
}
apiMessages := body["messages"].([]any)
first := apiMessages[0].(map[string]any)
if first["content"] != "Hello, world!" {
t.Errorf("content = %q, want unchanged %q", first["content"], "Hello, world!")
}
}
// TestBuildRequestBody_ToolResultNotPrefixed verifies that tool result
// messages (msg.ToolCallID set) are never prefixed even if Name is
// somehow set, because tool results are not user utterances.
func TestBuildRequestBody_ToolResultNotPrefixed(t *testing.T) {
messages := []Message{
{Role: "assistant", Content: "Let me check"},
{Role: "user", Content: `{"temp":72}`, ToolCallID: "call_1", Name: "alice"},
}
body, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 1024})
if err != nil {
t.Fatalf("buildRequestBody: %v", err)
}
data, _ := json.Marshal(body)
// The tool_result block must contain the raw content, not a prefixed form.
if !strings.Contains(string(data), `"content":"{\"temp\":72}"`) {
t.Errorf("expected tool_result content to be raw JSON, got: %s", string(data))
}
if strings.Contains(string(data), `[alice]`) {
t.Errorf("tool_result content should not carry sender prefix, got: %s", string(data))
}
}
// TestBuildRequestBody_MultiUserHistory verifies the issue #2702 scenario:
// two distinct users in the same session each get their own attributed
// message in the wire payload, so the model can disambiguate who said what.
func TestBuildRequestBody_MultiUserHistory(t *testing.T) {
messages := []Message{
{Role: "user", Content: "My name is Alice", Name: "U_alice"},
{Role: "assistant", Content: "Hi Alice!"},
{Role: "user", Content: "What's my name?", Name: "U_bob"},
}
body, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 1024})
if err != nil {
t.Fatalf("buildRequestBody: %v", err)
}
apiMessages := body["messages"].([]any)
if len(apiMessages) != 3 {
t.Fatalf("len(messages) = %d, want 3", len(apiMessages))
}
if got := apiMessages[0].(map[string]any)["content"]; got != "[U_alice] My name is Alice" {
t.Errorf("messages[0].content = %q, want prefixed Alice content", got)
}
if got := apiMessages[2].(map[string]any)["content"]; got != "[U_bob] What's my name?" {
t.Errorf("messages[2].content = %q, want prefixed Bob content", got)
}
}