Make @mentions visible to the agents they name

A DM addressed to one agent that @-mentions another was silently
dropping the second agent out of the conversation:

* `dispatchAgentMessage` added the mentioned agent to the wake set,
  so CEO's heartbeat fired — but
* `listAgentInboxUnread` only surfaced messages where the current
  agent was `to_agent_id` or sat in a matching channel/project/
  department. Mentions were invisible, so CEO's next turn saw nothing
  new and returned silently. From the operator's chair this looked
  exactly like "CEO ignored me; 张工程师 answered instead".
* `findMentionedAgentIds` had a latent bug on top: its unique-alias
  filter used `ids.length === 1`, but a single agent whose name
  matched its role registered itself twice, tripped the filter, and
  every `@CEO` in a scaffold with role === name was a no-op. Once
  inbox visibility was fixed the bug became load-bearing.

Root-cause fixes:

* New migration v48 adds `agent_message_mentions(message_id, agent_id,
  company_id, created_at)` so mention fan-out is a first-class join.
* `sendAgentMessage` now takes the resolved mention set and writes
  the row + mentions in a single transaction — a crash between the
  two writes can no longer leave a message visible to the recipient
  but invisible to the mentioned agent.
* `dispatchAgentMessage` computes the mention set exactly once via
  `resolveMentionedAgentIds` and hands the same list to both the DB
  write and `resolveMessageWakeTargets`, so persistence and wake
  stay in lockstep.
* `listAgentInboxUnread` and `listAgentMessages` gain an `id IN
  (select message_id from agent_message_mentions …)` clause so the
  mentioned agent's inbox actually contains the message that woke it.
* `findMentionedAgentIds` now stores per-alias agent IDs in a `Set`
  and filters on `ids.size === 1`, dropping the self-duplication
  bug while preserving the "refuse ambiguous aliases" semantics.
* Company teardown drops `agent_message_mentions` alongside the
  existing `agent_message_reads` cleanup so deletes stay clean.

Tests pin the new behaviour: a DM with a `@CEO` mention reaches
CEO's inbox and both agents get woken, while a DM whose recipient
is `__board__` never attempts to wake the human sentinel.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-20 08:21:43 -04:00
1 parent e74dafcd36
commit 7833ded4a1
8 files changed
+231 -36

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
export function createMigrationsV48V48(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
// Persist @mention fan-out so inbox visibility stops depending on
// a runtime-only regex against every message body.
//
// Before v48 `dispatchAgentMessage` woke mentioned agents via
// `findMentionedAgentIds`, but `listAgentInboxUnread` filtered
// purely on `channel`, `to_agent_id`, and department/project
// membership. A mid-chat `@ceo update please` targeted at
// 张工程师 therefore woke CEO with an empty inbox — CEO's
// heartbeat fired, saw nothing new, and stayed silent while the
// DM recipient replied and the operator assumed CEO had ignored
// them. The join table makes "mentioned in a message" a first-
// class visibility path: persisted once at dispatch, joined on
// read, so every consumer (inbox projection, read-receipts,
// search) sees a single source of truth.
version: 48,
name: "agent_message_mentions",
fn: () => {
db.exec(`
CREATE TABLE IF NOT EXISTS agent_message_mentions (
message_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
company_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, agent_id)
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_agent_message_mentions_agent
ON agent_message_mentions(company_id, agent_id, created_at)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_agent_message_mentions_message
ON agent_message_mentions(message_id)
`);
log.info("[migration v48] agent_message_mentions join table installed");
},
},
];
}