Merge pull request #3067 from SiYue-ZO/fix/session-dm-scope-save
fix: add DmScope field to SessionConfig to persist dm_scope setting
This commit is contained in:
commit
f8472d6f27
3 changed files with 226 additions and 1 deletions
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -257,7 +258,7 @@ func (c *Config) MarshalJSON() ([]byte, error) {
|
||||||
Alias: (*Alias)(c),
|
Alias: (*Alias)(c),
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(c.Session.Dimensions) > 0 || len(c.Session.IdentityLinks) > 0 {
|
if len(c.Session.Dimensions) > 0 || len(c.Session.IdentityLinks) > 0 || c.Session.DmScope != "" {
|
||||||
sessionCfg := c.Session
|
sessionCfg := c.Session
|
||||||
aux.Session = &sessionCfg
|
aux.Session = &sessionCfg
|
||||||
}
|
}
|
||||||
|
|
@ -349,6 +350,47 @@ type DispatchSelector struct {
|
||||||
type SessionConfig struct {
|
type SessionConfig struct {
|
||||||
Dimensions []string `json:"dimensions,omitempty"`
|
Dimensions []string `json:"dimensions,omitempty"`
|
||||||
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveDmScope sets DmScope based on Dimensions when DmScope is empty.
|
||||||
|
// This handles legacy/fresh configs that only have explicit Dimensions
|
||||||
|
// without a corresponding DmScope value, ensuring the API response always
|
||||||
|
// includes a dm_scope that matches the actual runtime dimensions.
|
||||||
|
func (s *SessionConfig) DeriveDmScope() {
|
||||||
|
if s.DmScope != "" || len(s.Dimensions) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case slices.Equal(s.Dimensions, []string{"chat", "sender"}):
|
||||||
|
s.DmScope = "per-channel-peer"
|
||||||
|
case slices.Equal(s.Dimensions, []string{"chat"}):
|
||||||
|
s.DmScope = "per-channel"
|
||||||
|
case slices.Equal(s.Dimensions, []string{"sender"}):
|
||||||
|
s.DmScope = "per-peer"
|
||||||
|
}
|
||||||
|
// Dimensions not matching any known scope mapping (custom array)
|
||||||
|
// is fine — DmScope stays empty and the UI can handle it.
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoutingConfig controls the intelligent model routing feature.
|
// RoutingConfig controls the intelligent model routing feature.
|
||||||
|
|
@ -1476,6 +1518,9 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName)
|
cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.Session.ApplyDmScope()
|
||||||
|
cfg.Session.DeriveDmScope()
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1697,6 +1742,8 @@ func ResetToDefaults(configPath string) error {
|
||||||
return fmt.Errorf("backup before reset: %w", err)
|
return fmt.Errorf("backup before reset: %w", err)
|
||||||
}
|
}
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
cfg.Session.ApplyDmScope()
|
||||||
|
cfg.Session.DeriveDmScope()
|
||||||
if err := cfg.SecurityCopyFrom(configPath); err != nil {
|
if err := cfg.SecurityCopyFrom(configPath); err != nil {
|
||||||
logger.WarnF("could not preserve security config", map[string]any{"error": err})
|
logger.WarnF("could not preserve security config", map[string]any{"error": err})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1728,6 +1728,166 @@ 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 TestSessionConfig_DeriveDmScope(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dimensions []string
|
||||||
|
dmScope string
|
||||||
|
wantScope string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "per-channel-peer from dimensions",
|
||||||
|
dimensions: []string{"chat", "sender"},
|
||||||
|
wantScope: "per-channel-peer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "per-channel from dimensions",
|
||||||
|
dimensions: []string{"chat"},
|
||||||
|
wantScope: "per-channel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "per-peer from dimensions",
|
||||||
|
dimensions: []string{"sender"},
|
||||||
|
wantScope: "per-peer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom dimensions does not set scope",
|
||||||
|
dimensions: []string{"chat", "extra"},
|
||||||
|
wantScope: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty dimensions does not set scope",
|
||||||
|
dimensions: nil,
|
||||||
|
wantScope: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing dm_scope is not overwritten",
|
||||||
|
dimensions: []string{"chat", "sender"},
|
||||||
|
dmScope: "per-channel",
|
||||||
|
wantScope: "per-channel",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := &SessionConfig{
|
||||||
|
DmScope: tt.dmScope,
|
||||||
|
Dimensions: tt.dimensions,
|
||||||
|
}
|
||||||
|
s.DeriveDmScope()
|
||||||
|
if s.DmScope != tt.wantScope {
|
||||||
|
t.Errorf("DmScope = %q, want %q", s.DmScope, tt.wantScope)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionConfig_ApplyDmScope_ClearsStaleDimensions(t *testing.T) {
|
||||||
|
// Simulates the PATCH handler scenario: dm_scope changed but stale
|
||||||
|
// dimensions remain from the old scope. After clearing dimensions,
|
||||||
|
// ApplyDmScope should re-derive from the new dm_scope.
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dmScope string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "per-channel-peer to per-channel",
|
||||||
|
dmScope: "per-channel",
|
||||||
|
want: []string{"chat"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "per-channel-peer to per-peer",
|
||||||
|
dmScope: "per-peer",
|
||||||
|
want: []string{"sender"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "per-channel-peer to global",
|
||||||
|
dmScope: "global",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := &SessionConfig{
|
||||||
|
DmScope: tt.dmScope,
|
||||||
|
Dimensions: []string{"chat", "sender"}, // stale from per-channel-peer
|
||||||
|
}
|
||||||
|
// Simulate what the PATCH handler does: clear dimensions when dm_scope changes
|
||||||
|
s.Dimensions = nil
|
||||||
|
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) {
|
func TestDefaultConfig_WorkspacePath_Default(t *testing.T) {
|
||||||
t.Setenv("PICOCLAW_HOME", "")
|
t.Setenv("PICOCLAW_HOME", "")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cfg.Session.ApplyDmScope()
|
||||||
|
cfg.Session.DeriveDmScope()
|
||||||
if execAllowRemoteOmitted(body) {
|
if execAllowRemoteOmitted(body) {
|
||||||
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
|
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
|
||||||
}
|
}
|
||||||
|
|
@ -164,6 +166,20 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Recursively merge patch into base
|
// Recursively merge patch into base
|
||||||
mergeMap(base, patch)
|
mergeMap(base, patch)
|
||||||
|
|
||||||
|
// When the patch updates dm_scope, the old derived dimensions from the
|
||||||
|
// base must be cleared so that ApplyDmScope() can re-derive them from
|
||||||
|
// the new dm_scope value. Otherwise the stale dimensions survive the
|
||||||
|
// merge and ApplyDmScope() exits early due to its precedence guard.
|
||||||
|
if sess, ok := base["session"].(map[string]any); ok {
|
||||||
|
if patchSess, patchHasSession := patch["session"].(map[string]any); patchHasSession {
|
||||||
|
if _, hasDmScope := patchSess["dm_scope"]; hasDmScope {
|
||||||
|
if _, hasDimsInPatch := patchSess["dimensions"]; !hasDimsInPatch {
|
||||||
|
delete(sess, "dimensions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if err = normalizeChannelArrayFields(base); err != nil {
|
if err = normalizeChannelArrayFields(base); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -181,6 +197,8 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
newCfg.Session.ApplyDmScope()
|
||||||
|
newCfg.Session.DeriveDmScope()
|
||||||
|
|
||||||
// Restore security fields (tokens/keys) from the loaded config before validation,
|
// Restore security fields (tokens/keys) from the loaded config before validation,
|
||||||
// because private fields are lost during JSON round-trip.
|
// because private fields are lost during JSON round-trip.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue