agent: Cursor Private Inference's row picks which of magpie's models its own model picker lists, as Claude Desktop's does (mamba on Discord: 如果是以这种环境变量的方式接入的话,选不了模型在仪表盘) — its picker is the gateway's /v1/models as its key (magpie-cursor-local) is shown them, which already left out the models taken out for it (settings.HiddenModels), but its row had no way to take any out: no field of its picks among the catalog, so it had no model line or list. Agent.ListsModels marks an agent whose menu is that list; the row's line counts its models for it (agentModelCount takes the agent), and in the window and the tray panel the connected row has the Models button beside the launch square (MENU_FROM_LIST), which opens its model list to tick several at once; it says to quit and reopen it after a change. cursor-local-models.test.cjs (WebKit, en and zh, window and panel: the row's words, the button beside the launch square, a model taken out and put back posted, no scroll, no <select>) and TestCursorLocalModelLine fail on the old code; TestCursorLocalModels checks a model taken out isn't listed with its key and still is with another agent's. desktop-model-menu, cursor-local-launch and agy-launch pass on WebKit. Not tried with the real app (not installed here). go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn, TestUserPath and TestClaudeSignedOut flaked in the full run, pass alone).

This commit is contained in:
yetone committed 2026-10-05 01:21:35 +08:00
1 parent 6ac01d2835
commit 2f183c4cad
8 files changed
+212 -9

No files matched your search

