mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 19:09:27 +02:00
Compare commits
1 Commits
agent/lamb
...
fix/agent-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c92fb2674 |
@@ -1376,20 +1376,17 @@ function AgentDetail({
|
||||
onUpdate,
|
||||
onArchive,
|
||||
onRestore,
|
||||
onDelete,
|
||||
}: {
|
||||
agent: Agent;
|
||||
runtimes: RuntimeDevice[];
|
||||
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
|
||||
onArchive: (id: string) => Promise<void>;
|
||||
onRestore: (id: string) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const st = statusConfig[agent.status];
|
||||
const runtimeDevice = getRuntimeDevice(agent, runtimes);
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("instructions");
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const isArchived = !!agent.archived_at;
|
||||
|
||||
return (
|
||||
@@ -1431,7 +1428,8 @@ function AgentDetail({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
{!isArchived && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" />
|
||||
@@ -1440,21 +1438,16 @@ function AgentDetail({
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!isArchived && (
|
||||
<DropdownMenuItem onClick={() => setConfirmArchive(true)}>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
Archive Agent
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
onClick={() => setConfirmArchive(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete Agent
|
||||
Archive Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -1540,39 +1533,6 @@ function AgentDetail({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{confirmDelete && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">Delete agent?</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
This will permanently delete "{agent.name}" and all its configuration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmDelete(false);
|
||||
onDelete(agent.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1650,20 +1610,6 @@ export default function AgentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.deleteAgent(id);
|
||||
if (selectedId === id) {
|
||||
const remaining = agents.filter((a) => a.id !== id);
|
||||
setSelectedId(remaining[0]?.id ?? "");
|
||||
}
|
||||
await refreshAgents();
|
||||
toast.success("Agent deleted");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete agent");
|
||||
}
|
||||
};
|
||||
|
||||
const selected = agents.find((a) => a.id === selectedId) ?? null;
|
||||
|
||||
if (isLoading) {
|
||||
@@ -1782,7 +1728,6 @@ export default function AgentsPage() {
|
||||
onUpdate={handleUpdate}
|
||||
onArchive={handleArchive}
|
||||
onRestore={handleRestore}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
|
||||
@@ -513,7 +513,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{agents.filter((a) => !a.archived_at && canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
|
||||
{agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
|
||||
<DropdownMenuItem
|
||||
key={a.id}
|
||||
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
|
||||
@@ -742,9 +742,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{agents.filter((a) => !a.archived_at).length > 0 && (
|
||||
{agents.length > 0 && (
|
||||
<CommandGroup heading="Agents">
|
||||
{agents.filter((a) => !a.archived_at).map((a) => {
|
||||
{agents.map((a) => {
|
||||
const sub = subscribers.find((s) => s.user_type === "agent" && s.user_id === a.id);
|
||||
const isSubbed = !!sub;
|
||||
return (
|
||||
|
||||
@@ -162,7 +162,7 @@ function ActorSubContent({
|
||||
m.name.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredAgents = agents.filter((a) =>
|
||||
!a.archived_at && a.name.toLowerCase().includes(query),
|
||||
a.name.toLowerCase().includes(query),
|
||||
);
|
||||
|
||||
const isSelected = (type: "member" | "agent", id: string) =>
|
||||
|
||||
@@ -99,7 +99,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
|
||||
const assigneeQuery = assigneeFilter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
|
||||
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(assigneeQuery));
|
||||
const filteredAgents = agents.filter((a) => a.name.toLowerCase().includes(assigneeQuery));
|
||||
|
||||
const assigneeLabel =
|
||||
assigneeType && assigneeId
|
||||
|
||||
@@ -325,10 +325,6 @@ export class ApiClient {
|
||||
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
async deleteAgent(id: string): Promise<void> {
|
||||
await this.fetch(`/api/agents/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async listRuntimes(params?: { workspace_id?: string }): Promise<AgentRuntime[]> {
|
||||
const search = new URLSearchParams();
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
|
||||
@@ -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 {
|
||||
@@ -71,10 +71,10 @@ export interface Agent {
|
||||
skills: Skill[];
|
||||
tools: AgentTool[];
|
||||
triggers: AgentTrigger[];
|
||||
archived_at: string | null;
|
||||
archived_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
archived_at: string | null;
|
||||
archived_by: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
|
||||
@@ -60,10 +60,10 @@ export const mockAgents: Agent[] = [
|
||||
skills: [],
|
||||
tools: [],
|
||||
triggers: [],
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -197,7 +197,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetAgent)
|
||||
r.Put("/", h.UpdateAgent)
|
||||
r.Delete("/", h.DeleteAgent)
|
||||
r.Post("/archive", h.ArchiveAgent)
|
||||
r.Post("/restore", h.RestoreAgent)
|
||||
r.Get("/tasks", h.ListAgentTasks)
|
||||
|
||||
@@ -30,10 +30,10 @@ type AgentResponse struct {
|
||||
Skills []SkillResponse `json:"skills"`
|
||||
Tools any `json:"tools"`
|
||||
Triggers any `json:"triggers"`
|
||||
ArchivedAt *string `json:"archived_at"`
|
||||
ArchivedBy *string `json:"archived_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ArchivedAt *string `json:"archived_at"`
|
||||
ArchivedBy *string `json:"archived_by"`
|
||||
}
|
||||
|
||||
func agentToResponse(a db.Agent) AgentResponse {
|
||||
@@ -78,10 +78,10 @@ func agentToResponse(a db.Agent) AgentResponse {
|
||||
Skills: []SkillResponse{},
|
||||
Tools: tools,
|
||||
Triggers: triggers,
|
||||
ArchivedAt: timestampToPtr(a.ArchivedAt),
|
||||
ArchivedBy: uuidToPtr(a.ArchivedBy),
|
||||
CreatedAt: timestampToString(a.CreatedAt),
|
||||
UpdatedAt: timestampToString(a.UpdatedAt),
|
||||
ArchivedAt: timestampToPtr(a.ArchivedAt),
|
||||
ArchivedBy: uuidToPtr(a.ArchivedBy),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,32 +493,6 @@ func (h *Handler) RestoreAgent(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
agent, ok := h.loadAgentForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wsID := uuidToString(agent.WorkspaceID)
|
||||
|
||||
if !h.canManageAgent(w, r, agent) {
|
||||
return
|
||||
}
|
||||
|
||||
err := h.Queries.DeleteAgent(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
slog.Warn("delete agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete agent")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("agent deleted", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", wsID)...)
|
||||
userID := requestUserID(r)
|
||||
actorType, actorID := h.resolveActor(r, userID, wsID)
|
||||
h.publish(protocol.EventAgentDeleted, wsID, actorType, actorID, map[string]any{"agent_id": id, "workspace_id": wsID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, ok := h.loadAgentForUser(w, r, id); !ok {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -313,15 +313,6 @@ func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteAgent = `-- name: DeleteAgent :exec
|
||||
DELETE FROM agent WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteAgent(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteAgent, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const failAgentTask = `-- name: FailAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'failed', completed_at = now(), error = $2
|
||||
|
||||
@@ -52,9 +52,6 @@ UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteAgent :exec
|
||||
DELETE FROM agent WHERE id = $1;
|
||||
|
||||
-- name: ListAgentTasks :many
|
||||
SELECT * FROM agent_task_queue
|
||||
WHERE agent_id = $1
|
||||
|
||||
@@ -19,7 +19,6 @@ const (
|
||||
// Agent events
|
||||
EventAgentStatus = "agent:status"
|
||||
EventAgentCreated = "agent:created"
|
||||
EventAgentDeleted = "agent:deleted"
|
||||
EventAgentArchived = "agent:archived"
|
||||
EventAgentRestored = "agent:restored"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user