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:
committed by
brooklyn!
parent
d631ad5f5c
commit
1ce9733113
@@ -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'}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user