diff --git a/pkg/config/config.go b/pkg/config/config.go index f3cbf43ad..20cdd74c2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -352,6 +352,26 @@ type SessionConfig struct { DmScope string `json:"dm_scope,omitempty"` } +// ApplyDmScope translates the user-facing dm_scope value into the internal +// dimensions array that the routing layer consumes. It is a no-op when +// DmScope is empty or when Dimensions is already set (explicit Dimensions +// take precedence over the derived value). +func (s *SessionConfig) ApplyDmScope() { + if s.DmScope == "" || len(s.Dimensions) > 0 { + return + } + switch s.DmScope { + case "per-channel-peer": + s.Dimensions = []string{"chat", "sender"} + case "per-channel": + s.Dimensions = []string{"chat"} + case "per-peer": + s.Dimensions = []string{"sender"} + case "global": + s.Dimensions = nil + } +} + // RoutingConfig controls the intelligent model routing feature. // When enabled, each incoming message is scored against structural features // (message length, code blocks, tool call history, conversation depth, attachments). @@ -1477,6 +1497,8 @@ func LoadConfig(path string) (*Config, error) { cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName) } + cfg.Session.ApplyDmScope() + return cfg, nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index ea90fafe4..12cd70734 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1728,6 +1728,65 @@ func TestDefaultConfig_SessionDimensions(t *testing.T) { } } +func TestSessionConfig_ApplyDmScope(t *testing.T) { + tests := []struct { + name string + dmScope string + dimensions []string + want []string + }{ + { + name: "per-channel-peer", + dmScope: "per-channel-peer", + want: []string{"chat", "sender"}, + }, + { + name: "per-channel", + dmScope: "per-channel", + want: []string{"chat"}, + }, + { + name: "per-peer", + dmScope: "per-peer", + want: []string{"sender"}, + }, + { + name: "global", + dmScope: "global", + want: nil, + }, + { + name: "explicit dimensions take precedence", + dmScope: "per-channel-peer", + dimensions: []string{"sender"}, + want: []string{"sender"}, + }, + { + name: "empty dm_scope is no-op", + dmScope: "", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &SessionConfig{ + DmScope: tt.dmScope, + Dimensions: tt.dimensions, + } + s.ApplyDmScope() + if len(s.Dimensions) != len(tt.want) { + t.Fatalf("Dimensions = %v, want %v", s.Dimensions, tt.want) + } + for i, v := range tt.want { + if s.Dimensions[i] != v { + t.Errorf("Dimensions[%d] = %q, want %q", i, s.Dimensions[i], v) + } + } + }) + } +} + func TestDefaultConfig_WorkspacePath_Default(t *testing.T) { t.Setenv("PICOCLAW_HOME", "") diff --git a/web/backend/api/config.go b/web/backend/api/config.go index b37958040..50d9b0293 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -76,6 +76,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } + cfg.Session.ApplyDmScope() if execAllowRemoteOmitted(body) { cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote } @@ -181,6 +182,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest) return } + newCfg.Session.ApplyDmScope() // Restore security fields (tokens/keys) from the loaded config before validation, // because private fields are lost during JSON round-trip.