From 5956280d56353cd9a227d4bb3e71d8383575ff83 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:29:01 +0800 Subject: [PATCH] fix(server): don't inherit parent agent mentions when reply has its own mentions (#693) When a reply explicitly @mentions anyone (agents or members), the user is making a deliberate choice about who to involve. Previously, replying with @AgentB under a comment mentioning @AgentA would trigger both agents. Now parent mentions are only inherited when the reply has no mentions at all. --- .../comment_trigger_integration_test.go | 51 ++++++++++++++++++- server/internal/handler/comment.go | 35 +++---------- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/server/cmd/server/comment_trigger_integration_test.go b/server/cmd/server/comment_trigger_integration_test.go index e2ead5e538..237bf31980 100644 --- a/server/cmd/server/comment_trigger_integration_test.go +++ b/server/cmd/server/comment_trigger_integration_test.go @@ -70,6 +70,38 @@ func getAgentID(t *testing.T) string { return agents[0]["id"].(string) } +// createSecondAgent creates a second agent in the test workspace and returns its ID. +// It reuses the same runtime as the first agent. +func createSecondAgent(t *testing.T) string { + t.Helper() + // Fetch the first agent to get its runtime_id. + resp := authRequest(t, "GET", "/api/agents?workspace_id="+testWorkspaceID, nil) + var agents []map[string]any + readJSON(t, resp, &agents) + if len(agents) == 0 { + t.Fatal("no agents in test workspace") + } + runtimeID := agents[0]["runtime_id"].(string) + + resp = authRequest(t, "POST", "/api/agents?workspace_id="+testWorkspaceID, map[string]any{ + "name": "Second Test Agent", + "runtime_id": runtimeID, + "visibility": "workspace", + }) + if resp.StatusCode != 201 { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + t.Fatalf("CreateAgent: expected 201, got %d: %s", resp.StatusCode, body) + } + var agent map[string]any + readJSON(t, resp, &agent) + id := agent["id"].(string) + t.Cleanup(func() { + authRequest(t, "POST", "/api/agents/"+id+"/archive?workspace_id="+testWorkspaceID, nil) + }) + return id +} + // createIssueAssignedToAgent creates a todo issue assigned to the given agent. func createIssueAssignedToAgent(t *testing.T, title, agentID string) string { t.Helper() @@ -396,13 +428,28 @@ func TestCommentTriggerThreadInheritedMention(t *testing.T) { } }) - t.Run("reply mentioning agent and member still inherits", func(t *testing.T) { + t.Run("reply mentioning a different agent does not inherit parent agent", func(t *testing.T) { + clearTasks(t, issueID) + agentB := createSecondAgent(t) + // Top-level comment @mentions agent A. + content := fmt.Sprintf("[@AgentA](mention://agent/%s) please review", agentID) + threadID := postComment(t, issueID, content, nil) + clearTasks(t, issueID) + // Reply @mentions agent B — should trigger ONLY agent B, not agent A. + reply := fmt.Sprintf("[@AgentB](mention://agent/%s) can you also look?", agentB) + postComment(t, issueID, reply, strPtr(threadID)) + if n := countPendingTasks(t, issueID); n != 1 { + t.Errorf("expected 1 pending task (only agent B), got %d", n) + } + }) + + t.Run("reply mentioning same agent and member triggers via explicit mention", func(t *testing.T) { clearTasks(t, issueID) // Top-level comment @mentions the agent. content := fmt.Sprintf("[@Agent](mention://agent/%s) review this", agentID) threadID := postComment(t, issueID, content, nil) clearTasks(t, issueID) - // Reply mentions both agent and member — should still trigger. + // Reply re-mentions the same agent along with a member — triggers via the reply's own mention. reply := fmt.Sprintf("[@Agent](mention://agent/%s) and cc [@Someone](mention://member/%s)", agentID, testUserID) postComment(t, issueID, reply, strPtr(threadID)) if n := countPendingTasks(t, issueID); n != 1 { diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 6cc328d5e5..03729018f3 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -369,34 +369,13 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu 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. - // However, skip inheritance when the reply explicitly @mentions only - // non-agent entities (members, issues) — the user is directing the reply - // at other people, not requesting work from agents in the parent thread. - if parentComment != nil { - hasAgentMention := false - hasNonAgentMention := false - for _, m := range mentions { - if m.Type == "agent" { - hasAgentMention = true - } else { - hasNonAgentMention = true - } - } - if hasAgentMention || !hasNonAgentMention { - 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 - } - } - } + // 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. + if parentComment != nil && len(mentions) == 0 { + mentions = util.ParseMentions(parentComment.Content) } for _, m := range mentions { if m.Type != "agent" {