Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
89b082ccdb fix(server): trigger agent on reply in thread where agent already participated
When a member replies in a member-started thread without @mentioning the
assigned agent, the on_comment trigger was suppressed — even if the agent
had already replied in that thread. This meant the common flow of
"member posts → agent replies → member follows up" would not re-trigger
the agent on the follow-up.

Add HasAgentRepliedInThread SQL query and check it in isReplyToMemberThread
so that agent participation in a thread is treated as an ongoing conversation.
2026-04-14 17:50:38 +08:00
5 changed files with 62 additions and 6 deletions

View File

@@ -241,6 +241,20 @@ func TestCommentTriggerOnComment(t *testing.T) {
}
})
t.Run("reply to member thread after agent replied triggers agent", func(t *testing.T) {
clearTasks(t, issueID)
// Member starts a thread (top-level comment).
threadID := postComment(t, issueID, "Please fix this bug", nil)
clearTasks(t, issueID)
// Agent replies in the thread.
postCommentAsAgent(t, issueID, "Working on it, found the root cause.", agentID, strPtr(threadID))
// Member follows up in the same thread without @mentioning the agent.
postComment(t, issueID, "Great, please also check the edge case", strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task (agent participated in thread), got %d", n)
}
})
t.Run("reply to member thread mentioning assignee triggers agent", func(t *testing.T) {
clearTasks(t, issueID)
// Member starts a thread.

View File

@@ -262,7 +262,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
// 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(parentComment, comment.Content, issue) {
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
// Resolve thread root: if the comment is a reply, agent should reply
// to the thread root (matching frontend behavior where all replies
// in a thread share the same top-level parent).
@@ -325,7 +325,9 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I
// 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.
func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issue db.Issue) bool {
// 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
}
@@ -333,7 +335,8 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
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 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
}
@@ -351,7 +354,18 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
return false // Assignee mentioned in thread root — allow trigger
}
}
return true // Reply to member thread without mentioning agent — suppress
// 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

View File

@@ -1,6 +1,7 @@
package handler
import (
"context"
"fmt"
"testing"
@@ -205,7 +206,7 @@ func TestIsReplyToMemberThread(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := h.isReplyToMemberThread(tt.parent, tt.content, issue)
got := h.isReplyToMemberThread(context.Background(), tt.parent, tt.content, issue)
if got != tt.want {
t.Errorf("isReplyToMemberThread() = %v, want %v", got, tt.want)
}
@@ -233,7 +234,7 @@ func TestOnCommentTriggerDecision(t *testing.T) {
// !commentMentionsOthersButNotAssignee && !isReplyToMemberThread
shouldTrigger := func(parent *db.Comment, content string) bool {
return !h.commentMentionsOthersButNotAssignee(content, issue) &&
!h.isReplyToMemberThread(parent, content, issue)
!h.isReplyToMemberThread(context.Background(), parent, content, issue)
}
tests := []struct {

View File

@@ -153,6 +153,26 @@ func (q *Queries) HasAgentCommentedSince(ctx context.Context, arg HasAgentCommen
return commented, err
}
const hasAgentRepliedInThread = `-- name: HasAgentRepliedInThread :one
SELECT count(*) > 0 AS has_replied FROM comment
WHERE parent_id = $1 AND author_type = 'agent' AND author_id = $2
`
type HasAgentRepliedInThreadParams struct {
ParentID pgtype.UUID `json:"parent_id"`
AgentID pgtype.UUID `json:"agent_id"`
}
// Returns true if the given agent has posted a reply in the thread rooted at
// the specified parent comment. Used to detect agent participation in a
// member-started thread so that follow-up member replies still trigger the agent.
func (q *Queries) HasAgentRepliedInThread(ctx context.Context, arg HasAgentRepliedInThreadParams) (bool, error) {
row := q.db.QueryRow(ctx, hasAgentRepliedInThread, arg.ParentID, arg.AgentID)
var has_replied bool
err := row.Scan(&has_replied)
return has_replied, err
}
const listComments = `-- name: ListComments :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2

View File

@@ -53,5 +53,12 @@ SELECT EXISTS (
AND created_at >= @since
) AS commented;
-- name: HasAgentRepliedInThread :one
-- Returns true if the given agent has posted a reply in the thread rooted at
-- the specified parent comment. Used to detect agent participation in a
-- member-started thread so that follow-up member replies still trigger the agent.
SELECT count(*) > 0 AS has_replied FROM comment
WHERE parent_id = @parent_id AND author_type = 'agent' AND author_id = @agent_id;
-- name: DeleteComment :exec
DELETE FROM comment WHERE id = $1;