diff --git a/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx b/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx index 481bcb4c16..55dcc8e2df 100644 --- a/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx +++ b/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx @@ -19,7 +19,7 @@ import type * as HermesSdk from '@hermes/plugin-sdk' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { BotRow, GroupRow } from './bot-row' +import { BotRow } from './bot-row' import { translateBots } from './i18n-test-helper' import type { RosterRow } from './types' @@ -175,23 +175,3 @@ describe('context-menu mutations hydrate the alias first', () => { expect(params).toMatchObject({ name: 'backend-worker', ui_meta: { 'hermes-bots': { pinned: false } } }) }) }) - -describe('GroupRow renders its needs-you badge from the needsYou prop', () => { - const noopGroup = () => undefined - - it('shows the question badge when needsYou is true', () => { - render( - - ) - - expect(screen.getByLabelText('Needs your input')).toBeTruthy() - }) - - it('hides the question badge when needsYou is false', () => { - render( - - ) - - expect(screen.queryByLabelText('Needs your input')).toBeNull() - }) -}) diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx index 059c895fd8..c93ca55793 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx @@ -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: " title lookup instead of a fresh "Group: ". * 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) diff --git a/apps/desktop/src/plugins/hermes-bots/group-membership.ts b/apps/desktop/src/plugins/hermes-bots/group-membership.ts index 8d8e8a00e4..bfbe5814f2 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-membership.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-membership.ts @@ -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])}` } diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index ff65ff2745..476921c0cd 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -21,7 +21,7 @@ import { updateGroupChat } from './group-chat' import type { GroupChatRoom, GroupHoldStamp } from './group-chat' -import { durableGroupChatMembers, groupMemberKey } from './group-membership' +import { durableGroupChatMembers, followGroupChat, groupMemberKey } from './group-membership' import { harvestStrandedGroupReply, isGroupPassText, runGroupChatMemberTurn } from './group-turns' import { requestForBot } from './routing' import type { Attachment, GroupMember, GroupMessage } from './types' @@ -493,8 +493,9 @@ 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 let posted = 0 let continuations = 0 // #94478: how this drive ended. 'settled' means quiet consensus (everyone @@ -519,6 +520,10 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], } await harvestStrandedGroupReply(group, member) + + if (!binding.isLive()) { + return + } } const roomLog = (($groupChats.get()[group] || {}).log || []).filter( @@ -676,6 +681,10 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], // 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 + } + const roomNow = $groupChats.get()[group] || { log: [] } @@ -911,6 +920,8 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], void harvestStrandedUntilSettled(group, members, thread) } } + + binding.dispose() } } @@ -919,39 +930,53 @@ 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 +1051,31 @@ export function sendToGroupChat( thread: target }) - if (!wasRunning) { - void runGroupChatRounds(group, members, target).catch(() => { - updateGroupChat(group, (r: GroupChatRoom) => { - r.running = false + const binding = followGroupChat(group, name => { group = name }) - return r - }) - }) - } 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(() => { + 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 }) - }) - }, 250) + } + }).finally(binding.dispose) + } + + if (!wasRunning) { + drive() + } else { + // Preserve the existing newer-send handoff delay, without pinning its name. + setTimeout(drive, 250) } return target diff --git a/apps/desktop/src/plugins/hermes-bots/group-turns.test.ts b/apps/desktop/src/plugins/hermes-bots/group-turns.test.ts index 948af00790..c7414b74dc 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-turns.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-turns.test.ts @@ -485,104 +485,194 @@ describe('clarify and approvals (#90694)', () => { expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Other')).toBe(true) }) - it('keeps the derived badge lit until every clarify in the room is answered', async () => { - const room = await loadRoom() - const research: GroupMember = { name: 'research', title: '' } - const ops: GroupMember = { name: 'ops', title: '' } - - room.turns.syncGroupClarify('Core', research, { pending_clarify: CLARIFY }) - room.turns.syncGroupClarify('Core', ops, { pending_clarify: { ...CLARIFY, request_id: 'req-2' } }) - - expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(true) - - const [first, second] = Object.values(room.chat.$groupClarify.get()) - await room.turns.answerGroupClarify(first, research, 'staging') - - // One of two questions answered — the room still owes the user a reply. - expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(true) - - await room.turns.answerGroupClarify(second, ops, 'staging') - - // Last pending clarify answered — nothing left to badge. - expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(false) + 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) }) - // $groupNeedsYou (mention attention) and - // $groupClarify (pending clarify/approval attention) are independent - // sources — resolving one must never clear the other. The roster reads - // BOTH: groupNeedsYou[group] || groupHasPendingClarify(clarifies, group). - it('resolving the last clarify preserves independent @user mention attention', async () => { - const room = await loadRoom() - const research: GroupMember = { name: 'research', title: '' } + 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(resolve => { entered = resolve }) + const held = new Promise(resolve => { release = resolve }) + const original = host.request as (method: string, params: Record) => Promise + let submitted = false + let answered = false + let polls = 0 - // A member addressing @user badges the room — independent of clarify. - room.chat.appendGroupChatEntry('Core', { kind: 'member', name: 'research' }, '@user deployment needs a call') - expect(room.chat.$groupNeedsYou.get().Core).toBe(true) + host.request = async (method: string, params: Record) => { + const result = await original(method, params) - room.turns.syncGroupClarify('Core', research, { pending_clarify: CLARIFY }) - expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(true) + if (method === 'prompt.submit') {submitted = true} - await room.turns.answerGroupClarify(Object.values(room.chat.$groupClarify.get())[0], research, 'staging') + if (method === 'clarify.respond') {answered = true} - // Clarify resolved — but the unrelated mention attention must survive. - expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(false) - expect(room.chat.$groupNeedsYou.get().Core).toBe(true) - }) + if (method === 'session.resume' && submitted && !answered) { + if (++polls === 1) { entered(); await held } - it('preserves pending clarify attention across a room rename', async () => { - const room = await loadRoom() - const research: GroupMember = { name: 'research', title: '' } + return { ...result, pending_clarify: CLARIFY } + } - // Core has a pending clarify and no @user mention — attention is - // entirely clarify-derived, the case a plain group-key swap loses. - room.turns.syncGroupClarify('Core', research, { pending_clarify: CLARIFY }) - expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(true) - expect(room.chat.$groupNeedsYou.get().Core).toBeFalsy() + return result + } - room.turns.renameGroupClarify('Core', 'Renamed') + const drive = room.rounds.runGroupChatRounds('Core', [member], 'thread') + await polled - // The prompt now belongs to the new name — not the old one. - expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Renamed')).toBe(true) - expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(false) - const [prompt] = Object.values(room.chat.$groupClarify.get()) - expect(prompt.group).toBe('Renamed') + 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', []) - // Resolving it under the new name clears attention there. - await room.turns.answerGroupClarify(prompt, research, 'staging') - expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Renamed')).toBe(false) - }) + const mirrored = new Promise(resolve => { + const stop = room.chat.$groupClarify.listen(entries => { + if (Object.keys(entries).length) { stop(); resolve() } + }) + }) - it('lets the renamed room\'s current prompt replace a stale mirror already at the destination', async () => { - const room = await loadRoom() - - // Current prompt inserted FIRST, stale destination mirror SECOND — a - // single-pass rekey that iterates Object.entries in insertion order - // would let the stale entry (visited last) clobber the just-migrated - // current one. Two passes must let the live room win regardless of - // insertion order. - room.turns.syncGroupClarify('Core', { name: 'research', title: '' }, { pending_clarify: CLARIFY }) - room.chat.$groupClarify.set({ - ...room.chat.$groupClarify.get(), - 'Renamed::research': { - at: Date.now(), - choices: [], - group: 'Renamed', - kind: 'clarify', - member: 'research', - memberKey: 'research', - multiSelect: false, - question: 'stale question', - questions: null, - requestId: 'req-stale', - sessionId: null + 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) + } } - }) + } - room.turns.renameGroupClarify('Core', 'Renamed') + const clock = vi.spyOn(Date, 'now').mockReturnValue(0) + onTestFinished(() => clock.mockRestore()) - const migrated = room.chat.$groupClarify.get()['Renamed::research'] - expect(migrated.requestId).toBe(CLARIFY.request_id) - expect(Object.keys(room.chat.$groupClarify.get())).toEqual(['Renamed::research']) + 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(resolve => { entered = resolve }) + const held = new Promise(resolve => { release = resolve }) + const original = host.request as (method: string, params: Record) => Promise + let submitted = false + + host.request = async (method: string, params: Record) => { + 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(resolve => { entered = resolve }) + const held = new Promise(resolve => { release = resolve }) + let background = false + const backgroundProfiles: unknown[] = [] + const original = host.request as (method: string, params: Record) => Promise + + host.request = async (method: string, params: Record) => { + 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 () => { diff --git a/apps/desktop/src/plugins/hermes-bots/group-turns.ts b/apps/desktop/src/plugins/hermes-bots/group-turns.ts index e3e6cfdec6..67f737ba87 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-turns.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-turns.ts @@ -11,7 +11,7 @@ import { host } from '@hermes/plugin-sdk' import { recordGroupActivity } from './group-activity' 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,128 @@ 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 { - 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() } } @@ -529,13 +547,8 @@ export function clearGroupClarify(group: string) { } } -/** Re-key every mirrored clarify belonging to `oldName` onto `newName` - * (rename). Unlike disband, the room still exists under its new name — a - * pending clarify's attention must follow it, not disappear. Known gap: - * an in-flight member turn captured `oldName` as its closure argument - * (runGroupChatMemberTurnLeased) and keeps polling under that name, so a - * poll that lands after this rename can still re-mirror a NEW entry under - * oldName. That race predates this function and isn't fixed here. */ +/** 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 = {} @@ -583,40 +596,51 @@ export async function answerGroupClarify( member: GroupMember, answers: Record | 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)?.[qid] ?? '' + }) + } + } else { await requestForBot(member, 'clarify.respond', { request_id: entry.requestId, - question_id: qid, - answer: (answers as Record)?.[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() } } @@ -638,12 +662,16 @@ 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() } } @@ -654,281 +682,315 @@ async function runGroupChatMemberTurnLeased( thread: string, images?: Attachment[] ): Promise { - 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([runtime]) + const binding = followGroupChat(group, name => { group = name }) try { - const pre = (await requestForBot(member, 'session.resume', { - session_id: stored || runtime, - profile: member.name - })) as GroupSessionSnapshot + const { runtime, stored } = await ensureGroupChatSession(group, member) - 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 */ - } - - // 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 - // gateway can't see this machine's files). Images queue as vision tiles, - // PDFs render per-page via pdf.attach, and other files materialize in the - // session workspace (their @file: refs are appended to the prompt so the - // member's file tools can read them). A failed attach degrades that - // member to text-only; the transcript line still names the attachment so - // the member knows something was shared. - const fileRefs: string[] = [] - - for (const img of Array.isArray(images) ? images : []) { - if (!img || typeof img.data !== 'string' || !img.data) { - continue - } - - try { - if (img.kind === 'pdf') { - await requestForBot(member, 'pdf.attach', { - session_id: runtime, - content_base64: img.data, - filename: img.name || 'attachment.pdf' - }) - } else if (img.kind === 'file') { - const res = (await requestForBot(member, 'file.attach', { - session_id: runtime, - data_url: img.data, - name: img.name || 'attachment' - })) as { ref_text?: string } - - if (res?.ref_text) { - fileRefs.push(`${img.name || 'attachment'} → ${res.ref_text}`) - } - } else { - await requestForBot(member, 'image.attach_bytes', { - session_id: runtime, - content_base64: img.data, - filename: img.name || 'attachment.png' - }) - } - } catch { - /* text-only fallback for this member */ - } - } - - 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) - runtimeIds.add(liveRuntime) - const started = Date.now() - let deadline = started + GROUP_TURN_TIMEOUT_MS - // After the terminal frame fires, the gateway still has to flip - // session.running off in its turn `finally` — re-check quickly for a few - // beats instead of falling back to the slow backstop cadence. - let quickRechecks = 0 - - while (Date.now() < deadline) { - const signalled = quickRechecks - ? await waitForTurnSignal([], GROUP_TURN_SETTLE_RECHECK_MS) - : await waitForTurnSignal([...runtimeIds], GROUP_TURN_POLL_MS) - - quickRechecks = signalled ? GROUP_TURN_SETTLE_RECHECKS : Math.max(0, quickRechecks - 1) - - // #91868/#94569: an explicit stop (stopGroupThread) bumped the epoch AND - // held this member — the member's session was interrupted, so nothing is - // coming; abandon the poll instead of grinding until the deadline. Both - // 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 ((roomDuringPoll.epoch || 0) !== dispatchEpoch && (roomDuringPoll.holds || {})[memberKey]) { + if (!runtime || !binding.isLive()) { return null } - let state: GroupSessionSnapshot | null = 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([runtime]) try { - state = (await requestForBot(member, 'session.resume', { - session_id: stored || liveRuntime, + 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 { - continue + /* lazy session — zero messages */ } - if (state?.session_id) { - runtimeIds.add(state.session_id) + // 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 + // gateway can't see this machine's files). Images queue as vision tiles, + // PDFs render per-page via pdf.attach, and other files materialize in the + // session workspace (their @file: refs are appended to the prompt so the + // member's file tools can read them). A failed attach degrades that + // member to text-only; the transcript line still names the attachment so + // the member knows something was shared. + const fileRefs: string[] = [] + + for (const img of Array.isArray(images) ? images : []) { + if (!img || typeof img.data !== 'string' || !img.data) { + continue + } + + try { + if (img.kind === 'pdf') { + await requestForBot(member, 'pdf.attach', { + session_id: runtime, + content_base64: img.data, + filename: img.name || 'attachment.pdf' + }) + } else if (img.kind === 'file') { + const res = (await requestForBot(member, 'file.attach', { + session_id: runtime, + data_url: img.data, + name: img.name || 'attachment' + })) as { ref_text?: string } + + if (res?.ref_text) { + fileRefs.push(`${img.name || 'attachment'} → ${res.ref_text}`) + } + } else { + await requestForBot(member, 'image.attach_bytes', { + session_id: runtime, + content_base64: img.data, + filename: img.name || 'attachment.png' + }) + } + } catch { + /* text-only fallback for this member */ + } } - const messages = Array.isArray(state?.messages) ? state.messages : [] - const busy = Boolean(state?.inflight || state?.running) - // 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 done = !busy && !awaitingUser + if (!binding.isLive()) { + return null + } - if (messages.length > before && done) { - const replyText = pickGroupTurnReply(messages, before) + 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) + const started = Date.now() + let deadline = started + GROUP_TURN_TIMEOUT_MS + // After the terminal frame fires, the gateway still has to flip + // session.running off in its turn `finally` — re-check quickly for a few + // beats instead of falling back to the slow backstop cadence. + let quickRechecks = 0 + + while (Date.now() < deadline) { + const signalled = quickRechecks + ? await waitForTurnSignal([], GROUP_TURN_SETTLE_RECHECK_MS) + : await waitForTurnSignal([...runtimeIds], GROUP_TURN_POLL_MS) + + quickRechecks = signalled ? GROUP_TURN_SETTLE_RECHECKS : Math.max(0, quickRechecks - 1) + + // #91868/#94569: an explicit stop (stopGroupThread) bumped the epoch AND + // held this member — the member's session was interrupted, so nothing is + // coming; abandon the poll instead of grinding until the deadline. Both + // 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). + if (!binding.isLive()) { + return null + } + + const roomDuringPoll = $groupChats.get()[group] || {} + + if ((roomDuringPoll.epoch || 0) !== dispatchEpoch && (roomDuringPoll.holds || {})[memberKey]) { + return null + } + + let state: GroupSessionSnapshot | null = null + + try { + state = (await requestForBot(member, 'session.resume', { + session_id: stored || liveRuntime, + profile: member.name + })) as GroupSessionSnapshot + } catch { + continue + } + + if (!binding.isLive()) { + return null + } + + if (state?.session_id) { + runtimeIds.add(state.session_id) + } + + const messages = Array.isArray(state?.messages) ? state.messages : [] + const busy = Boolean(state?.inflight || state?.running) + // 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 done = !busy && !awaitingUser + + if (messages.length > before && done) { + const replyText = pickGroupTurnReply(messages, before) + + if (replyText !== null) { + recordGroupActivity(group, { + kind: isGroupPassText(replyText) ? 'passed' : 'replied', + member: member.name, + thread + }) + + return replyText + } - if (replyText !== null) { recordGroupActivity(group, { - kind: isGroupPassText(replyText) ? 'passed' : 'replied', + kind: 'passed', member: member.name, thread }) - return replyText + return null } - recordGroupActivity(group, { - kind: 'passed', - member: member.name, - thread - }) + // Still visibly working — or waiting on the user's answer to a clarify: + // extend the deadline (never past the hard cap). A pending question must + // outlive the base turn timeout or it dies unanswered at 3 minutes. + if (busy || awaitingUser) { + deadline = Math.min(started + GROUP_TURN_HARD_CAP_MS, Math.max(deadline, Date.now() + GROUP_TURN_TIMEOUT_MS)) + } + } + if (!binding.isLive()) { return null } - // Still visibly working — or waiting on the user's answer to a clarify: - // extend the deadline (never past the hard cap). A pending question must - // outlive the base turn timeout or it dies unanswered at 3 minutes. - if (busy || awaitingUser) { - deadline = Math.min(started + GROUP_TURN_HARD_CAP_MS, Math.max(deadline, Date.now() + GROUP_TURN_TIMEOUT_MS)) - } - } - - // 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, { - kind: 'timed-out', - member: member.name, - thread - }) - syncGroupClarify(group, member, null) - updateGroupChat(group, (r: GroupChatRoom) => { - r.stranded = { - ...(r.stranded || {}), - [groupMemberKey(member)]: { - before, - thread + // 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, { + kind: 'timed-out', + member: member.name, + thread + }) + syncGroupClarify(group, member, null) + updateGroupChat(group, (r: GroupChatRoom) => { + r.stranded = { + ...(r.stranded || {}), + [groupMemberKey(member)]: { + before, + thread + } } - } - return r - }) + return r + }) - return null + return null + } 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() } } diff --git a/apps/desktop/src/plugins/hermes-bots/roster-attention.test.tsx b/apps/desktop/src/plugins/hermes-bots/roster-attention.test.tsx deleted file mode 100644 index f8265028a6..0000000000 --- a/apps/desktop/src/plugins/hermes-bots/roster-attention.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * The needs-you attention badge the roster shows for a Group Chat row is - * derived from TWO independent stores: $groupNeedsYou (an @user mention) - * and $groupClarify (a pending clarify/approval, via groupHasPendingClarify). - * roster-pane.tsx subscribes to both and combines them with ||. - * - * This harness proves the REAL composition works end-to-end: real stores, - * real useValue subscriptions, and the real groupHasPendingClarify helper — - * not a re-implementation of the derive expression. Mutating a store here - * must be enough to flip what the harness renders, with no manual refresh. - */ -import { useValue } from '@hermes/plugin-sdk' -import { act, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' - -import { $groupClarify, $groupNeedsYou } from './group-chat' -import { groupHasPendingClarify } from './group-turns' -import type { GroupPrompt } from './types' - -const GROUP = 'Core' - -function mockPrompt(overrides: Partial = {}): GroupPrompt { - return { - at: Date.now(), - choices: ['staging', 'prod'], - group: GROUP, - kind: 'clarify', - member: 'research', - memberKey: 'research', - multiSelect: false, - question: 'Which env?', - questions: null, - requestId: 'req-1', - sessionId: null, - ...overrides - } -} - -function AttentionHarness({ group }: { group: string }) { - const mentions = useValue($groupNeedsYou) - const clarifies = useValue($groupClarify) - const needsYou = Boolean(mentions[group]) || groupHasPendingClarify(clarifies, group) - - return
{String(needsYou)}
-} - -describe('roster attention badge — real stores, real useValue, real helper', () => { - afterEach(() => { - $groupNeedsYou.set({}) - $groupClarify.set({}) - }) - - it('reflects a clarify being added and then removed', () => { - act(() => { - render() - }) - - expect(screen.getByTestId('attention').textContent).toBe('false') - - act(() => { - $groupClarify.set({ 'Core::research': mockPrompt() }) - }) - - expect(screen.getByTestId('attention').textContent).toBe('true') - - act(() => { - $groupClarify.set({}) - }) - - expect(screen.getByTestId('attention').textContent).toBe('false') - }) - - it('keeps attention true when clarify clears but the mention persists', () => { - act(() => { - render() - }) - - act(() => { - $groupNeedsYou.set({ [GROUP]: true }) - $groupClarify.set({ 'Core::research': mockPrompt() }) - }) - - expect(screen.getByTestId('attention').textContent).toBe('true') - - // Clarify resolves — the unrelated mention must keep the badge lit. - act(() => { - $groupClarify.set({}) - }) - - expect(screen.getByTestId('attention').textContent).toBe('true') - }) -}) diff --git a/website/docs/user-guide/bot-mode.md b/website/docs/user-guide/bot-mode.md index 02a2070b84..6f7a8794a1 100644 --- a/website/docs/user-guide/bot-mode.md +++ b/website/docs/user-guide/bot-mode.md @@ -96,7 +96,7 @@ Groups are standalone rows in the same activity-ordered roster as Bot DMs. A Bot - **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: ` 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.