Compare commits

..

5 Commits

Author SHA1 Message Date
Jiayuan
3d2d52dc33 feat(activity): enrich task_completed/task_failed with contextual details
Activity entries for "completed the task" and "task failed" were generic
and repetitive. Now they include:
- issue_title: always present, helps differentiate in cross-issue views
- trigger: "comment" when task was triggered by a comment reply
- pr_url: PR link extracted from task result (for completed tasks)
- error: truncated error message (for failed tasks)

Frontend displays richer text:
- "completed the follow-up" for comment-triggered tasks
- clickable PR link when available
- error summary for failed tasks
2026-04-03 23:58:14 +08:00
Bohan Jiang
fc6405e4be fix(trigger): allow on_comment when thread root @mentions assignee agent (#382)
When a member-started thread root @mentions the assignee agent, replies
in that thread should trigger on_comment — the thread is a conversation
with the agent, not a member-to-member chat.

Previously isReplyToMemberThread only checked the reply content for
assignee mentions. Now it also checks the parent (thread root) content.
This fixes a gap where path 1 (on_comment) suppressed the trigger and
path 2 (on_mention) skipped the assignee, leaving no trigger path.
2026-04-03 15:07:39 +08:00
devv-eve
7b610a4013 feat(agents): hide archived agents from default list (#373)
* feat(agents): hide archived agents from default list

Archived agents are now filtered out of the default agent list view.
A toggle button (archive icon) appears when archived agents exist,
allowing users to switch between viewing active and archived agents.
The @mention suggestion list already filters out archived agents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(agents): show "No active agents" when all agents are archived

When there are archived agents but no active ones, the empty state now
shows "No active agents" instead of "No agents yet" to avoid confusion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:59:36 +08:00
Bohan Jiang
978e81a268 fix(inbox): prevent comment highlight from re-triggering on every timeline change (#374)
The useEffect that scrolls to and highlights a comment had timeline.length
in its dependency array. When a new reply was posted, timeline.length changed,
re-triggering the scroll and highlight animation. Added a ref to track whether
we've already highlighted for the current highlightCommentId so it only fires once.
2026-04-03 13:47:20 +08:00
Bohan Jiang
c9c8230271 feat(trigger): inherit thread-root mentions for reply-triggered agent tasks (#375)
When a top-level comment @mentions an agent (non-assignee), subsequent
replies in the same thread now also trigger that agent via on_mention.
Previously only the current comment's mentions were checked, so replies
without an explicit re-mention would silently skip the agent.

Extends enqueueMentionedAgentTasks to accept the parent comment and
merge its parsed mentions (deduplicated) into the trigger set, reusing
all existing guards (self-trigger, assignee skip, visibility, dedup).

Closes MUL-177
2026-04-03 13:46:07 +08:00
12 changed files with 287 additions and 57 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import {
Bot,
@@ -29,6 +29,7 @@ import {
Lock,
Settings,
Camera,
Archive,
} from "lucide-react";
import type {
Agent,
@@ -1546,6 +1547,7 @@ export default function AgentsPage() {
const agents = useWorkspaceStore((s) => s.agents);
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
const [selectedId, setSelectedId] = useState<string>("");
const [showArchived, setShowArchived] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const runtimes = useRuntimeStore((s) => s.runtimes);
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
@@ -1557,12 +1559,19 @@ export default function AgentsPage() {
if (workspace) fetchRuntimes();
}, [workspace, fetchRuntimes]);
// Select first agent on initial load
const filteredAgents = useMemo(
() => showArchived ? agents.filter((a) => !!a.archived_at) : agents.filter((a) => !a.archived_at),
[agents, showArchived],
);
const archivedCount = useMemo(() => agents.filter((a) => !!a.archived_at).length, [agents]);
// Select first agent on initial load or when filter changes
useEffect(() => {
if (agents.length > 0 && !selectedId) {
setSelectedId(agents[0]!.id);
if (filteredAgents.length > 0 && !filteredAgents.some((a) => a.id === selectedId)) {
setSelectedId(filteredAgents[0]!.id);
}
}, [agents, selectedId]);
}, [filteredAgents, selectedId]);
const handleCreate = async (data: CreateAgentRequest) => {
const agent = await api.createAgent(data);
@@ -1655,30 +1664,46 @@ export default function AgentsPage() {
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Agents</h1>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
<div className="flex items-center gap-1">
{archivedCount > 0 && (
<Button
variant={showArchived ? "secondary" : "ghost"}
size="icon-xs"
onClick={() => setShowArchived(!showArchived)}
title={showArchived ? "Show active agents" : "Show archived agents"}
>
<Archive className="h-4 w-4 text-muted-foreground" />
</Button>
)}
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
{agents.length === 0 ? (
{filteredAgents.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Bot className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No agents yet</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
<p className="mt-3 text-sm text-muted-foreground">
{showArchived ? "No archived agents" : archivedCount > 0 ? "No active agents" : "No agents yet"}
</p>
{!showArchived && (
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
)}
</div>
) : (
<div className="divide-y">
{agents.map((agent) => (
{filteredAgents.map((agent) => (
<AgentListItem
key={agent.id}
agent={agent}

View File

@@ -133,6 +133,24 @@ function InboxDetailLabel({ item }: { item: InboxItem }) {
if (emoji) return <span>Reacted {emoji} to your comment</span>;
return <span>{typeLabels[item.type]}</span>;
}
case "task_completed": {
const label = details.trigger === "comment" ? "Completed follow-up" : "Task completed";
if (details.pr_url) {
return (
<span className="inline-flex items-center gap-1">
{label} {" "}
<a href={details.pr_url} target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
PR
</a>
</span>
);
}
return <span>{label}</span>;
}
case "task_failed": {
if (details.error) return <span>Task failed: {details.error}</span>;
return <span>{typeLabels[item.type]}</span>;
}
default:
return <span>{typeLabels[item.type] ?? item.type}</span>;
}

View File

@@ -109,11 +109,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
// Trim trailing whitespace: ProseMirror always keeps an empty
// paragraph at the document end (schema: "block+"), which
// getMarkdown() serializes as trailing "\n\n". Without trimming,
// each save→reload cycle would add an extra empty line.
onUpdateRef.current?.(ed.getMarkdown().replace(/(\n\s*)+$/, ""));
onUpdateRef.current?.(ed.getMarkdown());
}, debounceMs);
},
onBlur: () => {
@@ -179,7 +175,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
}, [editor, editable, defaultValue]);
useImperativeHandle(ref, () => ({
getMarkdown: () => editor?.getMarkdown().replace(/(\n\s*)+$/, "") ?? "",
getMarkdown: () => editor?.getMarkdown() ?? "",
clearContent: () => {
editor?.commands.clearContent();
},

View File

@@ -131,7 +131,10 @@ function CommentRow({
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editEditorRef.current?.getMarkdown()?.trim();
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
return;
@@ -297,7 +300,10 @@ function CommentCard({
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editEditorRef.current?.getMarkdown()?.trim();
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
return;

View File

@@ -23,7 +23,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
};
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.trim();
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
setSubmitting(true);
try {

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useRef, memo } from "react";
import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -93,7 +93,7 @@ function priorityLabel(priority: string): string {
function formatActivity(
entry: TimelineEntry,
resolveActorName?: (type: string, id: string) => string,
): string {
): ReactNode {
const details = (entry.details ?? {}) as Record<string, string>;
switch (entry.action) {
case "created":
@@ -121,10 +121,24 @@ function formatActivity(
return `renamed this issue from "${details.from ?? "?"}" to "${details.to ?? "?"}"`;
case "description_updated":
return "updated the description";
case "task_completed":
return "completed the task";
case "task_failed":
case "task_completed": {
const label = details.trigger === "comment" ? "completed the follow-up" : "completed the task";
if (details.pr_url) {
return (
<>
{label} {" "}
<a href={details.pr_url} target="_blank" rel="noopener noreferrer" className="text-foreground underline underline-offset-2 hover:text-foreground/80">
PR
</a>
</>
);
}
return label;
}
case "task_failed": {
if (details.error) return `task failed: ${details.error}`;
return "task failed";
}
default:
return entry.action ?? "";
}
@@ -198,6 +212,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const didHighlightRef = useRef<string | null>(null);
// Single source of truth: read issue directly from global store
const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null;
@@ -239,17 +254,16 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const loading = issueLoading;
// Scroll to highlighted comment once timeline loads
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
useEffect(() => {
if (!highlightCommentId || timeline.length === 0) return;
// Find the comment element — could be a top-level comment or a reply
if (didHighlightRef.current === highlightCommentId) return;
const el = document.getElementById(`comment-${highlightCommentId}`);
if (el) {
// Small delay to ensure layout is settled
didHighlightRef.current = highlightCommentId;
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "center" });
setHighlightedId(highlightCommentId);
// Clear highlight after animation
const timer = setTimeout(() => setHighlightedId(null), 2000);
return () => clearTimeout(timer);
});

View File

@@ -56,7 +56,7 @@ function ReplyInput({
};
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.trim();
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
setSubmitting(true);
try {

View File

@@ -230,11 +230,12 @@ func handleTaskActivity(ctx context.Context, bus *events.Bus, queries *db.Querie
}
agentID, _ := payload["agent_id"].(string)
issueID, _ := payload["issue_id"].(string)
taskID, _ := payload["task_id"].(string)
if issueID == "" {
return
}
// Look up issue to get workspace_id
// Look up issue to get workspace_id and title
issue, err := queries.GetIssue(ctx, parseUUID(issueID))
if err != nil {
slog.Error("activity: failed to get issue for task event",
@@ -242,13 +243,46 @@ func handleTaskActivity(ctx context.Context, bus *events.Bus, queries *db.Querie
return
}
// Build enriched details from the task record
detailsMap := map[string]string{
"issue_title": issue.Title,
}
if taskID != "" {
if task, err := queries.GetAgentTask(ctx, parseUUID(taskID)); err == nil {
// Trigger type: comment-triggered vs assignment-triggered
if task.TriggerCommentID.Valid {
detailsMap["trigger"] = "comment"
}
if action == "task_completed" && len(task.Result) > 0 {
var completed protocol.TaskCompletedPayload
if err := json.Unmarshal(task.Result, &completed); err == nil {
if completed.PRURL != "" {
detailsMap["pr_url"] = completed.PRURL
}
}
}
if action == "task_failed" && task.Error.Valid && task.Error.String != "" {
// Truncate long error messages for the activity summary
errMsg := task.Error.String
if len(errMsg) > 200 {
errMsg = errMsg[:200] + "…"
}
detailsMap["error"] = errMsg
}
}
}
details, _ := json.Marshal(detailsMap)
activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
WorkspaceID: issue.WorkspaceID,
IssueID: parseUUID(issueID),
ActorType: util.StrToText("agent"),
ActorID: parseUUID(agentID),
Action: action,
Details: []byte("{}"),
Details: details,
})
if err != nil {
slog.Error("activity: failed to record task activity",

View File

@@ -308,6 +308,15 @@ func TestActivityTaskCompleted(t *testing.T) {
if util.UUIDToString(activities[0].ActorID) != agentID {
t.Fatalf("expected actor_id %s, got %s", agentID, util.UUIDToString(activities[0].ActorID))
}
// Verify enriched details contain issue_title
var details map[string]string
if err := json.Unmarshal(activities[0].Details, &details); err != nil {
t.Fatalf("failed to unmarshal details: %v", err)
}
if details["issue_title"] != "subscriber test issue" {
t.Fatalf("expected issue_title 'subscriber test issue', got %q", details["issue_title"])
}
}
func TestActivityTaskFailed(t *testing.T) {
@@ -343,4 +352,13 @@ func TestActivityTaskFailed(t *testing.T) {
if activities[0].Action != "task_failed" {
t.Fatalf("expected action 'task_failed', got %q", activities[0].Action)
}
// Verify enriched details contain issue_title
var details map[string]string
if err := json.Unmarshal(activities[0].Details, &details); err != nil {
t.Fatalf("failed to unmarshal details: %v", err)
}
if details["issue_title"] != "subscriber test issue" {
t.Fatalf("expected issue_title 'subscriber test issue', got %q", details["issue_title"])
}
}

View File

@@ -230,6 +230,20 @@ func TestCommentTriggerOnComment(t *testing.T) {
t.Errorf("expected 1 pending task (assignee mentioned in member thread), got %d", n)
}
})
t.Run("reply to member thread that @mentioned assignee triggers without re-mention", func(t *testing.T) {
clearTasks(t, issueID)
// Member starts a thread that @mentions the assignee agent.
content := fmt.Sprintf("[@Agent](mention://agent/%s) can you review this?", agentID)
threadID := postComment(t, issueID, content, nil)
// Clear the task created by the top-level mention.
clearTasks(t, issueID)
// Reply in the thread WITHOUT re-mentioning the assignee.
postComment(t, issueID, "Here is more context for you", strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task (assignee mentioned in thread root), got %d", n)
}
})
}
// TestCommentTriggerAtAllSuppression verifies that @all mentions do not
@@ -323,6 +337,52 @@ func TestCommentTriggerOnMentionNoStatusGate(t *testing.T) {
}
}
// TestCommentTriggerThreadInheritedMention verifies that when a top-level
// comment @mentions an agent (not the assignee), replies in that thread
// also trigger the mentioned agent — even without explicitly re-mentioning it.
func TestCommentTriggerThreadInheritedMention(t *testing.T) {
agentID := getAgentID(t)
// Create an issue NOT assigned to the agent, so on_comment won't fire.
issueID := createIssue(t, "Thread-inherited mention test")
t.Cleanup(func() {
clearTasks(t, issueID)
resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
resp.Body.Close()
})
t.Run("reply in thread inherits parent mention", func(t *testing.T) {
clearTasks(t, issueID)
// Top-level comment @mentions the agent.
content := fmt.Sprintf("[@Agent](mention://agent/%s) can you review this?", agentID)
threadID := postComment(t, issueID, content, nil)
if n := countPendingTasks(t, issueID); n != 1 {
t.Fatalf("expected 1 pending task after initial mention, got %d", n)
}
// Clear the task so we can test the reply independently.
clearTasks(t, issueID)
// Reply in the thread WITHOUT mentioning the agent.
postComment(t, issueID, "Here is more context for you", strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task from thread-inherited mention, got %d", n)
}
})
t.Run("reply does not double-trigger when re-mentioning same agent", func(t *testing.T) {
clearTasks(t, issueID)
// Top-level comment @mentions the agent.
content := fmt.Sprintf("[@Agent](mention://agent/%s) help", agentID)
threadID := postComment(t, issueID, content, nil)
clearTasks(t, issueID)
// Reply also @mentions the same agent — should still be just 1 task.
reply := fmt.Sprintf("[@Agent](mention://agent/%s) any update?", agentID)
postComment(t, issueID, reply, strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task (no duplicate), got %d", n)
}
})
}
// TestCommentTriggerCoalescing verifies that rapid-fire comments don't create
// duplicate tasks (coalescing dedup).
func TestCommentTriggerCoalescing(t *testing.T) {

View File

@@ -188,7 +188,8 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
}
// Trigger @mentioned agents: parse agent mentions and enqueue tasks for each.
h.enqueueMentionedAgentTasks(r.Context(), issue, comment, authorType, authorID)
// 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)
}
@@ -226,6 +227,8 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I
// 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.
func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issue db.Issue) bool {
if parent == nil {
return false // Not a reply — normal top-level comment
@@ -234,30 +237,56 @@ 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
// explicitly @mentions the assignee agent.
// or the parent explicitly @mentions the assignee agent.
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 — allow trigger
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
}
}
return true // Reply to member thread without mentioning agent — suppress
}
// enqueueMentionedAgentTasks parses @agent mentions from comment content and
// enqueues a task for each mentioned agent. Skips self-mentions, agents that
// are already the issue's assignee (handled by on_comment), 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).
// 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.
// Skips self-mentions, agents that are already the issue's assignee (handled
// by on_comment), 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, authorType, authorID string) {
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, also include mentions from the parent comment
// so that agents mentioned in the thread root are triggered by replies.
if parentComment != nil {
parentMentions := util.ParseMentions(parentComment.Content)
seen := make(map[string]bool, len(mentions))
for _, m := range mentions {
seen[m.Type+":"+m.ID] = true
}
for _, m := range parentMentions {
if !seen[m.Type+":"+m.ID] {
mentions = append(mentions, m)
seen[m.Type+":"+m.ID] = true
}
}
}
for _, m := range mentions {
if m.Type != "agent" {
continue

View File

@@ -117,8 +117,20 @@ func TestIsReplyToMemberThread(t *testing.T) {
h := &Handler{}
issue := issueWithAgentAssignee()
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID)}
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID)}
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID), Content: "plain thread starter"}
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID), Content: "agent thread starter"}
// Member-started thread root that @mentions the assignee agent.
memberParentMentioningAssignee := &db.Comment{
AuthorType: "member",
AuthorID: testUUID(memberID),
Content: fmt.Sprintf("[@Agent](mention://agent/%s) can you look at this?", agentAssigneeID),
}
// Member-started thread root that @mentions a non-assignee agent.
memberParentMentioningOther := &db.Comment{
AuthorType: "member",
AuthorID: testUUID(memberID),
Content: fmt.Sprintf("[@Other](mention://agent/%s) what do you think?", otherAgentID),
}
tests := []struct {
name string
@@ -168,6 +180,18 @@ func TestIsReplyToMemberThread(t *testing.T) {
content: fmt.Sprintf("[@Other](mention://agent/%s) take a look", otherAgentID),
want: true,
},
{
name: "reply to member thread that @mentioned assignee, no re-mention → allow",
parent: memberParentMentioningAssignee,
content: "here is more context for you",
want: false,
},
{
name: "reply to member thread that @mentioned other agent, no re-mention → suppress",
parent: memberParentMentioningOther,
content: "here is more context",
want: true, // parent mentioned other agent, not assignee — still suppress on_comment
},
}
for _, tt := range tests {
@@ -188,8 +212,13 @@ func TestOnCommentTriggerDecision(t *testing.T) {
h := &Handler{}
issue := issueWithAgentAssignee()
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID)}
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID)}
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID), Content: "plain thread starter"}
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID), Content: "agent thread starter"}
memberParentMentioningAssignee := &db.Comment{
AuthorType: "member",
AuthorID: testUUID(memberID),
Content: fmt.Sprintf("[@Agent](mention://agent/%s) help me", agentAssigneeID),
}
// Simulates the combined check from CreateComment:
// !commentMentionsOthersButNotAssignee && !isReplyToMemberThread
@@ -213,6 +242,7 @@ func TestOnCommentTriggerDecision(t *testing.T) {
{"reply member thread, no mention", memberParent, "agreed", false},
{"reply member thread, mention other member", memberParent, fmt.Sprintf("[@Bob](mention://member/%s) ok", memberID), false},
{"reply member thread, mention assignee", memberParent, fmt.Sprintf("[@Agent](mention://agent/%s) help", agentAssigneeID), true},
{"reply member thread that @mentioned assignee, no re-mention", memberParentMentioningAssignee, "here is more info", true},
{"top-level, @all broadcast", nil, "[@All](mention://all/all) heads up team", false},
{"reply agent thread, @all broadcast", agentParent, "[@All](mention://all/all) update for everyone", false},
{"reply member thread, @all broadcast", memberParent, "[@All](mention://all/all) fyi", false},