fix(bot-mode): an empty member seat surfaces an error instead of swallowing the group send

Ported from the pre-TSX PR onto the current module layout. main's
group-chat-view.tsx already restores the composer draft when
sendToGroupChat returns null (feat(desktop): retain Bot group drafts by
room, b42d8279ed), so the draft-loss half of the original fix is
FIXED_ON_MAIN and not re-applied here. What remained: sendToGroupChat in
group-rounds.ts still folded "no text" and "no members" into one silent
`return null`, so a fully typed message into a room whose roster had not
hydrated (or a legacy room record without member descriptors) was
rejected with no thread, no log entry and no error.

The guards are split: empty content stays silent, an empty member seat
raises host.notify with a Bot Mode i18n string (en/ja/zh/zh-hant). The
wording no longer promises a retry will help (review: a legacy room with
no member descriptors never recovers by retrying) and points at the two
real remedies.

The original source-regex .mjs tests are dropped per review; the
contract is pinned by a behavioural vitest in group-rounds.test.ts that
drives sendToGroupChat through the scripted room harness: empty members
→ null + one error toast + no log entry; blank text → null, no toast.
This commit is contained in:
teknium1
2026-09-14 18:34:28 -07:00
committed by Teknium
parent aaf38bb2fb
commit 9c689bee1a
3 changed files with 36 additions and 2 deletions

View File

@@ -514,6 +514,19 @@ describe('threads', () => {
expect(alphaMessages.some(message => message.content.includes('BETA_TOPIC'))).toBe(false)
expect(betaMessages.some(message => message.content.includes('ALPHA_TOPIC'))).toBe(false)
})
it('surfaces an empty member seat instead of swallowing the send; empty text stays silent', async () => {
const room = await loadRoom()
const notify = room.gateway.host.notify as ReturnType<typeof vi.fn>
expect(room.rounds.sendToGroupChat('Seating', [], 'anyone there?')).toBeNull()
expect(log(room, 'Seating')).toHaveLength(0)
expect(notify).toHaveBeenCalledTimes(1)
expect(notify.mock.calls[0][0]).toMatchObject({ kind: 'error', message: expect.stringContaining('Seating') })
expect(room.rounds.sendToGroupChat('Seating', MEMBERS, ' ')).toBeNull()
expect(notify).toHaveBeenCalledTimes(1)
})
})
describe('turn prompt', () => {

View File

@@ -1,9 +1,11 @@
import { botFriendlyNames, botHandle, mentionNameForms } from './data'
/**
* Room-level coordination: who speaks, in what order, for how long — the
* @mention parse, the round-robin driver, the #93129 member holds, the stop
* path, and the user send that starts it all.
*/
import { host } from '@hermes/plugin-sdk'
import { botFriendlyNames, botHandle, mentionNameForms } from './data'
import { recordGroupActivity } from './group-activity'
import {
$groupChats,
@@ -28,6 +30,7 @@ import {
import { runGroupContinuationMembers, runGroupRoundMember } from './group-round-members'
import { rejectGroupSlashCommand } from './group-slash'
import { GROUP_TURN_HARD_CAP_MS, harvestStrandedGroupReply } from './group-turns'
import { botsText } from './i18n'
import { requestForBot } from './routing'
import type { Attachment, GroupMember, GroupMessage } from './types'
@@ -685,7 +688,20 @@ export function sendToGroupChat(
const attached = Array.isArray(images) ? images.filter((img: Attachment) => img && img.data) : []
if ((!trimmed && !attached.length) || !members.length) {
if (!trimmed && !attached.length) {
return null
}
// An empty member seat (roster hydration race, meta clobber, legacy room
// record without member descriptors) used to swallow the send: a fully
// typed message vanished with no thread and no error. Surface it — the
// caller keeps the draft, so nothing is lost.
if (!members.length) {
host.notify({
kind: 'error',
message: botsText().group.noMembersToSend(group)
})
return null
}

View File

@@ -191,6 +191,7 @@ type BotsMessages = {
heldMembersStatus: (members: string) => string
holdReleaseHint: string
needsYourInput: string
noMembersToSend: (group: string) => string
pictureGenerationFailed: string
nameTaken: (name: string) => string
memberCount: (count: number) => string
@@ -424,6 +425,7 @@ const en: BotsMessages = {
heldMembersStatus: members => `Paused: ${members}`,
holdReleaseHint: 'Mention a paused bot or send @all resume to release them.',
needsYourInput: 'A bot in this group chat needs your input',
noMembersToSend: group => `${group} has no members to send to — add a bot, or reopen the room if members are still loading.`,
pictureGenerationFailed: 'Group picture generation failed',
nameTaken: name => `A group named “${name}” already exists.`,
memberCount: count => `${count} bots`,
@@ -650,6 +652,7 @@ const ja: BotsMessages = {
heldMembersStatus: members => `一時停止中: ${members}`,
holdReleaseHint: '一時停止中のボットにメンションするか、@all resume を送信して再開します。',
needsYourInput: 'このグループチャットのボットが入力を待っています',
noMembersToSend: group => `${group} に送信先のメンバーがいません。ボットを追加するか、メンバーの読み込み中であればルームを開き直してください。`,
pictureGenerationFailed: 'グループ画像の生成に失敗しました',
nameTaken: name => `「${name}」という名前のグループはすでに存在します。`,
memberCount: count => `ボット${count}体`,
@@ -871,6 +874,7 @@ const zh: BotsMessages = {
heldMembersStatus: members => `已暂停:${members}`,
holdReleaseHint: '提及已暂停的机器人,或发送 @all resume 以恢复它们。',
needsYourInput: '此群聊中有机器人需要你输入',
noMembersToSend: group => `${group} 没有可发送的成员——请添加机器人,如果成员仍在加载,请重新打开该群聊。`,
pictureGenerationFailed: '群组图片生成失败',
nameTaken: name => `已存在名为“${name}”的群聊。`,
memberCount: count => `${count} 个机器人`,
@@ -1092,6 +1096,7 @@ const zhHant: BotsMessages = {
heldMembersStatus: members => `已暫停:${members}`,
holdReleaseHint: '提及已暫停的機器人,或傳送 @all resume 以恢復它們。',
needsYourInput: '此群組聊天中有機器人需要您的輸入',
noMembersToSend: group => `${group} 沒有可傳送的成員——請新增機器人,若成員仍在載入中,請重新開啟該群組聊天。`,
pictureGenerationFailed: '群組圖片產生失敗',
nameTaken: name => `已存在名為「${name}」的群組聊天。`,
memberCount: count => `${count} 個機器人`,