fix(config): align dm_scope values with backend routing

This commit is contained in:
xiwuqi 2026-03-25 03:04:37 -05:00
parent 6bd8fec87a
commit e6497e5324
8 changed files with 157 additions and 11 deletions

View file

@ -44,8 +44,8 @@ func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
accountID := NormalizeAccountID(input.AccountID) accountID := NormalizeAccountID(input.AccountID)
peer := input.Peer peer := input.Peer
dmScope := DMScope(r.cfg.Session.DMScope) dmScope, ok := NormalizeDMScope(r.cfg.Session.DMScope)
if dmScope == "" { if !ok {
dmScope = DMScopeMain dmScope = DMScopeMain
} }
identityLinks := r.cfg.Session.IdentityLinks identityLinks := r.cfg.Session.IdentityLinks

View file

@ -15,6 +15,22 @@ const (
DMScopePerAccountChannelPeer DMScope = "per-account-channel-peer" DMScopePerAccountChannelPeer DMScope = "per-account-channel-peer"
) )
// NormalizeDMScope converts known legacy aliases to their canonical backend values.
func NormalizeDMScope(scope string) (DMScope, bool) {
switch strings.ToLower(strings.TrimSpace(scope)) {
case string(DMScopeMain), "global":
return DMScopeMain, true
case string(DMScopePerPeer):
return DMScopePerPeer, true
case string(DMScopePerChannelPeer), "per-channel":
return DMScopePerChannelPeer, true
case string(DMScopePerAccountChannelPeer):
return DMScopePerAccountChannelPeer, true
default:
return "", false
}
}
// RoutePeer represents a chat peer with kind and ID. // RoutePeer represents a chat peer with kind and ID.
type RoutePeer struct { type RoutePeer struct {
Kind string // "direct", "group", "channel" Kind string // "direct", "group", "channel"
@ -56,8 +72,8 @@ func BuildAgentPeerSessionKey(params SessionKeyParams) string {
} }
if peerKind == "direct" { if peerKind == "direct" {
dmScope := params.DMScope dmScope, ok := NormalizeDMScope(string(params.DMScope))
if dmScope == "" { if !ok {
dmScope = DMScopeMain dmScope = DMScopeMain
} }
peerID := strings.TrimSpace(peer.ID) peerID := strings.TrimSpace(peer.ID)

View file

@ -18,6 +18,32 @@ func TestBuildAgentMainSessionKey_Normalizes(t *testing.T) {
} }
} }
func TestNormalizeDMScope(t *testing.T) {
tests := []struct {
name string
input string
want DMScope
ok bool
}{
{name: "main", input: "main", want: DMScopeMain, ok: true},
{name: "global alias", input: "global", want: DMScopeMain, ok: true},
{name: "per peer", input: "per-peer", want: DMScopePerPeer, ok: true},
{name: "per channel peer", input: "per-channel-peer", want: DMScopePerChannelPeer, ok: true},
{name: "per channel alias", input: "per-channel", want: DMScopePerChannelPeer, ok: true},
{name: "per account channel peer", input: "per-account-channel-peer", want: DMScopePerAccountChannelPeer, ok: true},
{name: "unknown", input: "shared", want: "", ok: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := NormalizeDMScope(tt.input)
if got != tt.want || ok != tt.ok {
t.Fatalf("NormalizeDMScope(%q) = (%q, %v), want (%q, %v)", tt.input, got, ok, tt.want, tt.ok)
}
})
}
}
func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) { func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) {
got := BuildAgentPeerSessionKey(SessionKeyParams{ got := BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "main", AgentID: "main",
@ -31,6 +57,19 @@ func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) {
} }
} }
func TestBuildAgentPeerSessionKey_DMScopeGlobalAlias(t *testing.T) {
got := BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "main",
Channel: "telegram",
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
DMScope: DMScope("global"),
})
want := "agent:main:main"
if got != want {
t.Errorf("DMScope(global) = %q, want %q", got, want)
}
}
func TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) { func TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) {
got := BuildAgentPeerSessionKey(SessionKeyParams{ got := BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "main", AgentID: "main",
@ -57,6 +96,19 @@ func TestBuildAgentPeerSessionKey_DMScopePerChannelPeer(t *testing.T) {
} }
} }
func TestBuildAgentPeerSessionKey_DMScopePerChannelAlias(t *testing.T) {
got := BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "main",
Channel: "telegram",
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
DMScope: DMScope("per-channel"),
})
want := "agent:main:telegram:direct:user123"
if got != want {
t.Errorf("DMScope(per-channel) = %q, want %q", got, want)
}
}
func TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) { func TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) {
got := BuildAgentPeerSessionKey(SessionKeyParams{ got := BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "main", AgentID: "main",

View file

@ -10,6 +10,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/routing"
) )
// registerConfigRoutes binds configuration management endpoints to the ServeMux. // registerConfigRoutes binds configuration management endpoints to the ServeMux.
@ -250,6 +251,21 @@ func (h *Handler) handleTestCommandPatterns(w http.ResponseWriter, r *http.Reque
func validateConfig(cfg *config.Config) []string { func validateConfig(cfg *config.Config) []string {
var errs []string var errs []string
if rawScope := strings.TrimSpace(cfg.Session.DMScope); rawScope != "" {
normalizedScope, ok := routing.NormalizeDMScope(rawScope)
if !ok {
errs = append(
errs,
fmt.Sprintf(
"session.dm_scope %q is invalid; supported values: main, per-peer, per-channel-peer, per-account-channel-peer",
cfg.Session.DMScope,
),
)
} else {
cfg.Session.DMScope = string(normalizedScope)
}
}
// Validate model_list entries // Validate model_list entries
if err := cfg.ValidateModelList(); err != nil { if err := cfg.ValidateModelList(); err != nil {
errs = append(errs, err.Error()) errs = append(errs, err.Error())

View file

@ -143,6 +143,45 @@ func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *tes
} }
} }
func TestValidateConfig_NormalizesLegacyDMScopeAliases(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "global", input: "global", want: "main"},
{name: "per-channel", input: "per-channel", want: "per-channel-peer"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Session.DMScope = tt.input
errs := validateConfig(cfg)
if len(errs) != 0 {
t.Fatalf("validateConfig() errors = %v, want none", errs)
}
if cfg.Session.DMScope != tt.want {
t.Fatalf("Session.DMScope = %q, want %q", cfg.Session.DMScope, tt.want)
}
})
}
}
func TestValidateConfig_RejectsUnknownDMScope(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Session.DMScope = "shared"
errs := validateConfig(cfg)
if len(errs) == 0 {
t.Fatal("validateConfig() errors = nil, want dm_scope validation error")
}
if !bytes.Contains([]byte(errs[0]), []byte("session.dm_scope")) {
t.Fatalf("validateConfig() first error = %q, want dm_scope validation", errs[0])
}
}
// setupPicoEnabledEnv creates a test environment with Pico channel enabled and // setupPicoEnabledEnv creates a test environment with Pico channel enabled and
// its token stored only in .security.yml (not in the JSON payload). // its token stored only in .security.yml (not in the JSON payload).
func setupPicoEnabledEnv(t *testing.T) (string, func()) { func setupPicoEnabledEnv(t *testing.T) (string, func()) {

View file

@ -40,11 +40,12 @@ export const DM_SCOPE_OPTIONS = [
descDefault: "Separate context for each user in each channel.", descDefault: "Separate context for each user in each channel.",
}, },
{ {
value: "per-channel", value: "per-account-channel-peer",
labelKey: "pages.config.session_scope_per_channel", labelKey: "pages.config.session_scope_per_account_channel_peer",
labelDefault: "Per Channel", labelDefault: "Per Account + Channel + Peer",
descKey: "pages.config.session_scope_per_channel_desc", descKey: "pages.config.session_scope_per_account_channel_peer_desc",
descDefault: "One shared context per channel.", descDefault:
"Separate context for each user on each channel account instance.",
}, },
{ {
value: "per-peer", value: "per-peer",
@ -54,7 +55,7 @@ export const DM_SCOPE_OPTIONS = [
descDefault: "One context per user across channels.", descDefault: "One context per user across channels.",
}, },
{ {
value: "global", value: "main",
labelKey: "pages.config.session_scope_global", labelKey: "pages.config.session_scope_global",
labelDefault: "Global", labelDefault: "Global",
descKey: "pages.config.session_scope_global_desc", descKey: "pages.config.session_scope_global_desc",
@ -62,6 +63,11 @@ export const DM_SCOPE_OPTIONS = [
}, },
] as const ] as const
const DM_SCOPE_ALIASES: Record<string, string> = {
global: "main",
"per-channel": "per-channel-peer",
}
export const EMPTY_FORM: CoreConfigForm = { export const EMPTY_FORM: CoreConfigForm = {
workspace: "", workspace: "",
restrictToWorkspace: true, restrictToWorkspace: true,
@ -104,6 +110,19 @@ function asString(value: unknown): string {
return typeof value === "string" ? value : "" return typeof value === "string" ? value : ""
} }
function normalizeDmScope(value: unknown): string {
const raw = asString(value).trim()
if (!raw) {
return EMPTY_FORM.dmScope
}
const normalized = DM_SCOPE_ALIASES[raw] ?? raw
return DM_SCOPE_OPTIONS.some((scope) => scope.value === normalized)
? normalized
: raw
}
function asBool(value: unknown): boolean { function asBool(value: unknown): boolean {
return value === true return value === true
} }
@ -195,7 +214,7 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
defaults.summarize_token_percent, defaults.summarize_token_percent,
EMPTY_FORM.summarizeTokenPercent, EMPTY_FORM.summarizeTokenPercent,
), ),
dmScope: asString(session.dm_scope) || EMPTY_FORM.dmScope, dmScope: normalizeDmScope(session.dm_scope),
heartbeatEnabled: heartbeatEnabled:
heartbeat.enabled === undefined heartbeat.enabled === undefined
? EMPTY_FORM.heartbeatEnabled ? EMPTY_FORM.heartbeatEnabled

