Make @mentions in a DM redirect the turn (group-chat semantics)
The operator's mental model: a chat is a room, @X means "X takes the turn", unmentioned participants keep working. Two separate gaps were letting 张工程师 answer when the user wrote "@CEO 更新一下进度" inside their Board↔张工程师 chat: * `resolveMessageWakeTargets` always woke the DM recipient, regardless of who the body @-mentioned. CEO woke too, but 张工程师 — the addressed party — replied first and CEO's busy-queued wake arrived after the operator had already taken the earlier reply as "the answer". Explicit mentions now shadow the implicit DM recipient: with mentions, only the named agents wake; the recipient still sees the message in their inbox, they just aren't obliged to answer. No mentions → fall back to the historical "DM wakes the recipient" contract so every existing flow is untouched. * `channel-utils` keyed each chat by `sort(fromAgent, toAgent)`. CEO's reply with `toAgentId=__board__, parentMessageId=<original>` landed in a fresh Board↔CEO pane — the exact "new session" the operator complained about. Walk `parentMessageId` back to the root of the reply chain and use the root's participant pair: every reply in a thread now collapses into the chat the sender originally opened, cycle-guarded against malformed parent pointers. Pinned by a new unit test: DM to CTO with "@CEO 更新一下进度。" wakes only CEO while keeping the message visible to both CTO's and CEO's inbox.
This commit is contained in:
1 parent
7833ded4a1
commit
af72f1b4d9
3 files changed
+99
-11
No files matched your search
@@ -237,9 +237,21 @@ export function resolveMessageWakeTargets(
|
||||
mentionedAgentIds?: readonly string[],
|
||||
): string[] {
|
||||
const wakeTargets = new Set<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,45 @@ function labelForParticipant(id: string | undefined, agentMap: Map<string, Agent
|
||||
return agentMap.get(id)?.name ?? id.slice(0, 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the participant-pair key that a direct message belongs to.
|
||||
*
|
||||
* A chat is a conversation thread, not a one-hop DM pair. When the user
|
||||
* writes "@CEO 更新一下进度" inside their chat with 张工程师, CEO's
|
||||
* reply targets `__board__` (the human reader) via
|
||||
* `toAgentId=__board__, parentMessageId=<original>`. 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, AgentMessageRecord>,
|
||||
): [string, string] | null {
|
||||
const visited = new Set<string>()
|
||||
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<string, ChannelDescriptor>()
|
||||
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
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user