From 3646ec5a536c066f421bd32b7a85dd147f8196b8 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:30:24 +0800 Subject: [PATCH 1/4] feat(server): trigger agents via @mention in comments When a user @mentions an agent in any issue's comment, the system now enqueues a task for that agent. The agent reads the issue context and replies to the triggering comment thread. Changes: - Add shared util.ParseMentions for mention parsing (used by both comment handler and notification listeners) - Add EnqueueTaskForMention to TaskService for explicit agent targeting - Add on_mention trigger type support in agent trigger config - Add HasPendingTaskForIssueAndAgent SQL query for per-agent dedup - Add enqueueMentionedAgentTasks in CreateComment handler Safety: prevents self-trigger (agent mentioning itself), dedup with assignee on_comment trigger, terminal issue status check, and per-agent pending task dedup. --- server/cmd/server/notification_listeners.go | 21 +++----- server/internal/handler/comment.go | 54 +++++++++++++++++++++ server/internal/handler/issue.go | 24 +++++++++ server/internal/service/task.go | 30 ++++++++++++ server/internal/util/mention.go | 28 +++++++++++ server/pkg/db/generated/agent.sql.go | 19 ++++++++ server/pkg/db/queries/agent.sql | 6 +++ 7 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 server/internal/util/mention.go diff --git a/server/cmd/server/notification_listeners.go b/server/cmd/server/notification_listeners.go index 0443b37a..1ad0ed92 100644 --- a/server/cmd/server/notification_listeners.go +++ b/server/cmd/server/notification_listeners.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "log/slog" - "regexp" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" @@ -13,15 +12,12 @@ import ( "github.com/multica-ai/multica/server/pkg/protocol" ) -// mention represents a parsed @mention from markdown content. +// mention represents a parsed @mention from markdown content (local alias). type mention struct { Type string // "member" or "agent" ID string // user_id or agent_id } -// mentionRe matches [@Label](mention://type/id) in markdown. -var mentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`) - // statusLabels maps DB status values to human-readable labels for notifications. var statusLabels = map[string]string{ "backlog": "Backlog", @@ -59,17 +55,12 @@ func priorityLabel(p string) string { var emptyDetails = []byte("{}") // parseMentions extracts mentions from markdown content. +// Delegates to the shared util.ParseMentions and converts to the local type. func parseMentions(content string) []mention { - matches := mentionRe.FindAllStringSubmatch(content, -1) - seen := make(map[string]bool) - var result []mention - for _, m := range matches { - key := m[1] + ":" + m[2] - if seen[key] { - continue - } - seen[key] = true - result = append(result, mention{Type: m[1], ID: m[2]}) + parsed := util.ParseMentions(content) + result := make([]mention, len(parsed)) + for i, m := range parsed { + result[i] = mention{Type: m.Type, ID: m.ID} } return result } diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index a1b2c38c..658a016b 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -1,6 +1,7 @@ package handler import ( + "context" "encoding/json" "log/slog" "net/http" @@ -8,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/logger" + "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) @@ -158,9 +160,61 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { } } + // Trigger @mentioned agents: parse agent mentions and enqueue tasks for each. + h.enqueueMentionedAgentTasks(r.Context(), issue, comment, authorType, authorID) + writeJSON(w, http.StatusCreated, resp) } +// enqueueMentionedAgentTasks parses @agent mentions from comment content and +// enqueues a task for each mentioned agent. Skips self-mentions, agents that +// are already the issue's assignee (handled by on_comment), and agents with +// on_mention trigger disabled. +func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue, comment db.Comment, authorType, authorID string) { + // Don't trigger on terminal statuses. + if issue.Status == "done" || issue.Status == "cancelled" { + return + } + + mentions := util.ParseMentions(comment.Content) + for _, m := range mentions { + if m.Type != "agent" { + continue + } + // Prevent self-trigger: skip if the comment author is this agent. + if authorType == "agent" && authorID == m.ID { + continue + } + agentUUID := parseUUID(m.ID) + // Prevent duplicate: skip if this agent is the issue's assignee + // (already handled by the on_comment trigger above). + if issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" && + issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID { + continue + } + // Check if the agent has on_mention trigger enabled. + if !h.isAgentMentionTriggerEnabled(ctx, agentUUID) { + continue + } + // Dedup: skip if this agent already has a pending task for this issue. + hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{ + IssueID: issue.ID, + AgentID: agentUUID, + }) + if err != nil || hasPending { + continue + } + // Resolve thread root for reply threading. + replyTo := comment.ID + if comment.ParentID.Valid { + replyTo = comment.ParentID + } + if _, err := h.TaskService.EnqueueTaskForMention(ctx, issue, agentUUID, replyTo); err != nil { + slog.Warn("enqueue mention agent task failed", "issue_id", uuidToString(issue.ID), "agent_id", m.ID, "error", err) + } + } +} + func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { commentId := chi.URLParam(r, "commentId") diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index bb24fcd7..ca3d9bd8 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -515,6 +515,30 @@ func (h *Handler) isAgentTriggerEnabled(ctx context.Context, issue db.Issue, tri return false } +// isAgentMentionTriggerEnabled checks if a specific agent has the on_mention +// trigger enabled. Unlike isAgentTriggerEnabled, this takes an explicit agent +// ID rather than deriving it from the issue assignee. +func (h *Handler) isAgentMentionTriggerEnabled(ctx context.Context, agentID pgtype.UUID) bool { + agent, err := h.Queries.GetAgent(ctx, agentID) + if err != nil || !agent.RuntimeID.Valid { + return false + } + if agent.Triggers == nil || len(agent.Triggers) == 0 { + return true // No config = all triggers enabled by default + } + + var triggers []agentTriggerSnapshot + if err := json.Unmarshal(agent.Triggers, &triggers); err != nil { + return false + } + for _, trigger := range triggers { + if trigger.Type == "on_mention" { + return trigger.Enabled + } + } + return true // on_mention not configured = enabled by default +} + func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") issue, ok := h.loadIssueForUser(w, r, id) diff --git a/server/internal/service/task.go b/server/internal/service/task.go index b2574aba..b09d56d3 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -69,6 +69,36 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue, t return task, nil } +// EnqueueTaskForMention creates a queued task for a mentioned agent on an issue. +// Unlike EnqueueTaskForIssue, this takes an explicit agent ID rather than +// deriving it from the issue assignee. +func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue, agentID pgtype.UUID, triggerCommentID pgtype.UUID) (db.AgentTaskQueue, error) { + agent, err := s.Queries.GetAgent(ctx, agentID) + if err != nil { + slog.Error("mention task enqueue failed: agent not found", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err) + return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err) + } + if !agent.RuntimeID.Valid { + slog.Error("mention task enqueue failed: agent has no runtime", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID)) + return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime") + } + + task, err := s.Queries.CreateAgentTask(ctx, db.CreateAgentTaskParams{ + AgentID: agentID, + RuntimeID: agent.RuntimeID, + IssueID: issue.ID, + Priority: priorityToInt(issue.Priority), + TriggerCommentID: triggerCommentID, + }) + if err != nil { + slog.Error("mention task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err) + return db.AgentTaskQueue{}, fmt.Errorf("create task: %w", err) + } + + slog.Info("mention task enqueued", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID)) + return task, nil +} + // CancelTasksForIssue cancels all active tasks for an issue. func (s *TaskService) CancelTasksForIssue(ctx context.Context, issueID pgtype.UUID) error { return s.Queries.CancelAgentTasksByIssue(ctx, issueID) diff --git a/server/internal/util/mention.go b/server/internal/util/mention.go new file mode 100644 index 00000000..83249b2c --- /dev/null +++ b/server/internal/util/mention.go @@ -0,0 +1,28 @@ +package util + +import "regexp" + +// Mention represents a parsed @mention from markdown content. +type Mention struct { + Type string // "member" or "agent" + ID string // user_id or agent_id +} + +// MentionRe matches [@Label](mention://type/id) in markdown. +var MentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`) + +// ParseMentions extracts deduplicated mentions from markdown content. +func ParseMentions(content string) []Mention { + matches := MentionRe.FindAllStringSubmatch(content, -1) + seen := make(map[string]bool) + var result []Mention + for _, m := range matches { + key := m[1] + ":" + m[2] + if seen[key] { + continue + } + seen[key] = true + result = append(result, Mention{Type: m[1], ID: m[2]}) + } + return result +} diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index f345e656..a951d44e 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -458,6 +458,25 @@ func (q *Queries) HasPendingTaskForIssue(ctx context.Context, issueID pgtype.UUI return has_pending, err } +const hasPendingTaskForIssueAndAgent = `-- name: HasPendingTaskForIssueAndAgent :one +SELECT count(*) > 0 AS has_pending FROM agent_task_queue +WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched') +` + +type HasPendingTaskForIssueAndAgentParams struct { + IssueID pgtype.UUID `json:"issue_id"` + AgentID pgtype.UUID `json:"agent_id"` +} + +// Returns true if a specific agent already has a queued or dispatched task +// for the given issue. Used by @mention trigger dedup. +func (q *Queries) HasPendingTaskForIssueAndAgent(ctx context.Context, arg HasPendingTaskForIssueAndAgentParams) (bool, error) { + row := q.db.QueryRow(ctx, hasPendingTaskForIssueAndAgent, arg.IssueID, arg.AgentID) + var has_pending bool + err := row.Scan(&has_pending) + return has_pending, err +} + const listActiveTasksByIssue = `-- name: ListActiveTasksByIssue :many SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id FROM agent_task_queue WHERE issue_id = $1 AND status IN ('dispatched', 'running') diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index 83dd1a1b..2b581204 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -124,6 +124,12 @@ WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running'); SELECT count(*) > 0 AS has_pending FROM agent_task_queue WHERE issue_id = $1 AND status IN ('queued', 'dispatched'); +-- name: HasPendingTaskForIssueAndAgent :one +-- Returns true if a specific agent already has a queued or dispatched task +-- for the given issue. Used by @mention trigger dedup. +SELECT count(*) > 0 AS has_pending FROM agent_task_queue +WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched'); + -- name: ListPendingTasksByRuntime :many SELECT * FROM agent_task_queue WHERE runtime_id = $1 AND status IN ('queued', 'dispatched') From c902460eaee15a8587d72fd1c2c348bc75b23814 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:42:54 +0800 Subject: [PATCH 2/4] fix(server): suppress assignee on_comment when mentions target others MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a comment @mentions anyone but not the assignee agent, the assignee's on_comment trigger is now suppressed. This prevents the assignee agent from being re-triggered when users share results with colleagues or ask other agents for help. The rule: @mention is an intent signal — if you're talking to someone else, the assignee agent should not respond. --- server/internal/handler/comment.go | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 658a016b..c3f9ebb4 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -147,7 +147,10 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { // If the issue is assigned to an agent with on_comment trigger, enqueue a new task. // Skip when the comment comes from the assigned agent itself to avoid loops. - if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) { + // Also skip when the comment @mentions others but not the assignee agent — + // the user is talking to someone else, not requesting work from the assignee. + if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) && + !h.commentMentionsOthersButNotAssignee(comment.Content, issue) { // Resolve thread root: if the comment is a reply, agent should reply // to the thread root (matching frontend behavior where all replies // in a thread share the same top-level parent). @@ -166,6 +169,27 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, resp) } +// commentMentionsOthersButNotAssignee returns true if the comment @mentions +// anyone but does NOT @mention the issue's assignee agent. This is used to +// suppress the on_comment trigger when the user is directing their comment at +// someone else (e.g. sharing results with a colleague, asking another agent). +func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.Issue) bool { + mentions := util.ParseMentions(content) + if len(mentions) == 0 { + return false // No mentions — normal on_comment behavior + } + if !issue.AssigneeID.Valid { + return true // No assignee — mentions target others + } + assigneeID := uuidToString(issue.AssigneeID) + for _, m := range mentions { + if m.ID == assigneeID { + return false // Assignee is mentioned — allow trigger + } + } + return true // Others mentioned but not assignee — suppress trigger +} + // enqueueMentionedAgentTasks parses @agent mentions from comment content and // enqueues a task for each mentioned agent. Skips self-mentions, agents that // are already the issue's assignee (handled by on_comment), and agents with From 259f2823bc29c1a53c906eb1227b52639dbf70b6 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 16:52:05 +0800 Subject: [PATCH 3/4] fix(web): show executing agent name in task log, not assigned agent When a task is executed by a mentioned agent (not the assigned one), the live card now resolves the agent name from activeTask.agent_id instead of using the assignee-based agentName prop. --- apps/web/features/issues/components/agent-live-card.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/features/issues/components/agent-live-card.tsx b/apps/web/features/issues/components/agent-live-card.tsx index 6d1de879..2147ce6a 100644 --- a/apps/web/features/issues/components/agent-live-card.tsx +++ b/apps/web/features/issues/components/agent-live-card.tsx @@ -8,6 +8,7 @@ import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload } from import type { AgentTask } from "@/shared/types/agent"; import { cn } from "@/lib/utils"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { useActorName } from "@/features/workspace"; import { redactSecrets } from "../utils/redact"; // ─── Shared types & helpers ───────────────────────────────────────────────── @@ -102,6 +103,7 @@ interface AgentLiveCardProps { } export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }: AgentLiveCardProps) { + const { getActorName } = useActorName(); const [activeTask, setActiveTask] = useState(null); const [items, setItems] = useState([]); const [elapsed, setElapsed] = useState(""); @@ -233,7 +235,7 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }:
- {agentName ?? "Agent"} is working + {(activeTask?.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"} is working
{elapsed} {toolCount > 0 && ( From f2289bd73379e51349de5b2ea291a9fadf818dae Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 17:04:29 +0800 Subject: [PATCH 4/4] fix(test): make createComment assertion resilient to trailing undefined args On CI (Node 22 / ubuntu), mockCreateComment receives extra undefined args. Check only the first two positional args instead of exact match. --- apps/web/app/(dashboard)/issues/[id]/page.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 8e27d5e1..54acedfd 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -331,10 +331,10 @@ describe("IssueDetailPage", () => { await user.click(submitBtn); await waitFor(() => { - expect(mockCreateComment).toHaveBeenCalledWith( - "issue-1", - "New test comment", - ); + expect(mockCreateComment).toHaveBeenCalled(); + const [issueId, content] = mockCreateComment.mock.calls[0]!; + expect(issueId).toBe("issue-1"); + expect(content).toBe("New test comment"); }); await waitFor(() => {