fix(bot-mode): local member replies carry from.source so cross-Desktop rooms attribute them
A group-room reply produced by a member local to the Desktop running the turn was appended without `from.source`; when the shared room projection reached another Desktop, that entry was indistinguishable from the reader's own same-named bot (#94863 D3). Every member that knows its connection now stamps `source: connectionLabel || connectionId` (groupMemberAuthor, used by both append sites), and the `(you)` self-check in member prompts follows the same resolution rule as routing.ts: a sourced line is "you" only when it matches the viewer's connection, an unsourced line only for a local viewer. Part of #94863 (D3 only; D2 realtime pull is a parked design decision).
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
import { $botMeta, botFriendlyNames, botHandle, botMetaKey, botRosterKey } from './data'
|
||||
import { $groupChats, groupChatRoomKey } from './group-chat'
|
||||
import { botConnectionRoute, botRosterMeta, resolveBotConnectionRoute } from './routing'
|
||||
import type { BotMeta, GroupChat, GroupMember, RosterRow } from './types'
|
||||
import type { BotMeta, GroupChat, GroupMember, GroupMessageAuthor, RosterRow } from './types'
|
||||
|
||||
/** Follow the authoritative room record for one async operation. Rename moves
|
||||
* the record wholesale (including legacy rooms without a roomId); disband
|
||||
@@ -57,6 +57,17 @@ export function groupMemberKey(member: GroupMember): string {
|
||||
return member?.sourceScoped || member?.remoteSource ? botRosterKey(member) : member?.name
|
||||
}
|
||||
|
||||
/** The `from` stamp for a member's appended reply. Every member that knows
|
||||
* its connection carries `source` — local ones included — so a reply
|
||||
* mirrored to another Desktop still names the machine it came from (#94863
|
||||
* D3: an unsourced `default` reply was indistinguishable from the reader's
|
||||
* own `default`). Only members without a connection stay bare. */
|
||||
export function groupMemberAuthor(member: GroupMember): GroupMessageAuthor {
|
||||
const source = member.connectionLabel || member.connectionId
|
||||
|
||||
return { kind: 'member', name: member.name, ...(source ? { source } : {}) }
|
||||
}
|
||||
|
||||
/** 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 `<connectionId>::<name>` — so the thread segment needs its
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
updateGroupChat
|
||||
} from './group-chat'
|
||||
import type { GroupChatRoom } from './group-chat'
|
||||
import { groupMemberKey } from './group-membership'
|
||||
import { groupMemberAuthor, groupMemberKey } from './group-membership'
|
||||
import { buildGroupChatTurnPrompt, formatGroupDeltaLines } from './group-round-prompt'
|
||||
import { isGroupPassText, runGroupChatMemberTurn } from './group-turns'
|
||||
import type { Attachment, GroupMember, GroupMessage } from './types'
|
||||
@@ -233,15 +233,7 @@ export async function runGroupRoundMember(
|
||||
if (reply !== null && !isGroupPassText(reply)) {
|
||||
appendGroupChatEntry(
|
||||
context.group,
|
||||
{
|
||||
kind: 'member',
|
||||
name: member.name,
|
||||
...(member.remoteSource
|
||||
? {
|
||||
source: member.connectionLabel || member.connectionId
|
||||
}
|
||||
: {})
|
||||
},
|
||||
groupMemberAuthor(member),
|
||||
reply,
|
||||
thread
|
||||
)
|
||||
|
||||
@@ -71,11 +71,11 @@ function viewerNameOf(viewer: GroupChatLineViewer): string {
|
||||
return typeof viewer === 'string' ? viewer : viewer?.name || ''
|
||||
}
|
||||
|
||||
/** Remote members stamp `from.source` as `connectionLabel || connectionId`.
|
||||
* Only a remoteSource viewer exposes those tokens; a string or local member
|
||||
* is unsourced so same-name remote lines fail open (no `(you)`). */
|
||||
/** Members stamp `from.source` as `connectionLabel || connectionId` (local
|
||||
* ones too, once they know their connection). A string viewer or a member
|
||||
* without a connection exposes no tokens. */
|
||||
function viewerConnectionSources(viewer: GroupChatLineViewer): string[] {
|
||||
if (typeof viewer === 'string' || !viewer?.remoteSource) {
|
||||
if (typeof viewer === 'string') {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -88,13 +88,14 @@ function isGroupChatSelf(from: GroupMessageAuthor, viewer: GroupChatLineViewer):
|
||||
}
|
||||
|
||||
const speakerSource = from.source || ''
|
||||
const viewerSources = viewerConnectionSources(viewer)
|
||||
|
||||
if (!speakerSource && viewerSources.length === 0) {
|
||||
return true
|
||||
// An unsourced same-name line is local by the room's resolution rule
|
||||
// (routing.ts: no source ⇒ `!remoteSource`), so only a local viewer owns it.
|
||||
if (!speakerSource) {
|
||||
return typeof viewer === 'string' || !viewer?.remoteSource
|
||||
}
|
||||
|
||||
return Boolean(speakerSource) && viewerSources.includes(speakerSource)
|
||||
return viewerConnectionSources(viewer).includes(speakerSource)
|
||||
}
|
||||
|
||||
interface GroupChatTurnPromptInput {
|
||||
|
||||
@@ -167,6 +167,26 @@ describe('routing', () => {
|
||||
expect(lines.some(line => line.startsWith('research:'))).toBe(true)
|
||||
expect(lines.some(line => line.startsWith('builder: On it'))).toBe(true)
|
||||
})
|
||||
|
||||
// #94863 D3: a LOCAL member's reply must carry from.source too, or the same
|
||||
// entry mirrored to another Desktop reads as that Desktop's own same-named bot.
|
||||
it('stamps a local member reply with its connection label and keeps (you) for that member only', async () => {
|
||||
const room = await loadRoom({ turn: () => 'Central here.' })
|
||||
const { formatGroupChatLine } = await import('./group-round-prompt')
|
||||
const local: GroupMember = { connectionId: 'central', connectionLabel: 'Central', name: 'default', title: '' }
|
||||
|
||||
room.rounds.sendToGroupChat('Core', [local], '@hermes status?')
|
||||
await settle(room, 'Core')
|
||||
|
||||
const reply = log(room, 'Core').find(entry => entry.from.kind === 'member')
|
||||
|
||||
expect(reply?.from).toEqual({ kind: 'member', name: 'default', source: 'Central' })
|
||||
expect(formatGroupChatLine(reply as GroupMessage, local)).toContain('(you)')
|
||||
// The same entry seen by the other machine's `default` is somebody else.
|
||||
expect(
|
||||
formatGroupChatLine(reply as GroupMessage, { connectionId: 'mbp', connectionLabel: 'MBP', name: 'default', remoteSource: true })
|
||||
).not.toContain('(you)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('round lifecycle', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { $groupChats, $groupClarify, appendGroupChatEntry, updateGroupChat } fro
|
||||
import type { GroupChatRoom } from './group-chat'
|
||||
import {
|
||||
followGroupChat,
|
||||
groupMemberAuthor,
|
||||
groupMemberKey,
|
||||
groupSessionKey,
|
||||
groupSessionOwner,
|
||||
@@ -1172,15 +1173,7 @@ export async function harvestStrandedGroupReply(group: string, member: GroupMemb
|
||||
})
|
||||
appendGroupChatEntry(
|
||||
group,
|
||||
{
|
||||
kind: 'member',
|
||||
name: member.name,
|
||||
...(member.remoteSource
|
||||
? {
|
||||
source: member.connectionLabel || member.connectionId
|
||||
}
|
||||
: {})
|
||||
},
|
||||
groupMemberAuthor(member),
|
||||
reply,
|
||||
strandedThread
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user