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)