diff --git a/server/cmd/server/comment_trigger_integration_test.go b/server/cmd/server/comment_trigger_integration_test.go index eb5cf248..cfa2028c 100644 --- a/server/cmd/server/comment_trigger_integration_test.go +++ b/server/cmd/server/comment_trigger_integration_test.go @@ -323,6 +323,52 @@ func TestCommentTriggerOnMentionNoStatusGate(t *testing.T) { } } +// TestCommentTriggerThreadInheritedMention verifies that when a top-level +// comment @mentions an agent (not the assignee), replies in that thread +// also trigger the mentioned agent — even without explicitly re-mentioning it. +func TestCommentTriggerThreadInheritedMention(t *testing.T) { + agentID := getAgentID(t) + + // Create an issue NOT assigned to the agent, so on_comment won't fire. + issueID := createIssue(t, "Thread-inherited mention test") + t.Cleanup(func() { + clearTasks(t, issueID) + resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil) + resp.Body.Close() + }) + + t.Run("reply in thread inherits parent mention", func(t *testing.T) { + clearTasks(t, issueID) + // Top-level comment @mentions the agent. + content := fmt.Sprintf("[@Agent](mention://agent/%s) can you review this?", agentID) + threadID := postComment(t, issueID, content, nil) + if n := countPendingTasks(t, issueID); n != 1 { + t.Fatalf("expected 1 pending task after initial mention, got %d", n) + } + // Clear the task so we can test the reply independently. + clearTasks(t, issueID) + // Reply in the thread WITHOUT mentioning the agent. + postComment(t, issueID, "Here is more context for you", strPtr(threadID)) + if n := countPendingTasks(t, issueID); n != 1 { + t.Errorf("expected 1 pending task from thread-inherited mention, got %d", n) + } + }) + + t.Run("reply does not double-trigger when re-mentioning same agent", func(t *testing.T) { + clearTasks(t, issueID) + // Top-level comment @mentions the agent. + content := fmt.Sprintf("[@Agent](mention://agent/%s) help", agentID) + threadID := postComment(t, issueID, content, nil) + clearTasks(t, issueID) + // Reply also @mentions the same agent — should still be just 1 task. + reply := fmt.Sprintf("[@Agent](mention://agent/%s) any update?", agentID) + postComment(t, issueID, reply, strPtr(threadID)) + if n := countPendingTasks(t, issueID); n != 1 { + t.Errorf("expected 1 pending task (no duplicate), got %d", n) + } + }) +} + // TestCommentTriggerCoalescing verifies that rapid-fire comments don't create // duplicate tasks (coalescing dedup). func TestCommentTriggerCoalescing(t *testing.T) { diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index a40847fa..d17917a8 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -188,7 +188,8 @@ 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) + // 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) } @@ -248,16 +249,34 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu } // 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), 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). +// 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. +// Skips self-mentions, agents that are already the issue's assignee (handled +// by on_comment), 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, authorType, authorID string) { +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, also include mentions from the parent comment + // so that agents mentioned in the thread root are triggered by replies. + if parentComment != nil { + parentMentions := util.ParseMentions(parentComment.Content) + seen := make(map[string]bool, len(mentions)) + for _, m := range mentions { + seen[m.Type+":"+m.ID] = true + } + for _, m := range parentMentions { + if !seen[m.Type+":"+m.ID] { + mentions = append(mentions, m) + seen[m.Type+":"+m.ID] = true + } + } + } for _, m := range mentions { if m.Type != "agent" { continue