diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index c2ace878..419142d7 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -240,6 +240,7 @@ function renderAgentsLoading() { let agentArranging = false, agentRenderPending = false; function renderAgents() { + renderCodexMemories(); // Settings' row is the same field if (agentArranging) { agentRenderPending = true; return; } if (unhideCleared()) return; // it renders again with the hiding dropped const page = $("#view-agents"); @@ -2696,7 +2697,7 @@ function extraField(a, f) { b.append(svg(EXTRA_GLYPH[f.label] || EXTRA_GLYPH.tiers, 13, 1.5)); const opt = optionFor(f, f.value); b.title = f.label === SUB_EFFORT ? subEffortTitle(a, f, opt) : f.menu ? menuTitle(f) - : t("{label}: {value}", { label: t(f.label), value: t(opt?.label || f.value || (f.label === MEMORIES ? MEMORIES_DEFAULT : "same as model")) }) + (opt?.note && !FOLLOWS_MODEL.includes(f.label) ? "\n" + t(opt.note) : ""); + : t("{label}: {value}", { label: f.label === MEMORIES ? t("Codex memories model") : t(f.label), value: t(opt?.label || f.value || (f.label === MEMORIES ? MEMORIES_DEFAULT : "same as model")) }) + (opt?.note && !FOLLOWS_MODEL.includes(f.label) ? "\n" + t(opt.note) : ""); b.setAttribute("aria-label", b.title); b.dataset.key = f.key; b.onclick = (ev) => openPicker(a, f, b, ev); @@ -17498,6 +17499,7 @@ function renderTrayUsage(s, keep) { $("#fullContextSegs").replaceChildren(compactRow); renderCodexTitles(s); renderCodexAutoReview(s); + renderCodexMemories(); const rate = s.fx?.rate; const currencySub = $("#currencySub"); currencySub.textContent = t("What a cost — the Usage page's, the tray panel's, the TUI's and the CLI's — is shown as; a vendor's own balance, already in its own currency, is never converted"); @@ -17767,6 +17769,31 @@ function renderCodexAutoReview(s) { $("#codexAutoReviewPick").replaceChildren(b); } +// renderCodexMemories: the model Codex writes its memories with (#985, +// Hu9956: the notebook square on Codex's card was all there was, beside the +// subagent squares, and wasn't found). It is the card's own field, picked +// in the same picker and written the same way, so the row and the square +// show one setting; renderAgents draws the row again after every pick. +// No Codex with a memories field to pick: no row. +function renderCodexMemories() { + const row = $("#codexMemoriesRow"); + if (!row) return; + const a = state.agents.find((x) => x.id === "codex"); + const f = a?.fields?.find((x) => x.label === MEMORIES); + row.hidden = gatewayMode || !f || (!f.options.length && !f.value); + if (row.hidden) return; + const opt = optionFor(f, f.value); + $("#codexMemoriesSub").textContent = f.value ? t("Codex sums threads up and folds them into its memories with this model, whatever model the conversation is on") + : t("Codex sums threads up and folds them into its memories with its own models, gpt-5.6-luna and gpt-5.6-terra, whatever model the conversation is on"); + const b = el("button", "rt-cond on"); + b.type = "button"; + b.setAttribute("aria-label", t("Codex memories model")); + if (f.value) b.append(icon(opt?.icon || "generic"), el("span", "", opt?.label || f.value)); + else b.append(icon("openai"), el("span", "", t("Codex’s own pick"))); + b.onclick = (ev) => openPicker(a, f, b, ev); + $("#codexMemoriesPick").replaceChildren(b); +} + // renderImages: the model that describes images to a model that can't see // them — the one magpie picks, one named, or none, and then such an image // is turned away. diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 7af479a8..77c8d4fa 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -531,6 +531,9 @@ const I18N = { "Codex’s own pick": "由 Codex 决定", "as Codex picks it": "按 Codex 默认选择", "Codex’s auto-review decides approvals on this model, whatever model the conversation is on": "Codex 的自动审批(auto-review)用这个模型决定是否批准,不论对话用的是哪个模型", + "Codex memories model": "Codex 记忆整理模型", + "Codex sums threads up and folds them into its memories with this model, whatever model the conversation is on": "Codex 用这个模型总结对话、整理成记忆,不论对话用的是哪个模型", + "Codex sums threads up and folds them into its memories with its own models, gpt-5.6-luna and gpt-5.6-terra, whatever model the conversation is on": "Codex 用自带的 gpt-5.6-luna 和 gpt-5.6-terra 总结对话、整理成记忆,不论对话用的是哪个模型", "Codex’s auto-review decides approvals on codex-auto-review when it has it, else on the conversation’s model at low effort": "Codex 的自动审批(auto-review)有 codex-auto-review 时用它,否则用当前对话的模型(low 推理强度)", "no title, nothing sent": "不生成标题,不发出请求", "Codex asks its own model for each thread’s title through its ChatGPT sign-in, even while the conversation is on magpie’s models": "Codex 经 ChatGPT 登录用自带模型为每个会话生成标题,即使对话用的是 magpie 的模型", @@ -3978,6 +3981,9 @@ const I18N = { "Codex’s own pick": "Codex に任せる", "as Codex picks it": "Codex の既定どおり", "Codex’s auto-review decides approvals on this model, whatever model the conversation is on": "Codex の自動レビュー(auto-review)は、会話のモデルにかかわらずこのモデルで承認を判断します", + "Codex memories model": "Codex メモリ整理のモデル", + "Codex sums threads up and folds them into its memories with this model, whatever model the conversation is on": "Codex は会話のモデルにかかわらず、このモデルでスレッドを要約し、メモリに整理します", + "Codex sums threads up and folds them into its memories with its own models, gpt-5.6-luna and gpt-5.6-terra, whatever model the conversation is on": "Codex は会話のモデルにかかわらず、自身の gpt-5.6-luna と gpt-5.6-terra でスレッドを要約し、メモリに整理します", "Codex’s auto-review decides approvals on codex-auto-review when it has it, else on the conversation’s model at low effort": "Codex の自動レビュー(auto-review)は codex-auto-review があればそれで、なければ会話のモデル(low の推論)で承認を判断します", "no title, nothing sent": "タイトルなし、送信なし", "Codex asks its own model for each thread’s title through its ChatGPT sign-in, even while the conversation is on magpie’s models": "会話が magpie のモデルで行われていても、Codex は ChatGPT のサインインを通じて自身のモデルに各スレッドのタイトルを生成させます", @@ -7428,6 +7434,9 @@ const I18N = { "Codex’s own pick": "Wie Codex es wählt", "as Codex picks it": "Codex’ Standard", "Codex’s auto-review decides approvals on this model, whatever model the conversation is on": "Codex’ Auto-Review entscheidet Freigaben mit diesem Modell, egal auf welchem Modell die Unterhaltung läuft", + "Codex memories model": "Codex-Modell für Erinnerungen", + "Codex sums threads up and folds them into its memories with this model, whatever model the conversation is on": "Codex fasst Threads mit diesem Modell zusammen und ordnet sie in seine Erinnerungen ein, egal auf welchem Modell die Unterhaltung läuft", + "Codex sums threads up and folds them into its memories with its own models, gpt-5.6-luna and gpt-5.6-terra, whatever model the conversation is on": "Codex fasst Threads mit seinen eigenen Modellen gpt-5.6-luna und gpt-5.6-terra zusammen und ordnet sie in seine Erinnerungen ein, egal auf welchem Modell die Unterhaltung läuft", "Codex’s auto-review decides approvals on codex-auto-review when it has it, else on the conversation’s model at low effort": "Codex’ Auto-Review entscheidet Freigaben mit codex-auto-review, wenn vorhanden, sonst mit dem Modell der Unterhaltung bei niedrigem Aufwand", "no title, nothing sent": "kein Titel, nichts gesendet", "Codex asks its own model for each thread’s title through its ChatGPT sign-in, even while the conversation is on magpie’s models": "Codex fragt über seine ChatGPT-Anmeldung sein eigenes Modell nach dem Titel jedes Threads, auch wenn die Unterhaltung auf magpie-Modellen läuft", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 2b7ebf0b..1ca42406 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -277,6 +277,10 @@
Codex auto-review model
+
Open at login
Start magpie in the tray when you log in, so agents find the gateway from the start
diff --git a/internal/gui/tests/codex-memories-settings.test.cjs b/internal/gui/tests/codex-memories-settings.test.cjs new file mode 100644 index 00000000..258793b1 --- /dev/null +++ b/internal/gui/tests/codex-memories-settings.test.cjs @@ -0,0 +1,180 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Settings' "Codex memories model" (#985, Hu9956: the model Codex writes its +// memories with was only the notebook square on Codex's card, beside the +// subagent squares, and wasn't found). The row sits under "Codex auto-review +// model" with a title and a line that says what happens; unset it is +// Codex's own pick. It opens the app's model picker (no native select), and +// a pick posts Codex's memories field as the card's square does: the square +// lights with it, and Default picked on the square puts the row back. No +// Codex with the field: no row. The square's label names it. No click +// scrolls the page. Chromium and WebKit, en/zh/ja/de, 440px and wide; 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 models = [{ value: "gpt-5.5", label: "GPT-5.5", ref: "openai/gpt-5.5" }, { value: "gpt-5.4-mini", label: "GPT-5.4 mini", ref: "openai/gpt-5.4-mini" }]; +const agents = (codex) => codex ? [{ + id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true, + fields: [ + { key: "model", label: "model", value: "gpt-5.5", options: models }, + { key: "subagent", label: "subagents", value: "", options: models }, + { key: "memories", label: "memories", value: "", options: models }, + ], +}] : [{ id: "claude", name: "Claude Code", path: "/test/settings.json", icon: "claude-color", fields: [{ key: "model", label: "model", value: "", options: [] }] }]; + +const settings = (lang) => ({ + theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd", + dock: false, dockWindow: false, proxy: "", redact: false, redactPersonal: false, redactWords: [], + codexWarmup: "", claudeWarmup: "", codexWarmAt: "", claudeWarmAt: "", workbuddyCheckin: false, noStats: false, + trayUsage: "", trayUsageEvery: 3, vision: "", imageGen: "", + version: "0.1.400", dir: "~/.config/magpie", gateway: "http://127.0.0.1:3425", + proxyNow: "none", proxySource: "none", login: false, + visionModels: [], imageGenModels: [], workbuddyCheckins: [], lanURLs: [], titleModels: [], + fx: { rate: 7.2, at: new Date().toISOString(), stale: false }, +}); + +function server(lang, sets, codex) { + let cur = { agents: agents(codex), profiles: [] }; + return async (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:false};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ ...cur, settings: { lang, theme: "light" } }); + if (url.pathname === "/api/set") { + const body = req.postDataJSON(); + sets.push(body); + cur = JSON.parse(JSON.stringify(cur)); + cur.agents.find((a) => a.id === body.agent).fields.find((f) => f.key === body.field).value = body.value; + return json({ ...cur, settings: { lang, theme: "light" } }); + } + if (url.pathname === "/api/settings") return json(settings(lang)); + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + 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)]; + try { + await route.fulfill({ body: await fs.readFile(file), contentType }); + } catch { + await route.fulfill({ status: 404, body: "" }); + } + }; +} + +// the words as i18n.js has them +const W = { + en: { name: "Codex memories model", own: "Codex’s own pick", subOwn: /gpt-5\.6-luna and gpt-5\.6-terra/, subSet: /with this model/, square: "Codex memories model: GPT-5.4 mini", def: "Default" }, + zh: { name: "Codex 记忆整理模型", own: "由 Codex 决定", subOwn: /gpt-5\.6-luna 和 gpt-5\.6-terra/, subSet: /用这个模型/, square: "Codex 记忆整理模型:GPT-5.4 mini", def: "默认" }, + ja: { name: "Codex メモリ整理のモデル", own: null, subOwn: /gpt-5\.6-luna と gpt-5\.6-terra/, subSet: /このモデルで/, square: null, def: null }, + de: { name: "Codex-Modell für Erinnerungen", own: null, subOwn: /gpt-5\.6-luna und gpt-5\.6-terra/, subSet: /mit diesem Modell/, square: null, def: null }, +}; +const view = (page) => page.locator("#view-settings").evaluate((v) => v.scrollTop); + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const width of [440, 1100]) { + for (const lang of ["en", "zh", "ja", "de"]) { + test(`${engine} ${width}px ${lang}: Settings picks the model Codex writes its memories with, the card's square in step`, async (t) => { + const w = W[lang]; + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const context = await browser.newContext({ viewport: { width, height: 520 }, reducedMotion: "reduce" }); + const page = await context.newPage(); + page.setDefaultTimeout(5000); + const errors = [], sets = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, sets, true)); + 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}-${width}-${lang}-codex-memories-settings.png`) }); + } + await browser.close(); + }); + await page.goto("http://magpie.test/?view=settings"); + const row = page.locator("#codexMemoriesRow"); + const pick = page.locator("#codexMemoriesPick button"); + await pick.waitFor(); + // beside the other two of Codex's background models + assert.equal(await page.evaluate(() => document.querySelector("#codexAutoReviewRow").nextElementSibling?.id), "codexMemoriesRow"); + assert.equal(await row.locator("select").count(), 0, "no native select"); + assert.equal((await row.locator(".name").textContent()).trim(), w.name); + if (w.own) assert.equal((await pick.textContent()).trim(), w.own, "Codex's own pick by default"); + assert.match(await row.locator(".sub").textContent(), w.subOwn); + // the title, its line and the button fit the row + const fit = await row.evaluate((r) => { + const box = r.getBoundingClientRect(); + return [...r.querySelectorAll(".name, .sub, button")].every((e) => { + const b = e.getBoundingClientRect(); + return b.width > 0 && b.left >= box.left - 1 && b.right <= box.right + 1; + }) && document.documentElement.scrollWidth <= innerWidth; + }); + assert(fit, "the row overflows"); + + // scrolled by a wheel till the row is mid-view, so a click that + // moved the page would show + const box = await page.locator("#view-settings").boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + const top = () => pick.evaluate((e) => e.getBoundingClientRect().top); + for (let i = 0; i < 60 && (await top()) > 200; i++) { await page.mouse.wheel(0, 100); await page.waitForTimeout(30); } + const before = await view(page); + assert(before > 0, "the settings list must scroll to the row"); + + // the app's model picker, Default first + await pick.click(); + await page.locator("#pop:not([hidden]) #list li").first().waitFor(); + assert.equal(await page.locator("#pop").evaluate((e) => e.classList.contains("model-picker")), true); + assert.equal(await page.locator("select").count(), 0, "a native select"); + if (w.def) assert((await page.locator("#pop #list li").first().innerText()).includes(w.def)); + await page.locator("#pop #list li", { hasText: "GPT-5.4 mini" }).first().click(); + await page.locator("#codexMemoriesPick button", { hasText: "GPT-5.4 mini" }).waitFor(); + assert.deepEqual(sets, [{ agent: "codex", field: "memories", value: "gpt-5.4-mini" }]); + assert.match(await row.locator(".sub").textContent(), w.subSet); + await page.waitForTimeout(300); + assert.equal(await view(page), before, "picking a model scrolled the page"); + + // the card's square is the same setting, and says what it is + await page.evaluate(() => show("agents")); + const codex = '.row.agent[data-id="codex"]'; + await page.locator(`${codex} .ag-link`).click(); + const square = page.locator(`${codex} .extras-cell .field.extra.set[data-key="memories"]`); + await square.waitFor(); + if (w.square) assert.equal(await square.getAttribute("aria-label"), w.square); + assert.equal(await square.getAttribute("title"), await square.getAttribute("aria-label")); + assert((await square.getAttribute("title")).startsWith(w.name), "the square's tooltip names it"); + await square.click(); + await page.locator("#pop:not([hidden]) #list li").first().waitFor(); + await page.locator("#pop #list li").first().click(); + await page.waitForFunction(() => !document.querySelector('.row.agent[data-id="codex"] .field.extra.set[data-key="memories"]')); + assert.deepEqual(sets.at(-1), { agent: "codex", field: "memories", value: "" }); + + // and Settings shows Codex's own again + await page.evaluate(() => show("settings")); + if (w.own) await page.locator("#codexMemoriesPick button", { hasText: w.own }).waitFor(); + assert.match(await row.locator(".sub").textContent(), w.subOwn); + assert.deepEqual(errors, []); + }); + } + } + + test(`${engine}: no Codex, no memories row`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const page = await browser.newPage({ viewport: { width: 900, height: 520 } }); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server("en", [], false)); + t.after(() => browser.close()); + await page.goto("http://magpie.test/?view=settings"); + await page.locator("#codexAutoReviewPick button").waitFor(); + await page.waitForTimeout(300); + assert.equal(await page.locator("#codexMemoriesRow").isHidden(), true); + assert.deepEqual(errors, []); + }); +} diff --git a/internal/gui/tests/codex-memories.test.cjs b/internal/gui/tests/codex-memories.test.cjs index e898830b..6671d76e 100644 --- a/internal/gui/tests/codex-memories.test.cjs +++ b/internal/gui/tests/codex-memories.test.cjs @@ -61,8 +61,8 @@ function server(lang, sets) { // the words as i18n.js has them const W = { - en: { unset: "memories: Codex's own: gpt-5.6-terra, threads summed up with gpt-5.6-luna", def: "Default", set: "memories: GPT-5.4 mini" }, - zh: { unset: "记忆整理模型:Codex 自带的:整理用 gpt-5.6-terra,总结对话用 gpt-5.6-luna", def: "默认", set: "记忆整理模型:GPT-5.4 mini" }, + en: { unset: "Codex memories model: Codex's own: gpt-5.6-terra, threads summed up with gpt-5.6-luna", def: "Default", set: "Codex memories model: GPT-5.4 mini" }, + zh: { unset: "Codex 记忆整理模型:Codex 自带的:整理用 gpt-5.6-terra,总结对话用 gpt-5.6-luna", def: "默认", set: "Codex 记忆整理模型:GPT-5.4 mini" }, }; const codex = '.row.agent[data-id="codex"]';