mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
refactor(runtime): drop non-session legacy context compatibility
This commit is contained in:
+60
-19
@@ -3,25 +3,21 @@ package routing
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// RouteInput contains the routing context from an inbound message.
|
||||
type RouteInput struct {
|
||||
Channel string
|
||||
AccountID string
|
||||
Peer *RoutePeer
|
||||
ParentPeer *RoutePeer
|
||||
GuildID string
|
||||
TeamID string
|
||||
}
|
||||
|
||||
// SessionPolicy describes how a routed message should be mapped to a session.
|
||||
type SessionPolicy struct {
|
||||
Dimensions []string
|
||||
IdentityLinks map[string][]string
|
||||
}
|
||||
|
||||
type RoutePeer struct {
|
||||
Kind string
|
||||
ID string
|
||||
}
|
||||
|
||||
// ResolvedRoute is the result of agent routing.
|
||||
type ResolvedRoute struct {
|
||||
AgentID string
|
||||
@@ -41,14 +37,15 @@ func NewRouteResolver(cfg *config.Config) *RouteResolver {
|
||||
return &RouteResolver{cfg: cfg}
|
||||
}
|
||||
|
||||
// ResolveRoute determines which agent handles the message and returns the
|
||||
// session policy that should be used to allocate session state.
|
||||
// ResolveRoute determines which agent handles the message from a normalized
|
||||
// inbound context and returns the session policy that should be used to
|
||||
// allocate session state.
|
||||
// Implements the 7-level priority cascade:
|
||||
// peer > parent_peer > guild > team > account > channel_wildcard > default
|
||||
func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
|
||||
channel := strings.ToLower(strings.TrimSpace(input.Channel))
|
||||
accountID := NormalizeAccountID(input.AccountID)
|
||||
peer := input.Peer
|
||||
func (r *RouteResolver) ResolveRoute(inbound bus.InboundContext) ResolvedRoute {
|
||||
channel := strings.ToLower(strings.TrimSpace(inbound.Channel))
|
||||
accountID := NormalizeAccountID(inbound.Account)
|
||||
peer := routePeerFromContext(inbound)
|
||||
|
||||
sessionPolicy := r.sessionPolicy()
|
||||
|
||||
@@ -73,7 +70,7 @@ func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
|
||||
}
|
||||
|
||||
// Priority 2: Parent peer binding
|
||||
parentPeer := input.ParentPeer
|
||||
parentPeer := parentPeerFromContext(inbound)
|
||||
if parentPeer != nil && strings.TrimSpace(parentPeer.ID) != "" {
|
||||
if match := r.findPeerMatch(bindings, parentPeer); match != nil {
|
||||
return choose(match.AgentID, "binding.peer.parent")
|
||||
@@ -81,7 +78,7 @@ func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
|
||||
}
|
||||
|
||||
// Priority 3: Guild binding
|
||||
guildID := strings.TrimSpace(input.GuildID)
|
||||
guildID := routeGuildIDFromContext(inbound)
|
||||
if guildID != "" {
|
||||
if match := r.findGuildMatch(bindings, guildID); match != nil {
|
||||
return choose(match.AgentID, "binding.guild")
|
||||
@@ -89,7 +86,7 @@ func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
|
||||
}
|
||||
|
||||
// Priority 4: Team binding
|
||||
teamID := strings.TrimSpace(input.TeamID)
|
||||
teamID := routeTeamIDFromContext(inbound)
|
||||
if teamID != "" {
|
||||
if match := r.findTeamMatch(bindings, teamID); match != nil {
|
||||
return choose(match.AgentID, "binding.team")
|
||||
@@ -276,6 +273,46 @@ func normalizeSessionDimensions(dimensions []string) []string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
func routePeerFromContext(ctx bus.InboundContext) *RoutePeer {
|
||||
peerKind := normalizeChannel(strings.TrimSpace(ctx.ChatType))
|
||||
if peerKind == "" || peerKind == "unknown" {
|
||||
return nil
|
||||
}
|
||||
|
||||
peerID := strings.TrimSpace(ctx.ChatID)
|
||||
if peerKind == "direct" && peerID == "" {
|
||||
peerID = strings.TrimSpace(ctx.SenderID)
|
||||
}
|
||||
if peerID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &RoutePeer{Kind: peerKind, ID: peerID}
|
||||
}
|
||||
|
||||
func parentPeerFromContext(ctx bus.InboundContext) *RoutePeer {
|
||||
if topicID := strings.TrimSpace(ctx.TopicID); topicID != "" {
|
||||
return &RoutePeer{Kind: "topic", ID: topicID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func routeGuildIDFromContext(ctx bus.InboundContext) string {
|
||||
if strings.EqualFold(strings.TrimSpace(ctx.SpaceType), "guild") {
|
||||
return strings.TrimSpace(ctx.SpaceID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func routeTeamIDFromContext(ctx bus.InboundContext) string {
|
||||
switch strings.ToLower(strings.TrimSpace(ctx.SpaceType)) {
|
||||
case "team", "workspace":
|
||||
return strings.TrimSpace(ctx.SpaceID)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func cloneIdentityLinks(src map[string][]string) map[string][]string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
@@ -288,3 +325,7 @@ func cloneIdentityLinks(src map[string][]string) map[string][]string {
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func normalizeChannel(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
+39
-34
@@ -3,6 +3,7 @@ package routing
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
@@ -26,9 +27,10 @@ func TestResolveRoute_DefaultAgent_NoBindings(t *testing.T) {
|
||||
cfg := testConfig(nil, nil)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user1"},
|
||||
route := r.ResolveRoute(bus.InboundContext{
|
||||
Channel: "telegram",
|
||||
ChatType: "direct",
|
||||
SenderID: "user1",
|
||||
})
|
||||
|
||||
if route.AgentID != DefaultAgentID {
|
||||
@@ -63,9 +65,10 @@ func TestResolveRoute_PeerBinding(t *testing.T) {
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||
route := r.ResolveRoute(bus.InboundContext{
|
||||
Channel: "telegram",
|
||||
ChatType: "direct",
|
||||
SenderID: "user123",
|
||||
})
|
||||
|
||||
if route.AgentID != "support" {
|
||||
@@ -94,10 +97,12 @@ func TestResolveRoute_GuildBinding(t *testing.T) {
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "discord",
|
||||
GuildID: "guild-abc",
|
||||
Peer: &RoutePeer{Kind: "channel", ID: "ch1"},
|
||||
route := r.ResolveRoute(bus.InboundContext{
|
||||
Channel: "discord",
|
||||
ChatID: "ch1",
|
||||
ChatType: "channel",
|
||||
SpaceID: "guild-abc",
|
||||
SpaceType: "guild",
|
||||
})
|
||||
|
||||
if route.AgentID != "gaming" {
|
||||
@@ -126,10 +131,12 @@ func TestResolveRoute_TeamBinding(t *testing.T) {
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "slack",
|
||||
TeamID: "T12345",
|
||||
Peer: &RoutePeer{Kind: "channel", ID: "C001"},
|
||||
route := r.ResolveRoute(bus.InboundContext{
|
||||
Channel: "slack",
|
||||
ChatID: "C001",
|
||||
ChatType: "channel",
|
||||
SpaceID: "T12345",
|
||||
SpaceType: "team",
|
||||
})
|
||||
|
||||
if route.AgentID != "work" {
|
||||
@@ -157,10 +164,11 @@ func TestResolveRoute_AccountBinding(t *testing.T) {
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "telegram",
|
||||
AccountID: "bot2",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user1"},
|
||||
route := r.ResolveRoute(bus.InboundContext{
|
||||
Channel: "telegram",
|
||||
Account: "bot2",
|
||||
ChatType: "direct",
|
||||
SenderID: "user1",
|
||||
})
|
||||
|
||||
if route.AgentID != "premium" {
|
||||
@@ -188,9 +196,10 @@ func TestResolveRoute_ChannelWildcard(t *testing.T) {
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user1"},
|
||||
route := r.ResolveRoute(bus.InboundContext{
|
||||
Channel: "telegram",
|
||||
ChatType: "direct",
|
||||
SenderID: "user1",
|
||||
})
|
||||
|
||||
if route.AgentID != "telegram-bot" {
|
||||
@@ -228,10 +237,12 @@ func TestResolveRoute_PriorityOrder_PeerBeatsGuild(t *testing.T) {
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "discord",
|
||||
GuildID: "guild-1",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user-vip"},
|
||||
route := r.ResolveRoute(bus.InboundContext{
|
||||
Channel: "discord",
|
||||
ChatType: "direct",
|
||||
SenderID: "user-vip",
|
||||
SpaceID: "guild-1",
|
||||
SpaceType: "guild",
|
||||
})
|
||||
|
||||
if route.AgentID != "vip" {
|
||||
@@ -258,9 +269,7 @@ func TestResolveRoute_InvalidAgentFallsToDefault(t *testing.T) {
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "telegram",
|
||||
})
|
||||
route := r.ResolveRoute(bus.InboundContext{Channel: "telegram"})
|
||||
|
||||
if route.AgentID != "main" {
|
||||
t.Errorf("AgentID = %q, want 'main' (invalid agent should fall to default)", route.AgentID)
|
||||
@@ -276,9 +285,7 @@ func TestResolveRoute_DefaultAgentSelection(t *testing.T) {
|
||||
cfg := testConfig(agents, nil)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "cli",
|
||||
})
|
||||
route := r.ResolveRoute(bus.InboundContext{Channel: "cli"})
|
||||
|
||||
if route.AgentID != "beta" {
|
||||
t.Errorf("AgentID = %q, want 'beta' (marked as default)", route.AgentID)
|
||||
@@ -293,9 +300,7 @@ func TestResolveRoute_NoDefaultUsesFirst(t *testing.T) {
|
||||
cfg := testConfig(agents, nil)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "cli",
|
||||
})
|
||||
route := r.ResolveRoute(bus.InboundContext{Channel: "cli"})
|
||||
|
||||
if route.AgentID != "alpha" {
|
||||
t.Errorf("AgentID = %q, want 'alpha' (first in list)", route.AgentID)
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DMScope controls DM session isolation granularity.
|
||||
type DMScope string
|
||||
|
||||
const (
|
||||
DMScopeMain DMScope = "main"
|
||||
DMScopePerPeer DMScope = "per-peer"
|
||||
DMScopePerChannelPeer DMScope = "per-channel-peer"
|
||||
DMScopePerAccountChannelPeer DMScope = "per-account-channel-peer"
|
||||
)
|
||||
|
||||
// RoutePeer represents a chat peer with kind and ID.
|
||||
type RoutePeer struct {
|
||||
Kind string // "direct", "group", "channel"
|
||||
ID string
|
||||
}
|
||||
|
||||
// SessionKeyParams holds all inputs for session key construction.
|
||||
type SessionKeyParams struct {
|
||||
AgentID string
|
||||
Channel string
|
||||
AccountID string
|
||||
Peer *RoutePeer
|
||||
DMScope DMScope
|
||||
IdentityLinks map[string][]string
|
||||
}
|
||||
|
||||
// ParsedSessionKey is the result of parsing an agent-scoped session key.
|
||||
type ParsedSessionKey struct {
|
||||
AgentID string
|
||||
Rest string
|
||||
}
|
||||
|
||||
// BuildAgentMainSessionKey returns "agent:<agentId>:main".
|
||||
func BuildAgentMainSessionKey(agentID string) string {
|
||||
return fmt.Sprintf("agent:%s:%s", NormalizeAgentID(agentID), DefaultMainKey)
|
||||
}
|
||||
|
||||
// BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope.
|
||||
func BuildAgentPeerSessionKey(params SessionKeyParams) string {
|
||||
agentID := NormalizeAgentID(params.AgentID)
|
||||
|
||||
peer := params.Peer
|
||||
if peer == nil {
|
||||
peer = &RoutePeer{Kind: "direct"}
|
||||
}
|
||||
peerKind := strings.TrimSpace(peer.Kind)
|
||||
if peerKind == "" {
|
||||
peerKind = "direct"
|
||||
}
|
||||
|
||||
if peerKind == "direct" {
|
||||
dmScope := params.DMScope
|
||||
if dmScope == "" {
|
||||
dmScope = DMScopeMain
|
||||
}
|
||||
peerID := CanonicalSessionPeerID(params.Channel, peer.ID, dmScope, params.IdentityLinks)
|
||||
|
||||
switch dmScope {
|
||||
case DMScopePerAccountChannelPeer:
|
||||
if peerID != "" {
|
||||
channel := normalizeChannel(params.Channel)
|
||||
accountID := NormalizeAccountID(params.AccountID)
|
||||
return fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, accountID, peerID)
|
||||
}
|
||||
case DMScopePerChannelPeer:
|
||||
if peerID != "" {
|
||||
channel := normalizeChannel(params.Channel)
|
||||
return fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID)
|
||||
}
|
||||
case DMScopePerPeer:
|
||||
if peerID != "" {
|
||||
return fmt.Sprintf("agent:%s:direct:%s", agentID, peerID)
|
||||
}
|
||||
}
|
||||
return BuildAgentMainSessionKey(agentID)
|
||||
}
|
||||
|
||||
// Group/channel peers always get per-peer sessions
|
||||
channel := normalizeChannel(params.Channel)
|
||||
peerID := strings.ToLower(strings.TrimSpace(peer.ID))
|
||||
if peerID == "" {
|
||||
peerID = "unknown"
|
||||
}
|
||||
return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID)
|
||||
}
|
||||
|
||||
// CanonicalSessionPeerID applies the current DM session canonicalization rules,
|
||||
// including identity-link collapse when enabled.
|
||||
func CanonicalSessionPeerID(
|
||||
channel, peerID string,
|
||||
dmScope DMScope,
|
||||
identityLinks map[string][]string,
|
||||
) string {
|
||||
normalizedPeerID := strings.TrimSpace(peerID)
|
||||
if normalizedPeerID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if dmScope != DMScopeMain {
|
||||
if linked := resolveLinkedPeerID(identityLinks, channel, normalizedPeerID); linked != "" {
|
||||
normalizedPeerID = linked
|
||||
}
|
||||
}
|
||||
|
||||
return strings.ToLower(normalizedPeerID)
|
||||
}
|
||||
|
||||
// CanonicalSessionIdentityID collapses an identity using identity_links when
|
||||
// possible, then returns a normalized lowercase identifier.
|
||||
func CanonicalSessionIdentityID(channel, rawID string, identityLinks map[string][]string) string {
|
||||
normalizedID := strings.TrimSpace(rawID)
|
||||
if normalizedID == "" {
|
||||
return ""
|
||||
}
|
||||
if linked := resolveLinkedPeerID(identityLinks, channel, normalizedID); linked != "" {
|
||||
normalizedID = linked
|
||||
}
|
||||
return strings.ToLower(normalizedID)
|
||||
}
|
||||
|
||||
// ParseAgentSessionKey extracts agentId and rest from "agent:<agentId>:<rest>".
|
||||
func ParseAgentSessionKey(sessionKey string) *ParsedSessionKey {
|
||||
raw := strings.TrimSpace(sessionKey)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.SplitN(raw, ":", 3)
|
||||
if len(parts) < 3 {
|
||||
return nil
|
||||
}
|
||||
if parts[0] != "agent" {
|
||||
return nil
|
||||
}
|
||||
agentID := strings.TrimSpace(parts[1])
|
||||
rest := parts[2]
|
||||
if agentID == "" || rest == "" {
|
||||
return nil
|
||||
}
|
||||
return &ParsedSessionKey{AgentID: agentID, Rest: rest}
|
||||
}
|
||||
|
||||
// IsSubagentSessionKey returns true if the session key represents a subagent.
|
||||
func IsSubagentSessionKey(sessionKey string) bool {
|
||||
raw := strings.TrimSpace(sessionKey)
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(raw), "subagent:") {
|
||||
return true
|
||||
}
|
||||
parsed := ParseAgentSessionKey(raw)
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(strings.ToLower(parsed.Rest), "subagent:")
|
||||
}
|
||||
|
||||
func normalizeChannel(channel string) string {
|
||||
c := strings.TrimSpace(strings.ToLower(channel))
|
||||
if c == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string {
|
||||
if len(identityLinks) == 0 {
|
||||
return ""
|
||||
}
|
||||
peerID = strings.TrimSpace(peerID)
|
||||
if peerID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
candidates := make(map[string]bool)
|
||||
rawCandidate := strings.ToLower(peerID)
|
||||
if rawCandidate != "" {
|
||||
candidates[rawCandidate] = true
|
||||
}
|
||||
channel = strings.ToLower(strings.TrimSpace(channel))
|
||||
if channel != "" {
|
||||
scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID))
|
||||
candidates[scopedCandidate] = true
|
||||
}
|
||||
|
||||
// If peerID is already in canonical "platform:id" format, also add the
|
||||
// bare ID part as a candidate for backward compatibility with identity_links
|
||||
// that use raw IDs (e.g. "123" instead of "telegram:123").
|
||||
if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 {
|
||||
bareID := rawCandidate[idx+1:]
|
||||
candidates[bareID] = true
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
for canonical, ids := range identityLinks {
|
||||
canonicalName := strings.TrimSpace(canonical)
|
||||
if canonicalName == "" {
|
||||
continue
|
||||
}
|
||||
for _, id := range ids {
|
||||
normalized := strings.ToLower(strings.TrimSpace(id))
|
||||
if normalized != "" && candidates[normalized] {
|
||||
return canonicalName
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package routing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildAgentMainSessionKey(t *testing.T) {
|
||||
got := BuildAgentMainSessionKey("sales")
|
||||
want := "agent:sales:main"
|
||||
if got != want {
|
||||
t.Errorf("BuildAgentMainSessionKey('sales') = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentMainSessionKey_Normalizes(t *testing.T) {
|
||||
got := BuildAgentMainSessionKey("Sales Bot")
|
||||
want := "agent:sales-bot:main"
|
||||
if got != want {
|
||||
t.Errorf("BuildAgentMainSessionKey('Sales Bot') = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) {
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||
DMScope: DMScopeMain,
|
||||
})
|
||||
want := "agent:main:main"
|
||||
if got != want {
|
||||
t.Errorf("DMScopeMain = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) {
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||
DMScope: DMScopePerPeer,
|
||||
})
|
||||
want := "agent:main:direct:user123"
|
||||
if got != want {
|
||||
t.Errorf("DMScopePerPeer = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_DMScopePerChannelPeer(t *testing.T) {
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||
DMScope: DMScopePerChannelPeer,
|
||||
})
|
||||
want := "agent:main:telegram:direct:user123"
|
||||
if got != want {
|
||||
t.Errorf("DMScopePerChannelPeer = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) {
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
AccountID: "bot1",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "User123"},
|
||||
DMScope: DMScopePerAccountChannelPeer,
|
||||
})
|
||||
want := "agent:main:telegram:bot1:direct:user123"
|
||||
if got != want {
|
||||
t.Errorf("DMScopePerAccountChannelPeer = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_GroupPeer(t *testing.T) {
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "group", ID: "chat456"},
|
||||
DMScope: DMScopePerPeer,
|
||||
})
|
||||
want := "agent:main:telegram:group:chat456"
|
||||
if got != want {
|
||||
t.Errorf("GroupPeer = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_NilPeer(t *testing.T) {
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Peer: nil,
|
||||
DMScope: DMScopePerPeer,
|
||||
})
|
||||
// nil peer defaults to direct with empty ID, falls to main
|
||||
want := "agent:main:main"
|
||||
if got != want {
|
||||
t.Errorf("NilPeer = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) {
|
||||
links := map[string][]string{
|
||||
"john": {"telegram:user123", "discord:john#1234"},
|
||||
}
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||
DMScope: DMScopePerPeer,
|
||||
IdentityLinks: links,
|
||||
})
|
||||
want := "agent:main:direct:john"
|
||||
if got != want {
|
||||
t.Errorf("IdentityLink = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLinkedPeerID_CanonicalPeerID(t *testing.T) {
|
||||
// When peerID is already in canonical "platform:id" format,
|
||||
// it should match identity_links that use the bare ID.
|
||||
links := map[string][]string{
|
||||
"john": {"123"},
|
||||
}
|
||||
got := resolveLinkedPeerID(links, "telegram", "telegram:123")
|
||||
if got != "john" {
|
||||
t.Errorf("resolveLinkedPeerID with canonical peerID = %q, want %q", got, "john")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLinkedPeerID_CanonicalInLinks(t *testing.T) {
|
||||
// When identity_links contain canonical IDs and peerID is canonical too
|
||||
links := map[string][]string{
|
||||
"john": {"telegram:123", "discord:456"},
|
||||
}
|
||||
got := resolveLinkedPeerID(links, "telegram", "telegram:123")
|
||||
if got != "john" {
|
||||
t.Errorf("resolveLinkedPeerID canonical in links = %q, want %q", got, "john")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLinkedPeerID_BarePeerIDMatchesCanonicalLink(t *testing.T) {
|
||||
// When peerID is bare "123" and links have "telegram:123",
|
||||
// the scoped candidate "telegram:123" should match.
|
||||
links := map[string][]string{
|
||||
"john": {"telegram:123"},
|
||||
}
|
||||
got := resolveLinkedPeerID(links, "telegram", "123")
|
||||
if got != "john" {
|
||||
t.Errorf("resolveLinkedPeerID bare peer matches canonical link = %q, want %q", got, "john")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLinkedPeerID_NoMatch(t *testing.T) {
|
||||
links := map[string][]string{
|
||||
"john": {"telegram:123"},
|
||||
}
|
||||
got := resolveLinkedPeerID(links, "discord", "999")
|
||||
if got != "" {
|
||||
t.Errorf("resolveLinkedPeerID no match = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentSessionKey_Valid(t *testing.T) {
|
||||
parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123")
|
||||
if parsed == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if parsed.AgentID != "sales" {
|
||||
t.Errorf("AgentID = %q, want 'sales'", parsed.AgentID)
|
||||
}
|
||||
if parsed.Rest != "telegram:direct:user123" {
|
||||
t.Errorf("Rest = %q, want 'telegram:direct:user123'", parsed.Rest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentSessionKey_Invalid(t *testing.T) {
|
||||
tests := []string{
|
||||
"",
|
||||
"foo:bar",
|
||||
"notprefix:sales:main",
|
||||
"agent::main",
|
||||
"agent:sales:",
|
||||
}
|
||||
for _, input := range tests {
|
||||
if got := ParseAgentSessionKey(input); got != nil {
|
||||
t.Errorf("ParseAgentSessionKey(%q) = %+v, want nil", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSubagentSessionKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"subagent:task-1", true},
|
||||
{"agent:main:subagent:task-1", true},
|
||||
{"agent:main:main", false},
|
||||
{"agent:main:telegram:direct:user123", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsSubagentSessionKey(tt.input); got != tt.want {
|
||||
t.Errorf("IsSubagentSessionKey(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user