mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 15:09:22 +02:00
Compare commits
1 Commits
agent/lamb
...
fix/agent-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c92fb2674 |
@@ -133,24 +133,6 @@ 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>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, memo } 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,
|
||||
): ReactNode {
|
||||
): string {
|
||||
const details = (entry.details ?? {}) as Record<string, string>;
|
||||
switch (entry.action) {
|
||||
case "created":
|
||||
@@ -121,24 +121,10 @@ function formatActivity(
|
||||
return `renamed this issue from "${details.from ?? "?"}" to "${details.to ?? "?"}"`;
|
||||
case "description_updated":
|
||||
return "updated the description";
|
||||
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}`;
|
||||
case "task_completed":
|
||||
return "completed the task";
|
||||
case "task_failed":
|
||||
return "task failed";
|
||||
}
|
||||
default:
|
||||
return entry.action ?? "";
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface AgentTrigger {
|
||||
id: string;
|
||||
type: AgentTriggerType;
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
config: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AgentTask {
|
||||
|
||||
@@ -230,12 +230,11 @@ 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 and title
|
||||
// Look up issue to get workspace_id
|
||||
issue, err := queries.GetIssue(ctx, parseUUID(issueID))
|
||||
if err != nil {
|
||||
slog.Error("activity: failed to get issue for task event",
|
||||
@@ -243,46 +242,13 @@ 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: details,
|
||||
Details: []byte("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("activity: failed to record task activity",
|
||||
|
||||
@@ -308,15 +308,6 @@ 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) {
|
||||
@@ -352,13 +343,4 @@ 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,20 +230,6 @@ 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
|
||||
|
||||
@@ -227,8 +227,6 @@ 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
|
||||
@@ -237,22 +235,14 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
|
||||
return false // Thread started by an agent — allow trigger
|
||||
}
|
||||
// Thread was started by a member. Suppress on_comment unless the reply
|
||||
// or the parent explicitly @mentions the assignee agent.
|
||||
// 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 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 false // Assignee explicitly mentioned — allow trigger
|
||||
}
|
||||
}
|
||||
return true // Reply to member thread without mentioning agent — suppress
|
||||
|
||||
@@ -117,20 +117,8 @@ func TestIsReplyToMemberThread(t *testing.T) {
|
||||
h := &Handler{}
|
||||
issue := issueWithAgentAssignee()
|
||||
|
||||
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),
|
||||
}
|
||||
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID)}
|
||||
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID)}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -180,18 +168,6 @@ 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 {
|
||||
@@ -212,13 +188,8 @@ func TestOnCommentTriggerDecision(t *testing.T) {
|
||||
h := &Handler{}
|
||||
issue := issueWithAgentAssignee()
|
||||
|
||||
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),
|
||||
}
|
||||
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID)}
|
||||
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID)}
|
||||
|
||||
// Simulates the combined check from CreateComment:
|
||||
// !commentMentionsOthersButNotAssignee && !isReplyToMemberThread
|
||||
@@ -242,7 +213,6 @@ 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