From ec32bca64472e34f8c341566cc373259d41fb981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 10 Nov 2026 08:17:30 +0000 Subject: [PATCH] Subagents: a new chat reads /models again, so subagents switched on later show The Subagents chip on a new chat follows `/models` -> model.subagents.offered, the same team_run.offered the web page renders from. The app read /models once, at sign-in, and kept it while it stayed open in the tray: subagents switched on afterwards (or a model added) never reached a new chat until a restart. startNew() now re-reads it (at most every 5 s, keeping the last list if the read fails) and redraws the draft's chips (modelsChanged()). An open chat already re-reads its own `subagents` with GET /chats/{id}. tests/ui: A5 cases -- parallel model offered: chip, slider to the server's cap, no note; one-request model offered named_only: the `own` note; offered false everywhere: no chip; switched on while open: the next new chat has it; an open chat follows its `subagents`. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++ tests/ui/run.mjs | 104 +++++++++++++++++++++++++++++++++++++++++++++++ ui/js/chat.js | 12 ++++++ ui/js/main.js | 14 +++++-- 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c3376..96d232d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ All notable changes to LLeMbas Desktop. The format follows subagent asks when one does. A card already on the screen is kept as it is while the reply goes on, so a command being corrected or an option picked is no longer lost when the server sends the list again; a card that cannot be drawn says why instead of leaving an empty space. +- **Subagents after they are switched on.** The app read which models a new chat is offered + subagents on only when it signed in, so subagents switched on (or a model added) on the server + while the app stayed open in the tray never showed on a new chat until it was restarted. A new + chat now asks the server again. (An agent chat has no Subagents setting: its subagents run on + the computer, and the server offers none for it.) ### Added diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index 1927aec..9226ee2 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -329,6 +329,110 @@ test("A1: a card at 390 px wide is on the screen and its buttons can be reached" assert.deepEqual(p.errors, []) }) +// --- A5: the Subagents setting ---------------------------------------------------------------- +// +// The server decides (`services/team_run.py:offered`, `services/subagents.py:view`): subagents +// enabled on the instance, the person's permission, a model that calls tools, and some model that +// could serve one -- the chat's own serving two requests at once, or another off its one-model +// server (`named_only`: then the setting says a subagent needs a model named in the chat). It says +// so per model in `/models` (a new chat) and per chat in `/chats/{id}` (`subagents`). The web page +// reads the same function (`api/pages.py:_team_context`). The app shows what it is told. + +async function newChatOn(p, kind, modelName) { + await p.click("#new-chat-btn") + await p.click(`.kind__tab >> text=${kind}`) + if (modelName) { + await p.click(".cchip--model") + await p.locator(".menu .menu__item", { hasText: modelName }).click() + } +} + +async function subagentsChip(p) { + // The chip comes after the others (an open chat's waits for the server's word). + await p.waitForTimeout(150) + return p.locator("#composer-chips .cchip--subagents") +} + +async function openSlider(p) { + await p.click("#composer-chips .cchip--subagents") + await p.waitForSelector(".range2") + return { + min: await p.getAttribute(".range2__input--min", "max"), + text: await p.textContent(".pop__body"), + } +} + +const OWN = "This model serves one request at a time: subagents need another model, on another server, named in the chat." + +function a5(routes = {}) { + return { status: status(), routes: baseRoutes(routes), streams: {} } +} + +test("A5: a new chat on a model serving two at once offers Subagents, Min and Max to the server's cap", async () => { + const p = await page(a5()) + await newChatOn(p, "Chat") + const chip = await subagentsChip(p) + assert.equal(await chip.count(), 1, "the chip is there") + const s = await openSlider(p) + assert.equal(s.min, "8", "up to the server's cap") + assert.ok(!s.text.includes(OWN), "no one-request note") + assert.deepEqual(p.errors, []) +}) + +test("A5: a new chat on a one-request model the server still offers: Subagents with the note", async () => { + const p = await page(a5()) + await newChatOn(p, "Chat", "Solo") + const chip = await subagentsChip(p) + assert.equal(await chip.count(), 1) + const s = await openSlider(p) + assert.ok(s.text.includes(OWN), "the one-request note") + assert.deepEqual(p.errors, []) +}) + +test("A5: subagents off on the instance (offered false for every model): no Subagents", async () => { + const off = SUB(false) + const p = await page(a5({ "GET /models": models({ qwen: off, solo: off, plain: off }) })) + await newChatOn(p, "Chat") + assert.equal(await (await subagentsChip(p)).count(), 0) + await newChatOn(p, "Chat", "Solo") + assert.equal(await (await subagentsChip(p)).count(), 0) + assert.deepEqual(p.errors, []) +}) + +test("A5: subagents switched on while the app stays open: the next new chat offers them", async () => { + const off = SUB(false) + const p = await page(a5({ "GET /models": models({ qwen: off, solo: off, plain: off }) })) + await newChatOn(p, "Chat") + assert.equal(await (await subagentsChip(p)).count(), 0, "off at sign-in") + // The administrator switches subagents on; the app read /models at sign-in. + await p.evaluate((m) => (window.__mockConfig.routes["GET /models"] = m), models()) + await p.waitForTimeout(5200) + await p.click("#new-chat-btn") + await p.waitForSelector("#composer-chips .cchip--subagents") + assert.deepEqual(p.errors, []) +}) + +test("A5: an open chat follows its own `subagents`: offered, offered with the note, not offered", async () => { + for (const [said, chip, note] of [ + [{ offered: true, named_only: false, cap: 8 }, 1, false], + [{ offered: true, named_only: true, cap: 8 }, 1, true], + [{ offered: false, named_only: false, cap: 8 }, 0, false], + ]) { + const p = await page( + a5({ + "GET /chats/chat1": { chat: chatRow({ subagents: said }), messages: [userMessage("m5", "How is lembas made?")], more: false, live: null }, + "GET /models?chat=chat1": models(), + }), + ) + await openChatRow(p, "chat1") + const c = await subagentsChip(p) + assert.equal(await c.count(), chip, `chip for ${JSON.stringify(said)}`) + if (chip) assert.equal((await openSlider(p)).text.includes(OWN), note) + assert.deepEqual(p.errors, []) + await p.context().close() + } +}) + // --- Run ---------------------------------------------------------------------------------------- const only = process.argv[2] || "" diff --git a/ui/js/chat.js b/ui/js/chat.js index 095e970..3696f38 100644 --- a/ui/js/chat.js +++ b/ui/js/chat.js @@ -996,6 +996,18 @@ export function draftKind() { return draft?.kind || lastKind } +/** `/models` was read again: a new chat being written is drawn with what it says now. */ +export function modelsChanged() { + if (!draft) return + const models = ctx.models() + if (!models.some((m) => m.id === draft.model)) { + const start = models.find((m) => m.default) || models[0] + draft.model = start?.id || "" + draft.effort = levelsOf(start).start + } + renderChips() +} + /** The greeting of a new chat, and the choice of what it is. */ function renderHello() { const box = $("new-chat") diff --git a/ui/js/main.js b/ui/js/main.js index fb779d7..ba66c7c 100644 --- a/ui/js/main.js +++ b/ui/js/main.js @@ -12,7 +12,7 @@ import { renderSettings, initSettings, scrollToSection } from "./settings.js" import { initViewer, onViewer, closeViewer } from "./viewer.js" import { initExplorer, showLocal, showFilesTab } from "./explorer.js" import { initList, refreshList, markCurrent } from "./list.js" -import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer } from "./chat.js" +import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged } from "./chat.js" import { initAttach, allowAttach } from "./attach.js" import { initPalette, openPalette } from "./palette.js" import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js" @@ -117,13 +117,17 @@ function stopApp() { closeViewer() } -async function loadModels() { +let modelsAt = 0 // when /models was last read + +/** The account's models, with what a new chat on each is offered. `keep`: a failed read keeps the last list. */ +async function loadModels({ keep = false } = {}) { try { const r = await get("/models") models = r.models || [] noteSubagentsCap(r.subagents_cap) + modelsAt = Date.now() } catch { - models = [] + if (!keep) models = [] } } @@ -177,6 +181,10 @@ function startNew(kind) { newChat(kind || draftKind()) markCurrent() localPane() + // What a new chat is offered (its models, Subagents) is the server's word now, not at sign-in: + // an administrator may have switched subagents on or added a model while the app stayed open in + // the tray. Drawn at once from the last list, then again from the fresh one. + if (started && Date.now() - modelsAt > 5000) loadModels({ keep: true }).then(modelsChanged) } /** Open a chat, from the list or the palette. */