diff --git a/internal/agent/model_order_test.go b/internal/agent/model_order_test.go new file mode 100644 index 00000000..c767f49a --- /dev/null +++ b/internal/agent/model_order_test.go @@ -0,0 +1,51 @@ +package agent + +import ( + "slices" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// An order dragged on the Agents page reaches every agent, not only Codex +// (#1052): Claude Code's /model picker (viaMagpie, which writePicker turns +// into settings.json's modelPicker) and the model files magpie writes for +// the others (magpieModels) list the models in it, and one agent's order +// leaves another's alone. +func TestModelOrderReachesEveryAgent(t *testing.T) { + syncHome(t) + if err := provider.Save(provider.Provider{ID: "other", Name: "Other", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1", "m2"}}); err != nil { + t.Fatal(err) + } + refs := func(agent string) (out []string) { + for _, o := range viaMagpie(agent, "") { + out = append(out, o.Ref) + } + return out + } + ids := func(agent string) (out []string) { + for _, m := range magpieModels(agent) { + out = append(out, m.ID) + } + return out + } + was := ids("opencode") + if want := []string{"relay/glm-4.6", "other/m1", "other/m2"}; !slices.Equal(was, want) { + t.Fatalf("default %v, want %v", was, want) + } + if err := provider.SetModelOrder("claude", []string{"other/m2", "relay/glm-4.6"}); err != nil { + t.Fatal(err) + } + if got, want := refs("claude"), []string{"other/m2", "relay/glm-4.6", "other/m1"}; !slices.Equal(got, want) { + t.Errorf("Claude Code's picker %v, want %v", got, want) + } + if got := ids("opencode"); !slices.Equal(got, was) { + t.Errorf("claude's order moved opencode's: %v", got) + } + if err := provider.SetModelOrder("opencode", []string{"other/m1"}); err != nil { + t.Fatal(err) + } + if got, want := ids("opencode"), []string{"other/m1", "relay/glm-4.6", "other/m2"}; !slices.Equal(got, want) { + t.Errorf("opencode's models %v, want %v", got, want) + } +} diff --git a/internal/gui/agent_model_order_test.go b/internal/gui/agent_model_order_test.go index db7b4446..3b37455e 100644 --- a/internal/gui/agent_model_order_test.go +++ b/internal/gui/agent_model_order_test.go @@ -15,8 +15,8 @@ import ( // Codex's model list goes in the order dragged on the Agents page (#855): // the list says it can be ordered, an order comes back in it and stays in -// the settings, the hidden kept as they were; none puts magpie's back, and -// another agent's list can't be ordered. +// the settings, the hidden kept as they were; none puts magpie's back. +// Another agent's list takes an order of its own (#1052). func TestAgentModelOrderAPI(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) @@ -34,9 +34,8 @@ func TestAgentModelOrderAPI(t *testing.T) { mux := http.NewServeMux() agentModelsAPI(mux) type reply struct { - Models []agentModelJSON - Orderable bool - Ordered bool + Models []agentModelJSON + Ordered bool } call := func(method, agent, body string) (int, reply) { t.Helper() @@ -55,8 +54,8 @@ func TestAgentModelOrderAPI(t *testing.T) { return out } _, got := call("GET", "codex", "") - if !got.Orderable || got.Ordered { - t.Fatalf("codex: orderable %v ordered %v", got.Orderable, got.Ordered) + if got.Ordered { + t.Fatalf("codex: ordered %v", got.Ordered) } if want := []string{"relay/m1", "relay/m2", "relay/m3"}; !slices.Equal(ids(got.Models), want) { t.Fatalf("%v, want %v", ids(got.Models), want) @@ -96,14 +95,18 @@ func TestAgentModelOrderAPI(t *testing.T) { if len(provider.ModelOrder("codex")) != 0 { t.Errorf("still saved %v", provider.ModelOrder("codex")) } - // only Codex's - if _, got = call("GET", "opencode", ""); got.Orderable { - t.Error("opencode's list says it can be ordered") + // every agent's list takes an order of its own (#1052), and Codex's + // stays as it was + if code, got = call("POST", "opencode", `{"order":["relay/m3"]}`); code != 200 || !got.Ordered { + t.Fatalf("opencode: %d %+v", code, got) } - if code, _ = call("POST", "opencode", `{"order":["relay/m3"]}`); code == 200 { - t.Error("opencode's list took an order") + if want := []string{"relay/m3", "relay/m1", "relay/m2", "relay/m4"}; !slices.Equal(ids(got.Models), want) { + t.Errorf("opencode ordered %v, want %v", ids(got.Models), want) } - if len(provider.ModelOrder("opencode")) != 0 { - t.Errorf("opencode's order saved: %v", provider.ModelOrder("opencode")) + if _, got = call("GET", "opencode", ""); !got.Ordered || ids(got.Models)[0] != "relay/m3" { + t.Errorf("opencode read back %+v", got) + } + if len(provider.ModelOrder("codex")) != 0 { + t.Errorf("opencode's order went to codex: %v", provider.ModelOrder("codex")) } } diff --git a/internal/gui/agent_models.go b/internal/gui/agent_models.go index c8f7192b..1ca848fa 100644 --- a/internal/gui/agent_models.go +++ b/internal/gui/agent_models.go @@ -3,7 +3,6 @@ package gui import ( "cmp" "encoding/json" - "errors" "net/http" "strings" @@ -177,11 +176,6 @@ func agentModelList(a *agent.Agent) []agentModelJSON { return out } -// orderable reports whether the agent's model list is put in the order the -// user drags it into: Codex's, whose /model lists the models by the -// priority magpie gives them (#855). -func orderable(id string) bool { return id == "codex" } - func agentModelsAPI(mux *http.ServeMux) { // a model the agent picks sent in its vendor's fast mode, or not (#954): // for is the option's fastFor, the agent whose requests it goes on @@ -206,13 +200,15 @@ func agentModelsAPI(mux *http.ServeMux) { fail(rw, err) return } - writeJSON(rw, map[string]any{"models": agentModelList(a), "orderable": orderable(a.ListsFor()), "ordered": len(provider.ModelOrder(a.ListsFor())) > 0}) + writeJSON(rw, map[string]any{"models": agentModelList(a), "ordered": len(provider.ModelOrder(a.ListsFor())) > 0}) }) // hidden is every entry to take out of the agent's lists; the others // are shown, and one the agent is set to is kept in whatever is asked // // order, instead, is the order the agent's list puts them in (#855): - // the ones named first, as named; none puts back magpie's own + // the ones named first, as named; none puts back magpie's own. Every + // agent's (#1052): its list, the gateway's /models and the files magpie + // writes for it all come from provider.CatalogFor, which keeps it mux.HandleFunc("POST /api/agent-models/{id}", func(rw http.ResponseWriter, r *http.Request) { var in struct { Hidden []string @@ -228,10 +224,6 @@ func agentModelsAPI(mux *http.ServeMux) { return } if in.Order != nil { - if !orderable(a.ListsFor()) { - fail(rw, errors.New(a.Name+"'s model list can't be put in an order")) - return - } if err := provider.SetModelOrder(a.ListsFor(), *in.Order); err != nil { fail(rw, err) return diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 23d0592f..e9f29fa0 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -2242,9 +2242,10 @@ async function openAgentModels(a, anchor, ev) { }, }; document.addEventListener("mousedown", loading.away, true); - // an agent whose list goes in the order dragged here (Codex's, #855) - let models, orderable = false, ordered = false; - try { ({ models, orderable, ordered } = await api("agent-models/" + encodeURIComponent(a.id))); } + // every agent's list goes in the order dragged here (Codex's, #855; the + // others', #1052) + let models, ordered = false; + try { ({ models, ordered } = await api("agent-models/" + encodeURIComponent(a.id))); } catch (e) { if (agentModelsLoading === loading) { loading.drop(); status(e.message, "err"); } return; @@ -2270,8 +2271,7 @@ async function openAgentModels(a, anchor, ev) { const seg = el("div", "am-seg"); const segAll = el("button", "on", t("All")), segOn = el("button", "", t("Shown")), segOrder = el("button", "", t("Order")); segAll.type = segOn.type = segOrder.type = "button"; - seg.append(segAll, segOn); - if (orderable) seg.append(segOrder); + seg.append(segAll, segOn, segOrder); tools.append(search, seg); const list = el("div", "am-list"); const foot = el("div", "am-foot"); @@ -2517,7 +2517,12 @@ async function openAgentModels(a, anchor, ev) { // ordering is of the whole list: no search, no provider picked, and the // foot puts magpie's own order back rather than showing or hiding box.classList.toggle("ordering", order); - footNote.textContent = order ? t("Drag to put them in the order {agent} lists them; new models go last", { agent: a.name }) : t("New models are shown"); + // Codex's /model, in WSL too, lists them as handed (#855); another + // agent is handed them in it, and one that sorts its own menu may sort + // them again + footNote.textContent = !order ? t("New models are shown") + : a.id.split("@wsl:")[0] === "codex" ? t("Drag to put them in the order {agent} lists them; new models go last", { agent: a.name }) + : t("Drag to put them in the order magpie hands them to {agent}; new models go last", { agent: a.name }); if (order) foot.replaceChildren(footNote, el("span", "sp"), unorder); else foot.replaceChildren(footNote, el("span", "sp"), hideAll, el("span", "am-dot", "·"), reset); list.scrollTop = 0; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 878b825c..b464aab9 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -202,6 +202,7 @@ const I18N = { "Default order": "恢复默认顺序", "Drag to move · Alt+↑/↓ from the keyboard": "拖动调整位置 · 键盘用 Alt+↑/↓", "Drag to put them in the order {agent} lists them; new models go last": "拖动排列 {agent} 列表中的顺序;新模型排在最后", + "Drag to put them in the order magpie hands them to {agent}; new models go last": "拖动排列 magpie 交给 {agent} 的模型顺序;新模型排在最后", "{n} hidden": "已隐藏 {n} 个", "{n} hidden agents": "已隐藏的 Agent({n} 个)", "Show {n} more ({h} hidden)": "显示其余 {n} 个(含 {h} 个已隐藏)", @@ -3765,6 +3766,7 @@ const I18N = { "Default order": "既定の順に戻す", "Drag to move · Alt+↑/↓ from the keyboard": "ドラッグで移動 · キーボードは Alt+↑/↓", "Drag to put them in the order {agent} lists them; new models go last": "ドラッグで {agent} の一覧の並び順を変更。新しいモデルは末尾に追加されます", + "Drag to put them in the order magpie hands them to {agent}; new models go last": "ドラッグで magpie が {agent} に渡すモデルの並び順を変更。新しいモデルは末尾に追加されます", "{n} hidden": "{n} 件非表示", "{n} hidden agents": "非表示のエージェント({n})", "Show {n} more ({h} hidden)": "さらに {n} 件表示(うち {h} 件は非表示)", @@ -7327,6 +7329,7 @@ const I18N = { "Default order": "Standardreihenfolge", "Drag to move · Alt+↑/↓ from the keyboard": "Ziehen zum Verschieben · Alt+↑/↓ per Tastatur", "Drag to put them in the order {agent} lists them; new models go last": "Ziehen, um die Reihenfolge in {agent} festzulegen; neue Modelle kommen ans Ende", + "Drag to put them in the order magpie hands them to {agent}; new models go last": "Ziehen, um die Reihenfolge festzulegen, in der magpie {agent} die Modelle übergibt; neue Modelle kommen ans Ende", "{n} hidden": "{n} ausgeblendet", "{n} hidden agents": "{n} ausgeblendete Agenten", "Show {n} more ({h} hidden)": "{n} weitere anzeigen ({h} ausgeblendet)", diff --git a/internal/gui/tests/codex-model-order.test.cjs b/internal/gui/tests/codex-model-order.test.cjs index e011b200..adf8edc3 100644 --- a/internal/gui/tests/codex-model-order.test.cjs +++ b/internal/gui/tests/codex-model-order.test.cjs @@ -3,6 +3,8 @@ // "Order" lists the models shown as Codex does, the account's own first, // and a drag or Alt+↑/↓ puts one elsewhere, saved at once; "Default order" // puts magpie's back. The hidden stay out, and nothing scrolls the page. +// Every agent's list has the Order (#1052): Claude Code's, which had none, +// orders and saves the same way, saying it is the order magpie hands it. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -19,13 +21,15 @@ const models = () => [ { id: "codex-acc/gpt-5-codex", name: "gpt-5-codex", group: "ChatGPT", icon: "openai", own: true }, ]; -function fixture(lang, orderable) { - const first = models(); - let list = models(); +function fixture(lang, agent = { id: "codex", name: "Codex", icon: "codex-color" }) { + // only Codex has models of its own, which the server marks + const mine = () => models().map((m) => agent.id === "codex" ? m : { ...m, own: undefined }); + const first = mine(); + let list = mine(); const posts = []; const state = () => ({ agents: [{ - id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true, + id: agent.id, name: agent.name, path: "/test/config.toml", icon: agent.icon, wired: true, fields: [{ key: "model", label: "model", value: "gpt-5.5", options: [{ value: "gpt-5.5", label: "gpt-5.5", ref: "codex-acc/gpt-5.5" }] }], models: { shown: 4, listed: 5, by: [{ name: "Relay", icon: "anthropic", n: 2 }, { name: "ChatGPT", icon: "openai", n: 2 }] }, }], @@ -38,7 +42,7 @@ function fixture(lang, orderable) { if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); if (url.pathname === "/api/state") return json(state()); - if (url.pathname === "/api/agent-models/codex") { + if (url.pathname === "/api/agent-models/" + agent.id) { if (req.method() === "POST") { const { order } = req.postDataJSON(); posts.push(order); @@ -46,7 +50,7 @@ function fixture(lang, orderable) { list = order.length ? [...first].sort((x, y) => at(x) - at(y)) : first; return json({ models: list, ordered: order.length > 0 }); } - return json({ models: list, orderable, ordered: false }); + return json({ models: list, ordered: false }); } if (url.pathname === "/api/groups") return json({ groups: [] }); if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true, window: true } }); @@ -59,8 +63,8 @@ function fixture(lang, orderable) { } const W = { - en: { entry: "1 hidden", order: "Order", unorder: "Default order", note: "Drag to put them in the order Codex lists them; new models go last" }, - zh: { entry: "已隐藏 1 个", order: "排序", unorder: "恢复默认顺序", note: "拖动排列 Codex 列表中的顺序;新模型排在最后" }, + en: { entry: "1 hidden", order: "Order", unorder: "Default order", note: "Drag to put them in the order Codex lists them; new models go last", handed: "Drag to put them in the order magpie hands them to Claude Code; new models go last" }, + zh: { entry: "已隐藏 1 个", order: "排序", unorder: "恢复默认顺序", note: "拖动排列 Codex 列表中的顺序;新模型排在最后", handed: "拖动排列 magpie 交给 Claude Code 的模型顺序;新模型排在最后" }, }; for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { @@ -72,7 +76,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", page.setDefaultTimeout(5000); const errors = []; page.on("pageerror", (e) => errors.push(e.message)); - const fx = fixture(lang, true); + const fx = fixture(lang); await page.route("**/*", fx.serve); t.after(async () => { if (process.env.ARTIFACT_DIR) { @@ -148,18 +152,59 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.deepEqual(errors, []); }); } - test(`${engine}: a list that can't be ordered has no Order`, async (t) => { - const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); - const page = await browser.newPage({ viewport: { width: 1000, height: 700 } }); - page.setDefaultTimeout(5000); - t.after(() => browser.close()); - await page.route("**/*", fixture("en", false).serve); - await page.goto("http://magpie.test/"); - const row = page.locator('.row.agent[data-id="codex"]'); - await row.locator(".ag-link").click(); - await row.locator(".ag-exp .ag-chips .ag-quiet").click(); - const pop = page.locator(".am-pop:not(.leaving)"); - await pop.waitFor(); - assert.equal(await pop.locator(".am-seg button").count(), 2); - }); + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: Claude Code's list has an Order too (#1052)`, async (t) => { + const w = W[lang]; + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + // narrow: the three views still fit beside the search + const page = await browser.newPage({ viewport: { width: 440, height: 700 } }); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + const fx = fixture(lang, { id: "claude", name: "Claude Code", icon: "claude-color" }); + await page.route("**/*", fx.serve); + t.after(async () => { + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-claude-order.png`) }); + } + await browser.close(); + }); + await page.goto("http://magpie.test/"); + const row = page.locator('.row.agent[data-id="claude"]'); + await row.locator(".ag-link").click(); + const entry = row.locator(".ag-exp .ag-chips .ag-quiet"); + await entry.waitFor(); + await page.waitForFunction(() => document.getAnimations().length === 0); + const scroll = () => page.evaluate(() => [scrollY, document.scrollingElement.scrollTop, $("#view-agents").scrollTop]); + const was = await scroll(); + await entry.click(); + const pop = page.locator(".am-pop:not(.leaving)"); + await pop.waitFor(); + assert.equal(await pop.locator(".am-seg button").count(), 3); + const seg = pop.locator(".am-seg button", { hasText: w.order }); + // the three views whole, not squeezed to "…", and inside the box + for (const b of await pop.locator(".am-seg button").all()) { + assert(await b.evaluate((e) => e.scrollWidth <= e.clientWidth + 1), "a view's name is cut"); + } + const box = await pop.boundingBox(), sb = await seg.boundingBox(); + assert(sb.x + sb.width <= box.x + box.width + 1 && sb.x >= box.x - 1, "Order is outside the box"); + await seg.click(); + assert.deepEqual(await scroll(), was, "nothing scrolls"); + const names = async () => (await pop.locator(".am-or .n").allInnerTexts()).map((s) => s.trim()); + assert.deepEqual(await names(), ["claude-opus-5.5", "claude-sonnet-4-5", "gpt-5.5", "gpt-5-codex"], "the shown as handed, the hidden left out"); + assert.equal(await pop.locator(".am-foot").innerText().then((s) => s.includes(w.handed)), true); + await pop.locator(".am-or").first().focus(); + await page.keyboard.press("Alt+ArrowDown"); + await page.waitForTimeout(150); + assert.deepEqual(fx.posts.at(-1), ["relay/claude-sonnet-4-5", "relay/claude-opus-5.5", "codex-acc/gpt-5.5", "codex-acc/gpt-5-codex"]); + assert.deepEqual(await names(), ["claude-sonnet-4-5", "claude-opus-5.5", "gpt-5.5", "gpt-5-codex"]); + const unorder = pop.locator(".am-foot .am-reset", { hasText: w.unorder }); + await unorder.click(); + await page.waitForTimeout(150); + assert.deepEqual(fx.posts.at(-1), []); + assert.deepEqual(await scroll(), was, "nothing scrolls"); + assert.deepEqual(errors, []); + }); + } }