Subagents: a new chat reads /models again, so subagents switched on later show
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s

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 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 08:17:30 +00:00
1 parent 5a87eec28d
commit ec32bca644
4 files changed
+132 -3

No files matched your search

+5
View File
@@ -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 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 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. 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 ### Added
+104
View File
@@ -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, []) 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 ---------------------------------------------------------------------------------------- // --- Run ----------------------------------------------------------------------------------------
const only = process.argv[2] || "" const only = process.argv[2] || ""
+12
View File
@@ -996,6 +996,18 @@ export function draftKind() {
return draft?.kind || lastKind 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. */ /** The greeting of a new chat, and the choice of what it is. */
function renderHello() { function renderHello() {
const box = $("new-chat") const box = $("new-chat")
+11 -3
View File
@@ -12,7 +12,7 @@ import { renderSettings, initSettings, scrollToSection } from "./settings.js"
import { initViewer, onViewer, closeViewer } from "./viewer.js" import { initViewer, onViewer, closeViewer } from "./viewer.js"
import { initExplorer, showLocal, showFilesTab } from "./explorer.js" import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
import { initList, refreshList, markCurrent } from "./list.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 { initAttach, allowAttach } from "./attach.js"
import { initPalette, openPalette } from "./palette.js" import { initPalette, openPalette } from "./palette.js"
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js" import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
@@ -117,13 +117,17 @@ function stopApp() {
closeViewer() 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 { try {
const r = await get("/models") const r = await get("/models")
models = r.models || [] models = r.models || []
noteSubagentsCap(r.subagents_cap) noteSubagentsCap(r.subagents_cap)
modelsAt = Date.now()
} catch { } catch {
models = [] if (!keep) models = []
} }
} }
@@ -177,6 +181,10 @@ function startNew(kind) {
newChat(kind || draftKind()) newChat(kind || draftKind())
markCurrent() markCurrent()
localPane() 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. */ /** Open a chat, from the list or the palette. */