mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 00:49:22 +02:00
Compare commits
5 Commits
fix/editor
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d2d52dc33 | ||
|
|
fc6405e4be | ||
|
|
7b610a4013 | ||
|
|
978e81a268 | ||
|
|
c9c8230271 |
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user