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:
brooklyn!
2026-09-11 05:04:27 -07:00
parent 6fa8518b44
commit 8429a54bac
11 changed files with 171 additions and 31 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}

View File

@@ -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

View 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)] : []
)
)
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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 })

View File

@@ -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)