Compare commits

..

1 Commits

Author SHA1 Message Date
Jiang Bohan
0c92fb2674 fix(types): make AgentTrigger.config nullable to match API reality
The API can return `config: null` for non-scheduled triggers, but the
type was `Record<string, unknown>` which doesn't reflect this. Update
to `Record<string, unknown> | null` so TypeScript catches unsafe access
at compile time.

Follow-up to #415.
2026-04-07 15:25:29 +08:00
15 changed files with 25 additions and 178 deletions

View File

@@ -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 &quot;{agent.name}&quot; 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">

View File

@@ -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 (

View File

@@ -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) =>

View File

@@ -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

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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,
},
];

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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},

View File

@@ -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

View File

@@ -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

View File

@@ -19,7 +19,6 @@ const (
// Agent events
EventAgentStatus = "agent:status"
EventAgentCreated = "agent:created"
EventAgentDeleted = "agent:deleted"
EventAgentArchived = "agent:archived"
EventAgentRestored = "agent:restored"