Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
63ca4f2eec chore(comment): gofmt trigger_test.go 2026-04-29 15:51:45 +08:00
Jiang Bohan
411478846d fix(comment): don't inherit parent @mentions when parent author is an agent
When an agent posts a comment that @mentions another agent (typically a
one-shot delegation, e.g. a PR-completion comment that asks a reviewer
agent to review), member follow-up replies in the same thread were
auto-inheriting that mention and re-triggering the reviewer on every
plain question. Same root cause: the inheritance branch only required
the reply to have no mentions, not that the parent was member-authored.

Tighten the guard: only inherit when the parent (thread root) is
authored by a member. Member-rooted threads still inherit so a member
who started by @mentioning an agent can keep replying without re-typing.
Agent-authored roots are treated as one-shot — explicit @mentions in
later comments still trigger normally.

Extracted the decision into shouldInheritParentMentions for direct unit
testing, and added an end-to-end regression
(TestMemberReplyToAgentRootDoesNotInheritParentMentions) that reproduces
MUL-1535: J posts a PR completion @mentioning Reviewer; a member's
plain follow-up must not re-enqueue Reviewer.
2026-04-29 15:46:41 +08:00
3 changed files with 174 additions and 12 deletions

View File

@@ -404,6 +404,39 @@ func (h *Handler) isReplyToMemberThread(ctx context.Context, parent *db.Comment,
return true // Reply to member thread without agent participation — suppress
}
// shouldInheritParentMentions decides whether a reply with no explicit
// mentions should inherit the parent (thread root) comment's mentions.
//
// Inheritance lets a member who started a thread by @mentioning an agent
// continue the conversation with that agent without re-typing the mention
// on every follow-up reply.
//
// It is intentionally narrow:
//
// - Only when the reply contains zero mentions of its own. Any explicit
// mention in the reply is a deliberate choice about who to involve.
// - Only when the reply author is a member. Agent-authored replies must
// never inherit, otherwise an agent posting in a thread whose root
// mentioned another agent would re-trigger that agent and create a loop.
// - Only when the parent author is a member. When an agent authors a
// comment that @mentions another agent, it is typically a one-shot
// delegation (e.g. an agent posting a PR completion that @mentions a
// reviewer agent). Subsequent member follow-ups in the same thread are
// directed at the assignee, not at the delegated agent — inheriting
// would re-trigger the delegated agent on every plain reply.
func shouldInheritParentMentions(parentComment *db.Comment, replyMentions []util.Mention, replyAuthorType string) bool {
if parentComment == nil {
return false
}
if len(replyMentions) > 0 {
return false
}
if replyAuthorType == "agent" {
return false
}
return parentComment.AuthorType == "member"
}
// 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
@@ -419,17 +452,7 @@ func (h *Handler) isReplyToMemberThread(ctx context.Context, parent *db.Comment,
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" {
if shouldInheritParentMentions(parentComment, mentions, authorType) {
mentions = util.ParseMentions(parentComment.Content)
}
for _, m := range mentions {

View File

@@ -2116,6 +2116,98 @@ func TestAgentReplyDoesNotInheritParentMentions(t *testing.T) {
}
}
// TestMemberReplyToAgentRootDoesNotInheritParentMentions is the regression
// for MUL-1535. When an agent posts a comment that @mentions another agent
// (e.g. J posting a PR completion that @mentions a reviewer agent), a later
// member reply in the same thread with no explicit mentions must NOT inherit
// the @reviewer mention. The reviewer was a one-shot delegation; subsequent
// member follow-ups are directed at the assignee, not the reviewer.
func TestMemberReplyToAgentRootDoesNotInheritParentMentions(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
jAgent := createHandlerTestAgent(t, "J", nil)
reviewerAgent := createHandlerTestAgent(t, "Reviewer", nil)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "PR review delegation no-leak test",
"status": "todo",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
countTasks := func(agentID string) int {
var n int
err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
issueID, agentID,
).Scan(&n)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
return n
}
// 1. Agent J posts a PR-completion comment that @mentions Reviewer for review.
// This is a deliberate handoff and must enqueue a task for Reviewer.
w = httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": fmt.Sprintf("PR ready. [@Reviewer](mention://agent/%s) please review this.", reviewerAgent),
})
r = withURLParam(r, "id", issueID)
r.Header.Set("X-Agent-ID", jAgent)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("J PR completion: expected 201, got %d: %s", w.Code, w.Body.String())
}
var rootComment CommentResponse
json.NewDecoder(w.Body).Decode(&rootComment)
if got := countTasks(reviewerAgent); got != 1 {
t.Fatalf("expected 1 task for Reviewer after explicit mention, got %d", got)
}
// Cancel reviewer's task so it's free to be re-triggered if the bug returns.
if _, err := testPool.Exec(ctx,
`UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1 AND agent_id = $2`,
issueID, reviewerAgent,
); err != nil {
t.Fatalf("cancel reviewer task: %v", err)
}
// 2. Member posts a plain follow-up reply under J's PR comment, with no
// explicit mentions. The pre-fix code path inherited mentions from the
// parent regardless of the parent author, which re-triggered Reviewer.
// With the fix, the reply must NOT inherit because the parent was
// authored by an agent.
w = httptest.NewRecorder()
r = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "How do I test this after merging?",
"parent_id": rootComment.ID,
})
r = withURLParam(r, "id", issueID)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("member follow-up: expected 201, got %d: %s", w.Code, w.Body.String())
}
if got := countTasks(reviewerAgent); got != 0 {
t.Fatalf("expected 0 tasks for Reviewer after plain member reply (no inheritance from agent root), got %d", got)
}
}
// TestAgentExplicitMentionStillTriggers documents the boundary the structural
// fix preserves: suppressing implicit parent-mention inheritance for agent
// authors does NOT block deliberate handoffs. An agent that explicitly

