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
8 changed files with 14 additions and 152 deletions

View File

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

View File

@@ -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 ?? "";
}

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 {

View File

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

View File

@@ -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"])
}
}

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

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