From 30fcf95805a02e5e927fcac53a2d493cda8492d8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:40:17 -0700 Subject: [PATCH] fix(bot-mode): group chats no longer lose long-running member turns, remade groups start fresh (Discord bot-mode feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From db's bot-mode feedback thread (Discord support, Aug 17): - A 7-minute member run timed out at the fixed 3-minute turn deadline, read as a (pass), and its finished result never reached the room. The turn deadline now extends while the session visibly reports inflight/running work (bounded by a 20-minute hard cap), and a turn that still times out records a runtime 'stranded' baseline so the finished reply is harvested into the room at the next turn boundary — late, never lost. Stranded markers persist with the room so a reply that finishes after a window reload is still delivered. - Re-creating a group under a taken name (easy: the default name is just the member names) silently reopened the old room with its full log. Create now uniquifies against live rooms and every bot's current grouping, so a new group is always a fresh room. - The generic 'The room is working…' line now names the member on turn ('Radar is thinking…') so slow models read as thinking, not stuck. - Room prompt no longer caps every reply at 1-3 sentences: chatter stays short, but results/answers/substantive work are explicitly full quality and length (outputs felt 'not as strong as usual'). - Room markdown gets list/pre styling (padded bullets instead of browser defaults flush against the pane edge). Tests: group-chat.test.mjs +7 (harvest posts late replies and consumes markers, late (pass) consumes without posting, stranded persistence, deadline-extension + turn-label + fresh-name source contracts, prompt quality rule). 245/245 plugin tests pass. --- .../desktop/src/plugins/hermes-bots/plugin.js | 163 +++++++++++++++++- .../hermes-bots/tests/group-chat.test.mjs | 88 +++++++++- 2 files changed, 241 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 98aaf64c07..a0c6ce96ea 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -3364,7 +3364,7 @@ function buildGroupChatTurnPrompt({ groupName, members, viewer, deltaLines }) { ...deltaLines.map(line => ` ${line}`), '', 'Rules for this room:', - '- Reply with ONE short conversational message (1-3 sentences) ONLY if you have something new worth adding: build on what was just said, claim or hand off work, answer a question aimed at you, or report a real result.', + '- Reply with ONE conversational message ONLY if you have something new worth adding: build on what was just said, claim or hand off work, answer a question aimed at you, or report a real result. Keep chatter short (1-3 sentences) — but when you are delivering a result, an answer the user asked for, or substantive work, give it at full quality and length; never thin out real content to fit the room.', '- If you have nothing new to add, reply with exactly "(pass)". Passing is good — it lets the conversation settle.', '- Mention a teammate as @name to pull them in; mention @user only for a judgment call or a result the user needs. Do not repeat points already made.', '- Never reveal content from your private 1:1 chats. Your reply text goes to the room verbatim — no preamble, no meta-commentary.' @@ -3408,6 +3408,10 @@ function updateGroupChat(group, mutate) { log: room.log, watermarks: room.watermarks, sessions: room.sessions || {}, + // Timed-out turns awaiting a late reply — keyed by member, valued + // with the pre-turn message baseline. Survives reloads so finished + // work is still harvested after a window restart. + stranded: room.stranded || {}, // Cross-connection member descriptors — remote bots can't ride // bot-meta, so the room record carries who they are. members: Array.isArray(room.members) ? room.members : [] @@ -3549,10 +3553,20 @@ async function ensureGroupChatSession(group, member) { const GROUP_TURN_TIMEOUT_MS = 180000 const GROUP_TURN_POLL_MS = 2000 +// A member turn that is VISIBLY still working (session reports +// inflight/running) keeps its slot alive up to this hard cap. The base +// timeout alone silently dropped long real turns: a 7-minute research run +// timed out at 3 minutes, read as a pass, and its finished result never +// reached the room (db's Aug 2026 report). +const GROUP_TURN_HARD_CAP_MS = 20 * 60000 /** One member turn, gateway-native: submit the room delta as a prompt into * the member's per-group session, then poll the session until a NEW - * assistant message lands (or timeout → pass). No shell composition. */ + * assistant message lands (or timeout → pass). While the session visibly + * reports work in flight the deadline extends (bounded by the hard cap), + * so slow models aren't cut off mid-run. A turn that still times out + * records a stranded marker so the finished reply can be harvested into + * the room at the member's next turn instead of being lost. */ async function runGroupChatMemberTurn(group, member, prompt) { const { runtime, stored } = await ensureGroupChatSession(group, member) @@ -3575,7 +3589,8 @@ async function runGroupChatMemberTurn(group, member, prompt) { await requestForBot(member, 'prompt.submit', { session_id: runtime, text: prompt }) - const deadline = Date.now() + GROUP_TURN_TIMEOUT_MS + const started = Date.now() + let deadline = started + GROUP_TURN_TIMEOUT_MS while (Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, GROUP_TURN_POLL_MS)) @@ -3592,7 +3607,8 @@ async function runGroupChatMemberTurn(group, member, prompt) { } const messages = Array.isArray(state?.messages) ? state.messages : [] - const done = !state?.inflight && !state?.running + const busy = Boolean(state?.inflight || state?.running) + const done = !busy if (messages.length > before && done) { for (let i = messages.length - 1; i >= 0; i--) { @@ -3611,9 +3627,91 @@ async function runGroupChatMemberTurn(group, member, prompt) { return null } + + // Still visibly working: extend the deadline (never past the hard cap). + if (busy) { + deadline = Math.min(started + GROUP_TURN_HARD_CAP_MS, Math.max(deadline, Date.now() + GROUP_TURN_TIMEOUT_MS)) + } } - return null // timeout — reads as a pass + // Timeout — reads as a pass, but remember the baseline (runtime-only) so + // the finished reply can be posted late instead of vanishing. + updateGroupChat(group, r => { + r.stranded = { ...(r.stranded || {}), [groupMemberKey(member)]: before } + return r + }) + + return null +} + +/** Post a timed-out member's finished reply into the room, if it landed + * after we stopped waiting. Called at the member's next turn boundary and + * on user sends, so long-running work is delivered late rather than lost. */ +async function harvestStrandedGroupReply(group, member) { + const memberKey = groupMemberKey(member) + const room = $groupChats.get()[group] || {} + const strandedBefore = room.stranded?.[memberKey] + + if (typeof strandedBefore !== 'number') { + return + } + + let state = null + + try { + const stored = room.sessions?.[memberKey] + state = await requestForBot(member, 'session.resume', { + session_id: stored || `Group: ${group}`, + profile: member.name + }) + } catch { + return // source unreachable — leave the marker for the next boundary + } + + if (state?.inflight || state?.running) { + return // still grinding — keep waiting + } + + // Done (or dead): the marker is consumed either way. + updateGroupChat(group, r => { + const next = { ...(r.stranded || {}) } + delete next[memberKey] + r.stranded = next + return r + }) + + const messages = Array.isArray(state?.messages) ? state.messages : [] + + if (messages.length <= strandedBefore) { + return + } + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + + if (msg?.role === 'assistant') { + const text = typeof msg.content === 'string' + ? msg.content + : Array.isArray(msg.content) + ? msg.content.map(p => (typeof p === 'string' ? p : p?.text || '')).join('') + : msg?.text || '' + const reply = String(text).trim() + + if (reply && !isGroupPassText(reply)) { + appendGroupChatEntry( + group, + { kind: 'member', name: member.name, ...(member.remoteSource ? { source: member.connectionLabel || member.connectionId } : {}) }, + reply + ) + updateGroupChat(group, r => { + r.watermarks[memberKey] = r.log.length + return r + }) + } + + return + } + } } /** Drive one bounded round-robin room turn. Serial — one member at a time. @@ -3626,6 +3724,17 @@ async function runGroupChatRounds(group, members) { try { for (let round = 0; round < GROUP_CHAT_MAX_ROUNDS; round++) { + // Deliver any replies that finished after their turn timed out — + // every member, not just this round's responders, so long work is + // late, never lost. + for (const member of members) { + if (!isCurrent()) { + return + } + + await harvestStrandedGroupReply(group, member) + } + const roomLog = ($groupChats.get()[group] || {}).log || [] const responders = rotateGroupSpeakers(resolveGroupResponders(roomLog, members), round) let spokeThisRound = 0 @@ -3651,6 +3760,14 @@ async function runGroupChatRounds(group, members) { deltaLines: delta.slice(-GROUP_CHAT_HISTORY_LIMIT).map(e => formatGroupChatLine(e, member.name)) }) + // Surface WHO is on turn (runtime-only, like running/epoch) so the + // room shows "Radar is thinking…" instead of a generic working line — + // long model turns otherwise read as the room being stuck. + updateGroupChat(group, r => { + r.turn = member.name + return r + }) + let reply = null try { @@ -3689,6 +3806,7 @@ async function runGroupChatRounds(group, members) { if (isCurrent()) { updateGroupChat(group, r => { r.running = false + r.turn = null return r }) } @@ -7203,12 +7321,37 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { const canCreate = selected.length >= 2 && Boolean(name.trim() || selected.length) const create = () => { - const groupName = (name.trim() || placeholder).slice(0, 64) + let groupName = (name.trim() || placeholder).slice(0, 64) if (selected.length < 2 || !groupName) { return } + // Creating a group is always a FRESH room. Without this, re-creating a + // group under an existing name (easy — the default name is just the + // member names) silently reopens the old room with its full log, which + // reads as "not a fresh group" (db's Aug 2026 report). Uniquify against + // both live rooms and any bot's current grouping. + const taken = new Set(Object.keys($groupChats.get())) + + for (const meta of Object.values($botMeta.get() || {})) { + const existing = String(meta?.group || '').trim() + + if (existing) { + taken.add(existing) + } + } + + if (taken.has(groupName)) { + let n = 2 + + while (taken.has(`${groupName} ${n}`)) { + n += 1 + } + + groupName = `${groupName} ${n}`.slice(0, 64) + } + for (const bot of selected) { if (!bot.remoteSource) { void saveBotMeta(bot.name, { group: groupName }) @@ -7536,7 +7679,8 @@ function GroupChatWorkspace({ group, members, onBack }) { ] }), jsx('div', { - className: 'text-xs text-(--ui-text-secondary) [&_p]:mb-1 [&_p:last-child]:mb-0', + className: + 'text-xs text-(--ui-text-secondary) [&_p]:mb-1 [&_p:last-child]:mb-0 [&_ul]:mb-1 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:mb-1 [&_ol]:list-decimal [&_ol]:pl-4 [&_pre]:overflow-x-auto', children: Streamdown ? jsx(Streamdown, { children: entry.text }) : entry.text }) ] @@ -7553,7 +7697,9 @@ function GroupChatWorkspace({ group, members, onBack }) { room.running ? jsx('div', { className: 'px-2 py-1 text-[0.7rem] italic text-(--ui-text-quaternary)', - children: 'The room is working…' + children: room.turn + ? `${groupSpeakerLabel(room.turn)} is thinking…` + : 'The room is working…' }, 'working') : null ] @@ -8299,6 +8445,7 @@ export default { log: room.log, watermarks: room.watermarks && typeof room.watermarks === 'object' ? room.watermarks : {}, sessions: room.sessions && typeof room.sessions === 'object' ? room.sessions : {}, + stranded: room.stranded && typeof room.stranded === 'object' ? room.stranded : {}, members: Array.isArray(room.members) ? room.members : [], epoch: 0, running: false diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs index 2104a64823..b5a608ee74 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs @@ -62,7 +62,7 @@ function load(turnScript) { .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') .replace('export default {', 'globalThis.plugin = {') .concat( - '\nglobalThis.__gc = { sendToGroupChat, runGroupChatRounds, resolveGroupResponders, parseGroupChatMentions, rotateGroupSpeakers, isGroupPassText, formatGroupChatLine, buildGroupChatTurnPrompt, trimGroupChatLog, disbandGroupChat, $groupChats, $groupNeedsYou, $groupChatWorkspace, $botMeta, GROUP_CHAT_MAX_ROUNDS, GROUP_CHAT_MAX_MESSAGES };\n' + '\nglobalThis.__gc = { sendToGroupChat, runGroupChatRounds, harvestStrandedGroupReply, resolveGroupResponders, parseGroupChatMentions, rotateGroupSpeakers, isGroupPassText, formatGroupChatLine, buildGroupChatTurnPrompt, trimGroupChatLog, disbandGroupChat, updateGroupChat, $groupChats, $groupNeedsYou, $groupChatWorkspace, $botMeta, GROUP_CHAT_MAX_ROUNDS, GROUP_CHAT_MAX_MESSAGES };\n' ) vm.runInNewContext(source, context, { filename: 'plugin.js' }) const storageWrites = new Map() @@ -70,7 +70,7 @@ function load(turnScript) { storage: { get: () => null, set: (key, value) => storageWrites.set(key, value) }, register: () => undefined }) - return { ...context.__gc, calls, storageWrites } + return { ...context.__gc, calls, storageWrites, transcripts } } const MEMBERS = [{ name: 'research', title: '' }, { name: 'builder', title: '' }, { name: 'ops', title: 'The Ops' }] @@ -382,3 +382,87 @@ test('source contract: room messages carry the speaker avatar via the roster app assert.match(workspace, /members\.slice\(0, 6\)\.map\(/) assert.match(workspace, /title: members\.map\(b => displayName\(b, botRosterMeta\(b, allMeta\)\)\)\.join\(', '\)/) }) + +test('stranded harvest: a timed-out turn whose reply landed late posts into the room and clears the marker', async () => { + const gc = load(() => '(pass)') + + // Room with a stranded marker for research: baseline 0 messages. + gc.updateGroupChat('Late', r => { + r.stranded = { research: 0 } + r.sessions = { research: 'sid-research' } + return r + }) + // The member's session finished after we stopped waiting. + gc.transcripts.set('research', [ + { role: 'user', content: 'the turn prompt' }, + { role: 'assistant', content: 'Here is the full research result, delivered late.' } + ]) + + await gc.harvestStrandedGroupReply('Late', { name: 'research', title: '' }) + + const log = roomLog(gc, 'Late') + assert.equal(log.length, 1) + assert.equal(log[0].from.name, 'research') + assert.match(log[0].text, /delivered late/) + assert.equal(gc.$groupChats.get().Late.stranded.research, undefined, 'marker consumed') +}) + +test('stranded harvest: a late (pass) or no-new-message consumes the marker without posting', async () => { + const gc = load(() => '(pass)') + + gc.updateGroupChat('Quiet2', r => { + r.stranded = { builder: 2 } + r.sessions = { builder: 'sid-builder' } + return r + }) + gc.transcripts.set('builder', [ + { role: 'user', content: 'prompt' }, + { role: 'assistant', content: '(pass)' } + ]) + + await gc.harvestStrandedGroupReply('Quiet2', { name: 'builder', title: '' }) + + assert.equal(roomLog(gc, 'Quiet2').length, 0) + assert.equal(gc.$groupChats.get().Quiet2.stranded.builder, undefined) +}) + +test('stranded markers persist so late replies survive a window reload', async () => { + const gc = load(() => '(pass)') + + gc.updateGroupChat('Persist', r => { + r.stranded = { research: 3 } + return r + }) + + const durable = gc.storageWrites.get('group-chats') + assert.ok(durable && durable.Persist, 'room persisted') + assert.equal(durable.Persist.stranded.research, 3, 'stranded marker rides the durable map') +}) + +test('source contract: long visible turns extend the deadline up to a hard cap', () => { + assert.match(pluginSource, /const GROUP_TURN_HARD_CAP_MS = /) + assert.match(pluginSource, /deadline = Math\.min\(started \+ GROUP_TURN_HARD_CAP_MS/) +}) + +test('source contract: the working line names the member on turn', () => { + assert.match(pluginSource, /is thinking…/) + assert.match(pluginSource, /r\.turn = member\.name/) + assert.match(pluginSource, /r\.turn = null/) +}) + +test('source contract: creating a group with a taken name mints a fresh room, never reopens the old log', () => { + assert.match(pluginSource, /const taken = new Set\(Object\.keys\(\$groupChats\.get\(\)\)\)/) + assert.match(pluginSource, /while \(taken\.has\(`\$\{groupName\} \$\{n\}`\)\)/) +}) + +test('turn prompt: results are full quality — only chatter is asked to stay short', () => { + const gc = load(() => '(pass)') + const prompt = gc.buildGroupChatTurnPrompt({ + groupName: 'Core', + members: [{ name: 'research', title: '' }, { name: 'builder', title: '' }], + viewer: { name: 'research', title: '' }, + deltaLines: [] + }) + assert.match(prompt, /never thin out real content/i) + assert.match(prompt, /Keep chatter short/i) +})