diff --git a/src/main/message-dispatch.ts b/src/main/message-dispatch.ts index ee46e5b0..cd5803b6 100644 --- a/src/main/message-dispatch.ts +++ b/src/main/message-dispatch.ts @@ -237,9 +237,21 @@ export function resolveMessageWakeTargets( mentionedAgentIds?: readonly string[], ): string[] { const wakeTargets = new Set(); + const mentioned = mentionedAgentIds ?? resolveMentionedAgentIds(db, input); + const hasExplicitMentions = mentioned.length > 0; if (input.channel === "direct" && input.toAgentId && input.toAgentId !== input.fromAgentId) { - wakeTargets.add(input.toAgentId); + // Chatroom semantics: when the body carries explicit @mentions, the + // mention list is the recipient list. The DM's `toAgentId` is only + // woken if the sender named them; otherwise the named agent takes + // over the turn and the DM partner "keeps working" — exactly the + // behaviour the operator asks for when they say "@CEO 更新一下进度" + // inside a chat with 张工程师 and expects CEO (not 张工程师) to + // answer. With no mentions, preserve the historical "DM always + // wakes the recipient" contract so every existing flow is intact. + if (!hasExplicitMentions || mentioned.includes(input.toAgentId)) { + wakeTargets.add(input.toAgentId); + } } if (input.channel === "department" && input.channelTargetId) { @@ -270,7 +282,6 @@ export function resolveMessageWakeTargets( } } - const mentioned = mentionedAgentIds ?? resolveMentionedAgentIds(db, input); for (const mentionedId of mentioned) { wakeTargets.add(mentionedId); } diff --git a/src/renderer/components/communication/channel-utils.ts b/src/renderer/components/communication/channel-utils.ts index c3ec5a8a..208d605f 100644 --- a/src/renderer/components/communication/channel-utils.ts +++ b/src/renderer/components/communication/channel-utils.ts @@ -9,10 +9,45 @@ function labelForParticipant(id: string | undefined, agentMap: Map`. Keying that reply + * by `sort(fromAgent, toAgent)` alone would split it into a separate + * Board↔CEO pane and strand the user in two half-conversations. By + * walking `parentMessageId` back to the root of the thread and using + * the root's participant pair, every reply in the chain lands in the + * same chat surface the sender originally chose. + * + * Cycle guard: `parentMessageId` is user-supplied in some replies, so a + * malformed cycle must not wedge the UI. We bail out once the visit set + * sees an id twice and treat the current message as its own root. + */ +function resolveDirectThreadPair( + msg: AgentMessageRecord, + byId: Map, +): [string, string] | null { + const visited = new Set() + let cursor: AgentMessageRecord | undefined = msg + while (cursor && cursor.parentMessageId && !visited.has(cursor.id)) { + visited.add(cursor.id) + const parent = byId.get(cursor.parentMessageId) + if (!parent || parent.channel !== "direct" || !parent.toAgentId) break + cursor = parent + } + if (!cursor || !cursor.toAgentId) return null + const ids = [cursor.fromAgentId, cursor.toAgentId].sort() + return [ids[0]!, ids[1]!] +} + export function deriveChannels(messages: AgentMessageRecord[], agents: AgentRecord[], projects?: ProjectRecord[]): ChannelDescriptor[] { const channelMap = new Map() const agentMap = new Map(agents.map((a) => [a.id, a])) const projectMap = new Map((projects ?? []).map((p) => [p.id, p])) + const byId = new Map(messages.map((m) => [m.id, m])) channelMap.set("company-all", { key: "company-all", kind: "message", channel: "company", label: "#company-wide", targetId: null, agentId: null, participantIds: [] }) @@ -25,15 +60,16 @@ export function deriveChannels(messages: AgentMessageRecord[], agents: AgentReco for (const msg of messages) { let key: string; let label: string; let agentId: string | null = null + let participantIds: string[] = [] switch (msg.channel) { case "direct": { - const otherId = msg.toAgentId - if (!otherId) { key = "direct-broadcast"; label = "Broadcast DMs" } + const pair = resolveDirectThreadPair(msg, byId) + if (!pair) { key = "direct-broadcast"; label = "Broadcast DMs" } else { - const ids = [msg.fromAgentId, otherId].sort() - key = `direct-${ids.join("-")}` - label = `${labelForParticipant(ids[0], agentMap)} \u2194 ${labelForParticipant(ids[1], agentMap)}` - agentId = ids.find((id) => id !== BOARD_ID) ?? ids[1] ?? null + key = `direct-${pair.join("-")}` + label = `${labelForParticipant(pair[0], agentMap)} \u2194 ${labelForParticipant(pair[1], agentMap)}` + agentId = pair.find((id) => id !== BOARD_ID) ?? pair[1] ?? null + participantIds = pair } break } case "department": { const d = msg.channelTargetId ?? "general"; key = `department-${d}`; label = `#${d.replace(/_/g, "-")}`; break } @@ -42,7 +78,7 @@ export function deriveChannels(messages: AgentMessageRecord[], agents: AgentReco case "incident": { const iid = msg.channelTargetId ?? "room"; key = `incident-${iid}`; label = iid === "room" ? "#incident-room" : `#incident-${iid.slice(0, 8)}`; break } } if (!channelMap.has(key)) { - channelMap.set(key, { key, kind: "message", channel: msg.channel, label, targetId: msg.channelTargetId, agentId, participantIds: msg.channel === "direct" && msg.toAgentId ? [msg.fromAgentId, msg.toAgentId].sort() : [] }) + channelMap.set(key, { key, kind: "message", channel: msg.channel, label, targetId: msg.channelTargetId, agentId, participantIds }) } } @@ -67,6 +103,7 @@ export function deriveTaskDiscussionChannels(comments: CommentRecord[], tasks: T } export function getChannelMessages(messages: AgentMessageRecord[], channel: ChannelDescriptor): AgentMessageRecord[] { + const byId = new Map(messages.map((m) => [m.id, m])) return messages.filter((msg) => { if (channel.channel === "company" && msg.channel === "company") return true if (channel.channel === "department" && msg.channel === "department") return msg.channelTargetId === channel.targetId @@ -75,8 +112,9 @@ export function getChannelMessages(messages: AgentMessageRecord[], channel: Chan if (channel.channel === "direct" && msg.channel === "direct") { if (channel.key === "direct-broadcast") return !msg.toAgentId if (!msg.toAgentId || channel.participantIds.length < 2) return false - const ids = [msg.fromAgentId, msg.toAgentId].sort() - return ids[0] === channel.participantIds[0] && ids[1] === channel.participantIds[1] + const pair = resolveDirectThreadPair(msg, byId) + if (!pair) return false + return pair[0] === channel.participantIds[0] && pair[1] === channel.participantIds[1] } return false }) diff --git a/tests/unit/message-dispatch.test.ts b/tests/unit/message-dispatch.test.ts index f2cc8091..61b80775 100644 --- a/tests/unit/message-dispatch.test.ts +++ b/tests/unit/message-dispatch.test.ts @@ -199,6 +199,45 @@ describe("message dispatch", () => { db.close(); }); + it("redirects a DM's wake to the @mentioned agent instead of the DM recipient (chatroom semantics)", async () => { + const profileDir = await createTempDir("agentcompany-message-redirect-"); + const db = new AppDatabase(profileDir); + db.init(); + + const { companyId, ceoId, ctoId } = createScaffold(db); + const wakeAgentIfPossible = vi.fn().mockReturnValue(null); + + const result = dispatchAgentMessage({ + db, + wakeAgentIfPossible, + }, { + companyId, + fromAgentId: HUMAN_SENDER_ID, + fromHumanUser: true, + channel: "direct", + toAgentId: ctoId, + subject: "progress", + body: "@CEO 更新一下进度。", + }); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("Expected dispatch to succeed."); + // CEO is the only wake target — the DM recipient (CTO) is NOT + // woken because an explicit @mention overrides the implicit DM + // recipient. This is what lets the operator say "@CEO" inside a + // chat with CTO and have CEO (not CTO) take the turn. + expect(result.data.wakeTargets).toEqual([ceoId]); + + // The message is still visible to CTO's inbox — they were the + // addressed recipient — but CTO is not required to answer. + const ctoInbox = db.listAgentInboxUnread(companyId, ctoId); + expect(ctoInbox.map((entry) => entry.id)).toContain(result.data.id); + const ceoInbox = db.listAgentInboxUnread(companyId, ceoId); + expect(ceoInbox.map((entry) => entry.id)).toContain(result.data.id); + + db.close(); + }); + it("keeps __board__ out of wake targets even when the recipient is the human reader", async () => { const profileDir = await createTempDir("agentcompany-message-board-wake-"); const db = new AppDatabase(profileDir);