Merge pull request #105960 from NousResearch/fix/group-attention-salvage

fix(bot-mode): clear resolved Group Chat attention and bind prompts across rename
This commit is contained in:
Teknium
2026-09-08 14:07:46 -07:00
committed by GitHub
19 changed files with 2631 additions and 1481 deletions
@@ -43,8 +43,8 @@ vi.mock('@hermes/plugin-sdk', async importOriginal => {
const { botBackendProfileScope, botConnectionRoute, requestForBot } = await import('./routing')
const { groupMemberKey } = await import('./group-membership')
const { buildGroupChatTurnPrompt, formatGroupChatLine, parseGroupChatMentions, resolveGroupResponders } =
await import('./group-rounds')
const { buildGroupChatTurnPrompt, formatGroupChatLine } = await import('./group-round-prompt')
const { parseGroupChatMentions, resolveGroupResponders } = await import('./group-rounds')
const aliasBot = {
name: 'worker',
@@ -94,7 +94,7 @@ import {
} from './group-panes'
import type { GroupComposerDraft, GroupDraftSetter } from './group-panes'
import { sendToGroupChat, stopGroupThread } from './group-rounds'
import { clearGroupClarify } from './group-turns'
import { clearGroupClarify, renameGroupClarify } from './group-turns'
import { botsText, useBots } from './i18n'
import { displayName, slugify } from './labels'
import { botRosterMeta, setBotsWorkspaceOwner } from './routing'
@@ -238,7 +238,7 @@ export async function disbandGroupChat(group: string, members: RosterRow[]) {
* rename, so even a member whose sid is later lost falls back to the same
* "Group: <roomId>" title lookup instead of a fresh "Group: <new name>".
* Returns the new name, or null when the target name is taken. */
async function renameGroupChat(oldName: string, newName: string, members: GroupMember[] | null | undefined) {
export async function renameGroupChat(oldName: string, newName: string, members: GroupMember[] | null | undefined) {
const next = String(newName || '')
.trim()
.slice(0, 64)
@@ -299,9 +299,9 @@ async function renameGroupChat(oldName: string, newName: string, members: GroupM
$groupNeedsYou.set(needs)
}
// Mirrored clarify cards key by group name; drop the old room's — the
// next poll re-mirrors any still-blocking question under the new name.
clearGroupClarify(oldName)
// Mirrored clarify cards key by group name; a pending prompt's attention
// must follow the room to its new name, not disappear.
renameGroupClarify(oldName, next)
// Local memberships: swap the name inside each member's canonical groups
// list (syncs cross-machine via ui_meta). Remote members' seating lives in
@@ -102,8 +102,9 @@ describe('room naming', () => {
describe('speaker labels', () => {
it('the default profile speaks as Hermes in transcripts, not @default', async () => {
const { rounds } = await loadRoom()
const { formatGroupChatLine } = await import('./group-round-prompt')
const line = rounds.formatGroupChatLine(
const line = formatGroupChatLine(
{ from: { kind: 'member', name: 'default' }, text: 'hello room' } as GroupMessage,
'builder'
)
@@ -112,15 +113,16 @@ describe('speaker labels', () => {
// Other members keep their profile name; the (you) suffix survives.
expect(
rounds.formatGroupChatLine({ from: { kind: 'member', name: 'default' }, text: 'hi' } as GroupMessage, 'default')
formatGroupChatLine({ from: { kind: 'member', name: 'default' }, text: 'hi' } as GroupMessage, 'default')
).toBe('Hermes (you): hi')
expect(
rounds.formatGroupChatLine({ from: { kind: 'member', name: 'builder' }, text: 'yo' } as GroupMessage, 'research')
formatGroupChatLine({ from: { kind: 'member', name: 'builder' }, text: 'yo' } as GroupMessage, 'research')
).toBe('builder: yo')
})
it('honor friendly identity: Bot Mode title, then display_name, never a stale Hermes', async () => {
const { chat, rounds } = await loadRoom()
const { formatGroupChatLine } = await import('./group-round-prompt')
const data = await import('./data')
// A renamed default (core display_name via `hermes profile rename`) must
@@ -130,7 +132,7 @@ describe('speaker labels', () => {
expect(chat.groupSpeakerLabel('default')).toBe('Lucy')
expect(
rounds.formatGroupChatLine({ from: { kind: 'member', name: 'default' }, text: 'hi' } as GroupMessage, 'builder')
formatGroupChatLine({ from: { kind: 'member', name: 'default' }, text: 'hi' } as GroupMessage, 'builder')
).toBe('Lucy: hi')
// A Bot Mode title outranks display_name (same precedence as displayName).
@@ -9,6 +9,42 @@ import { $groupChats, groupChatRoomKey } from './group-chat'
import { botConnectionRoute, botRosterMeta, resolveBotConnectionRoute } from './routing'
import type { BotMeta, GroupChat, GroupMember, RosterRow } from './types'
/** Follow the authoritative room record for one async operation. Rename moves
* the record wholesale (including legacy rooms without a roomId); disband
* retires this binding permanently, even if the same display name is reused. */
export function followGroupChat(group: string, onRename: (name: string) => void) {
let live = !$groupChats.get()[group]?.tombstone
const dispose = $groupChats.listen((rooms, previous) => {
const prior = previous?.[group]
if (!live || !prior) {
return
}
const current = rooms[group]
if (current && !current.tombstone && current.roomId === prior.roomId) {
return
}
const moved = Object.entries(rooms).find(([, room]) =>
!room.tombstone && (prior.roomId ? room.roomId === prior.roomId : room === prior)
)
if (!moved) {
live = false
return
}
group = moved[0]
onRename(group)
})
return { dispose, isLive: () => live }
}
export function groupWorkspaceOwnerKey(group: string) {
return `group:${groupChatRoomKey(group, $groupChats.get()[group])}`
}
@@ -0,0 +1,377 @@
import { clearBotAttention, noteBotAttention } from './data'
import { recordGroupActivity } from './group-activity'
import {
$groupChats,
appendGroupChatEntry,
GROUP_CHAT_HISTORY_LIMIT,
GROUP_CHAT_MAX_CONTINUATIONS,
GROUP_CHAT_MAX_MESSAGES,
groupThreadOf,
shouldCommitMemberTurn,
updateGroupChat
} from './group-chat'
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'
export interface GroupRoundMemberContext {
group: string
members: GroupMember[]
thread: string
startEpoch: number
binding: { isLive(): boolean }
isCurrent(): boolean
}
/** #93129: a held member's skip must consume its delta exactly once —
* advance the watermark past the current log so the same entries never
* re-trigger the skip. Null = nothing to consume (no write, no spin). */
export function heldMemberWatermarkAdvance(seen: number | undefined, logLength: number): null | number {
return logLength > (seen || 0) ? logLength : null
}
function prepareGroupRoundMember(context: GroupRoundMemberContext, member: GroupMember) {
const { members, thread } = context
const room = $groupChats.get()[context.group] || {
log: [],
watermarks: {}
}
const memberKey = groupMemberKey(member)
const markKey = `${thread}::${memberKey}`
const seen = room.watermarks[markKey] || 0
// Delta: NEW room entries, narrowed to this thread — the member's
// turn sees only the conversation it's part of.
const delta = room.log.slice(seen).filter((e: GroupMessage) => groupThreadOf(e) === thread)
if (!delta.length) {
return null
}
// #93129: a member the user told to stop is HELD — no turn until an
// explicit release (resume / @all resume / a direct non-stop
// mention). Consume the delta exactly once (watermark past the
// current log) so the same entries never re-trigger this skip, and
// surface WHY the bot is silent in the activity feed the first time.
const heldEntry = (room.holds || {})[memberKey]
if (heldEntry) {
const advance = heldMemberWatermarkAdvance(seen, room.log.length)
updateGroupChat(context.group, (r: GroupChatRoom) => {
if (advance !== null) {
r.watermarks[markKey] = advance
}
if (r.holds?.[memberKey] && !r.holds[memberKey].noted) {
r.holds = {
...r.holds,
[memberKey]: {
...r.holds[memberKey],
noted: true
}
}
}
return r
})
if (!heldEntry.noted) {
recordGroupActivity(context.group, {
kind: 'held',
member: member.name,
thread
})
}
return null
}
const prompt = buildGroupChatTurnPrompt({
groupName: context.group,
members,
viewer: member,
deltaLines: delta.slice(-GROUP_CHAT_HISTORY_LIMIT).map((e: GroupMessage) => formatGroupChatLine(e, member.name))
})
// Images riding this delta (user attachments — member entries don't
// carry images today, but flatMap keeps this future-proof) get staged
// into the member's session so the model sees the pixels, not just
// the transcript's [attached image: …] marker.
const deltaImages = delta.flatMap((e: GroupMessage) => (Array.isArray(e.images) ? e.images : []))
return { room, memberKey, markKey, prompt, deltaImages }
}
export async function runGroupRoundMember(
context: GroupRoundMemberContext,
member: GroupMember
): Promise<boolean | null> {
const { thread, startEpoch, binding } = context
const prepared = prepareGroupRoundMember(context, member)
if (!prepared) {
return false
}
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)
// Needs-attention hook (#93091 item 3): a turn that produced a real
// reply (or an explicit pass) is a good turn — clear the badge.
// A timed-out turn also returns null but never threw; leaving any
// prior badge in place there is the conservative choice.
if (reply !== null) {
clearBotAttention(groupMemberKey(member))
}
} catch (error: any) {
if (!binding.isLive()) {
return null
}
const reason = String(error?.data?.reason || '').trim()
recordGroupActivity(context.group, {
kind: 'failed',
member: member.name,
thread,
...(reason
? {
reason
}
: {})
})
noteBotAttention(groupMemberKey(member), reason || error?.message || error)
reply = null // a failed turn is a pass, never a room error
}
// #93127: the turn may have finished AFTER a newer user send bumped
// the room epoch. That newer send's loop re-drives this member with
// the full delta, so committing this stale result (watermark advance
// + append) would double-deliver the same reply. Drop it here —
// BEFORE the watermark advance and BEFORE the append. Only a newer
// USER entry in THIS thread makes the re-drive premise true: a
// cross-thread send bumps the epoch too, but its loop filters this
// thread out and would never regenerate the finished reply. The
// during-turn tail is anchored by entry id, not index — the history
// trim drops entries from the FRONT, so an index slice could
// overshoot after a mid-turn trim and silently commit a stale turn.
if (!binding.isLive()) {
return null
}
const roomNow = $groupChats.get()[context.group] || {
log: []
}
const epochNow = roomNow.epoch || 0
const anchorId = room.log.length ? room.log[room.log.length - 1].id : null
const anchorIdx = anchorId === null ? -1 : roomNow.log.findIndex((e: GroupMessage) => e.id === anchorId)
// Anchor trimmed away ⇒ every pre-turn entry was dropped, so every
// surviving entry is newer — scanning the whole log stays exact.
const turnTail = anchorIdx >= 0 ? roomNow.log.slice(anchorIdx + 1) : roomNow.log
const newerUserEntryInThread = turnTail.some(
(e: GroupMessage) => e.from?.kind === 'user' && groupThreadOf(e) === thread
)
if (!shouldCommitMemberTurn(startEpoch, epochNow, newerUserEntryInThread)) {
recordGroupActivity(context.group, {
kind: 'cancelled',
member: member.name,
thread
})
return null
}
// The member has now seen everything up to the pre-reply log length.
updateGroupChat(context.group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
if (reply !== null && !isGroupPassText(reply)) {
appendGroupChatEntry(
context.group,
{
kind: 'member',
name: member.name,
...(member.remoteSource
? {
source: member.connectionLabel || member.connectionId
}
: {})
},
reply,
thread
)
// Its own message counts as seen too.
updateGroupChat(context.group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
return true
}
return false
}
async function runGroupContinuationMember(
context: GroupRoundMemberContext,
member: GroupMember
): Promise<boolean | null> {
const { members, thread, binding, isCurrent } = context
const room = $groupChats.get()[context.group] || {
log: [],
watermarks: {}
}
const memberKey = groupMemberKey(member)
const markKey = `${thread}::${memberKey}`
const seen = room.watermarks[markKey] || 0
const delta = room.log.slice(seen).filter((e: GroupMessage) => groupThreadOf(e) === thread)
// A cited member always has delta here (the citing reply IS in
// its tail); skip defensively anyway so an empty prompt never
// fires.
if (!delta.length) {
return false
}
const heldEntry = (room.holds || {})[memberKey]
if (heldEntry) {
return false // holds still apply to continuation turns (#93129)
}
const prompt = buildGroupChatTurnPrompt({
groupName: context.group,
members,
viewer: member,
// The continuation prompt centers on what the member missed:
// everything since its watermark, which includes the reply
// that cites it.
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)
if (continuationReply !== null) {
clearBotAttention(memberKey)
}
} catch (error: any) {
if (!binding.isLive()) {
return null
}
recordGroupActivity(context.group, {
kind: 'failed',
member: member.name,
thread
})
noteBotAttention(memberKey, error?.message || error)
continuationReply = null
}
if (!isCurrent()) {
return null
}
updateGroupChat(context.group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
if (continuationReply !== null && !isGroupPassText(continuationReply)) {
appendGroupChatEntry(
context.group,
{
kind: 'member',
name: member.name,
...(member.remoteSource
? {
source: member.connectionLabel || member.connectionId
}
: {})
},
continuationReply,
thread
)
updateGroupChat(context.group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
return true
}
return false
}
export async function runGroupContinuationMembers(
context: GroupRoundMemberContext,
pendingKeys: string[],
continuations: number,
posted: number
): Promise<number | null> {
const { members, isCurrent } = context
let spokeThisRound = 0
if (pendingKeys.length && continuations <= GROUP_CHAT_MAX_CONTINUATIONS) {
const citedMembers = members.filter((member: GroupMember) => pendingKeys.includes(groupMemberKey(member)))
if (citedMembers.length && posted < GROUP_CHAT_MAX_MESSAGES) {
const strandedNow = ($groupChats.get()[context.group] || {}).stranded || {}
const continuationResponders = citedMembers.filter(
(member: GroupMember) => !Object.prototype.hasOwnProperty.call(strandedNow, groupMemberKey(member))
)
for (const member of continuationResponders) {
if (!isCurrent() || posted >= GROUP_CHAT_MAX_MESSAGES || continuations > GROUP_CHAT_MAX_CONTINUATIONS) {
break
}
const result = await runGroupContinuationMember(context, member)
if (result === null) {
return null
}
if (result) {
posted += 1
spokeThisRound += 1
}
}
}
}
return spokeThisRound
}
@@ -0,0 +1,68 @@
import { botHandle } from './data'
import { groupSpeakerLabel } from './group-chat'
import { groupMemberKey } from './group-membership'
import type { GroupMember, GroupMessage } from './types'
/** Room-log line as a member sees it: `Name (user): …` / `Name: …` /
* `Name (you): …`. */
export function formatGroupChatLine(entry: GroupMessage, viewerName: string) {
// Attachments are staged into each member's session as real payloads; the
// transcript line names them so the delta text and the bytes line up.
const attached =
Array.isArray(entry.images) && entry.images.length
? ` ${entry.images
.map(img => {
const label = img.kind === 'pdf' ? 'attached PDF' : img.kind === 'file' ? 'attached file' : 'attached image'
return `[${label}: ${img.name || 'image'}]`
})
.join(' ')}`
: ''
if (entry.from.kind === 'user') {
return `${entry.from.name || 'User'} (user): ${entry.text}${attached}`
}
const suffix = entry.from.name === viewerName ? ' (you)' : ''
// Cross-connection speakers carry their device so same-named agents on
// two machines stay tellable apart in every member's transcript.
const source = entry.from.source ? ` [${entry.from.source}]` : ''
return `${groupSpeakerLabel(entry.from.name)}${suffix}${source}: ${entry.text}${attached}`
}
interface GroupChatTurnPromptInput {
deltaLines: string[]
groupName: string
members: GroupMember[]
viewer: GroupMember
}
/** The full per-turn payload for one member: participation rules + the room
* delta. Rules travel in the turn payload (not SOUL) so every existing bot
* can join a group chat without a profile migration. */
export function buildGroupChatTurnPrompt({ groupName, members, viewer, deltaLines }: GroupChatTurnPromptInput) {
const viewerKey = groupMemberKey(viewer)
const peers = members.filter(m => groupMemberKey(m) !== viewerKey)
const peerNames = peers
.map(m => {
const handle = m.title ? `${m.title} (@${botHandle(m.name, m)})` : `@${botHandle(m.name, m)}`
return m.remoteSource ? `${handle} [on ${m.connectionLabel || m.connectionId}]` : handle
})
.join(', ')
return [
`[Group chat: "${groupName}"] You are @${botHandle(viewer.name, viewer)}, one participant in a group chat with ${peerNames || 'no one else yet'} and the user.`,
'',
'New messages in the room since your last turn (oldest first):',
...deltaLines.map(line => ` ${line}`),
'',
'Rules for this room:',
'- Reply with ONE conversational message ONLY if you have something new worth adding: build on what was just said, claim or hand off work, answer a question aimed at you, or report a real result. Keep chatter short (1-3 sentences) — but when you are delivering a result, an answer the user asked for, or substantive work, give it at full quality and length; never thin out real content to fit the room.',
'- If you have nothing new to add, reply with exactly "(pass)". Passing is good — it lets the conversation settle.',
'- Mention a teammate as @name to pull them in; mention @user only for a judgment call or a result the user needs. Do not repeat points already made.',
'- Never reveal content from your private 1:1 chats. Your reply text goes to the room verbatim — no preamble, no meta-commentary.'
].join('\n')
}
@@ -349,13 +349,14 @@ describe('threads', () => {
describe('turn prompt', () => {
it('addresses the default profile as @hermes', async () => {
const { rounds } = await loadRoom()
const { buildGroupChatTurnPrompt } = await import('./group-round-prompt')
const members: GroupMember[] = [
{ name: 'default', title: '' },
{ name: 'builder', title: '' }
]
const own = rounds.buildGroupChatTurnPrompt({
const own = buildGroupChatTurnPrompt({
deltaLines: [],
groupName: 'Core',
members,
@@ -365,7 +366,7 @@ describe('turn prompt', () => {
expect(own).toMatch(/You are @hermes,/)
expect(own).not.toMatch(/@default\b/)
const peer = rounds.buildGroupChatTurnPrompt({
const peer = buildGroupChatTurnPrompt({
deltaLines: [],
groupName: 'Core',
members,
@@ -377,8 +378,9 @@ describe('turn prompt', () => {
it('asks for full-quality results and short chatter, not short results', async () => {
const { rounds } = await loadRoom()
const { buildGroupChatTurnPrompt } = await import('./group-round-prompt')
const prompt = rounds.buildGroupChatTurnPrompt({
const prompt = buildGroupChatTurnPrompt({
deltaLines: [],
groupName: 'Core',
members: [
@@ -560,9 +562,10 @@ describe('attachments', () => {
it('names attachments in the transcript line, labelling PDFs and files distinctly', async () => {
const { rounds } = await loadRoom()
const { formatGroupChatLine } = await import('./group-round-prompt')
const pdf: Attachment = { data: 'data:application/pdf;base64,JVBERi0=', kind: 'pdf', name: 'spec.pdf' }
const doc: Attachment = { data: 'data:text/plain;base64,aGVsbG8=', kind: 'file', name: 'notes.txt' }
const line = (entry: Partial<GroupMessage>) => rounds.formatGroupChatLine(entry as GroupMessage, 'research')
const line = (entry: Partial<GroupMessage>) => formatGroupChatLine(entry as GroupMessage, 'research')
expect(line({ from: { kind: 'user', name: 'You' }, images: [IMG], text: 'see attached' })).toBe(
'You (user): see attached [attached image: screenshot.png]'
@@ -692,14 +695,15 @@ describe('member holds (#93129)', () => {
it('consumes a held skip exactly once so the loop cannot spin', async () => {
const { rounds } = await loadRoom()
const { heldMemberWatermarkAdvance } = await import('./group-round-members')
// Fresh delta → advance to log length.
expect(rounds.heldMemberWatermarkAdvance(3, 7)).toBe(7)
expect(heldMemberWatermarkAdvance(3, 7)).toBe(7)
// Already consumed → no write, no spin.
expect(rounds.heldMemberWatermarkAdvance(7, 7)).toBeNull()
expect(rounds.heldMemberWatermarkAdvance(9, 7)).toBeNull()
expect(heldMemberWatermarkAdvance(7, 7)).toBeNull()
expect(heldMemberWatermarkAdvance(9, 7)).toBeNull()
// Unset watermark treated as 0.
expect(rounds.heldMemberWatermarkAdvance(undefined, 2)).toBe(2)
expect(heldMemberWatermarkAdvance(undefined, 2)).toBe(2)
})
})
@@ -1,28 +1,25 @@
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 { botFriendlyNames, botHandle, clearBotAttention, mentionNameForms, noteBotAttention } from './data'
import { recordGroupActivity } from './group-activity'
import {
$groupChats,
$groupNeedsYou,
appendGroupChatEntry,
GROUP_CHAT_HISTORY_LIMIT,
GROUP_CHAT_MAX_CONTINUATIONS,
GROUP_CHAT_MAX_MESSAGES,
GROUP_CHAT_MAX_ROUNDS,
groupSpeakerLabel,
groupThreadOf,
mintGroupThreadId,
shouldCommitMemberTurn,
updateGroupChat
} from './group-chat'
import type { GroupChatRoom, GroupHoldStamp } from './group-chat'
import { durableGroupChatMembers, groupMemberKey } from './group-membership'
import { harvestStrandedGroupReply, isGroupPassText, runGroupChatMemberTurn } from './group-turns'
import { durableGroupChatMembers, followGroupChat, groupMemberKey } from './group-membership'
import { runGroupContinuationMembers, runGroupRoundMember } from './group-round-members'
import { harvestStrandedGroupReply } from './group-turns'
import { requestForBot } from './routing'
import type { Attachment, GroupMember, GroupMessage } from './types'
@@ -154,70 +151,6 @@ export function rotateGroupSpeakers(members: GroupMember[], round: number) {
return [...members.slice(shift), ...members.slice(0, shift)]
}
/** Room-log line as a member sees it: `Name (user): …` / `Name: …` /
* `Name (you): …`. */
export function formatGroupChatLine(entry: GroupMessage, viewerName: string) {
// Attachments are staged into each member's session as real payloads; the
// transcript line names them so the delta text and the bytes line up.
const attached =
Array.isArray(entry.images) && entry.images.length
? ` ${entry.images
.map(img => {
const label = img.kind === 'pdf' ? 'attached PDF' : img.kind === 'file' ? 'attached file' : 'attached image'
return `[${label}: ${img.name || 'image'}]`
})
.join(' ')}`
: ''
if (entry.from.kind === 'user') {
return `${entry.from.name || 'User'} (user): ${entry.text}${attached}`
}
const suffix = entry.from.name === viewerName ? ' (you)' : ''
// Cross-connection speakers carry their device so same-named agents on
// two machines stay tellable apart in every member's transcript.
const source = entry.from.source ? ` [${entry.from.source}]` : ''
return `${groupSpeakerLabel(entry.from.name)}${suffix}${source}: ${entry.text}${attached}`
}
interface GroupChatTurnPromptInput {
deltaLines: string[]
groupName: string
members: GroupMember[]
viewer: GroupMember
}
/** The full per-turn payload for one member: participation rules + the room
* delta. Rules travel in the turn payload (not SOUL) so every existing bot
* can join a group chat without a profile migration. */
export function buildGroupChatTurnPrompt({ groupName, members, viewer, deltaLines }: GroupChatTurnPromptInput) {
const viewerKey = groupMemberKey(viewer)
const peers = members.filter(m => groupMemberKey(m) !== viewerKey)
const peerNames = peers
.map(m => {
const handle = m.title ? `${m.title} (@${botHandle(m.name, m)})` : `@${botHandle(m.name, m)}`
return m.remoteSource ? `${handle} [on ${m.connectionLabel || m.connectionId}]` : handle
})
.join(', ')
return [
`[Group chat: "${groupName}"] You are @${botHandle(viewer.name, viewer)}, one participant in a group chat with ${peerNames || 'no one else yet'} and the user.`,
'',
'New messages in the room since your last turn (oldest first):',
...deltaLines.map(line => ` ${line}`),
'',
'Rules for this room:',
'- Reply with ONE conversational message ONLY if you have something new worth adding: build on what was just said, claim or hand off work, answer a question aimed at you, or report a real result. Keep chatter short (1-3 sentences) — but when you are delivering a result, an answer the user asked for, or substantive work, give it at full quality and length; never thin out real content to fit the room.',
'- If you have nothing new to add, reply with exactly "(pass)". Passing is good — it lets the conversation settle.',
'- Mention a teammate as @name to pull them in; mention @user only for a judgment call or a result the user needs. Do not repeat points already made.',
'- Never reveal content from your private 1:1 chats. Your reply text goes to the room verbatim — no preamble, no meta-commentary.'
].join('\n')
}
// --- member-hold helpers (#93129) — pure, unit-tested ---
/** #93129: classify a USER room message's effect on member holds. Only user
@@ -325,13 +258,6 @@ export function applyGroupHoldDirective(
return next
}
/** #93129: a held member's skip must consume its delta exactly once —
* advance the watermark past the current log so the same entries never
* re-trigger the skip. Null = nothing to consume (no write, no spin). */
export function heldMemberWatermarkAdvance(seen: number | undefined, logLength: number): null | number {
return logLength > (seen || 0) ? logLength : null
}
// --- end member-hold helpers ---
/** Members cited by @mention in a thread who have not posted any entry after
@@ -493,8 +419,24 @@ export async function stopGroupThread(group: string, thread: null | string, memb
* Watermarks are per thread+member (`${thread}::${memberKey}`), so parallel
* topics never eat each other's deltas. */
export async function runGroupChatRounds(group: string, members: GroupMember[], thread: string) {
const binding = followGroupChat(group, name => {
group = name
})
const startEpoch = ($groupChats.get()[group] || {}).epoch || 0
const isCurrent = () => (($groupChats.get()[group] || {}).epoch || 0) === startEpoch
const isCurrent = () => binding.isLive() && (($groupChats.get()[group] || {}).epoch || 0) === startEpoch
const context = {
get group() {
return group
},
members,
thread,
startEpoch,
binding,
isCurrent
}
let posted = 0
let continuations = 0
// #94478: how this drive ended. 'settled' means quiet consensus (everyone
@@ -519,6 +461,10 @@ export async function runGroupChatRounds(group: string, members: GroupMember[],
}
await harvestStrandedGroupReply(group, member)
if (!binding.isLive()) {
return
}
}
const roomLog = (($groupChats.get()[group] || {}).log || []).filter(
@@ -560,175 +506,13 @@ export async function runGroupChatRounds(group: string, members: GroupMember[],
return
}
const room = $groupChats.get()[group] || {
log: [],
watermarks: {}
}
const memberKey = groupMemberKey(member)
const markKey = `${thread}::${memberKey}`
const seen = room.watermarks[markKey] || 0
// Delta: NEW room entries, narrowed to this thread — the member's
// turn sees only the conversation it's part of.
const delta = room.log.slice(seen).filter((e: GroupMessage) => groupThreadOf(e) === thread)
if (!delta.length) {
continue
}
// #93129: a member the user told to stop is HELD — no turn until an
// explicit release (resume / @all resume / a direct non-stop
// mention). Consume the delta exactly once (watermark past the
// current log) so the same entries never re-trigger this skip, and
// surface WHY the bot is silent in the activity feed the first time.
const heldEntry = (room.holds || {})[memberKey]
if (heldEntry) {
const advance = heldMemberWatermarkAdvance(seen, room.log.length)
updateGroupChat(group, (r: GroupChatRoom) => {
if (advance !== null) {
r.watermarks[markKey] = advance
}
if (r.holds?.[memberKey] && !r.holds[memberKey].noted) {
r.holds = {
...r.holds,
[memberKey]: {
...r.holds[memberKey],
noted: true
}
}
}
return r
})
if (!heldEntry.noted) {
recordGroupActivity(group, {
kind: 'held',
member: member.name,
thread
})
}
continue
}
const prompt = buildGroupChatTurnPrompt({
groupName: group,
members,
viewer: member,
deltaLines: delta
.slice(-GROUP_CHAT_HISTORY_LIMIT)
.map((e: GroupMessage) => formatGroupChatLine(e, member.name))
})
// Images riding this delta (user attachments — member entries don't
// carry images today, but flatMap keeps this future-proof) get staged
// into the member's session so the model sees the pixels, not just
// the transcript's [attached image: …] marker.
const deltaImages = delta.flatMap((e: GroupMessage) => (Array.isArray(e.images) ? e.images : []))
// 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(group, (r: GroupChatRoom) => {
r.turn = member.name
return r
})
let reply: null | string = null
try {
reply = await runGroupChatMemberTurn(group, member, prompt, thread, 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.
// A timed-out turn also returns null but never threw; leaving any
// prior badge in place there is the conservative choice.
if (reply !== null) {
clearBotAttention(groupMemberKey(member))
}
} catch (error: any) {
const reason = String(error?.data?.reason || '').trim()
recordGroupActivity(group, {
kind: 'failed',
member: member.name,
thread,
...(reason
? {
reason
}
: {})
})
noteBotAttention(groupMemberKey(member), reason || error?.message || error)
reply = null // a failed turn is a pass, never a room error
}
// #93127: the turn may have finished AFTER a newer user send bumped
// the room epoch. That newer send's loop re-drives this member with
// the full delta, so committing this stale result (watermark advance
// + append) would double-deliver the same reply. Drop it here —
// BEFORE the watermark advance and BEFORE the append. Only a newer
// USER entry in THIS thread makes the re-drive premise true: a
// cross-thread send bumps the epoch too, but its loop filters this
// thread out and would never regenerate the finished reply. The
// during-turn tail is anchored by entry id, not index — the history
// trim drops entries from the FRONT, so an index slice could
// overshoot after a mid-turn trim and silently commit a stale turn.
const roomNow = $groupChats.get()[group] || {
log: []
}
const epochNow = roomNow.epoch || 0
const anchorId = room.log.length ? room.log[room.log.length - 1].id : null
const anchorIdx = anchorId === null ? -1 : roomNow.log.findIndex((e: GroupMessage) => e.id === anchorId)
// Anchor trimmed away ⇒ every pre-turn entry was dropped, so every
// surviving entry is newer — scanning the whole log stays exact.
const turnTail = anchorIdx >= 0 ? roomNow.log.slice(anchorIdx + 1) : roomNow.log
const newerUserEntryInThread = turnTail.some(
(e: GroupMessage) => e.from?.kind === 'user' && groupThreadOf(e) === thread
)
if (!shouldCommitMemberTurn(startEpoch, epochNow, newerUserEntryInThread)) {
recordGroupActivity(group, {
kind: 'cancelled',
member: member.name,
thread
})
const result = await runGroupRoundMember(context, member)
if (!binding.isLive() || result === null) {
return
}
// The member has now seen everything up to the pre-reply log length.
updateGroupChat(group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
if (reply !== null && !isGroupPassText(reply)) {
appendGroupChatEntry(
group,
{
kind: 'member',
name: member.name,
...(member.remoteSource
? {
source: member.connectionLabel || member.connectionId
}
: {})
},
reply,
thread
)
// Its own message counts as seen too.
updateGroupChat(group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
if (result) {
posted += 1
spokeThisRound += 1
}
@@ -749,122 +533,15 @@ export async function runGroupChatRounds(group: string, members: GroupMember[],
// room's entire budget on back-and-forth handoffs.
continuations += 1
if (pendingKeys.length && continuations <= GROUP_CHAT_MAX_CONTINUATIONS) {
const citedMembers = members.filter((member: GroupMember) => pendingKeys.includes(groupMemberKey(member)))
const continued = await runGroupContinuationMembers(context, pendingKeys, continuations, posted)
if (citedMembers.length && posted < GROUP_CHAT_MAX_MESSAGES) {
const strandedNow = ($groupChats.get()[group] || {}).stranded || {}
const continuationResponders = citedMembers.filter(
(member: GroupMember) => !Object.prototype.hasOwnProperty.call(strandedNow, groupMemberKey(member))
)
for (const member of continuationResponders) {
if (!isCurrent() || posted >= GROUP_CHAT_MAX_MESSAGES || continuations > GROUP_CHAT_MAX_CONTINUATIONS) {
break
}
const room = $groupChats.get()[group] || {
log: [],
watermarks: {}
}
const memberKey = groupMemberKey(member)
const markKey = `${thread}::${memberKey}`
const seen = room.watermarks[markKey] || 0
const delta = room.log.slice(seen).filter((e: GroupMessage) => groupThreadOf(e) === thread)
// A cited member always has delta here (the citing reply IS in
// its tail); skip defensively anyway so an empty prompt never
// fires.
if (!delta.length) {
continue
}
const heldEntry = (room.holds || {})[memberKey]
if (heldEntry) {
continue // holds still apply to continuation turns (#93129)
}
const prompt = buildGroupChatTurnPrompt({
groupName: group,
members,
viewer: member,
// The continuation prompt centers on what the member missed:
// everything since its watermark, which includes the reply
// that cites it.
deltaLines: delta
.slice(-GROUP_CHAT_HISTORY_LIMIT)
.map((e: GroupMessage) => formatGroupChatLine(e, member.name))
})
updateGroupChat(group, (r: GroupChatRoom) => {
r.turn = member.name
return r
})
let continuationReply: null | string = null
try {
continuationReply = await runGroupChatMemberTurn(group, member, prompt, thread)
if (continuationReply !== null) {
clearBotAttention(memberKey)
}
} catch (error: any) {
recordGroupActivity(group, {
kind: 'failed',
member: member.name,
thread
})
noteBotAttention(memberKey, error?.message || error)
continuationReply = null
}
if (!isCurrent()) {
return
}
updateGroupChat(group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
if (continuationReply !== null && !isGroupPassText(continuationReply)) {
appendGroupChatEntry(
group,
{
kind: 'member',
name: member.name,
...(member.remoteSource
? {
source: member.connectionLabel || member.connectionId
}
: {})
},
continuationReply,
thread
)
updateGroupChat(group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
posted += 1
// The continuation's own reply may cite someone else — fall
// through to the normal loop so the next round handles it via
// the same responder machinery. Reaching here means the loop
// continues rather than settling; the outer for-loop's next
// iteration re-evaluates everything.
spokeThisRound += 1
}
}
}
if (!binding.isLive() || continued === null) {
return
}
posted += continued
spokeThisRound += continued
if (spokeThisRound === 0) {
// Genuinely nothing left to say — including after the continuation
// attempt above produced no spoken turns. Settle honestly, but if
@@ -911,6 +588,8 @@ export async function runGroupChatRounds(group: string, members: GroupMember[],
void harvestStrandedUntilSettled(group, members, thread)
}
}
binding.dispose()
}
}
@@ -919,39 +598,55 @@ export async function runGroupChatRounds(group: string, members: GroupMember[],
* stranded, a new loop takes the room over (it harvests on its own), or the
* room record disappears (disband). */
async function harvestStrandedUntilSettled(group: string, members: GroupMember[], thread: string) {
const HARVEST_INTERVAL_MS = 5000
const HARVEST_MAX_TRIES = 60
const binding = followGroupChat(group, name => {
group = name
})
for (let attempt = 0; attempt < HARVEST_MAX_TRIES; attempt++) {
await new Promise(resolve => window.setTimeout(resolve, HARVEST_INTERVAL_MS))
const room = $groupChats.get()[group]
try {
const HARVEST_INTERVAL_MS = 5000
const HARVEST_MAX_TRIES = 60
if (!room || room.running) {
return
}
for (let attempt = 0; attempt < HARVEST_MAX_TRIES; attempt++) {
await new Promise(resolve => window.setTimeout(resolve, HARVEST_INTERVAL_MS))
const room = $groupChats.get()[group]
const stranded = room.stranded || {}
if (!binding.isLive() || !room || room.running) {
return
}
if (!Object.keys(stranded).length) {
return
}
const stranded = room.stranded || {}
for (const member of members) {
if (Object.prototype.hasOwnProperty.call(stranded, groupMemberKey(member))) {
try {
await harvestStrandedGroupReply(group, member)
} catch {
// Best-effort: the next tick retries; the bound stops runaways.
if (!Object.keys(stranded).length) {
return
}
for (const member of members) {
if (!binding.isLive()) {
return
}
if (Object.prototype.hasOwnProperty.call(stranded, groupMemberKey(member))) {
try {
await harvestStrandedGroupReply(group, member)
} catch {
// Best-effort: the next tick retries; the bound stops runaways.
}
}
}
}
}
recordGroupActivity(group, {
kind: 'failed',
member: null,
thread
})
if (!binding.isLive()) {
return
}
recordGroupActivity(group, {
kind: 'failed',
member: null,
thread
})
} finally {
binding.dispose()
}
}
/** User send into a group room. `thread` continues that thread (its reply
@@ -1026,26 +721,35 @@ export function sendToGroupChat(
thread: target
})
const binding = followGroupChat(group, name => {
group = name
})
const drive = () => {
if (!binding.isLive()) {
binding.dispose()
return
}
void runGroupChatRounds(group, members, target)
.catch(() => {
if (binding.isLive()) {
updateGroupChat(group, (r: GroupChatRoom) => {
r.running = false
return r
})
}
})
.finally(binding.dispose)
}
if (!wasRunning) {
void runGroupChatRounds(group, members, target).catch(() => {
updateGroupChat(group, (r: GroupChatRoom) => {
r.running = false
return r
})
})
drive()
} else {
// A loop is live; it bails at its next boundary. Chain the fresh loop
// after a short settle so exactly one drive owns the room.
setTimeout(() => {
void runGroupChatRounds(group, members, target).catch(() => {
updateGroupChat(group, (r: GroupChatRoom) => {
r.running = false
return r
})
})
}, 250)
// Preserve the existing newer-send handoff delay, without pinning its name.
setTimeout(drive, 250)
}
return target
@@ -359,11 +359,23 @@ describe('clarify and approvals (#90694)', () => {
}
it('holds the turn open while a member is blocked on clarify, then lands the reply', async () => {
let live: Awaited<ReturnType<typeof loadRoom>> | null = null
let sawPendingAttention = false
const room = await loadRoom({
clarifyUntil: { research: { payload: CLARIFY, until: 3 } },
// The mirror pass runs while the question is still blocking — this is
// the observable proof the gate inspected pending_clarify. Asserting
// on $groupNeedsYou/$groupClarify AFTER the turn lands proves nothing:
// the clarify has already resolved and its mirror is gone by then.
onResumePoll: () => {
sawPendingAttention = sawPendingAttention || live!.turns.groupHasPendingClarify(live!.chat.$groupClarify.get(), 'Core')
},
turn: () => 'targeting staging'
})
live = room
const thread = room.rounds.sendToGroupChat(
'Core',
[{ name: 'research', title: '' }],
@@ -379,10 +391,9 @@ describe('clarify and approvals (#90694)', () => {
expect(replies).toHaveLength(1)
expect(replies[0].text).toBe('targeting staging')
expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0)
// The mirror pass ran while the question was blocking, badging the room.
// A poll that never inspects pending_clarify leaves this unset — it is the
// observable proof the gate executed.
expect(room.chat.$groupNeedsYou.get().Core).toBe(true)
expect(sawPendingAttention).toBe(true)
// Resolved and mirrored away — nothing left to badge.
expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(false)
})
it('mirrors a question, badges needs-you, and is idempotent per request', async () => {
@@ -397,16 +408,20 @@ describe('clarify and approvals (#90694)', () => {
expect(mirrored[0].requestId).toBe('req-clarify-1')
expect(mirrored[0].question).toBe('Which env should I target?')
expect(mirrored[0].choices).toEqual(['staging', 'prod'])
expect(chat.$groupNeedsYou.get().Core).toBe(true)
// Badge is derived from $groupClarify, not a copy — nothing writes
// $groupNeedsYou here, so there is nothing to keep in sync.
expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(true)
// Same request again: no new entry, identity preserved.
turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })
expect(Object.values(chat.$groupClarify.get())[0]).toBe(mirrored[0])
// Question resolved server-side: the mirror clears.
// Question resolved server-side: the mirror clears, and so does the
// derived badge — no separate cleanup path required.
expect(turns.syncGroupClarify('Core', member, {})).toBe(false)
expect(Object.keys(chat.$groupClarify.get())).toHaveLength(0)
expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(false)
})
it('never mirrors a question for older backends without pending_clarify', async () => {
@@ -465,14 +480,256 @@ describe('clarify and approvals (#90694)', () => {
expect(remaining).toHaveLength(1)
expect(remaining[0].group).toBe('Other')
// The derived badge follows $groupClarify with no separate cleanup step.
expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(false)
expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Other')).toBe(true)
})
it('keeps pending prompts independent from mention attention through their lifecycle', async () => {
const { chat, turns } = await loadRoom()
const member = { name: 'research', title: '' }
turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })
expect(chat.$groupNeedsYou.get().Core).toBeFalsy()
turns.syncGroupClarify('Core', { name: 'ops' }, { pending_approval: APPROVAL })
expect(Object.values(chat.$groupClarify.get())).toHaveLength(2)
turns.syncGroupClarify('Core', member, {})
expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(true)
turns.syncGroupClarify('Core', { name: 'ops' }, {})
expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(false)
chat.appendGroupChatEntry('Core', { kind: 'member', name: 'research' }, '@user please review')
turns.syncGroupClarify('Core', member, { pending_approval: APPROVAL })
await turns.answerGroupClarify(Object.values(chat.$groupClarify.get())[0], member, 'deny')
expect(Object.values(chat.$groupClarify.get())).toHaveLength(0)
expect(chat.$groupNeedsYou.get().Core).toBe(true)
})
it('keeps late prompt snapshots on the live room and never revives a disbanded room', async ({ onTestFinished }) => {
for (const roomId of ['stable-room', undefined]) {
for (const disband of [false, true]) {
const room = await loadRoom({ turn: ({ n }) => n === 1 ? 'Completed reply' : '(pass)' })
const view = await import('./group-chat-view')
const member = { name: 'research', title: '' }
room.chat.updateGroupChat('Core', current => ({
...current, roomId, running: true, epoch: 1,
log: [{ id: 'input', at: 1, from: { kind: 'user', name: 'You' }, text: '@research check', thread: 'thread' }]
}))
let entered!: () => void
let release!: () => void
const polled = new Promise<void>(resolve => { entered = resolve })
const held = new Promise<void>(resolve => { release = resolve })
const original = host.request as (method: string, params: Record<string, unknown>) => Promise<any>
let submitted = false
let answered = false
let polls = 0
host.request = async (method: string, params: Record<string, unknown>) => {
const result = await original(method, params)
if (method === 'prompt.submit') {submitted = true}
if (method === 'clarify.respond') {answered = true}
if (method === 'session.resume' && submitted && !answered) {
if (++polls === 1) { entered(); await held }
return { ...result, pending_clarify: CLARIFY }
}
return result
}
const drive = room.rounds.runGroupChatRounds('Core', [member], 'thread')
await polled
if (disband) {
await view.disbandGroupChat('Core', [])
release()
await drive
expect(Object.values(room.chat.$groupClarify.get())).toHaveLength(0)
expect(room.chat.$groupChats.get().Core === undefined || room.chat.$groupChats.get().Core.tombstone).toBe(true)
expect(room.chat.$groupChats.get().Core?.log || []).toHaveLength(0)
} else {
await view.renameGroupChat('Core', 'Renamed', [])
const mirrored = new Promise<void>(resolve => {
const stop = room.chat.$groupClarify.listen(entries => {
if (Object.keys(entries).length) { stop(); resolve() }
})
})
release()
await mirrored
const [prompt] = Object.values(room.chat.$groupClarify.get())
const correctRoom = prompt.group
await room.turns.answerGroupClarify(prompt, member, 'staging')
await drive
expect(correctRoom).toBe('Renamed')
expect(Object.keys(room.chat.$groupChats.get())).toEqual(['Renamed'])
expect(room.chat.$groupChats.get().Renamed.running).toBe(false)
expect(room.chat.$groupChats.get().Renamed.log.filter(entry => entry.from.kind === 'member').map(entry => entry.text)).toEqual(['Completed reply'])
expect(Object.values(room.chat.$groupClarify.get())).toHaveLength(0)
}
}
}
// Rejected member setup/submit must not publish failure cues into a new room.
for (const continuation of [false, true]) {
for (const rejectedMethod of ['session.resume', 'prompt.submit']) {
const room = await loadRoom()
const view = await import('./group-chat-view')
const activity = await import('./group-activity')
const data = await import('./data')
const members = [{ name: 'research' }, { name: 'ops' }]
room.chat.updateGroupChat('Core', current => ({
...current, roomId: 'old-rejection-room', running: true,
log: continuation
? [{ id: 'handoff', at: 1, from: { kind: 'member', name: 'research' }, text: '@ops check', thread: 'thread' },
{ id: 'input', at: 2, from: { kind: 'user', name: 'You' }, text: '@research check', thread: 'thread' }]
: [{ id: 'input', at: 1, from: { kind: 'user', name: 'You' }, text: 'check', thread: 'thread' }],
watermarks: { 'thread::research': continuation ? 2 : 0 }
}))
// The normal responder has no delta; the earlier unanswered @ops
// handoff is driven by the continuation phase.
let phaseEntered!: () => void
let release!: () => void
const entered = new Promise<void>(resolve => { phaseEntered = resolve })
const held = new Promise<void>(resolve => { release = resolve })
const original = host.request as (method: string, params: Record<string, unknown>) => Promise<any>
host.request = async (method: string, params: Record<string, unknown>) => {
if (method === rejectedMethod) { phaseEntered(); await held; throw new Error('401 unauthorized late rejection') }
return original(method, params)
}
const drive = room.rounds.runGroupChatRounds('Core', members, 'thread')
await entered
await view.disbandGroupChat('Core', [])
room.chat.updateGroupChat('Core', current => ({ ...current, roomId: 'replacement-rejection-room', tombstone: false }))
room.chat.appendGroupChatEntry('Core', { kind: 'member', name: 'research' }, '@user replacement needs you')
const before = structuredClone({ rooms: room.chat.$groupChats.get(), activity: activity.$groupActivity.get(), attention: data.$botAttention.get(), needsYou: room.chat.$groupNeedsYou.get() })
release()
await drive
expect({ rooms: room.chat.$groupChats.get(), activity: activity.$groupActivity.get(), attention: data.$botAttention.get(), needsYou: room.chat.$groupNeedsYou.get() }).toEqual(before)
expect(room.gateway.rpcFor('prompt.submit')).toHaveLength(0)
}
}
const clock = vi.spyOn(Date, 'now').mockReturnValue(0)
onTestFinished(() => clock.mockRestore())
for (const recreate of [false, true]) {
clock.mockReturnValue(0)
const room = await loadRoom()
const view = await import('./group-chat-view')
const member = { name: 'research', title: '' }
room.chat.updateGroupChat('Core', current => ({ ...current, roomId: 'retired-room' }))
let entered!: () => void
let release!: () => void
const polled = new Promise<void>(resolve => { entered = resolve })
const held = new Promise<void>(resolve => { release = resolve })
const original = host.request as (method: string, params: Record<string, unknown>) => Promise<any>
let submitted = false
host.request = async (method: string, params: Record<string, unknown>) => {
if (method === 'session.resume' && submitted) {
entered()
await held
throw new Error('poll rejected after deadline')
}
const result = await original(method, params)
if (method === 'prompt.submit') { submitted = true }
return result
}
const turn = room.turns.runGroupChatMemberTurn('Core', member, 'check', 'thread', [])
await polled
await view.disbandGroupChat('Core', [])
if (recreate) {
room.chat.updateGroupChat('Core', current => ({ ...current, roomId: 'replacement-room' }))
room.turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })
}
const roomsBefore = structuredClone(room.chat.$groupChats.get())
const promptsBefore = structuredClone(room.chat.$groupClarify.get())
// Cross even the hard cap while the rejected poll is still in flight.
clock.mockReturnValue(24 * 60 * 60 * 1000)
release()
expect(await turn).toBeNull()
expect(room.chat.$groupChats.get()).toEqual(roomsBefore)
expect(room.chat.$groupClarify.get()).toEqual(promptsBefore)
}
// Retirement during one background harvest must fence the next member too.
const room = await loadRoom()
const view = await import('./group-chat-view')
const members = [{ name: 'research' }, { name: 'ops' }]
room.chat.updateGroupChat('Core', current => ({
...current, roomId: 'old-harvest-room', running: true,
stranded: { research: 0, ops: 0 }
}))
let tick!: () => void
const previousWindow = globalThis.window
vi.stubGlobal('window', { setTimeout: (callback: () => void) => { tick = callback;
return 0 } })
onTestFinished(() => { vi.stubGlobal('window', previousWindow) })
let entered!: () => void
let release!: () => void
const polled = new Promise<void>(resolve => { entered = resolve })
const held = new Promise<void>(resolve => { release = resolve })
let background = false
const backgroundProfiles: unknown[] = []
const original = host.request as (method: string, params: Record<string, unknown>) => Promise<any>
host.request = async (method: string, params: Record<string, unknown>) => {
if (method !== 'session.resume') { return original(method, params) }
if (background) {
backgroundProfiles.push(params.profile)
if (params.profile === 'research') { entered(); await held }
}
return { running: true, pending_clarify: CLARIFY }
}
await room.rounds.runGroupChatRounds('Core', members, 'thread')
background = true
tick()
await polled
await view.disbandGroupChat('Core', [])
const { setImmediate } = await import('node:timers/promises')
await setImmediate()
room.chat.updateGroupChat('Core', current => ({
...current, roomId: 'new-harvest-room', stranded: { research: 0, ops: 0 }
}))
const roomsBefore = structuredClone(room.chat.$groupChats.get())
const promptsBefore = structuredClone(room.chat.$groupClarify.get())
release()
// Let the released RPC and its background caller finish their microtasks.
await setImmediate()
expect(backgroundProfiles).toEqual(['research'])
expect(room.chat.$groupChats.get()).toEqual(roomsBefore)
expect(room.chat.$groupClarify.get()).toEqual(promptsBefore)
})
it('holds the turn open on a command approval too', async () => {
let live: Awaited<ReturnType<typeof loadRoom>> | null = null
let sawPendingAttention = false
const room = await loadRoom({
approvalUntil: { research: { payload: APPROVAL, until: 3 } },
onResumePoll: () => {
sawPendingAttention = sawPendingAttention || live!.turns.groupHasPendingClarify(live!.chat.$groupClarify.get(), 'Core')
},
turn: () => 'build cleaned'
})
live = room
const thread = room.rounds.sendToGroupChat(
'Core',
[{ name: 'research', title: '' }],
@@ -488,7 +745,8 @@ describe('clarify and approvals (#90694)', () => {
expect(replies).toHaveLength(1)
expect(replies[0].text).toBe('build cleaned')
expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0)
expect(room.chat.$groupNeedsYou.get().Core).toBe(true)
expect(sawPendingAttention).toBe(true)
expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(false)
})
it('mirrors an approval with its kind, command and server choices', async () => {
@@ -9,9 +9,9 @@
import { host } from '@hermes/plugin-sdk'
import { recordGroupActivity } from './group-activity'
import { $groupChats, $groupClarify, $groupNeedsYou, appendGroupChatEntry, updateGroupChat } from './group-chat'
import { $groupChats, $groupClarify, appendGroupChatEntry, updateGroupChat } from './group-chat'
import type { GroupChatRoom } from './group-chat'
import { groupMemberKey, groupSessionOwner } from './group-membership'
import { followGroupChat, groupMemberKey, groupSessionOwner } from './group-membership'
import { botConnectionRoute, requestForBot } from './routing'
import type { Attachment, GroupMember, GroupPrompt, GroupPromptQuestion, ProfileRoute } from './types'
@@ -123,110 +123,132 @@ interface GroupMemberSessionHandle {
* it after restarts. Cross-connection members route to their OWN source
* via requestForBot; the window's gateway never switches. */
export async function ensureGroupChatSession(group: string, member: GroupMember): Promise<GroupMemberSessionHandle> {
const room = $groupChats.get()[group] || {}
// New rooms title member sessions by their immutable roomId so a
// same-name recreate never resumes the old room's sessions by title;
// legacy rooms without a roomId fall back to the display name.
const title = `Group: ${room.roomId || group}`
const key = groupMemberKey(member)
const known = room.sessions && room.sessions[key]
const binding = followGroupChat(group, name => {
group = name
})
// Try resuming what we know (stored sid first, then title lookup).
//
// FAIL CLOSED on a transient lookup failure — mirrors the sibling fix in
// findExistingCanonicalChat (87b645f52c). session.resume signals "this
// target genuinely doesn't exist" with JSON-RPC code 4007; every other
// failure (network blip, the backend still warming up after a restart,
// an oversized-resume refusal) means the real session might still be
// there and must not be read as "no session, mint a new one" — that
// forks the member's real history, and the fork silently overwrites
// room.sessions[key] so the old session becomes unreachable from the
// room. Only a genuine 4007 on BOTH targets means there truly is nothing
// to resume yet, so the loop falls through to session.create below.
for (const target of [known, title]) {
if (!target || target === true) {
continue
}
try {
const room = $groupChats.get()[group] || {}
// New rooms title member sessions by their immutable roomId so a
// same-name recreate never resumes the old room's sessions by title;
// legacy rooms without a roomId fall back to the display name.
const title = `Group: ${room.roomId || group}`
const key = groupMemberKey(member)
const known = room.sessions && room.sessions[key]
try {
const res = (await requestForBot(member, 'session.resume', {
session_id: target,
profile: member.name,
omit_messages: true
})) as GroupSessionSnapshot
// Try resuming what we know (stored sid first, then title lookup).
//
// FAIL CLOSED on a transient lookup failure — mirrors the sibling fix in
// findExistingCanonicalChat (87b645f52c). session.resume signals "this
// target genuinely doesn't exist" with JSON-RPC code 4007; every other
// failure (network blip, the backend still warming up after a restart,
// an oversized-resume refusal) means the real session might still be
// there and must not be read as "no session, mint a new one" — that
// forks the member's real history, and the fork silently overwrites
// room.sessions[key] so the old session becomes unreachable from the
// room. Only a genuine 4007 on BOTH targets means there truly is nothing
// to resume yet, so the loop falls through to session.create below.
for (const target of [known, title]) {
if (!target || target === true) {
continue
}
if (res?.session_id) {
// TODO(bot-mode-types): `known` is `room.sessions[key]`, which the
// domain model types `string | true` — and the `target === true` skip
// above shows the legacy `true` sentinel is expected here. A backend
// that answers the title resume without a `session_key` therefore
// stores `true` back into room.sessions and hands `true` on as the
// durable id, which later rides into `session_id` on the recovery
// resume and on session.interrupt. Typed as-written.
const stored = res.session_key || known
try {
const res = (await requestForBot(member, 'session.resume', {
session_id: target,
profile: member.name,
omit_messages: true
})) as GroupSessionSnapshot
if (stored) {
updateGroupChat(group, (current: GroupChatRoom) => {
current.sessions = {
...(current.sessions || {}),
[key]: stored
}
current.sessionOwners = {
...(current.sessionOwners || {}),
[key]: groupSessionOwner(member)
}
return current
})
if (!binding.isLive()) {
return { runtime: null }
}
return {
runtime: res.session_id,
stored
if (res?.session_id) {
// TODO(bot-mode-types): `known` is `room.sessions[key]`, which the
// domain model types `string | true` — and the `target === true` skip
// above shows the legacy `true` sentinel is expected here. A backend
// that answers the title resume without a `session_key` therefore
// stores `true` back into room.sessions and hands `true` on as the
// durable id, which later rides into `session_id` on the recovery
// resume and on session.interrupt. Typed as-written.
const stored = res.session_key || known
if (stored) {
updateGroupChat(group, (current: GroupChatRoom) => {
current.sessions = {
...(current.sessions || {}),
[key]: stored
}
current.sessionOwners = {
...(current.sessionOwners || {}),
[key]: groupSessionOwner(member)
}
return current
})
}
return {
runtime: res.session_id,
stored
}
}
} catch (error: any) {
if (error?.code !== 4007) {
const detail = error instanceof Error && error.message ? ` (${error.message})` : ''
throw new Error(
`Could not check ${member?.name || 'member'}'s group session${detail} — not starting a new one`
)
}
/* genuinely doesn't exist (4007) — try the next target / fall through to create */
}
} catch (error: any) {
if (error?.code !== 4007) {
const detail = error instanceof Error && error.message ? ` (${error.message})` : ''
throw new Error(`Could not check ${member?.name || 'member'}'s group session${detail} — not starting a new one`)
}
/* genuinely doesn't exist (4007) — try the next target / fall through to create */
}
}
const created = (await requestForBot(member, 'session.create', {
profile: member.name,
title,
// Room member sessions are plumbing — always hidden from the sidebar.
hidden: true,
// Explicit contracts (PR #97008): room plumbing sessions always rebuild
// from the member profile's CURRENT config on resume, never a stale
// stored model/provider pin. Older gateways ignore the unknown params;
// the server's hidden + "Group: " title fallback then covers legacy.
room_plumbing: true,
follow_profile_config: true
})) as { session_id?: string; stored_session_id?: string }
if (!binding.isLive()) {
return { runtime: null }
}
const stored = created?.stored_session_id || null
const created = (await requestForBot(member, 'session.create', {
profile: member.name,
title,
// Room member sessions are plumbing — always hidden from the sidebar.
hidden: true,
// Explicit contracts (PR #97008): room plumbing sessions always rebuild
// from the member profile's CURRENT config on resume, never a stale
// stored model/provider pin. Older gateways ignore the unknown params;
// the server's hidden + "Group: " title fallback then covers legacy.
room_plumbing: true,
follow_profile_config: true
})) as { session_id?: string; stored_session_id?: string }
if (stored) {
updateGroupChat(group, (r: GroupChatRoom) => {
r.sessions = {
...(r.sessions || {}),
[key]: stored
}
r.sessionOwners = {
...(r.sessionOwners || {}),
[key]: groupSessionOwner(member)
}
if (!binding.isLive()) {
return { runtime: null }
}
return r
})
}
const stored = created?.stored_session_id || null
return {
runtime: created?.session_id || null,
stored
if (stored) {
updateGroupChat(group, (r: GroupChatRoom) => {
r.sessions = {
...(r.sessions || {}),
[key]: stored
}
r.sessionOwners = {
...(r.sessionOwners || {}),
[key]: groupSessionOwner(member)
}
return r
})
}
return {
runtime: created?.session_id || null,
stored
}
} finally {
binding.dispose()
}
}
@@ -493,16 +515,24 @@ export function syncGroupClarify(group: string, member: GroupMember, state: Grou
questions: null
}
})
// A blocked member is a question for the human — badge the room.
$groupNeedsYou.set({
...$groupNeedsYou.get(),
[group]: true
})
return true
}
/** Drop every mirrored clarify belonging to `group` (disband/rename). */
/** Whether `group` has any member currently blocked on a clarify or
* approval, given a $groupClarify snapshot. Pure by design: the caller
* (roster-pane) subscribes to $groupClarify itself via useValue and passes
* the live snapshot in, so the subscription actually drives the
* recalculation instead of existing only to force a re-render. $groupClarify
* is the single source of truth for this kind of attention — nothing copies
* it into a second boolean, so there is nothing to keep in sync when a
* prompt resolves, is answered, or the room is disbanded/renamed. */
export function groupHasPendingClarify(clarifies: Record<string, GroupPrompt>, group: string): boolean {
return Object.values(clarifies).some(entry => entry?.group === group)
}
/** Drop every mirrored clarify belonging to `group` (disband — the room is
* gone, nothing to move the attention to). */
export function clearGroupClarify(group: string) {
const all = $groupClarify.get()
const next: Record<string, GroupPrompt> = {}
@@ -521,6 +551,41 @@ export function clearGroupClarify(group: string) {
}
}
/** Move existing prompts with the renamed room; active operations follow
* the same room through their scoped followGroupChat binding. */
export function renameGroupClarify(oldName: string, newName: string) {
const all = $groupClarify.get()
const next: Record<string, GroupPrompt> = {}
let changed = false
// Preserve every mirror that isn't being renamed. Iteration order matters:
// a single pass keyed by insertion order can let a STALE mirror already
// stranded at newName (left behind by the in-flight-poll race noted
// below) clobber the just-migrated CURRENT mirror if the stale entry
// happens to iterate after it. Copying unrelated entries first and
// writing the migrated ones last guarantees the live room's prompt
// always wins its destination key.
for (const [key, value] of Object.entries<GroupPrompt>(all)) {
if (value?.group !== oldName) {
next[key] = value
}
}
for (const value of Object.values<GroupPrompt>(all)) {
if (value?.group === oldName) {
changed = true
// Rebuild the key from the mirror's own memberKey rather than
// string-replacing oldName in place — a group name that happens to
// be a substring of the member key must not corrupt the rekey.
next[`${newName}::${value.memberKey}`] = { ...value, group: newName }
}
}
if (changed) {
$groupClarify.set(next)
}
}
/** Answer a member's pending prompt from the room. Routes to the member's
* OWN source (requestForBot), so cross-connection members work.
* - clarify: `clarify.respond`; batch questions send one respond per
@@ -535,40 +600,54 @@ export async function answerGroupClarify(
member: GroupMember,
answers: Record<string, string> | string | undefined
) {
if (entry.kind === 'approval') {
await requestForBot(member, 'approval.respond', {
session_id: entry.sessionId || undefined,
request_id: entry.requestId,
choice: typeof answers === 'string' && answers ? answers : 'deny'
})
} else if (entry.questions && entry.questions.length) {
for (const question of entry.questions) {
// Question ids are opaque on the wire (`GroupPrompt.questions` types
// them `unknown`); the batch card keys its answer bag by exactly them.
const qid = (question?.qid ?? question?.id) as string
let group = entry.group
const binding = followGroupChat(group, name => {
group = name
})
try {
if (entry.kind === 'approval') {
await requestForBot(member, 'approval.respond', {
session_id: entry.sessionId || undefined,
request_id: entry.requestId,
choice: typeof answers === 'string' && answers ? answers : 'deny'
})
} else if (entry.questions && entry.questions.length) {
for (const question of entry.questions) {
// Question ids are opaque on the wire (`GroupPrompt.questions` types
// them `unknown`); the batch card keys its answer bag by exactly them.
const qid = (question?.qid ?? question?.id) as string
await requestForBot(member, 'clarify.respond', {
request_id: entry.requestId,
question_id: qid,
answer: (answers as Record<string, string>)?.[qid] ?? ''
})
}
} else {
await requestForBot(member, 'clarify.respond', {
request_id: entry.requestId,
question_id: qid,
answer: (answers as Record<string, string>)?.[qid] ?? ''
answer: typeof answers === 'string' ? answers : ''
})
}
} else {
await requestForBot(member, 'clarify.respond', {
request_id: entry.requestId,
answer: typeof answers === 'string' ? answers : ''
})
}
const all = $groupClarify.get()
const key = `${entry.group}::${entry.memberKey}`
if (all[key]?.requestId === entry.requestId) {
const next = {
...all
if (!binding.isLive()) {
return
}
delete next[key]
$groupClarify.set(next)
const all = $groupClarify.get()
const key = `${group}::${entry.memberKey}`
if (all[key]?.requestId === entry.requestId) {
const next = {
...all
}
delete next[key]
$groupClarify.set(next)
}
} finally {
binding.dispose()
}
}
@@ -590,59 +669,23 @@ export async function runGroupChatMemberTurn(
// lease, every RPC below rides its own request-scoped socket lease; the
// socket that minted `runtime` can close between RPCs, the gateway reaps
// the runtime session, and prompt.submit dies 4001 — the bot goes silent.
const releaseTurnLease = await retainGroupTurnRoute(member)
const binding = followGroupChat(group, name => {
group = name
})
let releaseTurnLease: (() => void) | undefined
try {
return await runGroupChatMemberTurnLeased(group, member, prompt, thread, images)
releaseTurnLease = await retainGroupTurnRoute(member)
return binding.isLive() ? await runGroupChatMemberTurnLeased(group, member, prompt, thread, images) : null
} finally {
releaseTurnLease()
releaseTurnLease?.()
binding.dispose()
}
}
async function runGroupChatMemberTurnLeased(
group: string,
member: GroupMember,
prompt: string,
thread: string,
images?: Attachment[]
): Promise<null | string> {
const { runtime, stored } = await ensureGroupChatSession(group, member)
if (!runtime) {
return null
}
// #91868/#94569: remember the epoch this turn was dispatched under so the
// poll loop below can tell an explicit stop from ordinary room churn.
const dispatchEpoch = ($groupChats.get()[group] || {}).epoch || 0
const memberKey = groupMemberKey(member)
recordGroupActivity(group, {
kind: 'working',
member: member.name,
thread
})
// Baseline: how many messages exist before our submit.
let before = 0
// Every runtime id this turn has seen for the member's session. Terminal
// frames are keyed by runtime id, and a resume can hand back a fresh one.
const runtimeIds = new Set<string>([runtime])
try {
const pre = (await requestForBot(member, 'session.resume', {
session_id: stored || runtime,
profile: member.name
})) as GroupSessionSnapshot
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
if (pre?.session_id) {
runtimeIds.add(pre.session_id)
}
} catch {
/* lazy session — zero messages */
}
async function stageGroupTurnAttachments(member: GroupMember, runtime: string, images?: Attachment[]) {
// Stage this delta's attachments into the member's session so the model
// receives the actual payload with the prompt — the same attach RPCs the
// 1:1 chat uses (they also work cross-connection, where the member's
@@ -688,15 +731,24 @@ async function runGroupChatMemberTurnLeased(
}
}
const turnText = fileRefs.length
? `${prompt}\n\nAttached files staged in your session workspace:\n${fileRefs.join('\n')}`
: prompt
return fileRefs
}
// #93602: one-shot recovery when the runtime session was reaped between
// minting and submitting. Tracks the runtime id the submit landed on so
// the poll fallback below targets a live session.
const liveRuntime = await submitGroupTurnPrompt(member, runtime, stored, turnText)
runtimeIds.add(liveRuntime)
interface GroupTurnPollContext {
group: string
member: GroupMember
thread: string
dispatchEpoch: number
stored: GroupMemberSessionHandle['stored']
liveRuntime: string
runtimeIds: Set<string>
before: number
binding: { isLive(): boolean }
}
async function pollGroupMemberTurn(context: GroupTurnPollContext): Promise<null | string> {
const { member, thread, dispatchEpoch, stored, liveRuntime, runtimeIds, before, binding } = context
const memberKey = groupMemberKey(member)
const started = Date.now()
let deadline = started + GROUP_TURN_TIMEOUT_MS
// After the terminal frame fires, the gateway still has to flip
@@ -717,7 +769,11 @@ async function runGroupChatMemberTurnLeased(
// conditions on purpose: an ordinary newer send bumps the epoch WITHOUT
// a hold, and that turn must keep polling so finished work can still be
// delivered (the #93127 commit check decides its fate, not this loop).
const roomDuringPoll = $groupChats.get()[group] || {}
if (!binding.isLive()) {
return null
}
const roomDuringPoll = $groupChats.get()[context.group] || {}
if ((roomDuringPoll.epoch || 0) !== dispatchEpoch && (roomDuringPoll.holds || {})[memberKey]) {
return null
@@ -734,6 +790,10 @@ async function runGroupChatMemberTurnLeased(
continue
}
if (!binding.isLive()) {
return null
}
if (state?.session_id) {
runtimeIds.add(state.session_id)
}
@@ -743,14 +803,14 @@ async function runGroupChatMemberTurnLeased(
// A clarify blocking inside the member's session is a question for the
// HUMAN (#90694) — mirror it into the room store so a card renders, and
// hold the turn open: the member isn't stalling, it's waiting on us.
const awaitingUser = syncGroupClarify(group, member, state)
const awaitingUser = syncGroupClarify(context.group, member, state)
const done = !busy && !awaitingUser
if (messages.length > before && done) {
const replyText = pickGroupTurnReply(messages, before)
if (replyText !== null) {
recordGroupActivity(group, {
recordGroupActivity(context.group, {
kind: isGroupPassText(replyText) ? 'passed' : 'replied',
member: member.name,
thread
@@ -759,7 +819,7 @@ async function runGroupChatMemberTurnLeased(
return replyText
}
recordGroupActivity(group, {
recordGroupActivity(context.group, {
kind: 'passed',
member: member.name,
thread
@@ -776,17 +836,21 @@ async function runGroupChatMemberTurnLeased(
}
}
if (!binding.isLive()) {
return null
}
// Timeout — clear any still-mirrored question card (the server-side
// clarify timeout runs its own course) and read as a pass, but remember the baseline + thread
// (runtime-only) so the finished reply can be posted late into the RIGHT
// thread instead of vanishing.
recordGroupActivity(group, {
recordGroupActivity(context.group, {
kind: 'timed-out',
member: member.name,
thread
})
syncGroupClarify(group, member, null)
updateGroupChat(group, (r: GroupChatRoom) => {
syncGroupClarify(context.group, member, null)
updateGroupChat(context.group, (r: GroupChatRoom) => {
r.stranded = {
...(r.stranded || {}),
[groupMemberKey(member)]: {
@@ -801,86 +865,192 @@ async function runGroupChatMemberTurnLeased(
return null
}
async function prepareGroupTurnBaseline(
member: GroupMember,
runtime: string,
stored: GroupMemberSessionHandle['stored']
) {
// Baseline: how many messages exist before our submit.
let before = 0
// Every runtime id this turn has seen for the member's session. Terminal
// frames are keyed by runtime id, and a resume can hand back a fresh one.
const runtimeIds = new Set<string>([runtime])
try {
const pre = (await requestForBot(member, 'session.resume', {
session_id: stored || runtime,
profile: member.name
})) as GroupSessionSnapshot
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
if (pre?.session_id) {
runtimeIds.add(pre.session_id)
}
} catch {
/* lazy session — zero messages */
}
return { before, runtimeIds }
}
async function runGroupChatMemberTurnLeased(
group: string,
member: GroupMember,
prompt: string,
thread: string,
images?: Attachment[]
): Promise<null | string> {
const binding = followGroupChat(group, name => {
group = name
})
try {
const { runtime, stored } = await ensureGroupChatSession(group, member)
if (!runtime || !binding.isLive()) {
return null
}
// #91868/#94569: remember the epoch this turn was dispatched under so the
// poll loop below can tell an explicit stop from ordinary room churn.
const dispatchEpoch = ($groupChats.get()[group] || {}).epoch || 0
recordGroupActivity(group, {
kind: 'working',
member: member.name,
thread
})
const { before, runtimeIds } = await prepareGroupTurnBaseline(member, runtime, stored)
const fileRefs = await stageGroupTurnAttachments(member, runtime, images)
if (!binding.isLive()) {
return null
}
const turnText = fileRefs.length
? `${prompt}\n\nAttached files staged in your session workspace:\n${fileRefs.join('\n')}`
: prompt
// #93602: one-shot recovery when the runtime session was reaped between
// minting and submitting. Tracks the runtime id the submit landed on so
// the poll fallback below targets a live session.
const liveRuntime = await submitGroupTurnPrompt(member, runtime, stored, turnText)
if (!binding.isLive()) {
return null
}
runtimeIds.add(liveRuntime)
return await pollGroupMemberTurn({
get group() {
return group
},
member,
thread,
dispatchEpoch,
stored,
liveRuntime,
runtimeIds,
before,
binding
})
} finally {
binding.dispose()
}
}
/** Post a timed-out member's finished reply into the room, if it landed
* after we stopped waiting. Called at the member's next turn boundary and
* on user sends, so long-running work is delivered late rather than lost. */
export async function harvestStrandedGroupReply(group: string, member: GroupMember) {
const memberKey = groupMemberKey(member)
const room = $groupChats.get()[group] || {}
const marker = room.stranded?.[memberKey]
// Markers were a bare number before threads; normalize both shapes.
const strandedBefore = typeof marker === 'number' ? marker : marker?.before
const strandedThread = (typeof marker === 'object' && marker?.thread) || 'legacy'
if (typeof strandedBefore !== 'number') {
return
}
let state: GroupSessionSnapshot | null = null
const binding = followGroupChat(group, name => {
group = name
})
try {
const stored = room.sessions?.[memberKey]
state = (await requestForBot(member, 'session.resume', {
session_id: stored || `Group: ${room.roomId || group}`,
profile: member.name
})) as GroupSessionSnapshot
} catch {
return // source unreachable — leave the marker for the next boundary
}
const memberKey = groupMemberKey(member)
const room = $groupChats.get()[group] || {}
const marker = room.stranded?.[memberKey]
// Markers were a bare number before threads; normalize both shapes.
const strandedBefore = typeof marker === 'number' ? marker : marker?.before
const strandedThread = (typeof marker === 'object' && marker?.thread) || 'legacy'
if (state?.inflight || state?.running) {
return // still grinding — keep waiting
}
// A stranded member blocked on a clarify is not "grinding" — surface the
// question card (#90694) and keep the marker until it resolves.
if (syncGroupClarify(group, member, state)) {
return
}
// Done (or dead): the marker is consumed either way.
updateGroupChat(group, (r: GroupChatRoom) => {
const next = {
...(r.stranded || {})
if (typeof strandedBefore !== 'number') {
return
}
delete next[memberKey]
r.stranded = next
let state: GroupSessionSnapshot | null = null
return r
})
const messages = Array.isArray(state?.messages) ? state.messages : []
try {
const stored = room.sessions?.[memberKey]
state = (await requestForBot(member, 'session.resume', {
session_id: stored || `Group: ${room.roomId || group}`,
profile: member.name
})) as GroupSessionSnapshot
} catch {
return // source unreachable — leave the marker for the next boundary
}
if (messages.length <= strandedBefore) {
return
}
if (!binding.isLive()) {
return
}
const reply = pickGroupTurnReply(messages, strandedBefore)
// Pending prompts are authoritative even while the session is running.
const awaitingUser = syncGroupClarify(group, member, state)
if (reply && !isGroupPassText(reply)) {
recordGroupActivity(group, {
kind: 'delivered',
member: member.name,
thread: strandedThread
})
appendGroupChatEntry(
group,
{
kind: 'member',
name: member.name,
...(member.remoteSource
? {
source: member.connectionLabel || member.connectionId
}
: {})
},
reply,
strandedThread
)
if (state?.inflight || state?.running || awaitingUser) {
return
}
// Done (or dead): the marker is consumed either way.
updateGroupChat(group, (r: GroupChatRoom) => {
r.watermarks[`${strandedThread}::${memberKey}`] = r.log.length
const next = {
...(r.stranded || {})
}
delete next[memberKey]
r.stranded = next
return r
})
const messages = Array.isArray(state?.messages) ? state.messages : []
if (messages.length <= strandedBefore) {
return
}
const reply = pickGroupTurnReply(messages, strandedBefore)
if (reply && !isGroupPassText(reply)) {
recordGroupActivity(group, {
kind: 'delivered',
member: member.name,
thread: strandedThread
})
appendGroupChatEntry(
group,
{
kind: 'member',
name: member.name,
...(member.remoteSource
? {
source: member.connectionLabel || member.connectionId
}
: {})
},
reply,
strandedThread
)
updateGroupChat(group, (r: GroupChatRoom) => {
r.watermarks[`${strandedThread}::${memberKey}`] = r.log.length
return r
})
}
} finally {
binding.dispose()
}
}
@@ -0,0 +1,178 @@
import { Button, Codicon, DisclosureCaret, GlyphSpinner, PanelEmpty, RowButton } from '@hermes/plugin-sdk'
import type { ReactNode, RefObject } from 'react'
import type { useRoster } from './data'
import { $showHiddenBots } from './hidden-bots'
import type { useBots } from './i18n'
import type { deriveRosterPresentation, deriveRosterRows } from './roster-pane-derivation'
import type { rosterSectionRenderers } from './roster-pane-sections'
import type { rosterGatewayOptions } from './roster-sections'
import type { RosterRow } from './types'
interface RosterContentProps {
b: ReturnType<typeof useBots>
staleNotice: string | number | null
isLoading: boolean
initialRosterLoading: boolean
roster: RosterRow[]
error: ReturnType<typeof useRoster>['error']
gatewayUp: boolean
refetch: ReturnType<typeof useRoster>['refetch']
allBotsHidden: boolean
hiddenExpanded: boolean
rosterRows: ReturnType<typeof deriveRosterRows>['rosterRows']
matchingHiddenBots: RosterRow[]
query: string
selectedGateway: ReturnType<typeof rosterGatewayOptions>[number] | undefined
showGatewaySections: boolean
sortedGroupRows: ReturnType<typeof deriveRosterRows>['sortedGroupRows']
gatewaySections: ReturnType<typeof deriveRosterRows>['gatewaySections']
showHiddenSection: boolean
hiddenSectionRef: RefObject<HTMLDivElement | null>
hasRosterConstraint: boolean
hiddenBots: RosterRow[]
showHiddenRows: boolean
hiddenGatewaySections: ReturnType<typeof deriveRosterPresentation>['hiddenGatewaySections']
renderBotRow: (bot: RosterRow, keyPrefix?: string) => ReactNode
renderGroupChatSection: ReturnType<typeof rosterSectionRenderers>['renderGroupChatSection']
renderGatewaySection: ReturnType<typeof rosterSectionRenderers>['renderGatewaySection']
renderUserSections: ReturnType<typeof rosterSectionRenderers>['renderUserSections']
renderHiddenGatewaySection: ReturnType<typeof rosterSectionRenderers>['renderHiddenGatewaySection']
}
export function renderRosterContent({
b,
staleNotice,
isLoading,
initialRosterLoading,
roster,
error,
gatewayUp,
refetch,
allBotsHidden,
hiddenExpanded,
rosterRows,
matchingHiddenBots,
query,
selectedGateway,
showGatewaySections,
sortedGroupRows,
gatewaySections,
showHiddenSection,
hiddenSectionRef,
hasRosterConstraint,
hiddenBots,
showHiddenRows,
hiddenGatewaySections,
renderBotRow,
renderGroupChatSection,
renderGatewaySection,
renderUserSections,
renderHiddenGatewaySection
}: RosterContentProps) {
return (
<>
{staleNotice ? (
<div className="mx-2.5 mb-1 rounded-md bg-(--chrome-action-hover) px-2 py-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
{staleNotice}
</div>
) : null}
{(isLoading || initialRosterLoading) && !roster.length ? (
<div className="flex flex-1 items-center justify-center">
<GlyphSpinner className="text-(--ui-text-tertiary)" spinner="breathe" />
</div>
) : error && !roster.length ? (
<div className="grid gap-2 px-3 py-4 text-xs text-(--ui-text-tertiary)">
<div>
{gatewayUp
? b.roster.rosterUnavailable(error instanceof Error ? error.message : 'gateway error')
: b.roster.waitingForGateway}
</div>
<Button className="justify-self-start" onClick={() => void refetch()} size="sm" variant="secondary">
{b.roster.retryNow}
</Button>
</div>
) : roster.length === 0 ? (
<PanelEmpty description={b.roster.emptyDesc} icon="hubot" title={b.roster.emptyTitle} />
) : allBotsHidden && !hiddenExpanded ? (
<div className="grid content-start gap-2 px-3 py-4 text-xs text-(--ui-text-tertiary)">
<div className="flex items-center gap-1.5 font-medium text-(--ui-text-secondary)">
<Codicon className="text-(--ui-text-quaternary)" name="eye-closed" />
{b.roster.allHidden}
</div>
<p className="leading-relaxed">{b.roster.allHiddenDesc}</p>
<Button
className="justify-self-start"
onClick={() => $showHiddenBots.set(true)}
size="sm"
variant="secondary"
>
{b.roster.showHidden}
</Button>
</div>
) : rosterRows.length === 0 && matchingHiddenBots.length === 0 ? (
<div aria-live="polite" className="flex min-h-0 flex-1 flex-col" role="status">
<PanelEmpty
description={
query.trim()
? selectedGateway
? b.roster.noMatchQueryOn(query.trim(), String(selectedGateway.label))
: b.roster.noMatchQuery(query.trim())
: selectedGateway
? b.roster.noMatchFiltersOn(String(selectedGateway.label))
: b.roster.noMatchFilters
}
icon="search"
/>
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain" data-slot="bots-roster">
<div className="grid w-full min-w-0 gap-0.5 px-1.5 pb-2">
{showGatewaySections
? [
sortedGroupRows.length ? renderGroupChatSection() : null,
...gatewaySections.sections.map(renderGatewaySection)
].filter(Boolean)
: renderUserSections(rosterRows)}
{showHiddenSection ? (
<div
className="mt-1 border-t border-(--ui-stroke-tertiary) pt-1"
key={'hidden-section'}
ref={hiddenSectionRef}
>
{hasRosterConstraint ? (
<div className="flex w-full items-center gap-1 px-2 py-1.5 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
<Codicon name="eye-closed" />
<span>Hidden</span>
<span className="text-(--ui-text-quaternary)">{matchingHiddenBots.length}</span>
</div>
) : (
<RowButton
aria-expanded={hiddenExpanded}
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={() => $showHiddenBots.set(!hiddenExpanded)}
>
<DisclosureCaret open={hiddenExpanded} />
<span>Hidden</span>
<span className="text-(--ui-text-quaternary)">{hiddenBots.length}</span>
</RowButton>
)}
{showHiddenRows ? (
matchingHiddenBots.length ? (
hiddenGatewaySections.sectioned ? (
hiddenGatewaySections.sections.map(renderHiddenGatewaySection)
) : (
matchingHiddenBots.map((bot: RosterRow) => renderBotRow(bot, 'hidden:'))
)
) : (
<div className="px-2 py-2 text-xs text-(--ui-text-quaternary)">{b.roster.noHiddenMatch}</div>
)
) : null}
</div>
) : null}
</div>
</div>
)}
</>
)
}
@@ -0,0 +1,237 @@
import { botActivitySession } from './data'
import { botRosterKey, filterBots, preferReachableSameNameRows } from './data'
import type { $groupChats } from './group-chat'
import { groupChatMemberBots, groupChatNames, groupLastActivity } from './group-membership'
import { sortGroupRosterRows } from './group-order'
import { isBotPinned } from './hidden-bots'
import { isBotHidden } from './hidden-bots'
import { filterBotsByGateway, groupMatchesRosterFilters, rosterGatewaySections } from './roster-sections'
import type { rosterGatewayOptions } from './roster-sections'
import { botRosterMeta } from './routing'
import { BOT_ROSTER_SEARCH_THRESHOLD } from './row-helpers'
import { ACTIVE_WINDOW_S, rosterActivityMatches } from './row-helpers'
import type { BotMeta, GroupMember, RosterActivityFilter, RosterKindFilter, RosterRow } from './types'
/** The two row shapes the roster sorts together — `kind` is the discriminant. */
interface RosterBotRow {
active: boolean
activity: number
bot: RosterRow
kind: 'bot'
pinned: boolean
}
export interface RosterGroupRow {
active: boolean
activity: number
kind: 'group'
members: GroupMember[]
name: string
pinned: boolean
}
interface RosterRowsInput {
roster: RosterRow[]
allMeta: Record<string, BotMeta>
gatewayFilter: string
query: string
activityFilter: RosterActivityFilter
rowKindFilter: RosterKindFilter
groupRooms: ReturnType<typeof $groupChats.get>
activeRosterKeys: Set<string>
gatewayOptions: ReturnType<typeof rosterGatewayOptions>
activityOf: (bot: RosterRow) => number
isPinned: (bot: RosterRow) => boolean
}
export function deriveRosterRows({
roster,
allMeta,
gatewayFilter,
query,
activityFilter,
rowKindFilter,
groupRooms,
activeRosterKeys,
gatewayOptions,
activityOf,
isPinned
}: RosterRowsInput) {
const activeSourceRoster = roster.filter(bot => !bot.remoteSource)
// Hidden rows remain fully alive and recoverable at the bottom. Every
// non-display consumer continues to receive the complete roster.
const hiddenBots = roster.filter(bot => isBotHidden(bot, allMeta))
const visibleRoster = roster.filter(bot => !isBotHidden(bot, allMeta))
const gatewayRoster = filterBotsByGateway(visibleRoster, gatewayFilter)
const filteredRoster = filterBots(gatewayRoster, allMeta, query).filter((bot: RosterRow) =>
rosterActivityMatches(
{
activity: activityOf(bot),
active: activeRosterKeys.has(botRosterKey(bot))
},
activityFilter
)
)
const filteredHiddenBots = filterBots(filterBotsByGateway(hiddenBots, gatewayFilter), allMeta, query).filter(
(bot: RosterRow) =>
rosterActivityMatches(
{
activity: activityOf(bot),
active: activeRosterKeys.has(botRosterKey(bot))
},
activityFilter
)
)
const groupNames = groupChatNames(allMeta, groupRooms)
const groupRows = groupNames
.map(name => ({
name,
members: groupChatMemberBots(name, roster, allMeta)
}))
.filter(row => groupMatchesRosterFilters(row.name, row.members, allMeta, query, gatewayFilter))
.map((row): RosterGroupRow => ({
kind: 'group',
name: row.name,
members: row.members,
pinned: Boolean(groupRooms[row.name]?.pinned),
activity: groupLastActivity(groupRooms[row.name]),
active:
Boolean(
groupLastActivity(groupRooms[row.name]) &&
Date.now() - groupLastActivity(groupRooms[row.name]) <= ACTIVE_WINDOW_S * 1000
) || row.members.some(member => activeRosterKeys.has(botRosterKey(member)))
}))
.filter(row => rowKindFilter !== 'bots' && rosterActivityMatches(row, activityFilter))
const botRows =
rowKindFilter === 'groups'
? []
: preferReachableSameNameRows(filteredRoster).map((bot): RosterBotRow => ({
kind: 'bot',
bot,
pinned: isPinned(bot),
activity: activityOf(bot),
active: activeRosterKeys.has(botRosterKey(bot))
}))
const rosterRows = sortGroupRosterRows([...botRows, ...groupRows], groupRooms)
const sortedGroupRows = sortGroupRosterRows(groupRows, groupRooms)
const gatewaySections = rosterGatewaySections(botRows, gatewayOptions, gatewayFilter)
const showGatewaySections = gatewaySections.sectioned && botRows.length > 0
return {
activeSourceRoster,
hiddenBots,
visibleRoster,
filteredHiddenBots,
groupNames,
rosterRows,
sortedGroupRows,
gatewaySections,
showGatewaySections
}
}
interface RosterPresentationInput {
rowKindFilter: RosterKindFilter
activityFilter: RosterActivityFilter
gatewayFilter: string
query: string
filteredHiddenBots: RosterRow[]
hiddenBots: RosterRow[]
hiddenExpanded: boolean
roster: RosterRow[]
groupNames: string[]
visibleRoster: RosterRow[]
gatewayOptions: ReturnType<typeof rosterGatewayOptions>
}
export function deriveRosterPresentation({
rowKindFilter,
activityFilter,
gatewayFilter,
query,
filteredHiddenBots,
hiddenBots,
hiddenExpanded,
roster,
groupNames,
visibleRoster,
gatewayOptions
}: RosterPresentationInput) {
const activeFilterCount =
(rowKindFilter === 'all' ? 0 : 1) + (activityFilter === 'all' ? 0 : 1) + (gatewayFilter === 'all' ? 0 : 1)
const hasRosterConstraint = Boolean(query.trim()) || activeFilterCount > 0
const matchingHiddenBots = rowKindFilter === 'groups' ? [] : filteredHiddenBots
const showHiddenSection = hiddenBots.length > 0 && (!hasRosterConstraint || matchingHiddenBots.length > 0)
const showHiddenRows = hiddenExpanded || hasRosterConstraint
const rosterItemCount = roster.length + groupNames.length
const allBotsHidden =
!hasRosterConstraint && visibleRoster.length === 0 && groupNames.length === 0 && hiddenBots.length > 0
const showRosterSearch =
gatewayOptions.length > 1 || rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD || Boolean(query.trim())
const showRosterFilters =
gatewayOptions.length > 1 ||
groupNames.length > 0 ||
rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD ||
activeFilterCount > 0
const showRosterTools = showRosterSearch || showRosterFilters
const hiddenGatewaySections = rosterGatewaySections(
matchingHiddenBots.map((bot: RosterRow) => ({
kind: 'bot',
bot
})),
gatewayOptions,
gatewayFilter
)
return {
activeFilterCount,
hasRosterConstraint,
matchingHiddenBots,
showHiddenSection,
showHiddenRows,
allBotsHidden,
showRosterSearch,
showRosterFilters,
showRosterTools,
hiddenGatewaySections
}
}
export function sortRosterBots(sourceWithSelectedOwner: RosterRow[], allMeta: Record<string, BotMeta>) {
// Messaging-app order: most recent activity first, where "activity" is
// the newest of (bot created, last message in any of its sessions). A
// freshly created bot tops the list until another bot gets a message.
// No special slot for the primary bot — it competes on recency too.
const activityOf = (bot: RosterRow): number => {
const created = botRosterMeta(bot, allMeta)?.created || bot.ui_meta?.['hermes-bots']?.created || 0
const lastMsg = (botActivitySession(bot)?.last_active || 0) * 1000
return Math.max(created, lastMsg)
}
// Pin is a source-qualified Desktop preference, not gateway profile state.
const isPinned = (bot: RosterRow): boolean => isBotPinned(bot, allMeta)
const roster = sourceWithSelectedOwner.slice().sort((a, b) => {
const pa = isPinned(a) ? 1 : 0
const pb = isPinned(b) ? 1 : 0
if (pa !== pb) {
return pb - pa
}
return activityOf(b) - activityOf(a)
})
return { roster, activityOf, isPinned }
}
@@ -0,0 +1,161 @@
import { ConfirmDialog, host } from '@hermes/plugin-sdk'
import type { useI18n } from '@hermes/plugin-sdk'
import { CreateAgentDialog, CreateGroupChatDialog, GroupDialog } from './create-dialog'
import type { useRoster } from './data'
import { EditProfileDialog } from './edit-profile-dialog'
import { disbandGroupChat, openGroupChat } from './group-chat-view'
import type { useBots } from './i18n'
import { deleteBot } from './profile-ops'
import type { GroupMember, RosterRow } from './types'
import { createBotSection, renameBotSection } from './user-sections'
import { SectionNameDialog } from './user-sections-ui'
interface renderRosterDialogsProps {
b: ReturnType<typeof useBots>
t: ReturnType<typeof useI18n>['t']
createOpen: boolean
setCreateOpen: (value: boolean) => void
groupCreateOpen: boolean
setGroupCreateOpen: (value: boolean) => void
editing: RosterRow | null
setEditing: (value: RosterRow | null) => void
deleting: (RosterRow & { path?: string }) | null
setDeleting: (value: (RosterRow & { path?: string }) | null) => void
deletingGroup: { members: GroupMember[]; name: string } | null
setDeletingGroup: (value: { members: GroupMember[]; name: string } | null) => void
grouping: RosterRow | null
setGrouping: (value: RosterRow | null) => void
sectionDialog: null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string }
setSectionDialog: (
value: null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string }
) => void
roster: RosterRow[]
activeSourceRoster: RosterRow[]
refetch: ReturnType<typeof useRoster>['refetch']
}
export function renderRosterDialogs({
b,
t,
createOpen,
setCreateOpen,
groupCreateOpen,
setGroupCreateOpen,
editing,
setEditing,
deleting,
setDeleting,
deletingGroup,
setDeletingGroup,
grouping,
setGrouping,
sectionDialog,
setSectionDialog,
roster,
activeSourceRoster,
refetch
}: renderRosterDialogsProps) {
return (
<>
<CreateAgentDialog
onClose={() => {
setCreateOpen(false)
void refetch()
}}
open={createOpen}
roster={activeSourceRoster}
/>
<CreateGroupChatDialog
onClose={() => setGroupCreateOpen(false)}
onCreated={groupName => openGroupChat(groupName)}
open={groupCreateOpen} // Full multi-source roster: group chats can seat bots from other
// registered connections — their turns route to their own machines.
roster={roster}
/>
<SectionNameDialog
initialName={sectionDialog?.mode === 'rename' ? sectionDialog.name : ''}
mode={sectionDialog?.mode === 'rename' ? 'rename' : 'create'}
onOpenChange={open => {
if (!open) {
setSectionDialog(null)
}
}}
onSubmit={name => {
if (sectionDialog?.mode === 'rename') {
renameBotSection(sectionDialog.id, name)
} else {
createBotSection(name, sectionDialog?.bot ? [sectionDialog.bot] : [])
}
}}
open={Boolean(sectionDialog)}
/>
<EditProfileDialog
bot={editing}
onClose={() => {
setEditing(null)
void refetch()
}}
open={Boolean(editing)}
/>
{grouping ? <GroupDialog bot={grouping} onClose={() => setGrouping(null)} /> : null}
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel={t.common.delete}
description={
deleting ? (
<span>
{'This will permanently delete the bot '}
<span className="font-medium text-foreground">{deleting.name}</span>
{' and its associated Hermes profile at '}
<span className="font-mono text-xs">{deleting.path}</span>. This cannot be undone.
</span>
) : null
}
destructive
doneLabel="Deleted"
onClose={() => setDeleting(null)}
onConfirm={async () => {
if (!deleting) {
return
}
const name = deleting.name
await deleteBot(deleting)
await refetch()
host.notify({
kind: 'success',
message: `Deleted profile ${name}`
})
}}
open={Boolean(deleting)}
title={b.bot.deleteTitle}
/>
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel={b.group.deleteAction}
description={
deletingGroup
? `This removes “${deletingGroup.name}” from its bots and clears the shared room log. The bots and their individual chats are kept.`
: null
}
destructive
doneLabel="Deleted"
onClose={() => setDeletingGroup(null)}
onConfirm={async () => {
if (!deletingGroup) {
return
}
await disbandGroupChat(deletingGroup.name, deletingGroup.members)
host.notify({
kind: 'success',
message: `Deleted group “${deletingGroup.name}”`
})
}}
open={Boolean(deletingGroup)}
title={b.group.deleteTitle}
/>
</>
)
}
@@ -0,0 +1,76 @@
import { Button, Codicon, Tip } from '@hermes/plugin-sdk'
import type { ComponentProps } from 'react'
import { GroupRow } from './bot-row'
import { $botMeta } from './data'
import { $groupChats, updateGroupChat } from './group-chat'
import type { $groupClarify, $groupNeedsYou } from './group-chat'
import { groupChatNames, groupLastActivity } from './group-membership'
import { reorderGroupRows, sortGroupRosterRows } from './group-order'
import { groupHasPendingClarify } from './group-turns'
import type { useBots } from './i18n'
import type { RosterGroupRow } from './roster-pane-derivation'
interface RosterGroupRowViewProps extends Omit<ComponentProps<typeof GroupRow>, 'needsYou'> {
b: ReturnType<typeof useBots>
groupClarify: ReturnType<typeof $groupClarify.get>
groupNeedsYou: ReturnType<typeof $groupNeedsYou.get>
groupRooms: ReturnType<typeof $groupChats.get>
sortedGroupRows: RosterGroupRow[]
}
export function RosterGroupRowView({
b,
groupClarify,
groupNeedsYou,
groupRooms,
sortedGroupRows,
...rowProps
}: RosterGroupRowViewProps) {
const moveRoom = (name: string, delta: -1 | 1) => {
// Read at the gesture, not the last render: a sync or disband may have
// replaced this room in the meantime. Ordering never writes bot metadata.
const current = $groupChats.get()
if (current[name]?.roomId !== groupRooms[name]?.roomId || current[name]?.tombstone) {
return
}
const rows = groupChatNames($botMeta.get(), current).map(name => ({
kind: 'group' as const,
name,
pinned: Boolean(current[name]?.pinned),
activity: groupLastActivity(current[name])
}))
const order = reorderGroupRows(sortGroupRosterRows(rows, current), name, delta, sortedGroupRows.map(row => row.name))
order?.forEach((name, rosterOrder) => {
updateGroupChat(name, room => ({ ...room, rosterOrder }), { sync: false })
})
}
return (
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center">
<GroupRow
{...rowProps}
needsYou={Boolean(groupNeedsYou[rowProps.group]) || groupHasPendingClarify(groupClarify, rowProps.group)}
/>
<div className="flex flex-col">
{([-1, 1] as const).map(delta => (
<Tip key={delta} label={delta === -1 ? b.sections.moveUp : b.sections.moveDown}>
<Button
aria-label={`${rowProps.group}: ${delta === -1 ? b.sections.moveUp : b.sections.moveDown}`}
disabled={!reorderGroupRows(sortedGroupRows, rowProps.group, delta)}
onClick={() => moveRoom(rowProps.group, delta)}
size="icon-xs"
variant="ghost"
>
<Codicon name={delta === -1 ? 'chevron-up' : 'chevron-down'} />
</Button>
</Tip>
))}
</div>
</div>
)
}
@@ -0,0 +1,54 @@
import { atom, host } from '@hermes/plugin-sdk'
import { useEffect } from 'react'
import { $lastRoster } from './data'
import type { useRoster } from './data'
import { displayName } from './labels'
import { mergeServerMeta, pullServerAvatars } from './profile-ops'
import { trackInboundActivity } from './roster-actions'
import { botRosterMeta, botWorkspaceOwnerKey } from './routing'
import { backfillMessagingProtocol } from './soul'
import type { GatewaySource } from './types'
import type { BotMeta, RosterRow } from './types'
/** Last source inventory returned by the desktop-wide agent roster. */
export const $lastSources = atom<GatewaySource[]>([])
interface RosterSnapshotInput {
data: ReturnType<typeof useRoster>['data']
live: RosterRow[] | null
roster: RosterRow[]
allMeta: Record<string, BotMeta>
activeSourceRoster: RosterRow[]
}
export function usePublishRosterSnapshot({ data, live, roster, allMeta, activeSourceRoster }: RosterSnapshotInput) {
useEffect(() => {
if (!live) {
return
}
// Offline-owner ghosts belong only to this render. Shared roster state
// feeds merge caching, group membership, creation, and durable sync. These
// writes must settle after render: other subscribers of the same atoms
// would otherwise be updated while BotsPane was still rendering.
$lastRoster.set(roster.filter(row => !row?.ghost))
// Tabs caption a bot chat by its bot (#99152); republished with the
// roster so a rename follows and tiles restored at boot resolve.
roster.forEach(bot => {
host.setWorkspaceOwnerLabel?.(botWorkspaceOwnerKey(bot), displayName(bot, botRosterMeta(bot, allMeta)))
})
if (Array.isArray(data?.sources)) {
$lastSources.set(data.sources)
}
mergeServerMeta(activeSourceRoster, data?.fetchedAt || 0)
pullServerAvatars(activeSourceRoster)
trackInboundActivity(roster)
backfillMessagingProtocol(activeSourceRoster)
// React Query owns the stable server snapshot; derived arrays intentionally
// follow that snapshot rather than retriggering on their own atom writes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data])
}
@@ -0,0 +1,195 @@
import { host } from '@hermes/plugin-sdk'
import type { ReactNode } from 'react'
import { botRosterKey } from './data'
import type { useBots } from './i18n'
import type { RosterGroupRow } from './roster-pane-derivation'
import { GatewayKindGlyph, GatewaySectionHeading, RosterSectionHeader } from './roster-sections'
import type { ResolvedRosterGatewaySection } from './roster-sections'
import type { BotMeta, GroupMember, RosterRow } from './types'
import type { $botSections } from './user-sections'
import {
deleteBotSection,
groupRowsBySection,
moveBotSection,
moveBotsToSection,
UNASSIGNED_SECTION_KEY
} from './user-sections'
import { SectionDropZone, UserSectionHeader } from './user-sections-ui'
interface RosterSectionRenderersProps {
b: ReturnType<typeof useBots>
userSections: ReturnType<typeof $botSections.get>
roster: RosterRow[]
allMeta: Record<string, BotMeta>
dragging: string | null
rosterSectionCollapsed: (id: string) => boolean
toggleRosterSection: (id: string) => void
setSectionDialog: (
value: null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string }
) => void
renderBotRow: (bot: RosterRow, keyPrefix?: string) => ReactNode
renderGroupRow: (row: { members: GroupMember[]; name: string }) => ReactNode
sortedGroupRows: RosterGroupRow[]
}
export function rosterSectionRenderers({
b,
userSections,
roster,
allMeta,
dragging,
rosterSectionCollapsed,
toggleRosterSection,
setSectionDialog,
renderBotRow,
renderGroupRow,
sortedGroupRows
}: RosterSectionRenderersProps) {
const removeSection = (id: string) => {
const name = userSections.find(section => section.id === id)?.name || ''
const { members, undo } = deleteBotSection(id, roster)
// No confirmation: nothing is lost (the bots fall back to Unassigned) and
// the toast's Undo puts the section and its members back.
host.notify({
action: { label: b.sections.undo, onClick: undo },
durationMs: 8_000,
kind: 'info',
message: b.sections.deleted(name, members.length)
})
}
// USER SECTIONS — composed with the gateway sections, not instead of them.
// The gateway headings own the top level whenever the roster shows more
// than one connection (that axis answers "where does this run", which no
// folder name can, and a bot's membership lives in its profile on THAT
// gateway); user sections group the rows INSIDE each connection bucket,
// indented under it, and group the flat list when there is only one.
// `keyPrefix` keeps row keys unique across the gateway buckets.
type UserSectionRow = { bot: RosterRow; kind?: 'bot' } | RosterGroupRow
const renderUserSections = (rows: UserSectionRow[], keyPrefix = '') => {
// No sections made: the plain list, exactly as before this feature.
if (!userSections.length) {
return rows.map(row => (row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot, keyPrefix)))
}
const nested = Boolean(keyPrefix)
const blocks = groupRowsBySection(rows, userSections, allMeta)
return (
blocks
// An empty Unassigned is not worth a heading; an empty NAMED section
// is, because it is somewhere the user made and is about to drop into.
// Inside a gateway bucket the same empty section would repeat under
// every connection, so there it only appears while a drag is in flight
// (as the drop target it exists for); the row menu files into it
// regardless.
.filter(block => block.rows.length || (block.id && (!nested || dragging)))
.map(block => {
const key = `${keyPrefix}${block.id ? `user-section:${block.id}` : UNASSIGNED_SECTION_KEY}`
const collapsed = rosterSectionCollapsed(key)
const order = userSections.findIndex(section => section.id === block.id)
return (
<SectionDropZone
isSource={
Boolean(dragging) && block.rows.some(row => row.kind !== 'group' && botRosterKey(row.bot) === dragging)
}
key={key}
nested={nested}
onDropBot={rosterKey => {
const bot = roster.find(row => botRosterKey(row) === rosterKey)
// `block.id` is null for Unassigned, which is exactly the value
// moveBotsToSection wants for "clear the assignment".
if (bot) {
void moveBotsToSection([bot], block.id)
}
}}
>
<UserSectionHeader
canMoveDown={order >= 0 && order < userSections.length - 1}
canMoveUp={order > 0}
collapsed={collapsed}
count={block.rows.length}
id={block.id}
name={block.name}
onDelete={() => block.id && removeSection(block.id)}
onMove={delta => block.id && moveBotSection(block.id, delta)}
onRename={() => block.id && setSectionDialog({ id: block.id, mode: 'rename', name: block.name })}
onToggle={() => toggleRosterSection(key)}
/>
{collapsed ? null : block.rows.length ? (
<div className="grid min-w-0 gap-0.5">
{block.rows.map(row =>
row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot, `${key}:`)
)}
</div>
) : (
// Empty section: a quiet dashed slot that says what it is for,
// and doubles as a roomy drop target.
<div className="mx-1 mb-1 rounded-md border border-dashed border-(--ui-stroke-secondary) px-2 py-2 text-center text-[0.6875rem] text-(--ui-text-quaternary)">
{b.sections.emptyHint}
</div>
)}
</SectionDropZone>
)
})
)
}
const renderGatewaySection = (section: ResolvedRosterGatewaySection) => {
const sectionId = `gateway:${section.id}`
const collapsed = rosterSectionCollapsed(sectionId)
return (
<div className="min-w-0" key={sectionId}>
<GatewaySectionHeading
collapsed={collapsed}
count={section.rows.length}
onToggle={() => toggleRosterSection(sectionId)}
option={section.option}
/>
{collapsed ? null : (
<div className="grid min-w-0 gap-0.5">{renderUserSections(section.rows, `${section.id}:`)}</div>
)}
</div>
)
}
const renderGroupChatSection = () => {
const sectionId = 'group-chats'
const collapsed = rosterSectionCollapsed(sectionId)
return (
<div className="min-w-0" key={sectionId}>
<RosterSectionHeader
collapsed={collapsed}
count={sortedGroupRows.length}
icon="organization"
label={b.roster.groupChats}
onToggle={() => toggleRosterSection(sectionId)}
tip={`${sortedGroupRows.length} global group chat${sortedGroupRows.length === 1 ? '' : 's'}`}
/>
{collapsed ? null : <div className="grid min-w-0 gap-0.5">{sortedGroupRows.map(renderGroupRow)}</div>}
</div>
)
}
const renderHiddenGatewaySection = (section: ResolvedRosterGatewaySection) => (
<div className="min-w-0" key={`hidden-gateway:${section.id}`}>
<div className="flex min-w-0 items-center gap-1.5 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)">
<GatewayKindGlyph kind={section.option?.kind} />
<span className="min-w-0 flex-1 truncate">
{section.option?.label || section.option?.connectionId || 'Current gateway'}
</span>
<span className="shrink-0 font-normal tabular-nums">{section.rows.length}</span>
</div>
{section.rows.map(row => renderBotRow(row.bot, `hidden:${section.id}:`))}
</div>
)
return { renderUserSections, renderGatewaySection, renderGroupChatSection, renderHiddenGatewaySection }
}
@@ -0,0 +1,227 @@
import {
Button,
cn,
Codicon,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
SearchField,
Tip
} from '@hermes/plugin-sdk'
import { botSourceStatus } from './data'
import type { useBots } from './i18n'
import { setActivityToasts } from './roster-actions'
import { GatewayKindGlyph } from './roster-sections'
import type { rosterGatewayOptions } from './roster-sections'
import type { RosterActivityFilter, RosterKindFilter, RosterRow } from './types'
interface renderRosterToolbarProps {
b: ReturnType<typeof useBots>
activityToasts: boolean
activeSourceRoster: RosterRow[]
setCreateOpen: (value: boolean) => void
setGroupCreateOpen: (value: boolean) => void
setSectionDialog: (
value: null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string }
) => void
showRosterTools: boolean
showRosterSearch: boolean
showRosterFilters: boolean
query: string
setQuery: (value: string) => void
activeFilterCount: number
gatewayOptions: ReturnType<typeof rosterGatewayOptions>
rowKindFilter: RosterKindFilter
setRowKindFilter: (value: RosterKindFilter) => void
activityFilter: RosterActivityFilter
setActivityFilter: (value: RosterActivityFilter) => void
gatewayFilter: string
setGatewayFilter: (value: string) => void
}
export function renderRosterToolbar({
b,
activityToasts,
activeSourceRoster,
setCreateOpen,
setGroupCreateOpen,
setSectionDialog,
showRosterTools,
showRosterSearch,
showRosterFilters,
query,
setQuery,
activeFilterCount,
gatewayOptions,
rowKindFilter,
setRowKindFilter,
activityFilter,
setActivityFilter,
gatewayFilter,
setGatewayFilter
}: renderRosterToolbarProps) {
return (
<>
<div className="flex items-center justify-between gap-2 px-2.5 pt-2.5 pb-1.5">
<span className="text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)">
Bots
</span>
<div className="flex items-center gap-0.5">
<Tip
label={activityToasts ? 'Activity toasts on — click to silence' : 'Activity toasts off — click to enable'}
>
<Button
className="rounded-md text-(--ui-text-tertiary) hover:text-foreground"
onClick={() => setActivityToasts(!activityToasts)}
size="icon-xs"
variant="ghost"
>
<Codicon name={activityToasts ? 'bell' : 'bell-slash'} />
</Button>
</Tip>
<DropdownMenu>
<Tip label="New…">
<DropdownMenuTrigger asChild>
<Button
aria-label={b.roster.newBotOrGroup}
className="rounded-md text-(--ui-text-tertiary) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<Codicon name="add" />
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setCreateOpen(true)}>
<Codicon className="mr-1.5" name="hubot" />
{b.bot.newTitle}
</DropdownMenuItem>
<DropdownMenuItem disabled={activeSourceRoster.length < 2} onSelect={() => setGroupCreateOpen(true)}>
<Codicon className="mr-1.5" name="organization" />
{b.group.newTitle}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setSectionDialog({ mode: 'create' })}>
<Codicon className="mr-1.5" name="new-folder" />
{b.sections.newSection}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{showRosterTools ? (
<div className="flex min-w-0 items-center gap-1 px-2.5 pb-1.5">
{showRosterSearch ? (
<SearchField
aria-label={b.roster.search}
containerClassName={cn('min-w-0 flex-1', query ? 'opacity-100!' : 'opacity-50 focus-within:opacity-100')}
inputClassName="w-full text-[0.75rem] placeholder:text-(--ui-text-tertiary)"
key={'roster-search'}
onChange={setQuery}
placeholder={b.roster.searchPlaceholder}
value={query}
/>
) : (
<span className="min-w-0 flex-1" key={'roster-search-spacer'} />
)}
{showRosterFilters ? (
<DropdownMenu key={'roster-filters'}>
<Tip label={activeFilterCount ? `Filters (${activeFilterCount} active)` : 'Filter roster'}>
<DropdownMenuTrigger asChild>
<Button
aria-label={activeFilterCount ? `Filter roster, ${activeFilterCount} active` : 'Filter roster'}
className={cn(
'size-7 shrink-0 rounded-md text-(--ui-text-tertiary) hover:text-foreground',
activeFilterCount && 'text-(--ui-accent)'
)}
size="icon-xs"
variant="ghost"
>
<Codicon name="list-filter" />
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end">
{(
[
['all', b.roster.botsAndGroups],
['bots', b.roster.botsOnly],
['groups', b.roster.groupsOnly]
] as [RosterKindFilter, string][]
).map(([value, label]) => (
<DropdownMenuItem key={`kind:${value}`} onSelect={() => setRowKindFilter(value)}>
<span className="min-w-0 flex-1">{label}</span>
{rowKindFilter === value ? <Codicon name="check" /> : null}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{(
[
['all', b.roster.anyActivity],
['active', b.roster.activeNow],
['recent', b.roster.recentlyActive],
['older', b.roster.older]
] as [RosterActivityFilter, string][]
).map(([value, label]) => (
<DropdownMenuItem key={`activity:${value}`} onSelect={() => setActivityFilter(value)}>
<span className="min-w-0 flex-1">{label}</span>
{activityFilter === value ? <Codicon name="check" /> : null}
</DropdownMenuItem>
))}
{gatewayOptions.length > 1 ? <DropdownMenuSeparator /> : null}
{gatewayOptions.length > 1 ? (
<DropdownMenuItem onSelect={() => setGatewayFilter('all')}>
<Codicon className="mr-1.5" name="globe" />
<span className="min-w-0 flex-1">All gateways</span>
{gatewayFilter === 'all' ? <Codicon name="check" /> : null}
</DropdownMenuItem>
) : null}
{gatewayOptions.length > 1
? gatewayOptions.map(option => {
const status = botSourceStatus({
sourceError: option.error,
sourceReachable: option.reachable
})
return (
<DropdownMenuItem
key={option.connectionId}
onSelect={() => setGatewayFilter(option.connectionId)}
>
<GatewayKindGlyph
className={cn('mr-1.5', !status.available && 'text-amber-600 dark:text-amber-300')}
kind={option.kind}
/>
<span className="min-w-0 flex-1 truncate">{option.label || option.connectionId}</span>
<span className="text-[0.625rem] tabular-nums text-(--ui-text-quaternary)">
{option.count}
</span>
{gatewayFilter === option.connectionId ? <Codicon name="check" /> : null}
</DropdownMenuItem>
)
})
: []}
{activeFilterCount ? <DropdownMenuSeparator /> : null}
{activeFilterCount ? (
<DropdownMenuItem
onSelect={() => {
setRowKindFilter('all')
setActivityFilter('all')
setGatewayFilter('all')
}}
>
{b.roster.clearFilters}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
) : null}
</>
)
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -98,7 +98,7 @@ Use the **Move up** and **Move down** arrows beside a room to choose its positio
- **One visible conversation.** Public messages and each member's reply stay readable in arrival order, with the speaker's name and timestamp. Starting another topic does not collapse earlier replies. **Reply in thread** continues that topic without reordering the room; **Activity** is a secondary status view, not a replacement for messages. Private Bot Chats remain separate.
- Your message triggers up to **three serial rounds** of member turns. @-mentioned Bots respond (everyone responds when nobody is mentioned); each Bot replies briefly or passes, and the room settles when a full round stays silent.
- Bots pull each other in with `@name`, and escalate real judgment calls to you with `@user` — the group row shows a **needs you** badge when that happens.
- Bots pull each other in with `@name`, and escalate real judgment calls to you with `@user` — the group row shows a **needs you** badge when that happens. Pending questions and command approvals also light that badge; resolving the last prompt clears only prompt attention, not an independent mention. Prompts follow a renamed room, while disbanding retires them even if a member's in-flight poll arrives later.
- Hard caps (10 messages per send, 3 rounds) keep rooms from spinning.
- Each member keeps its own persistent `Group: <name>` session, so room context survives like any other conversation.
- **Not every Bot replies to every message.** Speaking is each member's own choice — a Bot replies only when it has something new to add and passes otherwise, and @-mentioning specific members scopes the round to them. Expect the members you addressed (or whoever has something to say) to speak, and the rest to stay quiet.