diff --git a/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx b/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx
index 55dcc8e2df..2193050a53 100644
--- a/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx
+++ b/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx
@@ -16,10 +16,11 @@
*/
import type * as HermesSdk from '@hermes/plugin-sdk'
-import { fireEvent, render, screen } from '@testing-library/react'
+import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BotRow } from './bot-row'
+import { $groupChats } from './group-chat'
import { translateBots } from './i18n-test-helper'
import type { RosterRow } from './types'
@@ -71,6 +72,29 @@ beforeEach(() => {
requestProfile.mockResolvedValue({})
})
+describe('group-turn presence', () => {
+ it('updates only the exact member face and clears it when the room stops', () => {
+ const local: RosterRow = { name: 'default', connectionId: 'local' }
+ const remote: RosterRow = { name: 'default', connectionId: 'remote', remoteSource: true }
+
+ const { container } = render(
+ <>
+ {[local, remote].map(bot => (
+
+ ))}
+ >
+ )
+
+ const moods = () => [...container.querySelectorAll('[data-hb-mood]')].map(el => el.getAttribute('data-hb-mood'))
+ act(() => $groupChats.set({ Room: { log: [], watermarks: {}, running: true, turn: remote } }))
+ expect(moods()).toEqual(['idle', 'think'])
+ act(() => $groupChats.set({ Room: { log: [], watermarks: {}, running: true, turn: local } }))
+ expect(moods()).toEqual(['think', 'idle'])
+ act(() => $groupChats.set({}))
+ expect(moods()).toEqual(['idle', 'idle'])
+ })
+})
+
describe('pre-warm is hover-scoped, never roster-wide', () => {
it('warms nothing on paint and exactly the hovered bot on pointer entry', async () => {
const row = renderRow({ name: 'alpha' } as RosterRow)
diff --git a/apps/desktop/src/plugins/hermes-bots/bot-row.tsx b/apps/desktop/src/plugins/hermes-bots/bot-row.tsx
index 07152ac510..f21a121026 100644
--- a/apps/desktop/src/plugins/hermes-bots/bot-row.tsx
+++ b/apps/desktop/src/plugins/hermes-bots/bot-row.tsx
@@ -56,6 +56,7 @@ import {
} from './data'
import { $groupChats, $groupChatWorkspace } from './group-chat'
import { botGroups, groupLastActivity } from './group-membership'
+import { $activeGroupMemberKeys } from './group-presence'
import { fallbackSelectionAfterHide, isBotHidden, isBotPinned } from './hidden-bots'
import { useBots } from './i18n'
import { displayName, stripPreviewMarkdown } from './labels'
@@ -144,7 +145,8 @@ export function BotRow({ bot, onDelete, onEdit, onGroup, onNewSection, showHandl
? Math.max(activitySession?.last_active || 0, bot.worker_session?.last_active || 0)
: activitySession?.last_active || 0
- const botMood = botWorkingMood(bot, focusedOwner, turnBusy, activeConnectionId)
+ const groupKeys = useValue($activeGroupMemberKeys)
+ const botMood = botWorkingMood(bot, focusedOwner, turnBusy, activeConnectionId, Date.now(), groupKeys)
// Status keys off the canonical Bot Chat — the very session this row opens,
// so the dot and the click can never describe different conversations.
const canonicalSessionId = botCanonicalSessionId(bot)
diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx
index c93ca55793..ba24af0d62 100644
--- a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx
+++ b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx
@@ -63,7 +63,6 @@ import {
$groupChatWorkspace,
$groupClarify,
$groupNeedsYou,
- groupSpeakerLabel,
groupThreadOf,
scheduleGroupChatServerSync,
setGroupChatImage,
@@ -1153,7 +1152,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
{roomClarifies.length
? b.group.waitingForAnswer
: room.turn
- ? b.group.memberThinking(groupSpeakerLabel(room.turn))
+ ? b.group.memberThinking(displayName(room.turn, botRosterMeta(room.turn, allMeta)))
: b.group.roomWorking}
) : null}
diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat.ts b/apps/desktop/src/plugins/hermes-bots/group-chat.ts
index 92684e5cfa..6559bf9c4b 100644
--- a/apps/desktop/src/plugins/hermes-bots/group-chat.ts
+++ b/apps/desktop/src/plugins/hermes-bots/group-chat.ts
@@ -1379,12 +1379,12 @@ export interface GroupHoldStamp extends GroupHold {
}
/** The room record as the coordination engine handles it: `GroupChat` plus
- * `turn`, the runtime-only name of the member currently mid-turn. Like
+ * `turn`, the runtime-only descriptor of the member currently mid-turn. Like
* `running`/`epoch` it never persists, so it has no place in the durable
* shape. Holds carry the fuller live stamp. */
export interface GroupChatRoom extends GroupChat {
holds?: Record
- turn?: null | string
+ turn?: GroupMember | null
}
/** Set or clear a group chat's room picture (small data URL, normalized by
diff --git a/apps/desktop/src/plugins/hermes-bots/group-presence.ts b/apps/desktop/src/plugins/hermes-bots/group-presence.ts
new file mode 100644
index 0000000000..78a35f0ff6
--- /dev/null
+++ b/apps/desktop/src/plugins/hermes-bots/group-presence.ts
@@ -0,0 +1,15 @@
+import { computed } from '@hermes/plugin-sdk'
+
+import { botRosterKey } from './data'
+import { $groupChats } from './group-chat'
+
+/** All presence consumers share the exact owner captured by the room driver. */
+export const $activeGroupMemberKeys = computed(
+ $groupChats,
+ rooms =>
+ new Set(
+ Object.values(rooms).flatMap(room =>
+ room.running && !room.tombstone && room.turn ? [botRosterKey(room.turn)] : []
+ )
+ )
+)
diff --git a/apps/desktop/src/plugins/hermes-bots/group-round-members.ts b/apps/desktop/src/plugins/hermes-bots/group-round-members.ts
index 65838ada67..ad62d8ec05 100644
--- a/apps/desktop/src/plugins/hermes-bots/group-round-members.ts
+++ b/apps/desktop/src/plugins/hermes-bots/group-round-members.ts
@@ -14,7 +14,7 @@ import type { GroupChatRoom } from './group-chat'
import { groupMemberKey } from './group-membership'
import { buildGroupChatTurnPrompt, formatGroupChatLine } from './group-round-prompt'
import { isGroupPassText, runGroupChatMemberTurn } from './group-turns'
-import type { GroupMember, GroupMessage } from './types'
+import type { Attachment, GroupMember, GroupMessage } from './types'
export interface GroupRoundMemberContext {
group: string
@@ -105,6 +105,25 @@ function prepareGroupRoundMember(context: GroupRoundMemberContext, member: Group
return { room, memberKey, markKey, prompt, deltaImages }
}
+/** Each invocation owns its descriptor, so an old completion cannot clear a newer turn. */
+async function runVisibleMemberTurn(
+ context: GroupRoundMemberContext,
+ member: GroupMember,
+ prompt: string,
+ images?: Attachment[]
+) {
+ const turn = { ...member }
+ updateGroupChat(context.group, (room: GroupChatRoom) => ({ ...room, turn }), { sync: false })
+
+ try {
+ return await runGroupChatMemberTurn(context.group, member, prompt, context.thread, images)
+ } finally {
+ if (context.binding.isLive() && $groupChats.get()[context.group]?.turn === turn) {
+ updateGroupChat(context.group, (room: GroupChatRoom) => ({ ...room, turn: null }), { sync: false })
+ }
+ }
+}
+
export async function runGroupRoundMember(
context: GroupRoundMemberContext,
member: GroupMember
@@ -117,18 +136,10 @@ export async function runGroupRoundMember(
}
const { room, markKey, prompt, deltaImages } = prepared
- // 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(context.group, (r: GroupChatRoom) => {
- r.turn = member.name
-
- return r
- })
let reply: null | string = null
try {
- reply = await runGroupChatMemberTurn(context.group, member, prompt, thread, deltaImages)
+ reply = await runVisibleMemberTurn(context, member, prompt, deltaImages)
// Needs-attention hook (#93091 item 3): a turn that produced a real
// reply (or an explicit pass) is a good turn — clear the badge.
@@ -271,15 +282,10 @@ async function runGroupContinuationMember(
deltaLines: delta.slice(-GROUP_CHAT_HISTORY_LIMIT).map((e: GroupMessage) => formatGroupChatLine(e, member.name))
})
- updateGroupChat(context.group, (r: GroupChatRoom) => {
- r.turn = member.name
-
- return r
- })
let continuationReply: null | string = null
try {
- continuationReply = await runGroupChatMemberTurn(context.group, member, prompt, thread)
+ continuationReply = await runVisibleMemberTurn(context, member, prompt)
if (continuationReply !== null) {
clearBotAttention(memberKey)
diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts
index eb99b09e32..92788f544f 100644
--- a/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts
+++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts
@@ -152,6 +152,59 @@ describe('routing', () => {
})
describe('round lifecycle', () => {
+ it('clears each exact member turn on success, failure and supersession without clearing a newer turn', async () => {
+ for (const outcome of ['success', 'failure', 'superseded', 'newer-turn']) {
+ let finish!: () => void
+
+ const gate = new Promise(resolve => {
+ finish = resolve
+ })
+
+ const room = await loadRoom({
+ turn: async () => {
+ await gate
+
+ if (outcome === 'failure') {throw new Error('member failed')}
+
+ return '(pass)'
+ }
+ })
+
+ const { runGroupRoundMember } = await import('./group-round-members')
+ const presence = await import('./group-presence')
+ const member: GroupMember = { name: 'default', connectionId: 'remote', remoteSource: true, sourceScoped: true }
+ const newer = { ...member }
+ room.chat.appendGroupChatEntry('Room', { kind: 'user', name: 'You' }, 'hello', 't1')
+ room.chat.updateGroupChat('Room', state => ({ ...state, running: true, epoch: 1 }))
+
+ const context = {
+ group: 'Room',
+ members: [member],
+ thread: 't1',
+ startEpoch: 1,
+ binding: { isLive: () => true },
+ isCurrent: () => room.chat.$groupChats.get().Room.epoch === 1
+ }
+
+ const pending = runGroupRoundMember(context, member)
+ await drain(() => room.gateway.calls.length === 0)
+ expect(room.chat.$groupChats.get().Room.turn).toEqual(member)
+ expect([...presence.$activeGroupMemberKeys.get()]).toEqual(['remote::default'])
+
+ if (outcome === 'superseded' || outcome === 'newer-turn') {
+ room.chat.updateGroupChat('Room', state => ({
+ ...state,
+ epoch: 2,
+ ...(outcome === 'newer-turn' ? { turn: newer } : {})
+ }))
+ }
+
+ finish()
+ await pending
+ expect(room.chat.$groupChats.get().Room.turn).toBe(outcome === 'newer-turn' ? newer : null)
+ expect([...presence.$activeGroupMemberKeys.get()]).toEqual(outcome === 'newer-turn' ? ['remote::default'] : [])
+ }
+ })
it('settles when everyone passes, logging only the user message', async () => {
const room = await loadRoom()
@@ -730,7 +783,7 @@ describe('stopGroupThread (#91868/#94569)', () => {
members: STOP_MEMBERS,
running: true,
sessions: { alpha: 'live-alpha-sid' },
- turn,
+ turn: turn ? STOP_MEMBERS.find(member => member.name === turn) : null,
watermarks: {}
}
} as unknown as Record)
@@ -754,6 +807,27 @@ describe('stopGroupThread (#91868/#94569)', () => {
}
})
+ it('interrupts the exact on-turn owner even when same-name members are reordered', async () => {
+ const room = await loadRoom()
+ const local: GroupMember = { name: 'default', connectionId: 'local', sourceScoped: true }
+ const remote: GroupMember = { name: 'default', connectionId: 'remote', remoteSource: true, sourceScoped: true }
+ room.chat.$groupChats.set({
+ Room: {
+ epoch: 3,
+ running: true,
+ log: [],
+ watermarks: {},
+ members: [local, remote],
+ turn: remote,
+ sessions: { 'local::default': 'local-session', 'remote::default': 'remote-session' }
+ }
+ })
+ await room.rounds.stopGroupThread('Room', 't1', [remote, local])
+ expect(room.gateway.rpcFor('session.interrupt').map(call => call.params.session_id)).toEqual(['remote-session'])
+ expect(room.chat.$groupChats.get().Room.turn).toBeNull()
+ expect(room.chat.$groupChats.get().Room.running).toBe(false)
+ })
+
it('interrupts the member ON TURN via its live session', async () => {
const room = await loadRoom()
seedRoom(room)
diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts
index 759dc35739..d49d44426d 100644
--- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts
+++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts
@@ -353,7 +353,7 @@ export function unaddressedGroupMentions(group: string, members: GroupMember[],
export async function stopGroupThread(group: string, thread: null | string, members: GroupMember[] | null = null) {
const room = $groupChats.get()[group] || {}
const roster = Array.isArray(members) && members.length ? members : room.members || []
- const turnName = room.turn || null
+ const onTurn = room.turn || null
const stamp: GroupHoldStamp = {
at: Date.now(),
@@ -397,9 +397,7 @@ export async function stopGroupThread(group: string, thread: null | string, memb
thread: thread || null
})
- // Interrupt the member actually mid-turn. room.turn is runtime-only and
- // names exactly one member (the loop is serial); a settled room has none.
- const onTurn = turnName ? roster.find((member: GroupMember) => member?.name === turnName) : null
+ // The captured descriptor owns routing even if the roster has changed.
const sessionId = onTurn ? (room.sessions || {})[groupMemberKey(onTurn)] : null
if (onTurn && sessionId) {
diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx b/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx
index e52f51ee4c..ed891f6d8c 100644
--- a/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx
+++ b/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx
@@ -36,6 +36,7 @@ import { $groupChats, $groupChatWorkspace, $groupClarify, $groupNeedsYou } from
import { GroupChatWorkspace, openGroupChat } from './group-chat-view'
import { groupChatMemberBots } from './group-membership'
import { $groupMainTabsRev, shouldRenderGroupChatInPane } from './group-panes'
+import { $activeGroupMemberKeys } from './group-presence'
import { $showHiddenBots, isBotHidden } from './hidden-bots'
import { useBots } from './i18n'
import { $activityToasts } from './roster-actions'
@@ -297,8 +298,10 @@ export function BotsPane() {
// neutral loading state instead of flashing the first-run "No bots" copy.
const initialRosterLoading = !data && !error && roster.length === 0
+ const groupKeys = useValue($activeGroupMemberKeys)
+
const activeRosterKeys = new Set(
- activeBots(roster, workingOwner, turnBusy, Date.now(), activeConnectionId).map(botRosterKey)
+ activeBots(roster, workingOwner, turnBusy, Date.now(), activeConnectionId, groupKeys).map(botRosterKey)
)
const gatewayOptions = rosterGatewayOptions(sourceSnapshot, roster)
diff --git a/apps/desktop/src/plugins/hermes-bots/row-helpers.test.ts b/apps/desktop/src/plugins/hermes-bots/row-helpers.test.ts
index bf1a3a746a..009b5ed245 100644
--- a/apps/desktop/src/plugins/hermes-bots/row-helpers.test.ts
+++ b/apps/desktop/src/plugins/hermes-bots/row-helpers.test.ts
@@ -19,6 +19,7 @@ import {
activeBots,
botCanonicalSessionId,
botRowOwnsWorkspace,
+ botWorkingMood,
previewKind,
rosterActivityMatches,
workerActiveAt
@@ -97,6 +98,18 @@ describe('which bots are working right now', () => {
row({ name: 'analyst' })
]
+ it('shares source-exact group presence between row mood and the active filter', () => {
+ const local = row({ name: 'default', connectionId: 'local' })
+ const remote = row({ name: 'default', connectionId: 'remote', remoteSource: true })
+ const groupKeys = new Set(['remote::default'])
+ expect(activeBots([local, remote], null, false, NOW, 'local', groupKeys)).toEqual([remote])
+ expect(botWorkingMood(remote, null, false, 'local', NOW, groupKeys)).toBe('think')
+ expect(botWorkingMood(local, null, false, 'local', NOW, groupKeys)).toBe('idle')
+ groupKeys.clear()
+ expect(activeBots([local, remote], null, false, NOW, 'local', groupKeys)).toEqual([])
+ expect(botWorkingMood(remote, null, false, 'local', NOW, groupKeys)).toBe('idle')
+ })
+
it('counts the focused live turn only for its connection-qualified owner', () => {
const local = row({ name: 'analyst', connectionId: 'local' })
const remote = row({ name: 'analyst', connectionId: 'remote', remoteSource: true })
diff --git a/apps/desktop/src/plugins/hermes-bots/row-helpers.ts b/apps/desktop/src/plugins/hermes-bots/row-helpers.ts
index 09d7f5bc56..ec8bd2b691 100644
--- a/apps/desktop/src/plugins/hermes-bots/row-helpers.ts
+++ b/apps/desktop/src/plugins/hermes-bots/row-helpers.ts
@@ -104,8 +104,13 @@ export function botWorkingMood(
owner: WorkingOwner | null,
turnBusy: boolean,
activeConnectionId = 'local',
- now = Date.now()
+ now = Date.now(),
+ groupKeys?: ReadonlySet
): 'idle' | 'think' | 'work' {
+ if (groupKeys?.has(botRosterKey(bot))) {
+ return 'think'
+ }
+
const botConnectionId = bot.connectionId || (bot.remoteSource ? '' : activeConnectionId)
if (
@@ -127,10 +132,11 @@ export function activeBots(
owner: WorkingOwner | null,
turnBusy: boolean,
now = Date.now(),
- activeConnectionId = 'local'
+ activeConnectionId = 'local',
+ groupKeys?: ReadonlySet
): RosterRow[] {
return (roster || []).filter(bot => {
- const busyTurn = botWorkingMood(bot, owner, turnBusy, activeConnectionId, now) !== 'idle'
+ const busyTurn = botWorkingMood(bot, owner, turnBusy, activeConnectionId, now, groupKeys) !== 'idle'
const last = botActivitySession(bot)?.last_active || 0
const inWindow = Boolean(last && now / 1000 - last < ACTIVE_WINDOW_S)