View File

@@ -6,6 +6,7 @@ import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@@ -214,6 +215,53 @@ func TestIsReplyToMemberThread(t *testing.T) {
}
}
// -------------------------------------------------------------------
// shouldInheritParentMentions
// -------------------------------------------------------------------
func TestShouldInheritParentMentions(t *testing.T) {
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID), Content: "thread starter"}
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID), Content: "agent thread starter"}
someMention := []util.Mention{{Type: "agent", ID: otherAgentID}}
tests := []struct {
name string
parent *db.Comment
replyMentions []util.Mention
replyAuthorType string
want bool
}{
{"nil parent → false", nil, nil, "member", false},
{"reply has explicit mentions → false", memberParent, someMention, "member", false},
{"agent-authored reply, member parent → false (loop guard)", memberParent, nil, "agent", false},
{"member reply, agent parent → false (parent author guard)", agentParent, nil, "member", false},
{"member reply, member parent, no mentions → true (intended use)", memberParent, nil, "member", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldInheritParentMentions(tt.parent, tt.replyMentions, tt.replyAuthorType)
if got != tt.want {
t.Errorf("shouldInheritParentMentions() = %v, want %v", got, tt.want)
}
})
}
}
// Regression for the case from MUL-1535: J posts a PR completion comment
// that @mentions GPT-Boy for review; later a member posts a plain follow-up
// reply asking the assignee a question. GPT-Boy must NOT be re-triggered.
func TestShouldInheritParentMentions_AgentReviewDelegationDoesNotLeak(t *testing.T) {
jPRCompletion := &db.Comment{
AuthorType: "agent",
AuthorID: testUUID(agentAssigneeID),
Content: fmt.Sprintf("PR ready. [@GPT-Boy](mention://agent/%s) please review this.", otherAgentID),
}
if got := shouldInheritParentMentions(jPRCompletion, nil, "member"); got {
t.Fatal("member follow-up to an agent's PR-review delegation must not inherit the @reviewer mention")
}
}
// -------------------------------------------------------------------
// Combined trigger decision (simulates the full on_comment check)
// -------------------------------------------------------------------
@@ -267,4 +315,3 @@ func TestOnCommentTriggerDecision(t *testing.T) {
})
}
}