Files
multica/server/internal/handler/comment.go
Bohan Jiang e19f7967b9 feat(prompt): thread-first comment reads for agent runs (MUL-2387) (#2816)
* feat(prompt): thread-first comment reads for agent runs (MUL-2387)

PR #2787 added --thread / --recent / --before / --before-id to the
ListComments API and CLI but kept the agent prompt steering at the
legacy "dump everything" recipe. On a long-running issue the flat dump
burns context on chatter unrelated to the trigger; agents acting on the
trigger want the trigger's thread first.

Prompt updates:

- Comment-triggered Workflow (runtime_config.go) now anchors step 2 on
  `multica issue comment list <issue-id> --thread <trigger-comment-id>
  --output json`. Fallback offers `--recent 20 --output json` with the
  stderr `Next thread cursor: --before <ts> --before-id <root-id>` line
  feeding the next-page cursor. `--since` is preserved and explicitly
  marked combinable with --thread / --recent.
- Per-turn buildCommentPrompt (prompt.go) carries the same thread-first
  guidance so a Codex-style runtime that re-reads the per-turn message
  every iteration gets the same steering, even if it ignores the
  injected runtime config.
- Assignment-triggered Workflow keeps the mandatory full-history rule
  (MUL-1124) but now also points at `--recent 20` as the long-issue
  alternative — this is the place that previously had no thread-aware
  guidance at all.
- Default fallback prompt (no trigger comment, no chat, no autopilot,
  no quick-create) gains the same --recent hint without --thread (no
  comment to anchor on).
- Available Commands core line surfaces the new flags so the discovery
  path matches the workflow guidance.

Default CLI/API semantics are unchanged: the unparameterized list still
returns the full chronological dump capped at 2000, --since still works
on its own, and the desktop UI is untouched.

Tests:

- prompt_test.go: TestBuildPromptCommentTriggerPromotesThreadReads pins
  --thread <triggerID>, --recent 20, the stderr cursor phrasing, and
  the absence of the legacy "returns all comments" prose.
- prompt_test.go: TestBuildPromptDefaultMentionsRecent guards the
  no-trigger fallback (mentions --recent, must NOT mention --thread).
- execenv_test.go: TestInjectRuntimeConfigCommentTriggerThreadFirstReads
  asserts the comment-triggered Workflow steers at --thread/--recent,
  the Available Commands line surfaces the new flags, and the legacy
  "read the conversation (returns all comments...)" string is gone.
- execenv_test.go: TestInjectRuntimeConfigAssignmentTriggerMentionsRecent
  keeps the mandatory full-history rule pinned AND asserts --recent is
  offered as the long-issue alternative.

Also fixes the recent+since cursor nit Elon flagged in #2787's second
review: when `since` empties the page, the `len(seenRoot) >= recentN`
check used to emit a cursor anyway. Pagination walks threads in
strictly decreasing last_activity_at — if every comment in this page is
<= since, every older thread's last_activity is also <= since by
transitivity, so the cursor would only invite the caller into a
guaranteed-empty walk. Now suppressed; new tests pin both branches
(suppressed when empty, retained when at least one row passes since).

MUL-2387

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): suppress recent+since cursor when head thread past since (MUL-2387)

Previous suppression only tripped when the `since` filter emptied the
page. That missed the mixed case Elon flagged in #2787's second review:
the page keeps rows from fresher threads but the head (oldest-active)
thread already sits at or before `since`, so every older page is
guaranteed empty too. Predicating on `headLast <= since` covers both
cases.

Add a recent=2 + since fixture that pins the mixed scenario: root1
(last_activity = base+3m) is filtered out, root2 stays, and the cursor
is suppressed even though the body is non-empty.

Co-authored-by: multica-agent <github@multica.ai>

* fix(prompt): clarify --recent is paging, not a replacement (MUL-2387)

