From a9ebcdcab976afab0d11d375ccf09d426e2f77cc Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 14 Sep 2026 12:08:57 -0400 Subject: [PATCH 1/3] fix(bot-mode): key group member sessions by thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A group member's hidden plumbing session was keyed by member alone, so every thread in a room collapsed onto one backend transcript: thread B resumed thread A's session and answered carrying A's context. Everything else in the room engine is already thread-aware — the delta is filtered by `groupThreadOf(e) === thread` and the watermark is `${thread}::${memberKey}` — so only session identity was missing. Key and title it per thread, and adopt a room's pre-thread pointer into the first thread that speaks so an upgraded room's history is continued rather than orphaned. Co-authored-by: Wenfengcheng <30426178+Wenfengcheng@users.noreply.github.com> --- .../plugins/hermes-bots/group-membership.ts | 36 +++++++ .../src/plugins/hermes-bots/group-turns.ts | 102 +++++++++++++----- 2 files changed, 113 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/group-membership.ts b/apps/desktop/src/plugins/hermes-bots/group-membership.ts index e8d9a86fd86fb..f4c2e9ebabbce 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-membership.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-membership.ts @@ -57,6 +57,42 @@ export function groupMemberKey(member: GroupMember): string { return member?.sourceScoped || member?.remoteSource ? botRosterKey(member) : member?.name } +/** Marks a session key as thread-scoped. Pre-thread rooms stored ONE session + * per member under the bare `groupMemberKey`, and a source-qualified member + * key is itself `::` — so the thread segment needs its + * own literal prefix to stay distinguishable from both. */ +const GROUP_SESSION_THREAD_PREFIX = 'thread:' + +/** Identity of a member's hidden plumbing session INSIDE one thread. Threads + * are separate conversations (own log slice, own watermarks — see + * `${thread}::${memberKey}` in group-round-members), so they must not share + * one backend transcript: thread B resuming thread A's session is how a + * member answered B carrying A's context (#90420, #106460). */ +export function groupSessionKey(thread: string, member: GroupMember): string { + return `${GROUP_SESSION_THREAD_PREFIX}${thread || 'legacy'}::${groupMemberKey(member)}` +} + +/** The member half of a session key. Bare keys are pre-thread rooms and are + * their own member key; the sweep reads owners by member, not by thread. */ +export function groupSessionMemberKey(key: string): string { + if (!key.startsWith(GROUP_SESSION_THREAD_PREFIX)) { + return key + } + + const rest = key.slice(GROUP_SESSION_THREAD_PREFIX.length) + const boundary = rest.indexOf('::') + + return boundary === -1 ? rest : rest.slice(boundary + 2) +} + +/** Whether this member already owns a thread-scoped session anywhere in the + * room — i.e. the room has been through the pre-thread migration. */ +export function hasThreadScopedGroupSession(sessions: Record, memberKey: string): boolean { + return Object.keys(sessions || {}).some( + key => key.startsWith(GROUP_SESSION_THREAD_PREFIX) && groupSessionMemberKey(key) === memberKey + ) +} + /** Serializable immutable owner captured beside every group plumbing session. */ export function groupSessionOwner(member: RosterRow) { const route = botConnectionRoute(member) diff --git a/apps/desktop/src/plugins/hermes-bots/group-turns.ts b/apps/desktop/src/plugins/hermes-bots/group-turns.ts index ee5de72f1b4d4..8811e9f1f071e 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-turns.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-turns.ts @@ -11,7 +11,13 @@ 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 { followGroupChat, groupMemberKey, groupSessionOwner } from './group-membership' +import { + followGroupChat, + groupMemberKey, + groupSessionKey, + groupSessionOwner, + hasThreadScopedGroupSession +} from './group-membership' import { botConnectionRoute, requestForBot } from './routing' import type { Attachment, GroupMember, GroupPrompt, GroupPromptQuestion, ProfileRoute } from './types' @@ -118,13 +124,25 @@ interface GroupMemberSessionHandle { stored?: null | string | true } -/** Ensure the member's per-group session exists and return a LIVE runtime - * session id for it. Gateway-native: session.create mints the session - * (lazy until its first message), session.resume by stored id — or by - * title, which also covers rehydrated rooms whose sid was lost — reopens +/** Ensure the member's session FOR THIS THREAD exists and return a LIVE + * runtime session id for it. Gateway-native: session.create mints the + * session (lazy until its first message), session.resume by stored id — or + * by title, which also covers rehydrated rooms whose sid was lost — reopens * 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 { + * via requestForBot; the window's gateway never switches. + * + * The session is keyed and titled per THREAD (#90420, #106460). Threads are + * separate conversations everywhere else in the room engine — own delta + * slice, own `${thread}::${memberKey}` watermark — so a member-only session + * pointer made thread B resume thread A's transcript and answer carrying + * A's context. Rooms persisted before this keep ONE bare member pointer; + * the first thread to ask adopts it (below) so that history continues in a + * real thread instead of being orphaned by the upgrade. */ +export async function ensureGroupChatSession( + group: string, + member: GroupMember, + thread: string +): Promise { const binding = followGroupChat(group, name => { group = name }) @@ -134,9 +152,15 @@ export async function ensureGroupChatSession(group: string, member: GroupMember) // 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 roomTitle = `Group: ${room.roomId || group}` + const title = `${roomTitle} · ${thread || 'legacy'}` + const memberKey = groupMemberKey(member) + const key = groupSessionKey(thread, member) + const sessions = room.sessions || {} + const known = sessions[key] + // Pre-thread pointer, adoptable only until some thread has taken it — + // after that the room is migrated and every other thread mints its own. + const legacy = hasThreadScopedGroupSession(sessions, memberKey) ? null : sessions[memberKey] || null // Try resuming what we know (stored sid first, then title lookup). // @@ -148,9 +172,16 @@ export async function ensureGroupChatSession(group: string, member: GroupMember) // 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]) { + // room. Only a genuine 4007 on EVERY target means there truly is + // nothing to resume yet, so the loop falls through to session.create. + // + // Target order: this thread's own pointer, then — only while the room is + // still unmigrated — the pre-thread pointer and the pre-thread title, so + // an upgraded room's existing conversation continues in the first thread + // that speaks instead of being stranded behind an unreferenced sid. + const targets = [known, title, ...(legacy === null ? [] : [legacy, roomTitle])] + + for (const target of targets) { if (!target || target === true) { continue } @@ -174,7 +205,11 @@ export async function ensureGroupChatSession(group: string, member: GroupMember) // 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 + // + // The fallback is the id we resumed BY, which on the adoption pass + // is the pre-thread pointer — the two title targets are titles, not + // ids, and were never eligible. + const stored = res.session_key || (target === title || target === roomTitle ? known : target) if (stored) { updateGroupChat(group, (current: GroupChatRoom) => { @@ -443,8 +478,17 @@ const GROUP_TURN_HARD_CAP_MS = 20 * 60000 * payload always sync to "no prompt". Clarify wins when both are somehow * present (approvals resolve inside tool batches; clarify is the outer * blocker). */ -export function syncGroupClarify(group: string, member: GroupMember, state: GroupSessionSnapshot | null): boolean { - const key = `${group}::${groupMemberKey(member)}` +export function syncGroupClarify( + group: string, + member: GroupMember, + thread: string, + state: GroupSessionSnapshot | null +): boolean { + const memberKey = groupMemberKey(member) + // Per-thread sessions mean one member can now be blocked in two threads at + // once, so the mirror is keyed by thread as well — a room-and-member key + // would let thread B's question silently replace thread A's card (#90694). + const key = `${group}::${thread || 'legacy'}::${memberKey}` const openClarify = Array.isArray(state?.open_requests) ? state.open_requests.find(entry => entry?.method === 'clarify' && typeof entry.id === 'string' && entry.id) @@ -489,7 +533,10 @@ export function syncGroupClarify(group: string, member: GroupMember, state: Grou requestId, group, member: member.name, - memberKey: groupMemberKey(member), + memberKey, + // The card carries its own thread so the answer/rename paths rebuild + // exactly this key instead of guessing one. + thread: thread || 'legacy', // approval.respond keys on the session, not just the request — carry the // runtime id the snapshot came from. sessionId: state?.session_id || null, @@ -586,7 +633,7 @@ export function renameGroupClarify(oldName: string, newName: string) { // 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 } + next[`${newName}::${value.thread || 'legacy'}::${value.memberKey}`] = { ...value, group: newName } } } @@ -646,7 +693,7 @@ export async function answerGroupClarify( } const all = $groupClarify.get() - const key = `${group}::${entry.memberKey}` + const key = `${group}::${entry.thread || 'legacy'}::${entry.memberKey}` if (all[key]?.requestId === entry.requestId) { const next = { @@ -828,7 +875,7 @@ async function pollGroupMemberTurn(context: GroupTurnPollContext): Promise before && done) { @@ -874,7 +921,7 @@ async function pollGroupMemberTurn(context: GroupTurnPollContext): Promise { r.stranded = { ...(r.stranded || {}), @@ -931,7 +978,7 @@ async function runGroupChatMemberTurnLeased( }) try { - const { runtime, stored } = await ensureGroupChatSession(group, member) + const { runtime, stored } = await ensureGroupChatSession(group, member, thread) if (!runtime || !binding.isLive()) { return null @@ -1009,9 +1056,14 @@ export async function harvestStrandedGroupReply(group: string, member: GroupMemb let state: GroupSessionSnapshot | null = null try { - const stored = room.sessions?.[memberKey] + // The marker's own thread owns the session the reply is stranded in — + // the harvest must not resume a sibling thread's transcript and post + // its answer here. + const sessions = room.sessions || {} + const scoped = sessions[groupSessionKey(strandedThread, member)] + const stored = scoped || (hasThreadScopedGroupSession(sessions, memberKey) ? null : sessions[memberKey]) state = (await requestForBot(member, 'session.resume', { - session_id: stored || `Group: ${room.roomId || group}`, + session_id: stored || `Group: ${room.roomId || group} · ${strandedThread}`, profile: member.name })) as GroupSessionSnapshot } catch { @@ -1023,7 +1075,7 @@ export async function harvestStrandedGroupReply(group: string, member: GroupMemb } // Pending prompts are authoritative even while the session is running. - const awaitingUser = syncGroupClarify(group, member, state) + const awaitingUser = syncGroupClarify(group, member, strandedThread, state) if (state?.inflight || state?.running || awaitingUser) { return From bf393a63a2cb7c1d2eb93d67c21eb9159a6ad1a3 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 14 Sep 2026 12:10:39 -0400 Subject: [PATCH 2/3] fix(bot-mode): scope stop, harvest and clarify to the originating thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The co-keyed readers of `room.sessions` had to follow session identity or they would split from it: the stop interrupt targeted a member's only session regardless of which thread issued the stop, and the stranded-reply harvest resumed by bare member key even though its marker already carries `{before, thread}`. The clarify mirror moves too — with per-thread sessions one member can be blocked in two threads at once, and a room-and-member key let thread B's question silently replace thread A's card. The sweep's owner lookup stays a MEMBER question, so it reads the member half of the key and keeps the `::` source-qualifier check on that half alone. Co-authored-by: Wenfengcheng <30426178+Wenfengcheng@users.noreply.github.com> --- .../plugins/hermes-bots/group-chat-view.tsx | 6 +++++- .../src/plugins/hermes-bots/group-rounds.ts | 18 ++++++++++++++++-- .../src/plugins/hermes-bots/session-sweep.ts | 15 +++++++++------ apps/desktop/src/plugins/hermes-bots/types.ts | 3 +++ 4 files changed, 33 insertions(+), 9 deletions(-) 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 ba24af0d6291a..66e2370ebc764 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx @@ -1145,7 +1145,11 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G ]} {roomClarifies.map(entry => ( - + ))} {room.running ? (
diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index d49d44426df6c..69c404e5aead1 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -17,7 +17,13 @@ import { updateGroupChat } from './group-chat' import type { GroupChatRoom, GroupHoldStamp } from './group-chat' -import { durableGroupChatMembers, followGroupChat, groupMemberKey } from './group-membership' +import { + durableGroupChatMembers, + followGroupChat, + groupMemberKey, + groupSessionKey, + hasThreadScopedGroupSession +} from './group-membership' import { runGroupContinuationMembers, runGroupRoundMember } from './group-round-members' import { rejectGroupSlashCommand } from './group-slash' import { harvestStrandedGroupReply } from './group-turns' @@ -398,7 +404,15 @@ export async function stopGroupThread(group: string, thread: null | string, memb }) // The captured descriptor owns routing even if the roster has changed. - const sessionId = onTurn ? (room.sessions || {})[groupMemberKey(onTurn)] : null + // Sessions are per thread, so a stop targets the session of the thread it + // was issued from; an unmigrated room still answers on its bare pointer. + const sessions = room.sessions || {} + const onTurnKey = onTurn ? groupMemberKey(onTurn) : '' + + const sessionId = onTurn + ? sessions[groupSessionKey(thread || 'legacy', onTurn)] || + (hasThreadScopedGroupSession(sessions, onTurnKey) ? null : sessions[onTurnKey]) + : null if (onTurn && sessionId) { try { diff --git a/apps/desktop/src/plugins/hermes-bots/session-sweep.ts b/apps/desktop/src/plugins/hermes-bots/session-sweep.ts index 2a5cc0e1c24d4..23bb8464de312 100644 --- a/apps/desktop/src/plugins/hermes-bots/session-sweep.ts +++ b/apps/desktop/src/plugins/hermes-bots/session-sweep.ts @@ -12,7 +12,7 @@ import { host } from '@hermes/plugin-sdk' import { PROFILE_SESSION_LIST_LIMIT } from './canonical-chat' import { $lastRoster } from './data' import { $groupChats } from './group-chat' -import { groupMemberKey } from './group-membership' +import { groupMemberKey, groupSessionMemberKey } from './group-membership' import { backendTargetProfile, botConnectionRoute, requestForBot } from './routing' import type { GroupMember, RosterRow } from './types' @@ -121,25 +121,28 @@ function hideOwnedBotSessions() { } const persisted = room?.sessionOwners?.[key] - const derived = (room?.members || []).find((member: GroupMember) => groupMemberKey(member) === key) + // Sessions are keyed per thread (`thread:::`); the + // owner lookup is a MEMBER question, so derive from the member half. + const memberKey = groupSessionMemberKey(key) + const derived = (room?.members || []).find((member: GroupMember) => groupMemberKey(member) === memberKey) // Bare keys are legacy local rooms. A source-qualified key without its // immutable owner is unsafe: never let it fall through ambient routing. const owner = persisted || derived || - (!key.includes('::') + (!memberKey.includes('::') ? { - name: key + name: memberKey } : null) - if (key.includes('::')) { + if (memberKey.includes('::')) { const route = owner?.route const sourceMarked = owner?.sourceScoped || owner?.remoteSource const routeKey = route?.connectionId && route?.profile ? `${route.connectionId}::${route.profile}` : '' - if (!sourceMarked || !route?.targetProfile || routeKey !== key) { + if (!sourceMarked || !route?.targetProfile || routeKey !== memberKey) { return null } } diff --git a/apps/desktop/src/plugins/hermes-bots/types.ts b/apps/desktop/src/plugins/hermes-bots/types.ts index 5b9ad0cc00d96..e4adfbce708e8 100644 --- a/apps/desktop/src/plugins/hermes-bots/types.ts +++ b/apps/desktop/src/plugins/hermes-bots/types.ts @@ -216,6 +216,9 @@ export interface GroupPrompt { questions?: GroupPromptQuestion[] | null requestId: string sessionId?: null | string + /** The thread the blocking question belongs to — part of the mirror key, + * since a member can be blocked in two threads at once. */ + thread?: string } export type GroupActivityKind = From c0a8d6db1cf332fe4b446d1c4673a48510e8d4a9 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 14 Sep 2026 12:10:50 -0400 Subject: [PATCH 3/3] test(bot-mode): pin thread-scoped session identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The room-level test is the reported defect: two composer sends, two threads, and neither backend transcript may contain the other thread's prompt. Proven red on the unfixed code — both threads resolved to sid-research-1. Also pins same-thread continuity, the pre-thread adoption (first thread continues the old conversation, later threads do not), and that the member half of the key stays source-qualified so a remote `research` and a local `research` never share a session. Co-authored-by: Wenfengcheng <30426178+Wenfengcheng@users.noreply.github.com> --- .../plugins/hermes-bots/group-rounds.test.ts | 54 +++++-- .../plugins/hermes-bots/group-turns.test.ts | 153 +++++++++++++----- .../hermes-bots/hide-bot-chats.test.ts | 4 +- 3 files changed, 161 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts index a3d164937a57a..7843b0883bed4 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts @@ -313,13 +313,15 @@ describe('per-member delta', () => { const shared: GroupMember[] = [{ name: 'research', title: '' }] const quiet = () => !room.chat.$groupChats.get().Alpha?.running && !room.chat.$groupChats.get().Beta?.running - // Start both rooms without waiting for either drive to finish. - room.rounds.sendToGroupChat('Alpha', shared, 'ALPHA_ONLY_1') - room.rounds.sendToGroupChat('Beta', shared, 'BETA_ONLY_1') + // Start both rooms without waiting for either drive to finish. Each send + // stays in ONE thread per room, so the isolation under test is + // cross-ROOM: two rooms sharing a member must not share its session. + const alphaThread = room.rounds.sendToGroupChat('Alpha', shared, 'ALPHA_ONLY_1') + const betaThread = room.rounds.sendToGroupChat('Beta', shared, 'BETA_ONLY_1') await drain(() => !quiet()) - const alphaFirst = room.gateway.calls.find(call => call.title === 'Group: Alpha') - const betaFirst = room.gateway.calls.find(call => call.title === 'Group: Beta') + const alphaFirst = room.gateway.calls.find(call => call.title?.startsWith('Group: Alpha')) + const betaFirst = room.gateway.calls.find(call => call.title?.startsWith('Group: Beta')) expect(alphaFirst && betaFirst).toBeTruthy() expect(alphaFirst?.stored).not.toBe(betaFirst?.stored) @@ -328,19 +330,20 @@ describe('per-member delta', () => { expect(alphaFirst?.prompt).not.toContain('BETA_ONLY_1') expect(betaFirst?.prompt).toContain('BETA_ONLY_1') expect(betaFirst?.prompt).not.toContain('ALPHA_ONLY_1') - expect(room.chat.$groupChats.get().Alpha.sessions?.research).toBe(alphaFirst?.stored) - expect(room.chat.$groupChats.get().Beta.sessions?.research).toBe(betaFirst?.stored) + // Sessions are keyed per thread; each room minted exactly one. + expect(Object.values(room.chat.$groupChats.get().Alpha.sessions || {})).toEqual([alphaFirst?.stored]) + expect(Object.values(room.chat.$groupChats.get().Beta.sessions || {})).toEqual([betaFirst?.stored]) // Interleave a second pair. Each room resumes its own session and receives // only its unseen room delta, never the sibling room's messages. const firstCallCount = room.gateway.calls.length - room.rounds.sendToGroupChat('Alpha', shared, 'ALPHA_ONLY_2') - room.rounds.sendToGroupChat('Beta', shared, 'BETA_ONLY_2') + room.rounds.sendToGroupChat('Alpha', shared, 'ALPHA_ONLY_2', alphaThread) + room.rounds.sendToGroupChat('Beta', shared, 'BETA_ONLY_2', betaThread) await drain(() => !quiet()) const second = room.gateway.calls.slice(firstCallCount) - const alphaSecond = second.find(call => call.title === 'Group: Alpha') - const betaSecond = second.find(call => call.title === 'Group: Beta') + const alphaSecond = second.find(call => call.title?.startsWith('Group: Alpha')) + const betaSecond = second.find(call => call.title?.startsWith('Group: Beta')) expect(alphaSecond?.stored).toBe(alphaFirst?.stored) expect(betaSecond?.stored).toBe(betaFirst?.stored) @@ -399,6 +402,35 @@ describe('threads', () => { expect(replies.length).toBeGreaterThanOrEqual(1) expect(replies.every(entry => entry.thread === billing)).toBe(true) }) + + it('never lets one thread answer out of another thread’s session (#90420, #106460)', async () => { + // The user-visible defect: two visible threads shared ONE hidden member + // session, so the model answering thread B had thread A's transcript in + // its context and replied with A's topic. + const room = await loadRoom() + const member: GroupMember[] = [{ name: 'research', title: '' }] + + const alpha = room.rounds.sendToGroupChat('Bleed', member, 'ALPHA_TOPIC secret') + await settle(room, 'Bleed') + const beta = room.rounds.sendToGroupChat('Bleed', member, 'BETA_TOPIC unrelated') + await settle(room, 'Bleed') + + const alphaCall = room.gateway.calls.find(call => call.prompt.includes('ALPHA_TOPIC')) + const betaCall = room.gateway.calls.find(call => call.prompt.includes('BETA_TOPIC')) + + expect(alpha).not.toBe(beta) + // Two threads, two distinct hidden sessions — the room stores one per thread. + expect(alphaCall?.stored).toBeTruthy() + expect(alphaCall?.stored).not.toBe(betaCall?.stored) + expect(Object.keys(room.chat.$groupChats.get().Bleed.sessions || {})).toHaveLength(2) + + // And neither backend transcript ever saw the other thread's prompt. + const alphaMessages = room.gateway.sessions.get(String(alphaCall?.stored))?.messages || [] + const betaMessages = room.gateway.sessions.get(String(betaCall?.stored))?.messages || [] + + expect(alphaMessages.some(message => message.content.includes('BETA_TOPIC'))).toBe(false) + expect(betaMessages.some(message => message.content.includes('ALPHA_TOPIC'))).toBe(false) + }) }) describe('turn prompt', () => { 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 76992f0ffc8e3..6b155650c5a3e 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-turns.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-turns.test.ts @@ -66,9 +66,9 @@ describe('session resolution', () => { // Rooms persisted before roomIds keep name-based titles so their existing // "Group: " sessions keep resolving after an upgrade. room.chat.updateGroupChat('Legacy', current => current) - const legacy = await room.turns.ensureGroupChatSession('Legacy', { name: 'research', title: '' }) + const legacy = await room.turns.ensureGroupChatSession('Legacy', { name: 'research', title: '' }, 't1') - expect(room.gateway.sessions.get(String(legacy.stored))?.title).toBe('Group: Legacy') + expect(room.gateway.sessions.get(String(legacy.stored))?.title).toBe('Group: Legacy · t1') // New rooms pin the title to the immutable roomId, never the display name. room.chat.updateGroupChat('New', current => { @@ -76,9 +76,9 @@ describe('session resolution', () => { return current }) - const fresh = await room.turns.ensureGroupChatSession('New', { name: 'research', title: '' }) + const fresh = await room.turns.ensureGroupChatSession('New', { name: 'research', title: '' }, 't1') - expect(room.gateway.sessions.get(String(fresh.stored))?.title).toBe('Group: r-abc') + expect(room.gateway.sessions.get(String(fresh.stored))?.title).toBe('Group: r-abc · t1') }) it('creates member sessions with the room_plumbing + follow_profile_config contracts', async () => { @@ -93,7 +93,7 @@ describe('session resolution', () => { return current }) - const handle = await room.turns.ensureGroupChatSession('Contract', { name: 'research', title: '' }) + const handle = await room.turns.ensureGroupChatSession('Contract', { name: 'research', title: '' }, 't1') expect(room.gateway.sessions.get(String(handle.stored))?.contracts).toEqual({ follow_profile_config: true, @@ -110,7 +110,7 @@ describe('session resolution', () => { return current }) - const first = await room.turns.ensureGroupChatSession('Alpha', member) + const first = await room.turns.ensureGroupChatSession('Alpha', member, 't1') // Disband: the room record is gone; the member's gateway session survives. const rooms = { ...room.chat.$groupChats.get() } @@ -123,11 +123,11 @@ describe('session resolution', () => { return current }) - const second = await room.turns.ensureGroupChatSession('Alpha', member) + const second = await room.turns.ensureGroupChatSession('Alpha', member, 't1') expect(first.stored).not.toBe(second.stored) - expect(room.gateway.sessions.get(String(first.stored))?.title).toBe('Group: r-one') - expect(room.gateway.sessions.get(String(second.stored))?.title).toBe('Group: r-two') + expect(room.gateway.sessions.get(String(first.stored))?.title).toBe('Group: r-one · t1') + expect(room.gateway.sessions.get(String(second.stored))?.title).toBe('Group: r-two · t1') }) it('fails closed on a transient resume failure instead of forking the member session', async () => { @@ -144,7 +144,7 @@ describe('session resolution', () => { return current }) - const first = await room.turns.ensureGroupChatSession('Alpha', member) + const first = await room.turns.ensureGroupChatSession('Alpha', member, 't1') const before = room.chat.$groupChats.get().Alpha.sessions // A real gateway error on the NEXT resume of the member's own stored @@ -159,7 +159,9 @@ describe('session resolution', () => { return request(method, params) } - await expect(room.turns.ensureGroupChatSession('Alpha', member)).rejects.toThrow(/Could not check .*group session/) + await expect(room.turns.ensureGroupChatSession('Alpha', member, 't1')).rejects.toThrow( + /Could not check .*group session/ + ) host.request = request @@ -181,11 +183,88 @@ describe('session resolution', () => { return current }) - const result = await room.turns.ensureGroupChatSession('Alpha', { name: 'research', title: '' }) + const result = await room.turns.ensureGroupChatSession('Alpha', { name: 'research', title: '' }, 't1') expect(result.stored).toBeTruthy() expect(result.stored).not.toBe('sid-gone') }) + + it('gives each thread its own session, and reuses it within that thread (#90420)', async () => { + // The defect: sessions were keyed by member alone, so thread B resumed + // thread A's transcript and answered carrying A's context. + const room = await loadRoom() + const member: GroupMember = { name: 'research', title: '' } + + room.chat.updateGroupChat('Alpha', current => { + current.roomId = 'r-one' + + return current + }) + + const a = await room.turns.ensureGroupChatSession('Alpha', member, 't-a') + const b = await room.turns.ensureGroupChatSession('Alpha', member, 't-b') + const aAgain = await room.turns.ensureGroupChatSession('Alpha', member, 't-a') + + expect(a.stored).not.toBe(b.stored) + expect(aAgain.stored).toBe(a.stored) + expect(room.gateway.sessions.get(String(a.stored))?.title).not.toBe( + room.gateway.sessions.get(String(b.stored))?.title + ) + }) + + it('adopts a pre-thread member session into the first thread that speaks', async () => { + // Rooms persisted before thread scoping hold ONE bare member pointer. + // The first thread continues that conversation; every later thread gets + // its own session, so the upgrade never orphans existing history. + const room = await loadRoom() + const member: GroupMember = { name: 'research', title: '' } + + room.chat.updateGroupChat('Alpha', current => { + current.roomId = 'r-one' + + return current + }) + const legacy = await room.turns.ensureGroupChatSession('Alpha', member, 'legacy') + // Re-seat it under the pre-thread bare key, as an upgraded room has it. + room.chat.updateGroupChat('Alpha', current => { + current.sessions = { research: String(legacy.stored) } + + return current + }) + + const adopted = await room.turns.ensureGroupChatSession('Alpha', member, 't-first') + + expect(adopted.stored).toBe(legacy.stored) + + const later = await room.turns.ensureGroupChatSession('Alpha', member, 't-second') + + expect(later.stored).not.toBe(legacy.stored) + }) + + it('keeps thread-scoped keys source-safe for remote members', async () => { + // The member half of the key is still the source-qualified roster key, so + // `research` on the Mini and a local `research` never share a session. + const room = await loadRoom() + + room.chat.updateGroupChat('Alpha', current => { + current.roomId = 'r-one' + + return current + }) + + await room.turns.ensureGroupChatSession('Alpha', { name: 'research', title: '' }, 't-a') + await room.turns.ensureGroupChatSession( + 'Alpha', + { connectionId: 'mini', name: 'research', remoteSource: true }, + 't-a' + ) + + const keys = Object.keys(room.chat.$groupChats.get().Alpha.sessions || {}) + + expect(keys).toHaveLength(2) + expect(keys.some(key => key.endsWith('::mini::research'))).toBe(true) + expect(keys.some(key => key.endsWith('::research') && !key.includes('mini'))).toBe(true) + }) }) describe('session-gone classification', () => { @@ -213,7 +292,7 @@ describe('session-gone classification', () => { // One failed submit + exactly one retry — never more. expect(room.gateway.rpcFor('prompt.submit')).toHaveLength(2) // The recovery re-resumed the durable stored id, not the dead runtime id. - expect(room.chat.$groupChats.get().Room.sessions?.helper).toBeTruthy() + expect(room.chat.$groupChats.get().Room.sessions?.['thread:t1::helper']).toBeTruthy() }) it('does not retry a persistent non-4001 submit failure', async () => { @@ -402,7 +481,7 @@ describe('clarify and approvals (#90694)', () => { const { chat, turns } = await loadRoom() const member: GroupMember = { name: 'research', title: '' } - expect(turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] })).toBe(true) + expect(turns.syncGroupClarify('Core', member, 't1', { open_requests: [CLARIFY] })).toBe(true) const mirrored = Object.values(chat.$groupClarify.get()) @@ -415,13 +494,13 @@ describe('clarify and approvals (#90694)', () => { expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(true) // Same request again: no new entry, identity preserved. - turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] }) + turns.syncGroupClarify('Core', member, 't1', { open_requests: [CLARIFY] }) expect(Object.values(chat.$groupClarify.get())[0]).toBe(mirrored[0]) // 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(turns.syncGroupClarify('Core', member, 't1', {})).toBe(false) expect(Object.keys(chat.$groupClarify.get())).toHaveLength(0) expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(false) }) @@ -429,7 +508,7 @@ describe('clarify and approvals (#90694)', () => { it('never mirrors a question for older backends without open_requests', async () => { const { chat, turns } = await loadRoom() - expect(turns.syncGroupClarify('Core', { name: 'research' }, { messages: [] })).toBe(false) + expect(turns.syncGroupClarify('Core', { name: 'research' }, 't1', { messages: [] })).toBe(false) expect(Object.keys(chat.$groupClarify.get())).toHaveLength(0) }) @@ -437,7 +516,7 @@ describe('clarify and approvals (#90694)', () => { const room = await loadRoom() const member: GroupMember = { name: 'research', title: '' } - room.turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] }) + room.turns.syncGroupClarify('Core', member, 't1', { open_requests: [CLARIFY] }) await room.turns.answerGroupClarify(Object.values(room.chat.$groupClarify.get())[0], member, 'staging') expect(room.gateway.rpcFor('request.answer').map(call => call.params)).toEqual([ @@ -450,7 +529,7 @@ describe('clarify and approvals (#90694)', () => { const room = await loadRoom() const member: GroupMember = { name: 'research', title: '' } - room.turns.syncGroupClarify('Core', member, { + room.turns.syncGroupClarify('Core', member, 't1', { open_requests: [ { id: 'req-batch-1', @@ -479,8 +558,8 @@ describe('clarify and approvals (#90694)', () => { it('clears only the disbanded room’s mirrored questions', async () => { const room = await loadRoom() - room.turns.syncGroupClarify('Core', { name: 'research' }, { open_requests: [CLARIFY] }) - room.turns.syncGroupClarify('Other', { name: 'ops' }, { open_requests: [{ ...CLARIFY, id: 'req-2' }] }) + room.turns.syncGroupClarify('Core', { name: 'research' }, 't1', { open_requests: [CLARIFY] }) + room.turns.syncGroupClarify('Other', { name: 'ops' }, 't1', { open_requests: [{ ...CLARIFY, id: 'req-2' }] }) room.turns.clearGroupClarify('Core') const remaining = Object.values(room.chat.$groupClarify.get()) @@ -495,16 +574,16 @@ describe('clarify and approvals (#90694)', () => { 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, { open_requests: [CLARIFY] }) + turns.syncGroupClarify('Core', member, 't1', { open_requests: [CLARIFY] }) expect(chat.$groupNeedsYou.get().Core).toBeFalsy() - turns.syncGroupClarify('Core', { name: 'ops' }, { pending_approval: APPROVAL }) + turns.syncGroupClarify('Core', { name: 'ops' }, 't1', { pending_approval: APPROVAL }) expect(Object.values(chat.$groupClarify.get())).toHaveLength(2) - turns.syncGroupClarify('Core', member, {}) + turns.syncGroupClarify('Core', member, 't1', {}) expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(true) - turns.syncGroupClarify('Core', { name: 'ops' }, {}) + turns.syncGroupClarify('Core', { name: 'ops' }, 't1', {}) 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 }) + turns.syncGroupClarify('Core', member, 't1', { 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) @@ -731,7 +810,7 @@ describe('clarify and approvals (#90694)', () => { if (recreate) { room.chat.updateGroupChat('Core', current => ({ ...current, roomId: 'replacement-room' })) - room.turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] }) + room.turns.syncGroupClarify('Core', member, 't1', { open_requests: [CLARIFY] }) } const roomsBefore = structuredClone(room.chat.$groupChats.get()) @@ -858,14 +937,10 @@ describe('clarify and approvals (#90694)', () => { const { chat, turns } = await loadRoom() expect( - turns.syncGroupClarify( - 'Core', - { name: 'research', title: '' }, - { - pending_approval: APPROVAL, - session_id: 'rt-research-1' - } - ) + turns.syncGroupClarify('Core', { name: 'research', title: '' }, 't1', { + pending_approval: APPROVAL, + session_id: 'rt-research-1' + }) ).toBe(true) const entry = Object.values(chat.$groupClarify.get())[0] @@ -880,7 +955,9 @@ describe('clarify and approvals (#90694)', () => { it('falls back to once/deny when the server sends no choice set', async () => { const { chat, turns } = await loadRoom() - turns.syncGroupClarify('Core', { name: 'research' }, { pending_approval: { command: 'ls', request_id: 'req-a2' } }) + turns.syncGroupClarify('Core', { name: 'research' }, 't1', { + pending_approval: { command: 'ls', request_id: 'req-a2' } + }) expect(Object.values(chat.$groupClarify.get())[0].choices).toEqual(['once', 'deny']) }) @@ -889,7 +966,7 @@ describe('clarify and approvals (#90694)', () => { const room = await loadRoom() const member: GroupMember = { name: 'research', title: '' } - room.turns.syncGroupClarify('Core', member, { pending_approval: APPROVAL, session_id: 'rt-research-1' }) + room.turns.syncGroupClarify('Core', member, 't1', { pending_approval: APPROVAL, session_id: 'rt-research-1' }) await room.turns.answerGroupClarify(Object.values(room.chat.$groupClarify.get())[0], member, 'once') expect(room.gateway.rpcFor('approval.respond').map(call => call.params)).toEqual([ @@ -902,7 +979,7 @@ describe('clarify and approvals (#90694)', () => { it('lets clarify outrank approval when a snapshot carries both', async () => { const { chat, turns } = await loadRoom() - turns.syncGroupClarify('Core', { name: 'research' }, { open_requests: [CLARIFY], pending_approval: APPROVAL }) + turns.syncGroupClarify('Core', { name: 'research' }, 't1', { open_requests: [CLARIFY], pending_approval: APPROVAL }) const entry = Object.values(chat.$groupClarify.get())[0] diff --git a/apps/desktop/src/plugins/hermes-bots/hide-bot-chats.test.ts b/apps/desktop/src/plugins/hermes-bots/hide-bot-chats.test.ts index 4c9dab7ff8f47..d0676e0d72050 100644 --- a/apps/desktop/src/plugins/hermes-bots/hide-bot-chats.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/hide-bot-chats.test.ts @@ -56,7 +56,9 @@ vi.mock('./group-chat', () => ({ $groupChats: { get: () => groupChats.value } }) vi.mock('./group-membership', () => ({ groupMemberKey: (member: GroupMember) => - member?.route?.connectionId ? `${member.route.connectionId}::${member.name}` : member?.name + member?.route?.connectionId ? `${member.route.connectionId}::${member.name}` : member?.name, + groupSessionMemberKey: (key: string) => + key.startsWith('thread:') ? key.slice(key.indexOf('::', 'thread:'.length) + 2) : key })) vi.mock('./routing', () => ({