Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
32ab7b54ad fix(server): treat agent-authored thread root as implicit mention
When a member posts a plain reply under a thread whose root was authored
by an agent, treat that agent as implicitly mentioned so the reply
re-triggers the agent. Matches the intuition that "reply to X" addresses
X and fixes the case where the thread author is not the issue assignee.

Only applies when the reply has no explicit mentions of its own — an
explicit @mention is a deliberate redirect and is respected. Dedup via
the existing HasPendingTaskForIssueAndAgent check prevents a double task
when the thread root author is also the issue assignee.
2026-04-17 15:27:39 +08:00
2 changed files with 47 additions and 0 deletions

View File

@@ -470,6 +470,31 @@ func TestCommentTriggerThreadInheritedMention(t *testing.T) {
t.Errorf("expected 1 pending task (reply mentions agent explicitly), got %d", n)
}
})
t.Run("reply to non-assignee agent's thread triggers that agent (implicit author mention)", func(t *testing.T) {
clearTasks(t, issueID)
// Agent (not the issue's assignee) starts a top-level thread with plain content.
threadID := postCommentAsAgent(t, issueID, "Here is what I found.", agentID, nil)
// Member replies without any explicit mention — expected: the thread's
// author agent is triggered via implicit parent-author mention.
postComment(t, issueID, "Thanks, please continue", strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task (implicit author mention), got %d", n)
}
})
t.Run("reply to non-assignee agent's thread does not inherit when reply mentions others", func(t *testing.T) {
clearTasks(t, issueID)
// Agent starts a top-level thread.
threadID := postCommentAsAgent(t, issueID, "Initial analysis.", agentID, nil)
// Member replies mentioning only another member — implicit author mention
// is suppressed because the reply already has explicit mentions.
reply := fmt.Sprintf("cc [@Someone](mention://member/%s)", testUserID)
postComment(t, issueID, reply, strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 0 {
t.Errorf("expected 0 pending tasks (explicit non-agent mention overrides author mention), got %d", n)
}
})
}
// TestCommentTriggerCoalescing verifies that rapid-fire comments don't create

View File

@@ -375,6 +375,11 @@ func (h *Handler) isReplyToMemberThread(ctx context.Context, parent *db.Comment,
// 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.
// When the parent (thread root) itself was authored by an agent, that agent
// is treated as an implicit mention so "replying under an agent's comment"
// re-triggers that agent — matching the intuition that replies address the
// author being replied to. Same gate: only when the reply has no explicit
// mentions.
// 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).
@@ -390,6 +395,23 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
// is making a deliberate choice about who to involve; don't auto-inherit.
if parentComment != nil && len(mentions) == 0 {
mentions = util.ParseMentions(parentComment.Content)
// Implicit mention: if the thread root was posted by an agent, treat
// this plain reply as addressing that agent. Dedup against parent's
// own mentions so we don't double-count when the parent @-mentioned
// itself somehow.
if parentComment.AuthorType == "agent" {
parentAuthorID := uuidToString(parentComment.AuthorID)
already := false
for _, m := range mentions {
if m.Type == "agent" && m.ID == parentAuthorID {
already = true
break
}
}
if !already {
mentions = append(mentions, util.Mention{Type: "agent", ID: parentAuthorID})
}
}
}
for _, m := range mentions {
if m.Type != "agent" {