Files
multica/server/internal/handler/comment.go
Bright Zheng 81231e06f8 fix(server): prevent agent-to-agent mention inheritance loops (BRI-34) (#1765)
When an agent replied in a thread whose root mentioned another agent,
the reply inherited the parent mention and re-triggered the other agent.
This caused 'No reply needed' ping-pong loops between co-assigned agents.

Structural fix:
- In enqueueMentionedAgentTasks, suppress parent-mention inheritance
  when authorType == 'agent'. Explicit @mentions in the agent's own
  comment still work for deliberate handoffs.

Defense-in-depth (prompt):
- Strengthen per-turn prompt and AGENTS.md workflow instructions to
  explicitly forbid posting 'No reply needed' noise comments.

Regression test:
- TestAgentReplyDoesNotInheritParentMentions covers both the fix
  (agent reply does not re-trigger) and the positive control
  (member reply still inherits mentions).

Also updates TestBuildPromptCommentTriggeredByAgent to match the
new prompt wording.
2026-04-28 15:14:14 +08:00

615 lines
22 KiB
Go

package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"time"
"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/mention"
"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"
)
type CommentResponse struct {
ID string `json:"id"`
IssueID string `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID string `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
ParentID *string `json:"parent_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Reactions []ReactionResponse `json:"reactions"`
Attachments []AttachmentResponse `json:"attachments"`
}
func commentToResponse(c db.Comment, reactions []ReactionResponse, attachments []AttachmentResponse) CommentResponse {
if reactions == nil {
reactions = []ReactionResponse{}
}
if attachments == nil {
attachments = []AttachmentResponse{}
}
return CommentResponse{
ID: uuidToString(c.ID),
IssueID: uuidToString(c.IssueID),
AuthorType: c.AuthorType,
AuthorID: uuidToString(c.AuthorID),
Content: c.Content,
Type: c.Type,
ParentID: uuidToPtr(c.ParentID),
CreatedAt: timestampToString(c.CreatedAt),
UpdatedAt: timestampToString(c.UpdatedAt),
Reactions: reactions,
Attachments: attachments,
}
}
func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
// Parse optional pagination query params.
q := r.URL.Query()
var limit, offset int32
var hasPagination bool
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "invalid limit parameter")
return
}
limit = int32(n)
hasPagination = true
}
if v := q.Get("offset"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
writeError(w, http.StatusBadRequest, "invalid offset parameter")
return
}
offset = int32(n)
hasPagination = true
}
var sinceTime pgtype.Timestamptz
if v := q.Get("since"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid since parameter; expected RFC3339 format")
return
}
sinceTime = pgtype.Timestamptz{Time: t, Valid: true}
}
var comments []db.Comment
var err error
switch {
case sinceTime.Valid && hasPagination:
if limit == 0 {
limit = 50
}
comments, err = h.Queries.ListCommentsSincePaginated(r.Context(), db.ListCommentsSincePaginatedParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
CreatedAt: sinceTime,
Limit: limit,
Offset: offset,
})
case sinceTime.Valid:
// Apply a server-side cap to prevent unbounded result sets when
// --since is used without --limit.
comments, err = h.Queries.ListCommentsSincePaginated(r.Context(), db.ListCommentsSincePaginatedParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
CreatedAt: sinceTime,
Limit: 500,
Offset: 0,
})
hasPagination = true
case hasPagination:
if limit == 0 {
limit = 50
}
comments, err = h.Queries.ListCommentsPaginated(r.Context(), db.ListCommentsPaginatedParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Limit: limit,
Offset: offset,
})
default:
comments, err = h.Queries.ListComments(r.Context(), db.ListCommentsParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
})
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
commentIDs := make([]pgtype.UUID, len(comments))
for i, c := range comments {
commentIDs[i] = c.ID
}
grouped := h.groupReactions(r, commentIDs)
groupedAtt := h.groupAttachments(r, commentIDs)
resp := make([]CommentResponse, len(comments))
for i, c := range comments {
cid := uuidToString(c.ID)
resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid])
}
// Include total count in response header when paginating.
if hasPagination {
total, countErr := h.Queries.CountComments(r.Context(), db.CountCommentsParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
})
if countErr == nil {
w.Header().Set("X-Total-Count", strconv.FormatInt(total, 10))
}
}
writeJSON(w, http.StatusOK, resp)
}
type CreateCommentRequest struct {
Content string `json:"content"`
Type string `json:"type"`
ParentID *string `json:"parent_id"`
AttachmentIDs []string `json:"attachment_ids"`
}
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req CreateCommentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
if req.Type == "" {
req.Type = "comment"
}
var parentID pgtype.UUID
var parentComment *db.Comment
if req.ParentID != nil {
var parsed pgtype.UUID
parsed, ok = parseUUIDOrBadRequest(w, *req.ParentID, "parent_id")
if !ok {
return
}
parentID = parsed
parent, err := h.Queries.GetComment(r.Context(), parentID)
if err != nil || uuidToString(parent.IssueID) != uuidToString(issue.ID) {
writeError(w, http.StatusBadRequest, "invalid parent comment")
return
}
parentComment = &parent
}
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
if !ok {
return
}
// Determine author identity: agent (via X-Agent-ID header) or member.
authorType, authorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
// Defense against resumed-session drift: when an agent posts from inside a
// comment-triggered task AND the comment is being posted on that same
// issue, the parent_id must exactly match the task's trigger comment.
// Resumed Claude sessions otherwise carry forward a previous turn's
// --parent UUID and silently misplace the reply.
//
// The task.IssueID scope is important: the CLI stamps X-Task-ID on every
// request, so an agent legitimately commenting on a different issue must
// not be blocked by its current task's trigger. Assignment-triggered
// tasks (no TriggerCommentID) are also unaffected.
if authorType == "agent" {
if taskIDHeader := r.Header.Get("X-Task-ID"); taskIDHeader != "" {
taskUUID, parseErr := util.ParseUUID(taskIDHeader)
if parseErr == nil {
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err == nil && task.TriggerCommentID.Valid && uuidToString(task.IssueID) == uuidToString(issue.ID) {
if uuidToString(parentID) != uuidToString(task.TriggerCommentID) {
writeError(w, http.StatusConflict,
"parent_id must equal this task's trigger comment id ("+uuidToString(task.TriggerCommentID)+")")
return
}
}
}
}
}
// Expand bare issue identifiers (e.g. MUL-117) into mention links.
req.Content = mention.ExpandIssueIdentifiers(r.Context(), h.Queries, issue.WorkspaceID, req.Content)
// NOTE: Comment content is stored as Markdown source. XSS is handled at the
// rendering layer (rehype-sanitize) and at the editor layer
// (@tiptap/markdown with html:false). Running an HTML sanitizer here would
// entity-encode Markdown syntax characters (>, ", &, <) and corrupt the
// source. See issue #1303 / discussion in MUL-1119, MUL-1125.
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
AuthorType: authorType,
AuthorID: parseUUID(authorID),
Content: req.Content,
Type: req.Type,
ParentID: parentID,
})
if err != nil {
slog.Warn("create comment failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...)
writeError(w, http.StatusInternalServerError, "failed to create comment: "+err.Error())
return
}
// Link uploaded attachments to this comment.
if len(attachmentIDs) > 0 {
h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, attachmentIDs)
}
// Fetch linked attachments so the response includes them.
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
resp := commentToResponse(comment, nil, groupedAtt[uuidToString(comment.ID)])
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{
"comment": resp,
"issue_title": issue.Title,
"issue_assignee_type": textToPtr(issue.AssigneeType),
"issue_assignee_id": uuidToPtr(issue.AssigneeID),
"issue_status": issue.Status,
})
// 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.
// 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.
// Also skip when replying in a member-started thread without mentioning the
// assignee — the user is continuing a member-to-member conversation.
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
// Always use the current comment as the trigger so the agent reads
// the actual new reply, not the thread root. Reply placement (flat
// thread grouping) is handled downstream by createAgentComment,
// which resolves parent_id to the thread root before posting. This
// mirrors the mention path's behavior (see enqueueMentionedAgentTasks).
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue, comment.ID); err != nil {
slog.Warn("enqueue agent task on comment failed", "issue_id", issueID, "error", err)
}
}
// Trigger @mentioned agents: parse agent mentions and enqueue tasks for each.
// Pass parentComment so that replies inherit mentions from the thread root.
h.enqueueMentionedAgentTasks(r.Context(), issue, comment, parentComment, authorType, authorID)
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).
// @all is treated as a broadcast — it suppresses the trigger because the user
// is announcing to everyone, not specifically requesting work from the agent.
func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.Issue) bool {
mentions := util.ParseMentions(content)
// Filter out issue mentions — they are cross-references, not @people.
filtered := mentions[:0]
for _, m := range mentions {
if m.Type != "issue" {
filtered = append(filtered, m)
}
}
mentions = filtered
if len(mentions) == 0 {
return false // No mentions (or only issue refs) — normal on_comment behavior
}
// @all is a broadcast to all members — suppress agent trigger.
if util.HasMentionAll(mentions) {
return true
}
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
}
// isReplyToMemberThread returns true if the comment is a reply in a thread
// started by a member and does NOT @mention the issue's assignee agent.
// When a member replies in a member-started thread, they are most likely
// continuing a human conversation — not requesting work from the assigned agent.
// Replying to an agent-started thread, or explicitly @mentioning the assignee
// in the reply, still triggers on_comment as expected.
// If the parent (thread root) itself @mentions the assignee, the thread is
// considered a conversation with the agent, so replies are allowed to trigger.
// If the assigned agent has already replied in the thread, the member is
// conversing with the agent, so replies are allowed to trigger.
func (h *Handler) isReplyToMemberThread(ctx context.Context, parent *db.Comment, content string, issue db.Issue) bool {
if parent == nil {
return false // Not a reply — normal top-level comment
}
if parent.AuthorType != "member" {
return false // Thread started by an agent — allow trigger
}
// Thread was started by a member. Suppress on_comment unless the reply
// or the parent explicitly @mentions the assignee agent, or the agent
// has already participated in this thread.
if !issue.AssigneeID.Valid {
return true // No assignee to mention
}
assigneeID := uuidToString(issue.AssigneeID)
// Check current comment mentions.
for _, m := range util.ParseMentions(content) {
if m.ID == assigneeID {
return false // Assignee explicitly mentioned in reply — allow trigger
}
}
// Check parent (thread root) mentions — if the thread was started by
// mentioning the assignee, replies continue that conversation.
for _, m := range util.ParseMentions(parent.Content) {
if m.ID == assigneeID {
return false // Assignee mentioned in thread root — allow trigger
}
}
// Check if the assigned agent has already replied in this thread —
// if so, the member is continuing a conversation with the agent.
if h.Queries != nil {
hasReplied, err := h.Queries.HasAgentRepliedInThread(ctx, db.HasAgentRepliedInThreadParams{
ParentID: parent.ID,
AgentID: issue.AssigneeID,
})
if err == nil && hasReplied {
return false // Agent participated in thread — allow trigger
}
}
return true // Reply to member thread without agent participation — suppress
}
// enqueueMentionedAgentTasks parses @agent mentions from comment content and
// enqueues a task for each mentioned agent. When parentComment is non-nil
// (i.e. the comment is a reply), mentions from the parent (thread root) are
// also included so that agents mentioned in the top-level comment are
// re-triggered by subsequent replies in the same thread — unless the reply
// explicitly @mentions only non-agent entities (members, issues), which
// signals the user is talking to other people and not the agent.
// Skips self-mentions, agents with on_mention trigger disabled, and private
// agents mentioned by non-owner members (only the agent owner or workspace
// admin/owner can mention a private agent).
// Note: no status gate here — @mention is an explicit action and should work
// even on done/cancelled issues (the agent can reopen the issue if needed).
func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue, comment db.Comment, parentComment *db.Comment, authorType, authorID string) {
wsID := uuidToString(issue.WorkspaceID)
mentions := util.ParseMentions(comment.Content)
// When replying in a thread, inherit mentions from the parent comment
// so that agents mentioned in the thread root are triggered by replies —
// but only when the reply contains no mentions at all (a plain follow-up).
// If the reply explicitly @mentions anyone (agents or members), the user
// is making a deliberate choice about who to involve; don't auto-inherit.
//
// CRITICAL: agent-authored replies must NOT inherit parent mentions.
// Otherwise an agent posting "No reply needed" in a thread whose root
// mentioned another agent would re-trigger that agent, creating a loop.
// Explicit @mentions in the agent's own comment still work for delegation.
if parentComment != nil && len(mentions) == 0 && authorType != "agent" {
mentions = util.ParseMentions(parentComment.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)
// Load the agent to check visibility, archive status, and trigger config.
agent, err := h.Queries.GetAgent(ctx, agentUUID)
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
continue
}
// Private agents can only be mentioned by the agent owner or workspace admin/owner.
if agent.Visibility == "private" && authorType == "member" {
isOwner := uuidToString(agent.OwnerID) == authorID
if !isOwner {
member, err := h.getWorkspaceMember(ctx, authorID, wsID)
if err != nil || !roleAllowed(member.Role, "owner", "admin") {
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
}
// Always use the current comment as the trigger so the agent reads the
// actual reply that mentioned it, not the thread root.
if _, err := h.TaskService.EnqueueTaskForMention(ctx, issue, agentUUID, comment.ID); 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")
userID, ok := requireUserID(w, r)
if !ok {
return
}
commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
if !ok {
return
}
// Load comment scoped to current workspace.
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
existing, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
ID: commentUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
return
}
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
isAuthor := existing.AuthorType == actorType && uuidToString(existing.AuthorID) == actorID
isAdmin := roleAllowed(member.Role, "owner", "admin")
if !isAuthor && !isAdmin {
writeError(w, http.StatusForbidden, "only comment author or admin can edit")
return
}
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
// NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here.
comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{
ID: commentUUID,
Content: req.Content,
})
if err != nil {
slog.Warn("update comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
writeError(w, http.StatusInternalServerError, "failed to update comment")
return
}
// Fetch reactions and attachments for the updated comment.
grouped := h.groupReactions(r, []pgtype.UUID{comment.ID})
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
cid := uuidToString(comment.ID)
resp := commentToResponse(comment, grouped[cid], groupedAtt[cid])
slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...)
h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp})
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
commentId := chi.URLParam(r, "commentId")
userID, ok := requireUserID(w, r)
if !ok {
return
}
commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
if !ok {
return
}
// Load comment scoped to current workspace.
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
ID: commentUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
return
}
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
isAuthor := comment.AuthorType == actorType && uuidToString(comment.AuthorID) == actorID
isAdmin := roleAllowed(member.Role, "owner", "admin")
if !isAuthor && !isAdmin {
writeError(w, http.StatusForbidden, "only comment author or admin can delete")
return
}
// Collect attachment URLs before CASCADE delete removes them.
attachmentURLs, _ := h.Queries.ListAttachmentURLsByCommentID(r.Context(), comment.ID)
// Cancel any active tasks triggered by this comment so the agent does not
// run with the now-deleted content already embedded in its prompt. Must
// run before DeleteComment because the FK ON DELETE SET NULL would
// otherwise nullify trigger_comment_id and orphan those tasks in queued.
if err := h.TaskService.CancelTasksByTriggerComment(r.Context(), comment.ID); err != nil {
slog.Warn("cancel tasks for deleted trigger comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
}
if err := h.Queries.DeleteComment(r.Context(), comment.ID); err != nil {
slog.Warn("delete comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
writeError(w, http.StatusInternalServerError, "failed to delete comment")
return
}
h.deleteS3Objects(r.Context(), attachmentURLs)
slog.Info("comment deleted", append(logger.RequestAttrs(r), "comment_id", commentId, "issue_id", uuidToString(comment.IssueID))...)
h.publish(protocol.EventCommentDeleted, workspaceID, actorType, actorID, map[string]any{
"comment_id": uuidToString(comment.ID),
"issue_id": uuidToString(comment.IssueID),
})
w.WriteHeader(http.StatusNoContent)
}