diff --git a/internal/agent/omp.go b/internal/agent/omp.go index a9e6cd38..af9b22cd 100644 --- a/internal/agent/omp.go +++ b/internal/agent/omp.go @@ -168,7 +168,10 @@ func omp(home string) *Agent { }, }, { // the thinking level sessions start with, as omp's settings save - // it; unset omp takes high (it also knows auto, its own pick) + // it; unset omp takes high. auto has omp pick a level each turn: + // not a level a model lists, so it is offered here and kept out of + // ompEfforts, which a model's thinking levels are filtered by. First, + // as omp's own picker has it (16.3.5 and 18.4.4 alike) Key: "effort", Label: "thinking", Get: func() string { v, _ := edit.GetYAML(path, "defaultThinkingLevel"); return v }, Set: func(v string) error { @@ -177,7 +180,7 @@ func omp(home string) *Agent { } return edit.SetYAML(path, edit.KV{Path: "defaultThinkingLevel", Value: v}) }, - Options: func(map[string]string) []Option { return static(ompEfforts...) }, + Options: func(map[string]string) []Option { return static(append([]string{"auto"}, ompEfforts...)...) }, }}, } } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index be467f30..bf615c56 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1536,7 +1536,10 @@ function effortBars(f) { // shows at once, bars and all; the write follows, in order, so a slow agent // config never holds the thumb (巨卡). function effortSeg(a, f) { - const options = f.options; + // a value that isn't one of the levels (unset, or one magpie doesn't list) + // is a stop of its own, as the picker's current value is: shown as the + // lowest level, a touch wrote that level over it + const options = f.options.some((o) => o.value === f.value) ? f.options : [{ value: f.value }, ...f.options]; const last = Math.max(1, options.length - 1); const box = el("div", "effort-control"); const head = el("div", "effort-head"); @@ -1562,7 +1565,7 @@ function effortSeg(a, f) { ends.setAttribute("aria-hidden", "true"); ends.append(el("span", "", effortName(options[0])), el("span", "", effortName(options[options.length - 1]))); box.append(head, track, ends); - let at = Math.max(0, options.findIndex((o) => o.value === f.value)); + let at = options.findIndex((o) => o.value === f.value); const show = (i) => { at = i; track.style.setProperty("--p", i / last); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index f6401e7a..02082385 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -198,6 +198,7 @@ const I18N = { "high": "高", "xhigh": "很高", "max": "最高", + "auto": "自动", "{label}: {value}": "{label}:{value}", "Profiles": "方案", "+ Save current": "+ 保存当前", diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 09e26464..b7b7816c 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -455,11 +455,16 @@ and says what came of it, Anthropic's `not_limited` in words. The Routing page's story names a Claude reset used by itself as Claude's. No click moves the page, nothing has a left-border accent; English and Chinese, Chromium and WebKit. +`panel-effort.test.cjs` opens a row in the tray panel whose effort is not one +of the levels offered (omp at auto, an agent with none set): the slider shows +it as it is ("auto", "default"; 自动 in Chinese) at a stop of its own, a touch +there posts nothing — it had shown the lowest level and a touch wrote it — +and the next stop is the lowest level. Chromium and WebKit. With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/panel-effort.test.cjs b/internal/gui/tests/panel-effort.test.cjs new file mode 100644 index 00000000..49ccbabe --- /dev/null +++ b/internal/gui/tests/panel-effort.test.cjs @@ -0,0 +1,101 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// The tray panel's effort slider shows an agent's effort as it is when that +// isn't one of the levels offered — omp at auto before magpie listed it, an +// agent with none set — rather than as the lowest level, which a touch on +// the slider then wrote over it. A touch where it stands posts nothing; the +// next stop is the lowest level. In English and Chinese. No backend: the API +// is 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 levels = ["minimal", "low", "medium", "high", "xhigh", "max"].map((value) => ({ value })); +const agent = (id, name, effort) => ({ + id, name, path: "/test/" + id, + fields: [{ key: "model", label: "model", value: "anthropic/claude-opus-5-5:max", options: [] }, + { key: "effort", label: "thinking", value: effort, options: levels }], +}); +const state = { agents: [agent("omp", "omp", "auto"), agent("pi", "Pi", "")], profiles: [] }; + +function server(lang, sets) { + return async (route) => { + const req = route.request(), url = new URL(req.url()); + 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 route.fulfill({ json: { ...state, settings: { lang, theme: "light" } } }); + if (url.pathname === "/api/set") { sets.push(req.postDataJSON()); return route.fulfill({ json: { ...state, settings: { lang, theme: "light" } } }); } + if (url.pathname === "/api/usage/quotas") return route.fulfill({ json: [] }); + if (url.pathname === "/api/groups") return route.fulfill({ json: { groups: [] } }); + if (url.pathname === "/api/providers") return route.fulfill({ json: { providers: [], gateway: { running: true } } }); + if (url.pathname.startsWith("/api/")) return route.fulfill({ 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 }); + }; +} + +const row = (id) => `.row.agent[data-id="${id}"]`; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": the panel's effort as it is", async (t) => { + assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit"); + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const errors = []; + t.after(async () => { + if (errors.length) console.log(errors); + await browser.close(); + }); + // the row opened, its slider, and a click on the slider's first stop + const open = async (lang, sets, id) => { + const page = await (await browser.newContext({ viewport: { width: 440, height: 560 } })).newPage(); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, sets)); + await page.goto("http://magpie.test/?mode=panel"); + await page.locator(`${row(id)} .ag-sum`).click(); + await page.waitForTimeout(700); + const box = page.locator(`${row(id)} .ag-open .effort-control`); + const slider = box.locator(".eslide"); + const first = async () => { + const r = await box.locator(".rail").evaluate((e) => e.getBoundingClientRect().toJSON()); + await page.mouse.click(r.left + 1, r.top + r.height / 2); + await page.waitForTimeout(300); + }; + return { page, box, slider, first }; + }; + + await t.test("omp at auto", async () => { + const sets = []; + const { box, slider, first } = await open("en", sets, "omp"); + assert.equal(await box.locator(".effort-head b").textContent(), "auto"); + assert.equal(await slider.getAttribute("aria-valuetext"), "auto"); + assert.equal(await box.locator(".effort-ends span").first().textContent(), "auto"); + await first(); + assert.deepEqual(sets, [], "a touch where it stands wrote a level over auto"); + await slider.press("ArrowRight"); + await slider.page().waitForTimeout(300); + assert.deepEqual(sets, [{ agent: "omp", field: "effort", value: "minimal" }]); + }); + + await t.test("none set", async () => { + const sets = []; + const { box, first } = await open("en", sets, "pi"); + assert.equal(await box.locator(".effort-head b").textContent(), "default"); + await first(); + assert.deepEqual(sets, []); + }); + + await t.test("in Chinese", async () => { + const sets = []; + const { box, first } = await open("zh", sets, "omp"); + assert.equal(await box.locator(".effort-head b").textContent(), "自动"); + await first(); + assert.deepEqual(sets, []); + }); + + assert.deepEqual(errors, []); + }); +} diff --git a/omp_cli_test.go b/omp_cli_test.go new file mode 100644 index 00000000..ac3b96c4 --- /dev/null +++ b/omp_cli_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/yetone/magpie/internal/agent" + "gopkg.in/yaml.v3" +) + +// `magpie omp auto` asks for omp's own thinking level, auto (omp picks one +// each turn), and so goes to the thinking field: taken as a model, it made +// modelRoles.default the literal "auto", which omp has no model by. +func TestOmpAutoIsAThinkingLevel(t *testing.T) { + groupsHome(t) + for _, k := range []string{"PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"} { + t.Setenv(k, "") + } + dir := filepath.Join(os.Getenv("HOME"), ".omp", "agent") + os.MkdirAll(dir, 0o755) + config := filepath.Join(dir, "config.yml") + os.WriteFile(config, []byte("modelRoles:\n default: anthropic/claude-opus-5-5:max\ndefaultThinkingLevel: high\n"), 0o644) + a, err := agent.Find("omp") + if err != nil { + t.Fatal(err) + } + f := fieldForValue(a, "auto") + if f == nil || f.Key != "effort" { + t.Fatalf("auto went to %v, not the thinking level", f) + } + if err := set(a, f.Key, "auto"); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(config) + var c struct { + ModelRoles map[string]string `yaml:"modelRoles"` + DefaultThinkingLevel string `yaml:"defaultThinkingLevel"` + } + if err := yaml.Unmarshal(b, &c); err != nil { + t.Fatal(err) + } + if c.DefaultThinkingLevel != "auto" || c.ModelRoles["default"] != "anthropic/claude-opus-5-5:max" { + t.Fatalf("config:\n%s", b) + } +}