Files
magpie/internal/agent/codex_multiline_test.go
T
yetone d37b821908 toml: Codex's config.toml with a multi-line array (notify = [ … ]) is edited right, and an edit that fails leaves every file as it was (#253 kerwinliu00001: "edited TOML is invalid: line 2, column 1: incomplete number", config.toml left with model_provider gone and [model_providers.magpie] added but no model) — the top-level key edits (GetTOMLTop, SetTOMLTop, DelTOMLTop) went line by line with regexes: a new key went after the last line that looked like key = …, which for notify = [ was inside the array; key = value lines inside a multi-line string were read as keys and a line starting with [ as a table; replacing or deleting a multi-line value touched only its first line. They now locate the top-level keys with the TOML parser, as the table edits already did, each with its value's whole range however many lines it takes (parseTOMLFile, the unnamed root table beside the tables); SetTOMLTop finds all its keys in the file as read and writes once. A file the parser refuses is still read line by line, so a broken config keeps showing its model, and writes to it are refused untouched. And a pick is several writes (Codex: drop model_provider, add magpie's provider table, set openai_base_url and model), each checked, so one failing left the earlier ones on disk: edit.Atomically(fn, paths…) puts each file back to its bytes and mode, or removes one it created, when fn fails, and every field set and Sync of Codex (config.toml and magpie-models.json, so Codex in WSL too), Kimi and Grok goes through it, as do Codex's MCP server writes (putCodex, delCodex). Tested in temp dirs and a temp HOME: the reporter's file read, set and deleted directly and through Codex's model field (signed in, with model_provider and model above the array, with magpie as the provider), the array's text kept and the TOML valid, and back to a Codex model; nested arrays, trailing commas, comments in arrays, [x] and a = b in strings, an inline table in an array; """ and ''' strings holding model = … and [table] lines not read as keys, new keys after their closing quotes, one deleted whole; a forced failure of Codex's last write leaving config.toml byte-identical; TestAtomically. They fail on main (the Codex ones with the reporter's error). go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried against a real ~/.codex, in WSL or in the GUI; multi-line inline tables (TOML 1.1) are refused by the parser and so left untouched with an error.
2026-09-30 09:31:59 +08:00

80 lines
2.9 KiB
Go

package agent
import (
"reflect"
"strings"
"testing"
"github.com/pelletier/go-toml/v2"
)
// #253: a config.toml with a top-level array over several lines. Picking a
// model through magpie put the new top-level keys inside the array, and the
// failed write left the provider table appended but no model.
const codexNotify = `notify = [
"/usr/local/bin/notifier",
"turn-ended",
]
[model_providers.example]
base_url = "http://127.0.0.1:1/v1"
`
func TestCodexMultilineArray(t *testing.T) {
for _, tc := range []struct{ name, auth, config string }{
{"signed in", `{"tokens":{"access_token":"x","id_token":"x.e30.x"}}`, codexNotify},
{"signed in, on a provider of its own", `{"tokens":{"access_token":"x","id_token":"x.e30.x"}}`,
"model = \"gpt-5.5\"\nmodel_provider = \"example\"\n" + codexNotify},
{"as a provider", "", codexNotify},
} {
t.Run(tc.name, func(t *testing.T) {
home, read := codexHome(t, tc.auth, tc.config)
cx := codex(home)
if err := cx.Fields[0].Set("fake/m1"); err != nil {
t.Fatal(err)
}
var cfg struct {
Model string
Notify []string
ModelProviders map[string]any `toml:"model_providers"`
}
if err := toml.Unmarshal([]byte(read()), &cfg); err != nil {
t.Fatalf("%v\n%s", err, read())
}
if want := []string{"/usr/local/bin/notifier", "turn-ended"}; cfg.Model != "fake/m1" || !reflect.DeepEqual(cfg.Notify, want) ||
cfg.ModelProviders["magpie"] == nil || cfg.ModelProviders["example"] == nil {
t.Fatalf("after the pick:\n%s", read())
}
if !strings.Contains(read(), "notify = [\n \"/usr/local/bin/notifier\",\n \"turn-ended\",\n]\n") {
t.Fatalf("the array was rewritten:\n%s", read())
}
// and back to one of Codex's own
if err := cx.Fields[0].Set("gpt-5.4"); err != nil {
t.Fatal(err)
}
if got := cx.Fields[0].Get(); got != "gpt-5.4" {
t.Fatalf("model = %q\n%s", got, read())
}
if !strings.Contains(read(), "notify = [\n \"/usr/local/bin/notifier\",\n \"turn-ended\",\n]\n") {
t.Fatalf("the array was rewritten:\n%s", read())
}
})
}
}
// A pick whose last step can't be written leaves config.toml byte for byte
// as it was, rather than with magpie's provider table added and no model:
// here a table named openai_base_url makes the key of that name a
// conflicting definition, found only once the earlier steps are written.
func TestCodexFailedPickLeavesConfig(t *testing.T) {
const config = "model = \"gpt-5.5\" # mine\nmodel_provider = \"example\"\n\n[openai_base_url]\nx = 1\n\n[model_providers.example]\nbase_url = \"http://127.0.0.1:1/v1\"\n"
home, read := codexHome(t, `{"tokens":{"access_token":"x","id_token":"x.e30.x"}}`, config)
err := codex(home).Fields[0].Set("fake/m1")
if err == nil || !strings.Contains(err.Error(), "edited TOML is invalid") {
t.Fatalf("err = %v\n%s", err, read())
}
if got := read(); got != config {
t.Fatalf("config.toml left half-edited:\n%s", got)
}
}