feat(messageutil): add IsSystemSenderID and ApplyUserNamePrefix helpers

Two more helpers needed for end-to-end multi-user attribution:

- IsSystemSenderID filters synthetic trigger sources (cron, heartbeat,
  async:* tool callbacks, system channel) so internal events do not
  surface as distinct human users in the conversation. Without this,
  cron-triggered messages would carry attribution like cron, producing
  a confusing [cron] [System: cron] ... double prefix on adapters that
  fall back to in-content rendering.

- ApplyUserNamePrefix renders msg.Name as a [name] prefix on user
  content for adapters whose APIs do not support a per-message author
  identity (Anthropic, Bedrock, OpenAI Responses). The persisted
  message is never mutated; only the wire payload carries the prefix.
  Tool result messages and non-user roles pass through unchanged.

These helpers are pure and independently testable; they will be wired
into the agent and provider adapters in subsequent commits.

Refs #2702.
This commit is contained in:
maxiaoyang 2026-04-29 21:01:03 +08:00
parent d1e9bb6ae3
commit 7c7d7146ef
2 changed files with 121 additions and 0 deletions

View file

@ -1,6 +1,7 @@
package messageutil package messageutil
import ( import (
"fmt"
"regexp" "regexp"
"strings" "strings"
@ -38,6 +39,35 @@ func SanitizeMessageName(raw string) string {
return cleaned return cleaned
} }
// IsSystemSenderID reports whether senderID identifies an internal trigger
// (cron, heartbeat, async tool callback, system channel) rather than a real
// human user. These should not propagate as message-level sender attribution
// because they don't represent distinct actors in a multi-user conversation.
func IsSystemSenderID(senderID string) bool {
id := strings.ToLower(strings.TrimSpace(senderID))
switch id {
case "", "cron", "heartbeat", "system":
return true
}
return strings.HasPrefix(id, "async:")
}
// ApplyUserNamePrefix returns the message content with a `[name] ` prefix
// when msg carries sender attribution that the calling adapter cannot send
// natively (Anthropic, Bedrock, etc.). The persisted msg is not mutated.
//
// Returns msg.Content unchanged when:
// - the role is not "user"
// - msg.Name is empty
// - msg.ToolCallID is set (tool result, not a user utterance)
// - msg.Content is empty (avoid producing a bare "[name] ")
func ApplyUserNamePrefix(msg protocoltypes.Message) string {
if msg.Role != "user" || msg.Name == "" || msg.ToolCallID != "" || msg.Content == "" {
return msg.Content
}
return fmt.Sprintf("[%s] %s", msg.Name, msg.Content)
}
// IsTransientAssistantThoughtMessage reports whether msg is an invalid // IsTransientAssistantThoughtMessage reports whether msg is an invalid
// reasoning-only assistant history record. These "hanging" thought messages // reasoning-only assistant history record. These "hanging" thought messages
// are not a canonical persisted format and should be discarded instead of // are not a canonical persisted format and should be discarded instead of

View file

@ -6,8 +6,11 @@
package messageutil package messageutil
import ( import (
"reflect"
"strings" "strings"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
) )
func TestSanitizeMessageName(t *testing.T) { func TestSanitizeMessageName(t *testing.T) {
@ -65,3 +68,91 @@ func TestSanitizeMessageName_OutputAlwaysWireSafe(t *testing.T) {
} }
} }
} }
func TestIsSystemSenderID(t *testing.T) {
tests := []struct {
in string
want bool
}{
{"", true},
{" ", true},
{"cron", true},
{"CRON", true},
{"heartbeat", true},
{"system", true},
{"async:tool_call", true},
{"ASYNC:Foo", true},
{"alice", false},
{"U07AB12C3DEF", false},
{"141455495", false},
{"alice#1234", false},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
if got := IsSystemSenderID(tt.in); got != tt.want {
t.Errorf("IsSystemSenderID(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
func TestApplyUserNamePrefix(t *testing.T) {
tests := []struct {
name string
msg protocoltypes.Message
want string
}{
{
name: "user with name",
msg: protocoltypes.Message{Role: "user", Name: "alice", Content: "hello"},
want: "[alice] hello",
},
{
name: "user without name",
msg: protocoltypes.Message{Role: "user", Content: "hello"},
want: "hello",
},
{
name: "user with name but empty content",
msg: protocoltypes.Message{Role: "user", Name: "alice", Content: ""},
want: "",
},
{
name: "user tool result is not prefixed",
msg: protocoltypes.Message{Role: "user", Name: "alice", Content: `{"ok":true}`, ToolCallID: "call_1"},
want: `{"ok":true}`,
},
{
name: "assistant with name not prefixed",
msg: protocoltypes.Message{Role: "assistant", Name: "alice", Content: "I am the assistant"},
want: "I am the assistant",
},
{
name: "tool role with name not prefixed",
msg: protocoltypes.Message{Role: "tool", Name: "alice", Content: `{"x":1}`},
want: `{"x":1}`,
},
{
name: "system role with name not prefixed",
msg: protocoltypes.Message{Role: "system", Name: "alice", Content: "instructions"},
want: "instructions",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ApplyUserNamePrefix(tt.msg)
if got != tt.want {
t.Errorf("ApplyUserNamePrefix(%+v) = %q, want %q", tt.msg, got, tt.want)
}
})
}
}
func TestApplyUserNamePrefix_DoesNotMutateInput(t *testing.T) {
msg := protocoltypes.Message{Role: "user", Name: "alice", Content: "hello"}
original := msg
_ = ApplyUserNamePrefix(msg)
if !reflect.DeepEqual(msg, original) {
t.Errorf("ApplyUserNamePrefix mutated the input message: got %+v, want %+v", msg, original)
}
}