+5
View File
@@ -93,6 +93,11 @@ type Agent struct {
// UA is what the agent's User-Agent begins with, lower-case: how the
// gateway tells its requests from others'
UA []string
// ListsModels: the agent's own model menu is the gateway's /v1/models
// as asked with its key, so the models picked on the Agents page for it
// (settings.HiddenModels) are its menu, though no field of its picks
// among the catalog (Cursor Private Inference)
ListsModels bool
// Sync, for an agent that reads magpie's models from a file of its own
// rather than asking the gateway, rewrites that list as the catalog is
// now — where magpie wrote one; nothing else changes (see SyncCatalog).
+4 -1
View File
@@ -242,7 +242,10 @@ func KeepCursorLocalEnv(context.Context) {
func cursorLocal() *Agent {
return &Agent{
ID: CursorLocalID, Name: cursorLocalName, Icon: "cursor", Aliases: []string{"cursor-private-inference"},
detect: func() bool { return cursorLocalApp() != "" },
// its model picker lists the gateway's /models as its key is shown
// them, which the row's Models button picks
ListsModels: true,
detect: func() bool { return cursorLocalApp() != "" },
Launch: func() string {
if app := cursorLocalApp(); app != "" {
return CursorLocalLaunch(app, gateway.URL())
@@ -72,4 +72,16 @@ func TestCursorLocalModels(t *testing.T) {
}
}
}
// the models picked on its row are its picker's: one taken out isn't
// listed with its key, and still is for another agent's
if err := provider.SetHiddenModels("cursor-local", []string{"ds/flash"}); err != nil {
t.Fatal(err)
}
if got := list(TokenFor("cursor-local")); got["ds/flash"] != nil || got["fake/m1"] == nil {
t.Errorf("with ds/flash taken out: %v", got)
}
if got := list(TokenFor("opencode")); got["ds/flash"] == nil {
t.Errorf("taken out of another agent's list: %v", got)
}
}
+5 -3
View File
@@ -83,11 +83,13 @@ func agentFields(a *agent.Agent, vals map[string]string) []fieldJSON {
}
// agentModelCount is the line under an agent's name, nil for an agent that
// doesn't pick among the catalog. Its pickers list only the models shown, so
// doesn't pick among the catalog nor has its menu from the gateway's list
// (agent.ListsModels). Its pickers list only the models shown, so
// with every one taken out they have no catalog entry left, yet the line is
// the one way to put them back (#356): it stays while any is hidden.
func agentModelCount(id string, fields []fieldJSON) *modelCountJSON {
if takesCatalog(fields) {
func agentModelCount(a *agent.Agent, fields []fieldJSON) *modelCountJSON {
id := a.ID
if takesCatalog(fields) || a.ListsModels {
return modelCount(id)
}
if len(provider.HiddenModels(id)) == 0 {
+34 -1
View File
@@ -98,7 +98,7 @@ func TestAgentModelLineAllHidden(t *testing.T) {
if err != nil {
t.Fatal(err)
}
line := func() *modelCountJSON { return agentModelCount(a.ID, agentFields(a, a.Values())) }
line := func() *modelCountJSON { return agentModelCount(a, agentFields(a, a.Values())) }
c := line()
if c == nil || c.Shown != c.Listed || c.Listed < 2 {
t.Fatalf("all shown: %+v", c)
@@ -122,3 +122,36 @@ func TestAgentModelLineAllHidden(t *testing.T) {
t.Fatalf("put back: %+v", c)
}
}
// Cursor Private Inference has no field picking among the catalog, yet its
// own picker is the gateway's list as its key is shown it (mamba on
// Discord: its models couldn't be picked in magpie), so its row counts the
// models shown, as Claude Desktop's does, and its list picks them.
func TestCursorLocalModelLine(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1", "m2"}}); err != nil {
t.Fatal(err)
}
a, err := agent.Find(agent.CursorLocalID)
if err != nil {
t.Fatal(err)
}
if takesCatalog(agentFields(a, a.Values())) {
t.Fatal("a field of its picks among the catalog; the case isn't reached")
}
line := func() *modelCountJSON { return agentModelCount(a, agentFields(a, a.Values())) }
c := line()
if c == nil || c.Shown != c.Listed || c.Listed < 2 {
t.Fatalf("no line, or not all shown: %+v", c)
}
if err := provider.SetHiddenModels(agent.CursorLocalID, []string{"relay/m2"}); err != nil {
t.Fatal(err)
}
if c := line(); c == nil || c.Shown != c.Listed-1 {
t.Fatalf("one taken out: %+v", c)
}
}
+1 -1
View File
@@ -1292,7 +1292,7 @@ func state() stateJSON {
}
vals := a.Values()
aj := agentJSON{ID: a.ID, Name: a.Name, Icon: a.Icon, Path: tilde(a.Path), Fields: agentFields(a, vals)}
aj.Models = agentModelCount(a.ID, aj.Fields)
aj.Models = agentModelCount(a, aj.Fields)
aj.Drift = a.Drift()
aj.Wired = a.Wired()
if aj.Wired {
+3 -3
View File
@@ -856,9 +856,9 @@ const NATIVE_ONLY = {
// the row
const NO_PICKER = new Set(["gemini", "hermes", "morph", "agy", "muse"]);
// agents whose own model menu is the list picked here: no one model to
// start on, but which of magpie's it offers (Claude Desktop reads
// /v1/models as it starts, and the user switches among them in it)
const MENU_FROM_LIST = new Set(["claude-desktop"]);
// start on, but which of magpie's it offers (Claude Desktop and Cursor
// Private Inference read /v1/models, and the user switches among them in it)
const MENU_FROM_LIST = new Set(["claude-desktop", "cursor-local"]);
const menuFromList = (a) => MENU_FROM_LIST.has(a.id) && a.wired && !!a.models;
// menuSaid: the models such an agent lists, by name while they are few
@@ -0,0 +1,148 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Cursor Private Inference's own model picker lists magpie's /v1/models as
// its key is shown them (mamba on Discord: connected through its
// environment variables, its models couldn't be picked in magpie): its
// connected row picks which models that picker lists, several at once, as
// Claude Desktop's does, beside the square copying its launch command. In
// the window the button beside its switch names them and opens the model
// list; in the tray panel the opened row has it. Unticking one is posted at
// once and the row says so. No native <select>, and no click scrolls the
// page. In English and Chinese. No backend: faked here.
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 LAUNCH = "CURSOR_LOCAL_AGENT_BASE_URL=http://127.0.0.1:3425/v1 CURSOR_LOCAL_AGENT_API_KEY=magpie-cursor-local '/Applications/Cursor.app/Contents/MacOS/Cursor'";
function fixture(lang) {
const list = [
{ id: "opencode-go/deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", group: "OpenCode Go", icon: "opencode" },
{ id: "grok-2/grok-4.6", name: "grok-4.6", group: "Grok", icon: "generic" },
{ id: "relay/glm-5", name: "GLM-5", group: "Relay", icon: "generic" },
{ id: "relay/kimi-k3", name: "Kimi K3", group: "Relay", icon: "generic", hidden: true },
];
const posts = [];
const count = () => {
const on = list.filter((m) => !m.hidden);
const by = [];
for (const m of on) {
let g = by.find((x) => x.name === m.group);
if (!g) by.push(g = { name: m.group, icon: m.icon, n: 0 });
g.n++;
}
return { shown: on.length, listed: list.length, by, names: on.slice(0, 3).map((m) => m.name) };
};
const state = () => ({
agents: [{
id: "cursor-local", name: "Cursor Private Inference", path: "", icon: "cursor", launch: LAUNCH, wired: true,
fields: [
{ key: "provider", label: "provider", value: "magpie", options: [{ value: "magpie", label: "magpie", icon: "magpie" }] },
],
models: count(),
}],
profiles: [], settings: { lang, theme: "light" },
});
async function serve(route) {
const req = route.request(), url = new URL(req.url());
const json = (data) => route.fulfill({ json: data });
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/cursor-local") {
if (req.method() === "POST") {
const { hidden } = req.postDataJSON();
posts.push(hidden);
for (const m of list) m.hidden = hidden.includes(m.id);
return json({ models: list, count: count() });
}
return json({ models: list });
}
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false });
if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true } });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
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 route.fulfill({ body: await fs.readFile(file), contentType });
}
return { serve, posts };
}
const W = {
en: { said: "Connected · 3 models in Cursor Private Inference's model menu", after: "Connected · 2 models in Cursor Private Inference's model menu", title: "Cursor Private Inference's model list", restart: "quit and reopen it", details: "Details" },
zh: { said: "已接入 · Cursor Private Inference 的模型菜单里有 3 个模型", after: "已接入 · Cursor Private Inference 的模型菜单里有 2 个模型", title: "Cursor Private Inference 的模型列表", restart: "退出并重新打开", details: "详情" },
};
const cd = '.row.agent[data-id="cursor-local"]';
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
for (const where of ["window", "panel"]) {
test(`${engine} ${lang} ${where}: Cursor Private Inference's row picks the models its picker lists`, async (t) => {
const w = W[lang];
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await browser.newPage({ viewport: where === "panel" ? { width: 420, height: 640 } : { width: 1100, height: 700 } });
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
const fx = fixture(lang);
await page.route("**/*", fx.serve);
t.after(() => browser.close());
await page.goto("http://magpie.test/" + (where === "panel" ? "?mode=panel" : ""));
await page.locator(cd).waitFor();
let menu;
if (where === "window") {
assert.equal(await page.locator(`${cd} .ag-st-t`).textContent(), w.said);
assert.equal(await page.locator(`${cd} .ag-link`).textContent(), w.details);
assert.equal(await page.locator(`${cd} > .field.launch`).count(), 1, "the launch command's square is gone");
menu = page.locator(`${cd} > .ag-menu`);
} else {
assert.equal(await page.locator(`${cd} .ag-sum .vt`).textContent(), "DeepSeek V4.1 Flash, grok-4.6 +1");
await page.locator(`${cd} .ag-sum`).click();
menu = page.locator(`${cd} .ag-open .ag-menu`);
await page.waitForFunction((sel) => document.querySelector(sel)?.getBoundingClientRect().height > 0, `${cd} .ag-open .ag-menu`);
assert.equal(await page.locator(`${cd} .ag-conn-said`).textContent(), w.said);
}
assert.equal(await menu.count(), 1, "no model button");
assert.equal(await menu.locator(".v").textContent(), "DeepSeek V4.1 Flash, grok-4.6 +1");
assert.ok((await menu.getAttribute("title")).includes(w.restart), await menu.getAttribute("title"));
const y = await page.evaluate(() => scrollY);
await menu.click();
const box = page.locator(".am-pop:not(.leaving)");
await box.waitFor();
assert.equal(await box.locator(".am-t").textContent(), w.title);
const rows = await box.locator(".am-mr").evaluateAll((es) => es.map((e) => [e.querySelector(".n").textContent, e.getAttribute("aria-checked")]));
assert.deepEqual(rows, [["DeepSeek V4.1 Flash", "true"], ["grok-4.6", "true"], ["GLM-5", "true"], ["Kimi K3", "false"]]);
// several at once: GLM-5 out, the list stays open, the row says so
await box.locator(".am-mr", { hasText: "GLM-5" }).click();
await page.waitForFunction(() => document.querySelector(".ag-menu .v")?.textContent === "DeepSeek V4.1 Flash, grok-4.6");
await page.waitForTimeout(150);
assert.equal(fx.posts.length, 1);
assert.deepEqual(fx.posts.at(-1).sort(), ["relay/glm-5", "relay/kimi-k3"]);
assert.equal(await box.count(), 1, "the list closed after one pick");
assert.equal(await menu.locator(".v").textContent(), "DeepSeek V4.1 Flash, grok-4.6");
const said = where === "window" ? `${cd} .ag-st-t` : `${cd} .ag-conn-said`;
assert.equal(await page.locator(said).textContent(), w.after);
if (where === "panel") assert.equal(await page.locator(`${cd} .ag-sum .vt`).textContent(), "DeepSeek V4.1 Flash, grok-4.6");
// and Kimi K3 back in
await box.locator(".am-mr", { hasText: "Kimi K3" }).click();
await page.waitForFunction(() => document.querySelector(".ag-menu .v")?.textContent === "DeepSeek V4.1 Flash, grok-4.6 +1");
await page.waitForTimeout(150);
assert.equal(fx.posts.length, 2);
assert.deepEqual(fx.posts.at(-1), ["relay/glm-5"]);
assert.equal(await page.locator(said).textContent(), w.said);
assert.equal(await page.evaluate(() => scrollY), y, "a click scrolled the page");
assert.equal(await page.locator("select").count(), 0, "a native select");
assert.deepEqual(errors, []);
});
}
}
}