Address Elon's second-pass nit on #2816: the assignment-trigger workflow
in runtime_config.go used "you may switch to --recent 20", which reads as
a replacement for the mandatory full-history rule. Rephrase --recent as a
paging strategy ("read the full history page-by-page, not a shortcut that
replaces it") so it cannot conflict with the rule it lives next to.

The default per-turn prompt in prompt.go opened with "If you need comment
history" — that soft conditional contradicts the runtime workflow's
mandatory read. Move it to a neutral "For comment history, follow the
rule in your runtime workflow file" framing that defers to whatever the
workflow says (mandatory for assignment, optional elsewhere) instead of
encoding its own policy.

Keep the runtime/prompt dual-layer fallback intact — different runtimes
propagate the config file vs. the per-turn user prompt with varying
fidelity, so both surfaces need the guidance.

Tests pin the new phrasing against regression:

- TestBuildPromptDefaultMentionsRecent now also forbids "If you need
  comment history" from sneaking back in.
- TestInjectRuntimeConfigAssignmentTriggerMentionsRecent now also forbids
  "you may switch to" / "switch to `--recent" replacement phrasing.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 14:42:35 +08:00

1083 lines
42 KiB
Go

package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/mention"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
type CommentResponse struct {
ID string `json:"id"`
IssueID string `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID string `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
ParentID *string `json:"parent_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ResolvedAt *string `json:"resolved_at"`
ResolvedByType *string `json:"resolved_by_type"`
ResolvedByID *string `json:"resolved_by_id"`
Reactions []ReactionResponse `json:"reactions"`
Attachments []AttachmentResponse `json:"attachments"`
}
func commentToResponse(c db.Comment, reactions []ReactionResponse, attachments []AttachmentResponse) CommentResponse {
if reactions == nil {
reactions = []ReactionResponse{}
}
if attachments == nil {
attachments = []AttachmentResponse{}
}
return CommentResponse{
ID: uuidToString(c.ID),
IssueID: uuidToString(c.IssueID),
AuthorType: c.AuthorType,
AuthorID: uuidToString(c.AuthorID),
Content: c.Content,
Type: c.Type,
ParentID: uuidToPtr(c.ParentID),
CreatedAt: timestampToString(c.CreatedAt),
UpdatedAt: timestampToString(c.UpdatedAt),
ResolvedAt: timestampToPtr(c.ResolvedAt),
ResolvedByType: textToPtr(c.ResolvedByType),
ResolvedByID: uuidToPtr(c.ResolvedByID),
Reactions: reactions,
Attachments: attachments,
}
}
// commentHardCap bounds the comments returned per issue. Sized as a defensive
// safety net rather than a UX paging window: prod p99 is ~30 comments and
// the all-time max observed is ~1.1k, so 2000 leaves ~2x headroom while still
// preventing a runaway response if some user manages to accumulate a wild
// number of rows on a single issue.
const commentHardCap = 2000
// ListComments returns comments for an issue. The default behaviour is
// unchanged — full chronological dump capped at commentHardCap — so existing
// callers and the desktop UI keep working as-is. Three optional query params
// give agent-style readers a thread-aware view that scales to long issues
// without dragging every prior comment into context:
//
// - thread=<comment-uuid> — return the root of the thread containing this
// comment plus every descendant. The anchor may be a root or any reply;
// the server walks up to the root via a recursive CTE, so callers do not
// need to know whether the id they have is a root.
// - recent=<N> — return the N most recently active threads (root + every
// descendant per thread). A thread's recency is MAX(created_at) across
// the whole subtree, so a stale-but-recently-replied thread ranks ahead
// of an active-but-quiet one. Row-based "newest N comments" is
// deliberately NOT exposed — it surfaces unrelated thread tails and
// hides relevant history (#2340).
// - before=<RFC3339> + before-id=<uuid> — thread cursor. The pair points at
// a thread's (last_activity_at, root_id); the next page returns threads
// strictly less recent. Both must be set together — a timestamp-only
// cursor cannot tie-break two threads whose last_activity falls in the
// same microsecond. The cursor for the next page is emitted via the
// X-Multica-Next-Before / X-Multica-Next-Before-Id response headers.
//
// Combination rules (kept narrow on purpose — Elon flagged the matrix risk):
//
// - thread is exclusive with recent and before/before-id. Asking for "the
// most recent N within thread X" or "thread X but only before T" mixes
// two different navigation models and is rejected with 400.
// - thread may combine with since (incremental polling of one thread).
// - recent may combine with before/before-id (scroll older threads) and
// with since (recent activity in a window).
// - since may combine with anything except (thread + recent / before).
//
// The response body is always chronological (oldest → newest); under recent
// that means threads are listed oldest-active first and the freshest thread
// sits at the tail, closest to "now" in an agent prompt.
func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
q := r.URL.Query()
var sinceTime pgtype.Timestamptz
if v := q.Get("since"); v != "" {
t, err := time.Parse(time.RFC3339Nano, v)
if err != nil {
// Fall back to RFC3339 for backwards-compat with the original CLI.
t, err = time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid since parameter; expected RFC3339 format")
return
}
}
sinceTime = pgtype.Timestamptz{Time: t, Valid: true}
}
threadStr := q.Get("thread")
recentStr := q.Get("recent")
beforeTimeStr := q.Get("before")
beforeIDStr := q.Get("before_id")
if beforeIDStr == "" {
// Accept hyphenated alias to match CLI flag convention.
beforeIDStr = q.Get("before-id")
}
// --- combination validation ----------------------------------------
if threadStr != "" && recentStr != "" {
writeError(w, http.StatusBadRequest, "thread and recent are mutually exclusive")
return
}
if threadStr != "" && (beforeTimeStr != "" || beforeIDStr != "") {
writeError(w, http.StatusBadRequest, "thread cannot be combined with before / before_id")
return
}
if (beforeTimeStr == "") != (beforeIDStr == "") {
writeError(w, http.StatusBadRequest, "before and before_id must be set together (composite cursor)")
return
}
// Cursor only makes sense as "scroll older within a recent window". A
// cursor without `recent` would otherwise be silently dropped by
// fetchCommentsForList and fall back to the default / since path —
// returning a full timeline that the caller did not ask for. Reject
// loudly so the API surface matches the documented semantics.
if beforeTimeStr != "" && recentStr == "" {
writeError(w, http.StatusBadRequest, "before / before_id require recent (cursor scrolls within a recent window)")
return
}
// --- parse cursor / recent ----------------------------------------
var beforeCursor pgtype.Timestamptz
var beforeUUID pgtype.UUID
hasCursor := false
if beforeTimeStr != "" {
t, err := time.Parse(time.RFC3339Nano, beforeTimeStr)
if err != nil {
t, err = time.Parse(time.RFC3339, beforeTimeStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid before parameter; expected RFC3339 format")
return
}
}
beforeCursor = pgtype.Timestamptz{Time: t, Valid: true}
uuid, perr := util.ParseUUID(beforeIDStr)
if perr != nil {
writeError(w, http.StatusBadRequest, "invalid before_id parameter; expected UUID")
return
}
beforeUUID = uuid
hasCursor = true
}
recentN := 0
if recentStr != "" {
n, err := strconv.Atoi(recentStr)
if err != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "invalid recent parameter; expected positive integer")
return
}
if n > commentHardCap {
n = commentHardCap
}
recentN = n
}
result, err := h.fetchCommentsForList(r.Context(), fetchCommentsArgs{
Issue: issue,
Since: sinceTime,
ThreadAnchor: threadStr,
RecentN: recentN,
HasCursor: hasCursor,
BeforeAt: beforeCursor,
BeforeID: beforeUUID,
})
if err != nil {
switch err {
case errCommentThreadNotFound:
writeError(w, http.StatusNotFound, "thread anchor not found in this issue")
return
case errCommentThreadBadID:
writeError(w, http.StatusBadRequest, "invalid thread parameter; expected UUID")
return
default:
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
}
commentIDs := make([]pgtype.UUID, len(result.Comments))
for i, c := range result.Comments {
commentIDs[i] = c.ID
}
grouped := h.groupReactions(r, commentIDs)
groupedAtt := h.groupAttachments(r, commentIDs)
resp := make([]CommentResponse, len(result.Comments))
for i, c := range result.Comments {
cid := uuidToString(c.ID)
resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid])
}
// Emit the next thread cursor as response headers when the recent page is
// likely not the last one (returned a full page of threads). Headers stay
// out of the JSON body so the default flat-array response shape — which
// the desktop UI and existing callers depend on — is unchanged.
if result.NextBefore != "" && result.NextBeforeID != "" {
w.Header().Set("X-Multica-Next-Before", result.NextBefore)
w.Header().Set("X-Multica-Next-Before-Id", result.NextBeforeID)
}
writeJSON(w, http.StatusOK, resp)
}
// fetchCommentsArgs bundles the parsed query params so fetchCommentsForList
// stays readable. Sentinel errors below let the caller turn DB-layer outcomes
// into the right HTTP status without leaking SQL details.
type fetchCommentsArgs struct {
Issue db.Issue
Since pgtype.Timestamptz
ThreadAnchor string
RecentN int
HasCursor bool
BeforeAt pgtype.Timestamptz
BeforeID pgtype.UUID
}
// fetchCommentsResult carries both the materialised comments and (for the
// recent/thread-grouped path) the cursor to use for the next page. Cursor
// fields are empty strings when there is no next page or the path does not
// support cursors.
type fetchCommentsResult struct {
Comments []db.Comment
NextBefore string
NextBeforeID string
}
var (
errCommentThreadNotFound = &commentFetchError{"thread anchor not found"}
errCommentThreadBadID = &commentFetchError{"invalid thread anchor id"}
)
type commentFetchError struct{ msg string }
func (e *commentFetchError) Error() string { return e.msg }
func (h *Handler) fetchCommentsForList(ctx context.Context, args fetchCommentsArgs) (fetchCommentsResult, error) {
issue := args.Issue
// Thread-scoped read. Server resolves the anchor → root via recursive
// CTE, so we don't have to assume two-layer flat threads here.
if args.ThreadAnchor != "" {
anchor, err := util.ParseUUID(args.ThreadAnchor)
if err != nil {
return fetchCommentsResult{}, errCommentThreadBadID
}
rows, err := h.Queries.ListThreadCommentsForIssue(ctx, db.ListThreadCommentsForIssueParams{
AnchorID: anchor,
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
RowLimit: commentHardCap,
})
if err != nil {
return fetchCommentsResult{}, err
}
if len(rows) == 0 {
return fetchCommentsResult{}, errCommentThreadNotFound
}
out := make([]db.Comment, 0, len(rows))
for _, r := range rows {
if args.Since.Valid && !r.CreatedAt.Time.After(args.Since.Time) {
continue
}
out = append(out, db.Comment{
ID: r.ID,
IssueID: r.IssueID,
AuthorType: r.AuthorType,
AuthorID: r.AuthorID,
Content: r.Content,
Type: r.Type,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
ParentID: r.ParentID,
WorkspaceID: r.WorkspaceID,
ResolvedAt: r.ResolvedAt,
ResolvedByType: r.ResolvedByType,
ResolvedByID: r.ResolvedByID,
})
}
return fetchCommentsResult{Comments: out}, nil
}
// Thread-grouped recent read: N most recently active threads.
if args.RecentN > 0 {
rows, err := h.Queries.ListRecentThreadCommentsForIssue(ctx, db.ListRecentThreadCommentsForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
HasCursor: args.HasCursor,
BeforeAt: args.BeforeAt,
BeforeID: args.BeforeID,
ThreadLimit: int32(args.RecentN),
})
if err != nil {
return fetchCommentsResult{}, err
}
// The SQL already orders rows by (last_activity_at ASC, root_id ASC,
// created_at ASC, id ASC), so the OLDEST-active thread sits at the
// head and the FRESHEST thread at the tail. Walk the rows once to:
// 1. Strip the thread-metadata columns down to db.Comment for the
// caller (uniform shape across paths).
// 2. Count distinct threads in the page so we know whether a "next
// older page" is likely to exist.
// 3. Capture the head thread's (last_activity_at, root_id) — that
// is the cursor for the next page (next page = threads strictly
// less recent than this one).
comments := make([]db.Comment, 0, len(rows))
var headRoot pgtype.UUID
var headLast pgtype.Timestamptz
seenRoot := map[string]struct{}{}
for _, r := range rows {
if !headRoot.Valid {
headRoot = r.ThreadRootID
headLast = r.ThreadLastActivityAt
}
seenRoot[uuidToString(r.ThreadRootID)] = struct{}{}
// Since filter on the recent path: drop comments older than
// `since`. Done in-memory so we keep the thread-grouped
// semantics from the query (don't pre-filter rows before the
// MAX(created_at) ranking — that would silently downgrade a
// thread whose most recent activity falls inside the window).
if args.Since.Valid && !r.CreatedAt.Time.After(args.Since.Time) {
continue
}
comments = append(comments, db.Comment{
ID: r.ID,
IssueID: r.IssueID,
AuthorType: r.AuthorType,
AuthorID: r.AuthorID,
Content: r.Content,
Type: r.Type,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
ParentID: r.ParentID,
WorkspaceID: r.WorkspaceID,
ResolvedAt: r.ResolvedAt,
ResolvedByType: r.ResolvedByType,
ResolvedByID: r.ResolvedByID,
})
}
// Only emit a cursor when the page is full. Fewer threads than
// requested ⇒ the SELECT exhausted matching threads, so there is
// no older page to scroll to.
//
// Additionally suppress the cursor when `since` is set and the head
// thread's last_activity_at is already <= since. The pagination
// walks threads in strictly decreasing last_activity_at, so every
// older page has last_activity_at strictly less than the head's —
// if the head itself can't satisfy `> since`, no older thread can
// either. Predicating on the head (not on whether `comments` is
// empty) also catches the mixed case where this page keeps rows
// from fresher threads but the head thread is already past `since`.
// Flagged by Elon in #2787's second review (MUL-2340 nit).
out := fetchCommentsResult{Comments: comments}
emitCursor := len(seenRoot) >= args.RecentN && headRoot.Valid && headLast.Valid
if emitCursor && args.Since.Valid && !headLast.Time.After(args.Since.Time) {
emitCursor = false
}
if emitCursor {
out.NextBefore = headLast.Time.UTC().Format(time.RFC3339Nano)
out.NextBeforeID = uuidToString(headRoot)
}
return out, nil
}
// Default + since paths preserved verbatim (no behavioural change for
// existing callers).
if args.Since.Valid {
comments, err := h.Queries.ListCommentsSinceForIssue(ctx, db.ListCommentsSinceForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
CreatedAt: args.Since,
Limit: commentHardCap,
})
return fetchCommentsResult{Comments: comments}, err
}
comments, err := h.Queries.ListCommentsForIssue(ctx, db.ListCommentsForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Limit: commentHardCap,
})
return fetchCommentsResult{Comments: comments}, err
}
type CreateCommentRequest struct {
Content string `json:"content"`
Type string `json:"type"`
ParentID *string `json:"parent_id"`
AttachmentIDs []string `json:"attachment_ids"`
}
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req CreateCommentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
if req.Type == "" {
req.Type = "comment"
}
var parentID pgtype.UUID
var parentComment *db.Comment
if req.ParentID != nil {
var parsed pgtype.UUID
parsed, ok = parseUUIDOrBadRequest(w, *req.ParentID, "parent_id")
if !ok {
return
}
parentID = parsed
parent, err := h.Queries.GetComment(r.Context(), parentID)
if err != nil || uuidToString(parent.IssueID) != uuidToString(issue.ID) {
writeError(w, http.StatusBadRequest, "invalid parent comment")
return
}
parentComment = &parent
}
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
if !ok {
return
}
// Determine author identity: agent (via X-Agent-ID header) or member.
authorType, authorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
// Defense against resumed-session drift: when an agent posts from inside a
// comment-triggered task AND the comment is being posted on that same
// issue, the parent_id must exactly match the task's trigger comment.
// Resumed Claude sessions otherwise carry forward a previous turn's
// --parent UUID and silently misplace the reply.
//
// The task.IssueID scope is important: the CLI stamps X-Task-ID on every
// request, so an agent legitimately commenting on a different issue must
// not be blocked by its current task's trigger. Assignment-triggered
// tasks (no TriggerCommentID) are also unaffected.
if authorType == "agent" {
if taskIDHeader := r.Header.Get("X-Task-ID"); taskIDHeader != "" {
taskUUID, parseErr := util.ParseUUID(taskIDHeader)
if parseErr == nil {
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err == nil && task.IssueID.Valid && uuidToString(task.IssueID) == uuidToString(issue.ID) {
if task.TriggerCommentID.Valid {
if uuidToString(parentID) != uuidToString(task.TriggerCommentID) {
writeError(w, http.StatusConflict,
"parent_id must equal this task's trigger comment id ("+uuidToString(task.TriggerCommentID)+")")
return
}
}
noAction, checkErr := service.HasSquadLeaderNoActionEvaluationForTask(r.Context(), h.Queries, task)
if checkErr != nil {
slog.Warn("checking squad leader no_action evaluation failed", append(logger.RequestAttrs(r),
"error", checkErr,
"task_id", taskIDHeader,
"issue_id", issueID,
)...)
} else if noAction {
writeError(w, http.StatusConflict, "squad leader recorded no_action; comments are not allowed for this task")
return
}
}
}
}
}
// Expand bare issue identifiers (e.g. MUL-117) into mention links.
req.Content = mention.ExpandIssueIdentifiers(r.Context(), h.Queries, issue.WorkspaceID, req.Content)
// NOTE: Comment content is stored as Markdown source. XSS is handled at the
// rendering layer (rehype-sanitize) and at the editor layer
// (@tiptap/markdown with html:false). Running an HTML sanitizer here would
// entity-encode Markdown syntax characters (>, ", &, <) and corrupt the
// source. See issue #1303 / discussion in MUL-1119, MUL-1125.
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
AuthorType: authorType,
AuthorID: parseUUID(authorID),
Content: req.Content,
Type: req.Type,
ParentID: parentID,
})
if err != nil {
slog.Warn("create comment failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...)
writeError(w, http.StatusInternalServerError, "failed to create comment: "+err.Error())
return
}
// Link uploaded attachments to this comment.
if len(attachmentIDs) > 0 {
h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, attachmentIDs)
}
// Fetch linked attachments so the response includes them.
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
resp := commentToResponse(comment, nil, groupedAtt[uuidToString(comment.ID)])
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{
"comment": resp,
"issue_title": issue.Title,
"issue_assignee_type": textToPtr(issue.AssigneeType),
"issue_assignee_id": uuidToPtr(issue.AssigneeID),
"issue_status": issue.Status,
})
// A reply in a resolved thread re-opens it. Done after CreateComment commits
// so the reply is visible regardless of the unresolve outcome. Shared with
// the agent task path (TaskService.createAgentComment) — both reply paths
// must keep the resolved root in sync.
h.TaskService.AutoUnresolveThreadOnReply(r.Context(), parentComment, uuidToString(issue.WorkspaceID), authorType, authorID)
// If the issue is assigned to an agent with on_comment trigger, enqueue a new task.
// Skip when the comment comes from the assigned agent itself to avoid loops.
// Also skip when the comment @mentions others but not the assignee agent —
// the user is talking to someone else, not requesting work from the assignee.
// Also skip when replying in a member-started thread without mentioning the
// 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(r.Context(), parentComment, comment.Content, issue) {
// Always use the current comment as the trigger so the agent reads
// the actual new reply, not the thread root. Reply placement (flat
// thread grouping) is handled downstream by createAgentComment,
// which resolves parent_id to the thread root before posting. This
// mirrors the mention path's behavior (see enqueueMentionedAgentTasks).
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue, comment.ID); err != nil {
slog.Warn("enqueue agent task on comment failed", "issue_id", issueID, "error", err)
}
}
// Squad trigger: if the issue is assigned to a squad, trigger the squad leader.
// Skip when the comment author is the leader (prevent internal loops), or
// when a member explicitly @mentions anyone (agent/member/squad/all) — that
// counts as deliberate routing and the leader stays out.
if h.shouldEnqueueSquadLeaderOnComment(r.Context(), issue, comment.Content, authorType, authorID) {
h.enqueueSquadLeaderTask(r.Context(), issue, comment.ID, authorType, authorID)
}
// Trigger @mentioned agents: parse agent mentions and enqueue tasks for each.
// Pass parentComment so that replies inherit mentions from the thread root.
h.enqueueMentionedAgentTasks(r.Context(), issue, comment, parentComment, authorType, authorID)
writeJSON(w, http.StatusCreated, resp)
}
// commentMentionsOthersButNotAssignee returns true if the comment @mentions
// anyone but does NOT @mention the issue's assignee agent. This is used to
// suppress the on_comment trigger when the user is directing their comment at
// someone else (e.g. sharing results with a colleague, asking another agent).
// @all is treated as a broadcast — it suppresses the trigger because the user
// is announcing to everyone, not specifically requesting work from the agent.
func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.Issue) bool {
mentions := util.ParseMentions(content)
// Filter out issue mentions — they are cross-references, not @people.
filtered := mentions[:0]
for _, m := range mentions {
if m.Type != "issue" {
filtered = append(filtered, m)
}
}
mentions = filtered
if len(mentions) == 0 {
return false // No mentions (or only issue refs) — normal on_comment behavior
}
// @all is a broadcast to all members — suppress agent trigger.
if util.HasMentionAll(mentions) {
return true
}
if !issue.AssigneeID.Valid {
return true // No assignee — mentions target others
}
assigneeID := uuidToString(issue.AssigneeID)
for _, m := range mentions {
if m.ID == assigneeID {
return false // Assignee is mentioned — allow trigger
}
}
return true // Others mentioned but not assignee — suppress trigger
}
// isReplyToMemberThread returns true if the comment is a reply in a thread
// started by a member and does NOT @mention the issue's assignee agent.
// When a member replies in a member-started thread, they are most likely
// continuing a human conversation — not requesting work from the assigned agent.
// Replying to an agent-started thread, or explicitly @mentioning the assignee
// 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.
// 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
}
if parent.AuthorType != "member" {
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 agent
// has already participated in this thread.
if !issue.AssigneeID.Valid {
return true // No assignee to mention
}
assigneeID := uuidToString(issue.AssigneeID)
// Check current comment mentions.
for _, m := range util.ParseMentions(content) {
if m.ID == assigneeID {
return false // Assignee explicitly mentioned in reply — allow trigger
}
}
// Check parent (thread root) mentions — if the thread was started by
// mentioning the assignee, replies continue that conversation.
for _, m := range util.ParseMentions(parent.Content) {
if m.ID == assigneeID {
return false // Assignee mentioned in thread root — allow trigger
}
}
// 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
}
// 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
// also included so that agents mentioned in the top-level comment are
// 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.
// 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).
// Note: no status gate here — @mention is an explicit action and should work
// even on done/cancelled issues (the agent can reopen the issue if needed).
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)
if shouldInheritParentMentions(parentComment, mentions, authorType) {
mentions = util.ParseMentions(parentComment.Content)
}
for _, m := range mentions {
if m.Type == "squad" {
// @squad mention → trigger the squad's leader agent.
squadUUID := parseUUID(m.ID)
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
ID: squadUUID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
continue
}
leaderID := squad.LeaderID
// Prevent self-trigger only when the agent's last activity on this
// issue was itself a leader task. An agent that holds both the
// leader and a worker role in the squad must still wake its
// leader role after posting a comment from its worker task.
if authorType == "agent" && authorID == uuidToString(leaderID) &&
h.lastTaskWasLeader(ctx, issue.ID, leaderID) {
continue
}
// Verify leader agent is ready (has runtime, not archived).
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: leaderID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
continue
}
// Private-agent gate: prevent triggering a private leader via squad mention.
if !h.canAccessPrivateAgent(ctx, agent, authorType, authorID, wsID) {
continue
}
// Dedup: skip if leader already has a pending task for this issue.
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
IssueID: issue.ID,
AgentID: leaderID,
})
if err != nil || hasPending {
continue
}
if _, err := h.TaskService.EnqueueTaskForSquadLeader(ctx, issue, leaderID, comment.ID); err != nil {
slog.Warn("enqueue squad leader mention task failed", "issue_id", uuidToString(issue.ID), "squad_id", m.ID, "error", err)
}
continue
}
if m.Type != "agent" {
continue
}
// Prevent self-trigger: skip if the comment author is this agent.
if authorType == "agent" && authorID == m.ID {
continue
}
agentUUID := parseUUID(m.ID)
// Load the agent scoped to the current issue's workspace. Using the
// bare GetAgent here would let a mention resolve to an agent in a
// different workspace, and the visibility check below would then be
// applied against the wrong workspace's roles (a workspace owner in
// THIS workspace would pass the gate for a private agent that lives
// in someone else's workspace).
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: agentUUID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
continue
}
// Private-agent gate (member→private requires allowed_principals;
// agent→agent always passes).
if !h.canAccessPrivateAgent(ctx, agent, authorType, authorID, wsID) {
continue
}
// Dedup: skip if this agent already has a pending task for this issue.
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
IssueID: issue.ID,
AgentID: agentUUID,
})
if err != nil || hasPending {
continue
}
// Always use the current comment as the trigger so the agent reads the
// actual reply that mentioned it, not the thread root.
if _, err := h.TaskService.EnqueueTaskForMention(ctx, issue, agentUUID, comment.ID); err != nil {
slog.Warn("enqueue mention agent task failed", "issue_id", uuidToString(issue.ID), "agent_id", m.ID, "error", err)
}
}
}
func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
commentId := chi.URLParam(r, "commentId")
userID, ok := requireUserID(w, r)
if !ok {
return
}
commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
if !ok {
return
}
// Load comment scoped to current workspace.
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
existing, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
ID: commentUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
return
}
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
isAuthor := existing.AuthorType == actorType && uuidToString(existing.AuthorID) == actorID
isAdmin := roleAllowed(member.Role, "owner", "admin")
if !isAuthor && !isAdmin {
writeError(w, http.StatusForbidden, "only comment author or admin can edit")
return
}
var req struct {
Content string `json:"content"`
AttachmentIDs []string `json:"attachment_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
if !ok {
return
}
// NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here.
comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{
ID: commentUUID,
Content: req.Content,
})
if err != nil {
slog.Warn("update comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
writeError(w, http.StatusInternalServerError, "failed to update comment")
return
}
// Bind any newly uploaded attachments referenced in the edited content so
// they appear in the timeline's comment.attachments after refresh. Existing
// attachments already point at this comment via the upload flow; passing
// them again is a no-op at the SQL level.
if len(attachmentIDs) > 0 {
h.linkAttachmentsByIDs(r.Context(), comment.ID, existing.IssueID, attachmentIDs)
}
// Fetch reactions and attachments for the updated comment.
grouped := h.groupReactions(r, []pgtype.UUID{comment.ID})
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
cid := uuidToString(comment.ID)
resp := commentToResponse(comment, grouped[cid], groupedAtt[cid])
slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...)
h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp})
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
commentId := chi.URLParam(r, "commentId")
userID, ok := requireUserID(w, r)
if !ok {
return
}
commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
if !ok {
return
}
// Load comment scoped to current workspace.
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
ID: commentUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
return
}
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
isAuthor := comment.AuthorType == actorType && uuidToString(comment.AuthorID) == actorID
isAdmin := roleAllowed(member.Role, "owner", "admin")
if !isAuthor && !isAdmin {
writeError(w, http.StatusForbidden, "only comment author or admin can delete")
return
}
// Collect attachment URLs before CASCADE delete removes them.
attachmentURLs, _ := h.Queries.ListAttachmentURLsByCommentID(r.Context(), comment.ID)
// Cancel any active tasks triggered by this comment so the agent does not
// run with the now-deleted content already embedded in its prompt. Must
// run before DeleteComment because the FK ON DELETE SET NULL would
// otherwise nullify trigger_comment_id and orphan those tasks in queued.
if err := h.TaskService.CancelTasksByTriggerComment(r.Context(), comment.ID); err != nil {
slog.Warn("cancel tasks for deleted trigger comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
}
if err := h.Queries.DeleteComment(r.Context(), comment.ID); err != nil {
slog.Warn("delete comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
writeError(w, http.StatusInternalServerError, "failed to delete comment")
return
}
h.deleteS3Objects(r.Context(), attachmentURLs)
slog.Info("comment deleted", append(logger.RequestAttrs(r), "comment_id", commentId, "issue_id", uuidToString(comment.IssueID))...)
h.publish(protocol.EventCommentDeleted, workspaceID, actorType, actorID, map[string]any{
"comment_id": uuidToString(comment.ID),
"issue_id": uuidToString(comment.IssueID),
})
w.WriteHeader(http.StatusNoContent)
}
// loadRootCommentForActor resolves a {commentId} URL param to a root comment in
// the caller's workspace. Returns the comment, the workspace UUID, the actor
// identity, and ok. Resolve / unresolve handlers share this scaffolding so the
// "must be a root comment" rule lives in one place.
func (h *Handler) loadRootCommentForActor(w http.ResponseWriter, r *http.Request) (db.Comment, string, string, string, bool) {
commentId := chi.URLParam(r, "commentId")
userID, ok := requireUserID(w, r)
if !ok {
return db.Comment{}, "", "", "", false
}
commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
if !ok {
return db.Comment{}, "", "", "", false
}
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return db.Comment{}, "", "", "", false
}
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
return db.Comment{}, "", "", "", false
}
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
ID: commentUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
return db.Comment{}, "", "", "", false
}
if comment.ParentID.Valid {
writeError(w, http.StatusBadRequest, "only root comments can be resolved")
return db.Comment{}, "", "", "", false
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
return comment, workspaceID, actorType, actorID, true
}
func (h *Handler) ResolveComment(w http.ResponseWriter, r *http.Request) {
comment, workspaceID, actorType, actorID, ok := h.loadRootCommentForActor(w, r)
if !ok {
return
}
wasResolved := comment.ResolvedAt.Valid
actorUUID, err := util.ParseUUID(actorID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid actor id")
return
}
updated, err := h.Queries.ResolveComment(r.Context(), db.ResolveCommentParams{
ID: comment.ID,
ResolvedByType: pgtype.Text{String: actorType, Valid: true},
ResolvedByID: actorUUID,
})
if err != nil {
slog.Warn("resolve comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", uuidToString(comment.ID))...)
writeError(w, http.StatusInternalServerError, "failed to resolve comment")
return
}
grouped := h.groupReactions(r, []pgtype.UUID{updated.ID})
groupedAtt := h.groupAttachments(r, []pgtype.UUID{updated.ID})
cid := uuidToString(updated.ID)
resp := commentToResponse(updated, grouped[cid], groupedAtt[cid])
// Suppress the event on a re-resolve no-op so consumers do not re-process
// an unchanged thread (notifications, log spam).
if !wasResolved {
slog.Info("comment resolved", append(logger.RequestAttrs(r), "comment_id", cid)...)
h.publish(protocol.EventCommentResolved, workspaceID, actorType, actorID, map[string]any{"comment": resp})
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) UnresolveComment(w http.ResponseWriter, r *http.Request) {
comment, workspaceID, actorType, actorID, ok := h.loadRootCommentForActor(w, r)
if !ok {
return
}
wasResolved := comment.ResolvedAt.Valid
updated, err := h.Queries.UnresolveComment(r.Context(), comment.ID)
if err != nil {
slog.Warn("unresolve comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", uuidToString(comment.ID))...)
writeError(w, http.StatusInternalServerError, "failed to unresolve comment")
return
}
grouped := h.groupReactions(r, []pgtype.UUID{updated.ID})
groupedAtt := h.groupAttachments(r, []pgtype.UUID{updated.ID})
cid := uuidToString(updated.ID)
resp := commentToResponse(updated, grouped[cid], groupedAtt[cid])
if wasResolved {
slog.Info("comment unresolved", append(logger.RequestAttrs(r), "comment_id", cid)...)
h.publish(protocol.EventCommentUnresolved, workspaceID, actorType, actorID, map[string]any{"comment": resp})
}
writeJSON(w, http.StatusOK, resp)
}