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:
ZacharyZhang-NY committed 2026-04-20 08:59:14 -04:00
1 parent 7833ded4a1
commit af72f1b4d9
3 files changed
+99 -11

No files matched your search

+39
View File
@@ -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);