View file

@ -460,6 +460,8 @@
"session_scope_hint": "How chat context is isolated across peers/channels.", "session_scope_hint": "How chat context is isolated across peers/channels.",
"session_scope_per_channel_peer": "Per Channel + Peer", "session_scope_per_channel_peer": "Per Channel + Peer",
"session_scope_per_channel_peer_desc": "Separate context for each user in each channel.", "session_scope_per_channel_peer_desc": "Separate context for each user in each channel.",
"session_scope_per_account_channel_peer": "Per Account + Channel + Peer",
"session_scope_per_account_channel_peer_desc": "Separate context for each user on each channel account instance.",
"session_scope_per_channel": "Per Channel", "session_scope_per_channel": "Per Channel",
"session_scope_per_channel_desc": "One shared context per channel.", "session_scope_per_channel_desc": "One shared context per channel.",
"session_scope_per_peer": "Per Peer", "session_scope_per_peer": "Per Peer",

View file

@ -460,6 +460,8 @@
"session_scope_hint": "定义不同用户/频道之间如何隔离会话上下文。", "session_scope_hint": "定义不同用户/频道之间如何隔离会话上下文。",
"session_scope_per_channel_peer": "按频道+用户隔离", "session_scope_per_channel_peer": "按频道+用户隔离",
"session_scope_per_channel_peer_desc": "同一频道内不同用户使用独立上下文。", "session_scope_per_channel_peer_desc": "同一频道内不同用户使用独立上下文。",
"session_scope_per_account_channel_peer": "按账号+频道+用户隔离",
"session_scope_per_account_channel_peer_desc": "不同频道账号实例中的不同用户使用独立上下文。",
"session_scope_per_channel": "按频道隔离", "session_scope_per_channel": "按频道隔离",
"session_scope_per_channel_desc": "同一频道内共享一个上下文。", "session_scope_per_channel_desc": "同一频道内共享一个上下文。",
"session_scope_per_peer": "按用户隔离", "session_scope_per_peer": "按用户隔离",