fix(desktop): track the exact group member through presence and stop
Keep a runtime turn descriptor, feed its roster key into row mood and activity filtering, and release only the completing invocation’s presence. Stop uses the captured owner rather than a name lookup. Co-authored-by: Tuna Dev <tuancookiez@gmail.com>
This commit is contained in:
@@ -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 => (
|
||||
<BotRow bot={bot} key={bot.connectionId} onDelete={noop} onEdit={noop} onGroup={noop} onNewSection={noop} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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<string, GroupHoldStamp>
|
||||
turn?: null | string
|
||||
turn?: GroupMember | null
|
||||
}
|
||||
|
||||
/** Set or clear a group chat's room picture (small data URL, normalized by
|
||||
|
||||
15
apps/desktop/src/plugins/hermes-bots/group-presence.ts
Normal file
15
apps/desktop/src/plugins/hermes-bots/group-presence.ts
Normal file
@@ -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)] : []
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void>(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<string, GroupChat>)
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -104,8 +104,13 @@ export function botWorkingMood(
|
||||
owner: WorkingOwner | null,
|
||||
turnBusy: boolean,
|
||||
activeConnectionId = 'local',
|
||||
now = Date.now()
|
||||
now = Date.now(),
|
||||
groupKeys?: ReadonlySet<string>
|
||||
): '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<string>
|
||||
): 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user