gui, provider: where a model or routing group is named, it says what agents' lists will call it and offers dropping the suffix there; a group the user made counts as a name they set (#868)

The reporter's screenshot was Codex's model menu, where a long name plus " · Relay" or " · routing group" is cut off. Magpie can't widen an agent's menu. What it can do is shorten the label. Models can already be renamed (provider editor › Names & levels), groups have a Name, and Settings › Provider in model names (#335) can drop the suffix. The reporter found none of these.

Changes:
- The provider editor's Names & levels and a routing group's editor now each have the three choices: Off, Not on names I set, On.
- Each says what the agents' lists will show, e.g. "My Sol · Relay" or "Fast · routing group".
- The line follows the name as it is typed and the choice as it is picked.
- A pick posts settings/plain-names once and doesn't scroll.
- "Not on names I set" now covers routing groups the user made or changed (Entry.Named = !g.Auto) as well as renamed models, still only where the name is unique in the list. A group magpie found keeps its suffix.
- zh, ja, de strings.

Tests:
- TestPlainOwnNamesGroups fails on the old code.
- name-suffix-where-named.test.cjs (WebKit, en and zh) fails 4/4 on the old assets.
- group-fold.test.cjs: viewport raised to 1500 for the taller editor.
- Pass on WebKit: group-context, plain-names, provider-same-as, provider-levels-save, gui-ja, gui-de, click-scroll, agent-models, agent-model-groups, group-* and routing-*.
- Fail the same on origin/main: group-from-model-add at 600x420, provider-levels-scroll.
- go vet passes. The darwin, GOOS=linux nogui and GOOS=windows builds pass. go test -tags nogui -count=1 ./... passes under a temp HOME.
This commit is contained in:
yetone committed 2026-10-05 14:03:51 +08:00
1 parent af25cc6977
commit bbb9729748
11 files changed
+249 -13

No files matched your search

+3
View File
@@ -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; }
+37 -6
View File
@@ -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) =>
+6
View File
@@ -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",
+2
View File
@@ -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; }
+12 -1
View File
@@ -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(); };
+2 -1
View File
@@ -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 = [];
@@ -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, []);
});
}
}
+1 -1
View File
@@ -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
+7 -4
View File
@@ -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
}
}
+3
View File
@@ -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
+44
View File
@@ -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)
}
}