fix(config): align dm_scope values with backend routing
This commit is contained in:
parent
6bd8fec87a
commit
e6497e5324
8 changed files with 157 additions and 11 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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()) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "按用户隔离",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue