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:
teknium1
2026-09-19 23:38:43 -07:00
committed by Teknium
parent 641679058f
commit 10a78f9916
5 changed files with 45 additions and 28 deletions

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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