diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index d7cf99a7..6c52660a 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -2258,6 +2258,9 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .mnames { display: grid; gap: 4px; max-height: 240px; overflow-y: auto; padding: 1px; } .mnames[hidden] { display: none; } .mnames > .hint { font-size: 11px; color: var(--faint); } +/* how the agents' lists will call them, and the setting that says so (#868) */ +.msuffix { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 2px 8px 4px; font-size: 12px; } +.msuffix .hint { font-size: 11px; color: var(--faint); overflow-wrap: anywhere; } .mname { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); } .mname .mwho { display: flex; align-items: center; gap: 8px; flex: 1 1 220px; min-width: 0; } .mname .mwho input { flex: 1; min-width: 0; max-width: 220px; height: 25px; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index dddd51e7..c0c14ee0 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -8184,6 +8184,18 @@ function renderModels(p) { if (!ids.length) { names.append(el("span", "hint", t("Pick a model first."))); return; } const prefs = draft.modelPrefs = draft.modelPrefs || {}; draft.onModelPrefs = drawNames; + // how the agents' lists will call them, and the setting that says so + // (#868), above the names given here + const sfx = el("div", "msuffix"); + const sfxSaid = el("span", "hint"); + const first = p.models.find((x) => x.id === ids[0]) || { id: ids[0], name: ids[0] }; + const sayLabel = (mode) => { + const name = prefs[first.id]?.name ?? (first.default ? first.name : ""); + sfxSaid.textContent = t("Agents’ lists show “{label}”", { label: suffixed(name || first.default || first.name || first.id, p.name || p.id, !!name, mode) }); + }; + sayLabel(); + sfx.append(el("span", "", t("Provider in model names")), suffixSegs(sayLabel), sfxSaid); + names.append(sfx); // the APIs a model can be asked on alone: those of a key's provider // it has a URL for, when it has more than one (01huadalang on Discord: // 一个 api 里有很多模型但是不同协议) @@ -15870,6 +15882,30 @@ const IN_USE = "|*"; // renderTrayUsage: the subscriptions and plans whose windows show beside // the tray icon, side by side. The cards are the Usage page's, asked for // when the menu opens; any number are ticked in it. +// suffixSegs: how the agents' lists name a model — with its provider's +// (or "routing group") after it, all but the names the user gave models +// and the groups they made (#92), or none (#335). Set on its own, so the +// agents are told. Offered on Settings and where names are given, the +// provider's Names & levels and a group's editor (#868: an agent's narrow +// menu cut "· routing group" short, and the setting wasn't found); +// drawn(mode) redraws the place it was picked in. +function suffixMode(s = prefs || {}) { return s.plainNames ? "off" : s.plainOwnNames ? "own" : "on"; } +function suffixSegs(drawn) { + const seg = segs([["off", t("Off")], ["own", t("Not on names I set")], ["on", t("On")]], suffixMode(), (v) => + writingPrefs(api("settings/plain-names", { mode: v })).then((ns) => { prefs = ns; renderSettings(); drawn?.(v); }) + .catch((e) => { status(t(e.message), "err"); renderSettings(); drawn?.(suffixMode()); })); + seg.classList.add("suffix-segs"); + return seg; +} +// suffixed: what an agent's list calls a model named name, by its +// provider's or group's name by, under mode; own when the user gave it +// (Labels in internal/provider) +function suffixed(name, by, own, mode = suffixMode()) { + if (mode === "off" || mode === "own" && own) return name; + if (own && name.toLowerCase().includes(by.toLowerCase())) return name; + return name + " · " + by; +} + function renderTrayUsage(s, keep) { $("#quotaLeftSegs").replaceChildren(segs([[false, t("Used")], [true, t("Left")]], !!s.quotaLeft, (on) => { if (on !== quotaLeft) setQuotaLeft(on); })); @@ -15879,12 +15915,7 @@ function renderTrayUsage(s, keep) { $("#unitsSegs").replaceChildren(segs([[false, t("K / M / B")], [true, t("万 / 亿")]], !!s.chineseUnits, (v) => savePrefs({ ...keep, chineseUnits: v }))); renderAlerts(s, keep); - // the agents' lists name a model with its provider's after it, all but - // the names the user gave (#92), or none (#335): set on its own, so the - // agents are told - const suffix = s.plainNames ? "off" : s.plainOwnNames ? "own" : "on"; - $("#plainNamesSegs").replaceChildren(segs([["off", t("Off")], ["own", t("Not on names I set")], ["on", t("On")]], suffix, (v) => - writingPrefs(api("settings/plain-names", { mode: v })).then((ns) => { prefs = ns; renderSettings(); }).catch((e) => { status(t(e.message), "err"); renderSettings(); }))); + $("#plainNamesSegs").replaceChildren(suffixSegs()); // Codex's OpenAI models in multi-agent V1, so their subagents can run on // magpie's other models (#141) $("#codexAgentsV1Segs").replaceChildren(segs([["off", t("Off")], ["on", t("On")]], s.codexAgentsV1 ? "on" : "off", (v) => diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 5654313c..f495ccd4 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -442,6 +442,8 @@ const I18N = { "Provider in model names": "模型名带供应商", "Agents’ lists put each model’s provider after its name, or not after names you set": "Agent 的模型列表在模型名后带上供应商,可设为自定义名称不带", "Not on names I set": "自定义名称不带供应商", + "Agents’ lists show “{label}”": "Agent 的模型列表里显示为「{label}」", + "In agents’ lists": "Agent 列表里的名称", "Codex subagents on any model": "Codex 子代理可用任意模型", "Long conversations": "长对话", "Codex and Claude Code compact a conversation at 272K tokens on a model with a larger window; in Claude Code a Claude model runs to its own window, 1M for a [1m] one, as Anthropic runs it. The model’s full window lets any conversation grow further, with every turn slower and costlier": "模型窗口超过 272K 时,Codex 和 Claude Code 在 272K tokens 处压缩对话;Claude Code 中的 Claude 模型按自身窗口([1m] 为 1M),与 Anthropic 官方一致。使用完整窗口可让对话更长,但每轮更慢、更贵", @@ -3577,6 +3579,8 @@ const I18N = { "Provider in model names": "モデル名にプロバイダ", "Agents’ lists put each model’s provider after its name, or not after names you set": "エージェントに渡すモデル一覧で、モデル名の後にプロバイダを付けます。自分で付けた名前には付けないこともできます", "Not on names I set": "自分で付けた名前には付けない", + "Agents’ lists show “{label}”": "エージェントの一覧では「{label}」と表示されます", + "In agents’ lists": "エージェントの一覧での名前", "Codex subagents on any model": "Codex のサブエージェントで任意のモデル", "Long conversations": "長い会話", "Codex and Claude Code compact a conversation at 272K tokens on a model with a larger window; in Claude Code a Claude model runs to its own window, 1M for a [1m] one, as Anthropic runs it. The model’s full window lets any conversation grow further, with every turn slower and costlier": "ウィンドウが大きいモデルでは、Codex と Claude Code は 272K トークンで会話を圧縮します。Claude Code の Claude モデルは Anthropic と同じく自身のウィンドウ([1m] なら 1M)まで使います。モデルのウィンドウ全体を使うとどの会話もさらに伸ばせますが、ターンごとに遅く、高くなります", @@ -6697,6 +6701,8 @@ const I18N = { "Provider in model names": "Anbieter in Modellnamen", "Agents’ lists put each model’s provider after its name, or not after names you set": "Die Listen der Agenten setzen den Anbieter hinter jeden Modellnamen, oder nicht hinter selbst vergebene Namen", "Not on names I set": "Nicht bei eigenen Namen", + "Agents’ lists show “{label}”": "In den Listen der Agenten: „{label}“", + "In agents’ lists": "In den Listen der Agenten", "Codex subagents on any model": "Codex-Subagenten auf jedem Modell", "Long conversations": "Lange Unterhaltungen", "Codex and Claude Code compact a conversation at 272K tokens on a model with a larger window; in Claude Code a Claude model runs to its own window, 1M for a [1m] one, as Anthropic runs it. The model’s full window lets any conversation grow further, with every turn slower and costlier": "Codex und Claude Code komprimieren eine Unterhaltung bei einem Modell mit größerem Kontextfenster ab 272K Token; in Claude Code nutzt ein Claude-Modell sein eigenes Fenster, 1M bei einem [1m]-Modell, wie Anthropic es betreibt. Mit dem vollen Fenster des Modells wächst jede Unterhaltung weiter, wobei jede Runde langsamer und teurer wird", diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index 6238d2a0..db11293b 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -338,6 +338,8 @@ button.rt-session { cursor: pointer; } .rt-gedit .segs { max-width: 100%; } .rt-gedit .ctx-wrap { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .rt-gedit .ctx-wrap .hint { flex-basis: 100%; } +.rt-gedit .gsuffix { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; } +.rt-gedit .gsuffix .hint { overflow-wrap: anywhere; } .rt-gedit .ctx-wrap input.ctx-in { width: 96px; flex: none; } .rt-pool { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 10px 12px 10px 14px; } .rt-pool .nm { flex: 1 1 12em; display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 13px; } diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 4d2293a2..85e462aa 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -2777,11 +2777,22 @@ idHint.textContent = t("Agents pick it as {id}", { id: "group/" + idOf() }) + (g && idOf() !== g.id ? " · " + t("an agent set to {id} needs setting again", { id: "group/" + g.id }) : ""); }; - name.oninput = () => { d.name = name.value; showId(); }; + // what the agents' lists will call it, and the setting that says + // whether "· routing group" follows the name (#868: an agent's narrow + // menu cut it to "· rou…"); a group saved here is one the user made + const sfxSaid = el("span", "hint"); + const sayLabel = (mode) => { + sfxSaid.textContent = t("Agents’ lists show “{label}”", { label: (d.name.trim() || t("New group")) + ((mode || suffixMode()) === "on" ? " · routing group" : "") }); + }; + name.oninput = () => { d.name = name.value; showId(); sayLabel(); }; const nw = el("div"); nw.append(name); if (!g) nw.append(idHint); ed.append(el("label", "", t("Name")), nw); + const sw = el("div", "gsuffix"); + sw.append(suffixSegs(sayLabel), sfxSaid); + sayLabel(); + ed.append(el("label", "", t("In agents’ lists")), sw); if (idIn) { idIn.oninput = () => { d.id = idIn.value; showId(); }; idIn.onblur = () => { d.id = idIn.value = idOf(); showId(); }; diff --git a/internal/gui/tests/group-fold.test.cjs b/internal/gui/tests/group-fold.test.cjs index 62b6ecda..e7bef957 100644 --- a/internal/gui/tests/group-fold.test.cjs +++ b/internal/gui/tests/group-fold.test.cjs @@ -63,7 +63,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const w = words[lang]; test(`${engine} ${lang}: an open group folds back from its heading`, async (t) => { const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); - const page = await (await browser.newContext({ viewport: { width: 1100, height: 1400 }, reducedMotion: "reduce" })).newPage(); + // tall enough for the whole editor, its Cancel clicked where it is + const page = await (await browser.newContext({ viewport: { width: 1100, height: 1500 }, reducedMotion: "reduce" })).newPage(); t.after(() => browser.close()); page.setDefaultTimeout(5000); const errors = [], posts = []; diff --git a/internal/gui/tests/name-suffix-where-named.test.cjs b/internal/gui/tests/name-suffix-where-named.test.cjs new file mode 100644 index 00000000..5ce5ec1f --- /dev/null +++ b/internal/gui/tests/name-suffix-where-named.test.cjs @@ -0,0 +1,132 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// #868 (模型名称太长了…路由组的名称也很长。会看不到后面代表什么): an agent's +// narrow model menu cut "Long name · routing group" short, and the setting +// that drops the suffix (Settings › Provider in model names, #335) wasn't +// found. It is now offered where names are given too: the provider editor's +// Names & levels and a routing group's editor each have the three choices, +// say what the agents' lists will show ("My Sol · Relay", "Fast · routing +// group"), and a pick posts settings/plain-names once, the line following +// it and the page not scrolled by the click. English and Chinese. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const words = { + en: { names: "Names & levels", edit: "Edit", own: "Not on names I set", on: "On", off: "Off", label: "In agents’ lists", + shows: (l) => `Agents’ lists show “${l}”` }, + zh: { names: "名称与推理档位", edit: "编辑", own: "自定义名称不带供应商", on: "开启", off: "关闭", label: "Agent 列表里的名称", + shows: (l) => `Agent 的模型列表里显示为「${l}」` }, +}; + +function serve(lang, posts) { + let cur = { lang, theme: "light", plainNames: false, plainOwnNames: false }; + const state = { agents: [{ id: "codex", name: "Codex", path: "/test/codex", fields: [] }], profiles: [], settings: { lang, theme: "light" } }; + const models = [ + { id: "sol-2026-preview-long", name: "My Sol", default: "sol-2026-preview-long", on: true, efforts: [], images: false }, + ]; + const provider = { id: "relay", name: "Relay", icon: "generic", chat: "https://relay.example/v1", responses: "", anthropic: "", models, agents: [], key: { set: true, masked: "sk-…1234" }, ready: true }; + const groups = { models: [{ id: "relay/sol-2026-preview-long", name: "My Sol", providerName: "Relay", icon: "generic" }], pools: [], + groups: [{ id: "fast", name: "Fast", members: ["relay/sol-2026-preview-long"], routing: "order", ready: true, memberInfo: [{ id: "relay/sol-2026-preview-long", ready: true }] }] }; + return async (r) => { + const req = r.request(), url = new URL(req.url()); + const json = (data) => r.fulfill({ json: data }); + if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/settings/plain-names") { + const body = req.postDataJSON(); + posts.push(body); + cur = { ...cur, plainNames: body.mode === "off", plainOwnNames: body.mode === "own" }; + return json(cur); + } + if (url.pathname === "/api/settings") return json(cur); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname === "/api/gateway/trace") { + if (url.searchParams.get("wait")) return new Promise(() => {}); + return json({ mine: true, now: new Date().toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] }); + } + if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] }); + if (url.pathname === "/api/groups") return json(groups); + if (url.pathname === "/api/providers") return json({ providers: [provider], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await r.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +async function launch(t, engine, lang, posts) { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" })); + const page = await (await browser.newContext({ viewport: { width: 900, height: 1200 }, reducedMotion: "reduce" })).newPage(); + 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}-${t.name.includes("group") ? "group" : "names"}-suffix.png`) }); + } + await browser.close(); + }); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, posts)); + return { page, errors }; +} + +const scrolls = (page) => page.evaluate(() => [scrollY, ...[...document.querySelectorAll(".view")].map((v) => v.scrollTop)]); + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + const w = words[lang]; + test(`${engine} ${lang}: Names & levels says what agents' lists show and drops the provider there`, async (t) => { + const posts = []; + const { page, errors } = await launch(t, engine, lang, posts); + await page.goto("http://magpie.test/?view=providers"); + await page.locator(".row.provider").click(); + if (!(await page.locator(".mnames:not([hidden])").count())) await page.getByRole("button", { name: w.names, exact: true }).click(); + const sfx = page.locator(".mnames:not([hidden]) .msuffix"); + await sfx.waitFor(); + assert.deepEqual((await sfx.locator(".segs .opt").allTextContents()).map((s) => s.trim()), [w.off, w.own, w.on]); + assert.equal((await sfx.locator(".opt.on").textContent()).trim(), w.on); + assert.equal(await sfx.locator(".hint").textContent(), w.shows("My Sol · Relay")); + const before = await scrolls(page); + await sfx.locator(".opt", { hasText: w.own }).click(); + await page.locator(".mnames:not([hidden]) .msuffix .opt.on", { hasText: w.own }).waitFor(); + assert.deepEqual(posts, [{ mode: "own" }]); + assert.equal(await sfx.locator(".hint").textContent(), w.shows("My Sol")); + assert.deepEqual(await scrolls(page), before, "the click scrolled the page"); + assert.deepEqual(errors, []); + }); + + test(`${engine} ${lang}: a group's editor says what agents' lists show and drops "· routing group" there`, async (t) => { + const posts = []; + const { page, errors } = await launch(t, engine, lang, posts); + await page.goto("http://magpie.test/?view=routing"); + const card = page.locator(".rt-group", { hasText: "Fast" }); + await card.waitFor(); + await card.locator("button", { hasText: w.edit }).click(); + const ed = page.locator(".rt-gedit"); + const row = ed.locator("label", { hasText: new RegExp(`^${w.label}$`) }).locator("xpath=following-sibling::div[1]"); + await row.waitFor(); + assert.equal(await row.locator(".hint").textContent(), w.shows("Fast · routing group")); + // the name typed is the name shown + await ed.locator("input").first().fill("Fast lane"); + assert.equal(await row.locator(".hint").textContent(), w.shows("Fast lane · routing group")); + const before = await scrolls(page); + await row.locator(".opt", { hasText: w.own }).click(); + await row.locator(".opt.on", { hasText: w.own }).waitFor(); + assert.deepEqual(posts, [{ mode: "own" }]); + assert.equal(await row.locator(".hint").textContent(), w.shows("Fast lane")); + assert.deepEqual(await scrolls(page), before, "the click scrolled the page"); + // nothing native, no stripe down the side + assert.equal(await ed.locator("select").count(), 0); + assert.equal(await row.evaluate((e) => getComputedStyle(e).borderLeftWidth), "0px"); + const missing = await page.evaluate(() => ["Agents’ lists show “{label}”", "In agents’ lists", "Not on names I set", "Provider in model names"] + .filter((k) => !I18N.zh[k] || !I18N.ja[k] || !I18N.de[k])); + assert.deepEqual(missing, [], "every string has its Chinese, Japanese and German"); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/group.go b/internal/provider/group.go index 9c433e12..d2557aa5 100644 --- a/internal/provider/group.go +++ b/internal/provider/group.go @@ -551,7 +551,7 @@ func groupEntries(entries []Entry) []Entry { if len(ms) == 0 { continue } - e := Entry{ID: GroupPrefix + g.ID, Model: ms[0].Model, Name: g.Name, Provider: ms[0].Provider, Group: g.ID, Reasoning: true} + e := Entry{ID: GroupPrefix + g.ID, Model: ms[0].Model, Name: g.Name, Provider: ms[0].Provider, Group: g.ID, Named: !g.Auto, Reasoning: true} var fixed []string // the efforts members are fixed at levelled := false // a member that follows the agent's effort was met // Codex's ultra (max, with Codex handing parts of the task to agents diff --git a/internal/provider/modelprefs.go b/internal/provider/modelprefs.go index 3a61ea2b..f3360932 100644 --- a/internal/provider/modelprefs.go +++ b/internal/provider/modelprefs.go @@ -611,22 +611,25 @@ func (e Entry) Label() string { // name alone — but for two or more the list would call the same, as a // routing group found for a model is called with that model left in the // list, which keep their provider's after it to tell them apart. With -// their own names plain (PlainOwnNames, #92), a name the user gave a model -// is that name just as they wrote it, and the vendor's keep Label's. +// their own names plain (PlainOwnNames, #92), a name the user gave — a +// model's, or a routing group's they made (#868: "· routing group" was +// what an agent's narrow menu cut off) — is that name just as they wrote +// it, but for two the list would call the same, and the rest keep Label's. func Labels(es []Entry) []string { out := make([]string, len(es)) s := heldSettings() plain := s.PlainNames own := !plain && s.PlainOwnNames same := map[string]int{} - if plain { + if plain || own { for _, e := range es { same[strings.ToLower(e.Name)]++ } } for i, e := range es { out[i] = e.Label() - if plain && e.Name != "" && same[strings.ToLower(e.Name)] == 1 || own && e.Default != "" && e.Name != "" { + mine := own && (e.Default != "" || e.Named) + if (plain || mine) && e.Name != "" && same[strings.ToLower(e.Name)] == 1 { out[i] = e.Name } } diff --git a/internal/provider/models.go b/internal/provider/models.go index b864e009..1cb6020b 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -1028,6 +1028,9 @@ type Entry struct { Efforts []string `json:"efforts,omitempty"` Provider Provider `json:"-"` // a group's: its first member's Group string `json:"group,omitempty"` // set on a routing group (group.go) + // Named is set on a routing group the user made or changed: its name is + // theirs, as a model's Default says one is (Labels). + Named bool `json:"-"` Icons []string `json:"-"` // a group's: its providers' icons, one per provider Images bool `json:"images,omitempty"` // takes images as input (a group's: a member does) ImageInput *bool `json:"-"` // explicit answer, nil when unknown diff --git a/internal/provider/plainown_groups_test.go b/internal/provider/plainown_groups_test.go new file mode 100644 index 00000000..255ec300 --- /dev/null +++ b/internal/provider/plainown_groups_test.go @@ -0,0 +1,44 @@ +package provider + +import ( + "slices" + "testing" +) + +// With names the user set plain (PlainOwnNames, #92), a routing group they +// made is one of them (#868: 模型名称太长…路由组的名称也很长, an agent's +// narrow menu cut "· routing group" to "· rou…"): its name stands alone in +// the agents' lists. A group magpie found keeps "· routing group", the +// vendors' names keep their providers', and a name the list would show +// twice keeps its suffix, so a picker never shows one name twice. +func TestPlainOwnNamesGroups(t *testing.T) { + prefsHome(t) + if err := SetModelName("a/sol", "My Sol"); err != nil { + t.Fatal(err) + } + if err := SaveGroup(Group{ID: "fast", Name: "Fast", Members: []string{"a/sol", "b/sol"}}); err != nil { + t.Fatal(err) + } + labels := func() []string { + shown, _ := CatalogFor("opencode") + out := Labels(shown) + slices.Sort(out) + return out + } + if got, want := labels(), []string{"Fast · routing group", "My Sol · A", "Sol · B", "Sol · routing group"}; !slices.Equal(got, want) { + t.Fatalf("by default: %q, want %q", got, want) + } + if err := SetSuffixMode(SuffixOwn); err != nil { + t.Fatal(err) + } + if got, want := labels(), []string{"Fast", "My Sol", "Sol · B", "Sol · routing group"}; !slices.Equal(got, want) { + t.Fatalf("own names plain: %q, want %q", got, want) + } + // a group named as a model the list has keeps its suffix + if err := SaveGroup(Group{ID: "fast", Name: "My Sol", Members: []string{"a/sol", "b/sol"}}); err != nil { + t.Fatal(err) + } + if got, want := labels(), []string{"My Sol · A", "My Sol · routing group", "Sol · B", "Sol · routing group"}; !slices.Equal(got, want) { + t.Fatalf("a group named as a model: %q, want %q", got, want) + } +}