Skip to content

Commit 488def6

Browse files
PierrunoYTampagent
andauthored
fix(config): preserve unknown top-level fields (#984)
* fix(config): preserve unknown top-level fields Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685 Co-authored-by: Amp <amp@ampcode.com> * fix(config): match JSON Unicode case folding Amp-Thread-ID: https://ampcode.com/threads/T-01a044e2-92ad-774b-9a86-094e2e5293bf Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch> --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent b04fe96 commit 488def6

3 files changed

Lines changed: 129 additions & 1 deletion

File tree

‎internal/config/types.go‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
import (
44
"encoding/json"
55
"fmt"
6+
"reflect"
67
"strings"
78
)
89

@@ -363,6 +364,9 @@ type FileConfig struct {
363364
LocalControl LocalControlConfig `json:"localControl,omitempty"`
364365
STT STTConfig `json:"stt,omitempty"`
365366
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
367+
// Extra preserves top-level fields written by newer Zero versions or
368+
// extensions so a read-modify-write through this version is non-destructive.
369+
Extra map[string]json.RawMessage `json:"-"`
366370
}
367371

368372
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
@@ -400,7 +404,32 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
400404
if !cfg.STT.Empty() {
401405
raw.STT = &cfg.STT
402406
}
403-
return json.Marshal(raw)
407+
known, err := json.Marshal(raw)
408+
if err != nil || len(cfg.Extra) == 0 {
409+
return known, err
410+
}
411+
var merged map[string]json.RawMessage
412+
if err := json.Unmarshal(known, &merged); err != nil {
413+
return nil, err
414+
}
415+
for key, value := range cfg.Extra {
416+
if !fileConfigKnownJSONKey(key) {
417+
merged[key] = value
418+
}
419+
}
420+
return json.Marshal(merged)
421+
}
422+
423+
func fileConfigKnownJSONKey(key string) bool {
424+
if strings.EqualFold(key, "mcpServers") || strings.EqualFold(key, "mcp_servers") {
425+
return true
426+
}
427+
for _, field := range knownJSONFields(reflect.TypeOf(FileConfig{})) {
428+
if strings.EqualFold(key, field.canonical) {
429+
return true
430+
}
431+
}
432+
return false
404433
}
405434

406435
type ResolveOptions struct {
@@ -516,6 +545,18 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
516545
if err := json.Unmarshal(data, &raw); err != nil {
517546
return err
518547
}
548+
var extra map[string]json.RawMessage
549+
if err := json.Unmarshal(data, &extra); err != nil {
550+
return err
551+
}
552+
for key := range extra {
553+
if fileConfigKnownJSONKey(key) {
554+
delete(extra, key)
555+
}
556+
}
557+
if len(extra) == 0 {
558+
extra = nil
559+
}
519560
cfg.ActiveProvider = raw.ActiveProvider
520561
cfg.Providers = raw.Providers
521562
// A negative maxTurns is unambiguously invalid; without this it would be
@@ -537,6 +578,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
537578
cfg.LocalControl = raw.LocalControl
538579
cfg.STT = raw.STT
539580
cfg.CrossSessionInbound = raw.CrossSessionInbound
581+
cfg.Extra = extra
540582
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
541583
cfg.MCP.Servers = map[string]MCPServerConfig{}
542584
}

‎internal/config/types_test.go‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,58 @@ func TestToolsConfigPresentOnOverridesAndResolved(t *testing.T) {
4343
t.Fatalf("ResolvedConfig.Tools.DeferThreshold = %d, want 4", resolved.Tools.DeferThreshold)
4444
}
4545
}
46+
47+
func TestFileConfigExtraCannotOverrideKnownFields(t *testing.T) {
48+
cfg := FileConfig{
49+
MaxTurns: 3,
50+
Extra: map[string]json.RawMessage{
51+
"maxTurns": json.RawMessage(`99`),
52+
"MaxTurns": json.RawMessage(`100`),
53+
"future": json.RawMessage(`{"enabled":true}`),
54+
},
55+
}
56+
data, err := json.Marshal(cfg)
57+
if err != nil {
58+
t.Fatal(err)
59+
}
60+
var persisted map[string]json.RawMessage
61+
if err := json.Unmarshal(data, &persisted); err != nil {
62+
t.Fatal(err)
63+
}
64+
if got := string(persisted["maxTurns"]); got != "3" {
65+
t.Fatalf("maxTurns = %s, want 3", got)
66+
}
67+
if _, exists := persisted["MaxTurns"]; exists {
68+
t.Fatalf("case-variant extra overrode a known field: %s", data)
69+
}
70+
if got := string(persisted["future"]); got != `{"enabled":true}` {
71+
t.Fatalf("future = %s, want preserved object", got)
72+
}
73+
}
74+
75+
func TestFileConfigUnicodeFoldedKnownFieldRoundTrip(t *testing.T) {
76+
const data = `{"mcpſervers":{"docs":{"url":"https://example.com/mcp"}}}`
77+
78+
var cfg FileConfig
79+
if err := json.Unmarshal([]byte(data), &cfg); err != nil {
80+
t.Fatal(err)
81+
}
82+
if _, exists := cfg.MCP.Servers["docs"]; !exists {
83+
t.Fatalf("MCP.Servers = %#v, want docs server", cfg.MCP.Servers)
84+
}
85+
if cfg.Extra != nil {
86+
t.Fatalf("Extra = %#v, want Unicode-folded known field excluded", cfg.Extra)
87+
}
88+
89+
encoded, err := json.Marshal(cfg)
90+
if err != nil {
91+
t.Fatal(err)
92+
}
93+
var roundTripped FileConfig
94+
if err := json.Unmarshal(encoded, &roundTripped); err != nil {
95+
t.Fatal(err)
96+
}
97+
if _, exists := roundTripped.MCP.Servers["docs"]; !exists {
98+
t.Fatalf("round-tripped MCP.Servers = %#v, want docs server", roundTripped.MCP.Servers)
99+
}
100+
}

‎internal/config/writer_test.go‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,37 @@ func TestSetThemePersistsUserPreference(t *testing.T) {
395395
}
396396
}
397397

398+
func TestSetThemePreservesUnknownTopLevelFields(t *testing.T) {
399+
path := filepath.Join(t.TempDir(), "zero.json")
400+
if err := os.WriteFile(path, []byte(`{
401+
"preferences": {"theme": "default"},
402+
"futureSetting": {"a": 1}
403+
}`), 0o600); err != nil {
404+
t.Fatal(err)
405+
}
406+
407+
if _, err := SetTheme(path, "dracula"); err != nil {
408+
t.Fatalf("SetTheme() error = %v", err)
409+
}
410+
data, err := os.ReadFile(path)
411+
if err != nil {
412+
t.Fatal(err)
413+
}
414+
var persisted map[string]json.RawMessage
415+
if err := json.Unmarshal(data, &persisted); err != nil {
416+
t.Fatal(err)
417+
}
418+
var futureSetting struct {
419+
A int `json:"a"`
420+
}
421+
if err := json.Unmarshal(persisted["futureSetting"], &futureSetting); err != nil {
422+
t.Fatalf("unknown field was not preserved: %v\nconfig: %s", err, data)
423+
}
424+
if futureSetting.A != 1 {
425+
t.Fatalf("futureSetting = %s, want {\"a\":1}", persisted["futureSetting"])
426+
}
427+
}
428+
398429
func TestRecapsPreferenceRoundTrips(t *testing.T) {
399430
// Default (unset) is ON.
400431
if !(PreferencesConfig{}).RecapsEnabled() {

0 commit comments

Comments
 (0)