diff --git a/server/cmd/server/comment_trigger_integration_test.go b/server/cmd/server/comment_trigger_integration_test.go index 237bf31980..3823d6c266 100644 --- a/server/cmd/server/comment_trigger_integration_test.go +++ b/server/cmd/server/comment_trigger_integration_test.go @@ -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. diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index d4bbdb20ee..5feca2e96b 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -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 diff --git a/server/internal/handler/trigger_test.go b/server/internal/handler/trigger_test.go index a735f403ab..cf59b53476 100644 --- a/server/internal/handler/trigger_test.go +++ b/server/internal/handler/trigger_test.go @@ -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 { diff --git a/server/pkg/db/generated/comment.sql.go b/server/pkg/db/generated/comment.sql.go index 9dd26060e6..b43594ff17 100644 --- a/server/pkg/db/generated/comment.sql.go +++ b/server/pkg/db/generated/comment.sql.go @@ -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 diff --git a/server/pkg/db/queries/comment.sql b/server/pkg/db/queries/comment.sql index 3120810e61..3d63a543ed 100644 --- a/server/pkg/db/queries/comment.sql +++ b/server/pkg/db/queries/comment.sql @@ -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;