fix(bot-mode): group chats no longer lose long-running member turns, remade groups start fresh (Discord bot-mode feedback)

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.
This commit is contained in:
Teknium
2026-08-17 20:40:17 -07:00
parent bdc9a810f3
commit 30fcf95805
2 changed files with 241 additions and 10 deletions
+155 -8
View File
@@ -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
@@ -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)
})