fix(bot-mode): scope stop, harvest and clarify to the originating thread

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>
This commit is contained in:
Austin Pickett
2026-09-14 12:10:39 -04:00
committed by brooklyn!
parent d631ad5f5c
commit 1ce9733113
4 changed files with 33 additions and 9 deletions

View File

@@ -1163,7 +1163,11 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
</div>
]}
{roomClarifies.map(entry => (
<GroupClarifyCard entry={entry} key={`clarify:${entry.memberKey}:${entry.requestId}`} members={members} />
<GroupClarifyCard
entry={entry}
key={`clarify:${entry.thread || 'legacy'}:${entry.memberKey}:${entry.requestId}`}
members={members}
/>
))}
{room.running ? (
<div className="px-2 py-1 text-[0.7rem] italic text-(--ui-text-quaternary)" key={'working'}>

View File

@@ -18,7 +18,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 { GROUP_TURN_HARD_CAP_MS, harvestStrandedGroupReply } from './group-turns'
@@ -399,7 +405,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 {

View File

@@ -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:<id>::<memberKey>`); 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
}
}

View File

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