diff --git a/apps/desktop/src/plugins/hermes-bots/group-membership.ts b/apps/desktop/src/plugins/hermes-bots/group-membership.ts index cac53302b6..e8a4a87d06 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-membership.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-membership.ts @@ -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 `::` — so the thread segment needs its diff --git a/apps/desktop/src/plugins/hermes-bots/group-round-members.ts b/apps/desktop/src/plugins/hermes-bots/group-round-members.ts index f8b2664dfe..2292f18fe5 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-round-members.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-round-members.ts @@ -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 ) diff --git a/apps/desktop/src/plugins/hermes-bots/group-round-prompt.ts b/apps/desktop/src/plugins/hermes-bots/group-round-prompt.ts index e4c68a6a31..372873691a 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-round-prompt.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-round-prompt.ts @@ -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 { 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 a12a1fbd9d..e9fbff508e 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts @@ -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', () => { diff --git a/apps/desktop/src/plugins/hermes-bots/group-turns.ts b/apps/desktop/src/plugins/hermes-bots/group-turns.ts index 44213fce4d..c349f5201e 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-turns.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-turns.ts @@ -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 )