mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 00:19:29 +02:00
Compare commits
6 Commits
fix/agent-
...
fix/agent-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ed5f3ce50 | ||
|
|
40a984c997 | ||
|
|
9ccaf18479 | ||
|
|
866b901943 | ||
|
|
9baa72cc68 | ||
|
|
576304519b |
@@ -7,7 +7,7 @@ import { api } from "@multica/core/api";
|
||||
import { useHasOnboarded } from "@multica/core/paths";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { PageviewTracker } from "./components/pageview-tracker";
|
||||
|
||||
@@ -89,6 +89,12 @@ export interface AgentTask {
|
||||
* or deleted.
|
||||
*/
|
||||
trigger_summary?: string;
|
||||
/**
|
||||
* Server-computed source discriminator used by the activity row to label
|
||||
* tasks that have no linked issue (so e.g. quick-create tasks render
|
||||
* with a meaningful title instead of falling through to "Untracked").
|
||||
*/
|
||||
kind?: "comment" | "autopilot" | "chat" | "quick_create" | "direct";
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
|
||||
@@ -5,11 +5,16 @@ import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
// Use `resolvedTheme` (the concrete "light" / "dark" value) instead of
|
||||
// `theme` (which can be "system"). When we forward "system", sonner reads
|
||||
// `prefers-color-scheme` itself, and the Electron renderer's media query
|
||||
// can disagree with next-themes' `html.dark` class — that's why the toast
|
||||
// sometimes rendered light on a dark UI.
|
||||
const { resolvedTheme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={resolvedTheme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
|
||||
@@ -392,12 +392,25 @@ function TaskRow({
|
||||
}
|
||||
};
|
||||
|
||||
// Terminal states never use active wording. The server links the new
|
||||
// issue back to a quick-create task on completion (so most successful
|
||||
// rows transition to kind=direct on next refetch), but rows whose link
|
||||
// write failed — or whose agent never created the issue at all — would
|
||||
// otherwise sit on "Creating issue" forever.
|
||||
const isTerminalStatus =
|
||||
task.status === "completed" ||
|
||||
task.status === "failed" ||
|
||||
task.status === "cancelled";
|
||||
const sourceFallback = !hasIssue
|
||||
? task.chat_session_id
|
||||
? "Chat session"
|
||||
: task.autopilot_run_id
|
||||
? "Autopilot run"
|
||||
: "Untracked"
|
||||
? task.kind === "quick_create"
|
||||
? isTerminalStatus
|
||||
? "Quick create"
|
||||
: "Creating issue"
|
||||
: task.chat_session_id
|
||||
? "Chat session"
|
||||
: task.autopilot_run_id
|
||||
? "Autopilot run"
|
||||
: "Untracked"
|
||||
: null;
|
||||
|
||||
// Origin marker — issue / chat / autopilot / untracked. The issue
|
||||
|
||||
@@ -57,7 +57,10 @@ export function CreateIssueDialog({
|
||||
? cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
"!top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2",
|
||||
"!max-w-xl !w-full",
|
||||
// Width is capped; height is content-driven up to 80vh so a
|
||||
// pasted screenshot can't push the dialog past the viewport
|
||||
// (the inner editor area scrolls instead).
|
||||
"!max-w-xl !w-full !max-h-[80vh]",
|
||||
// Smooth size transition when switching modes — the manual mode
|
||||
// uses the same easing.
|
||||
"!transition-all !duration-300 !ease-out",
|
||||
|
||||
@@ -257,14 +257,18 @@ export function AgentCreatePanel({
|
||||
{/* Prompt — same rich editor Advanced uses, so paste/drop images,
|
||||
mentions, and formatting all work. The dropZone wrapper enables
|
||||
drag-and-drop file uploads alongside paste. */}
|
||||
{/* `flex-1 min-h-0 overflow-y-auto` so the editor area absorbs the
|
||||
remaining vertical space inside the (now max-bounded) DialogContent
|
||||
and scrolls internally. Without it, pasting an image expanded the
|
||||
editor unbounded and pushed the modal past the viewport. */}
|
||||
<div
|
||||
{...dropZoneProps}
|
||||
className="relative px-5 pb-3 min-h-[140px]"
|
||||
className="relative px-5 pb-3 flex-1 min-h-[140px] overflow-y-auto"
|
||||
>
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
defaultValue={initialPrompt}
|
||||
placeholder='Describe the issue, e.g. "fix inbox loading slowness, assign to naiyuan, P1"'
|
||||
placeholder='Tell the agent what to do, e.g. "let Bohan fix the inbox loading slowness in the Web project"'
|
||||
onUpdate={(md) => setHasContent(md.trim().length > 0)}
|
||||
onUploadFile={handleUploadFile}
|
||||
onSubmit={submit}
|
||||
|
||||
@@ -151,24 +151,17 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("- If the task requires code changes, use `multica repo checkout <url>` to get the code first\n")
|
||||
b.WriteString("- Keep responses concise and direct\n\n")
|
||||
} else if ctx.QuickCreatePrompt != "" {
|
||||
// Quick-create task: no issue exists yet. The agent's only job is to
|
||||
// translate one line of natural language into a single
|
||||
// `multica issue create` call. Suppress the default assignment
|
||||
// workflow that would tell the agent to call `multica issue get` /
|
||||
// `multica issue status` / `multica issue comment add` against an
|
||||
// empty IssueID — those would either error or silently target the
|
||||
// wrong issue.
|
||||
b.WriteString("**This task was triggered by quick-create.** There is NO existing Multica issue. Translate the user's input into a single `multica issue create` invocation and exit.\n\n")
|
||||
fmt.Fprintf(&b, "User input:\n> %s\n\n", ctx.QuickCreatePrompt)
|
||||
b.WriteString("Field rules:\n")
|
||||
b.WriteString("- title: required, short imperative summary extracted from the user input.\n")
|
||||
b.WriteString("- description: optional; only include if the user supplied detail beyond the title.\n")
|
||||
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\"/\"紧急\" → urgent; \"低优先级\" → low.\n")
|
||||
b.WriteString("- assignee: when the user says \"分给 X\" / \"assign to X\" / \"@X\", call `multica workspace members --output json` and find the matching member. On clean match, pass `--assignee <name>`. On no/ambiguous match, OMIT `--assignee` and append a final line to the description: `未识别 assignee: X`.\n")
|
||||
b.WriteString("- project / status: omit (defaults apply).\n\n")
|
||||
b.WriteString("Output rules:\n")
|
||||
b.WriteString("- Run exactly one `multica issue create` invocation.\n")
|
||||
b.WriteString("- After it succeeds, print exactly one line: `Created MUL-<n>: <title>` and exit.\n")
|
||||
// Quick-create task: detailed field / output rules live in the
|
||||
// per-turn prompt (BuildPrompt → buildQuickCreatePrompt) so they
|
||||
// have a single source of truth. Quick-create is one-shot, so the
|
||||
// per-turn message is always present and the agent reads the rules
|
||||
// from there. We only keep the hard guardrails here so a provider
|
||||
// that doesn't propagate the user message into its working context
|
||||
// (or a resumed session) still avoids the assignment-task workflow
|
||||
// pointing at an empty issue id.
|
||||
b.WriteString("**This task was triggered by quick-create.** There is NO existing Multica issue. Follow the field and output rules in the user message you just received; ignore the default assignment-task workflow.\n\n")
|
||||
b.WriteString("Hard guardrails (apply even if the user message is missing):\n")
|
||||
b.WriteString("- Run exactly one `multica issue create` invocation, then exit.\n")
|
||||
b.WriteString("- Do NOT call `multica issue get`, `multica issue status`, or `multica issue comment add` for this task — there is no issue to query, transition, or comment on. The platform writes the user's success/failure inbox notification automatically based on whether `multica issue create` succeeded.\n")
|
||||
b.WriteString("- If the CLI returns an error, exit with that error as the only output. Do not retry.\n\n")
|
||||
} else if ctx.AutopilotRunID != "" {
|
||||
|
||||
@@ -45,7 +45,17 @@ func buildQuickCreatePrompt(task Task) string {
|
||||
b.WriteString("- title: required. A short, imperative summary extracted from the user input (e.g. \"fix inbox loading\"). Strip filler words.\n")
|
||||
b.WriteString("- description: optional. Include only if the user supplied detail beyond the title; otherwise omit. Never echo the title here.\n")
|
||||
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\"/\"紧急\" → urgent; \"低优先级\" → low. If unspecified, omit.\n")
|
||||
b.WriteString("- assignee: when the user says \"分给 X\" / \"assign to X\" / \"@X\", call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `未识别 assignee: X`.\n")
|
||||
b.WriteString("- assignee:\n")
|
||||
b.WriteString(" - When the user names someone (\"分给 X\" / \"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `未识别 assignee: X`.\n")
|
||||
agentName := ""
|
||||
if task.Agent != nil {
|
||||
agentName = task.Agent.Name
|
||||
}
|
||||
if agentName != "" {
|
||||
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to YOURSELF: pass `--assignee %q`. The picker agent is the expected owner because the user opened quick-create with you selected — never leave the issue unassigned.\n", agentName)
|
||||
} else {
|
||||
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee <your agent name>`. Never leave the issue unassigned.\n")
|
||||
}
|
||||
b.WriteString("- project: omit. The platform will route the issue to the workspace default.\n")
|
||||
b.WriteString("- status: omit (defaults to `todo`).\n\n")
|
||||
b.WriteString("Output format:\n")
|
||||
|
||||
@@ -156,6 +156,7 @@ type AgentTaskResponse struct {
|
||||
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
|
||||
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
|
||||
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
|
||||
Kind string `json:"kind"` // discriminator: "comment" | "autopilot" | "chat" | "quick_create" | "direct" — used by the activity row to label tasks that have no linked issue
|
||||
}
|
||||
|
||||
// TaskAgentData holds agent info included in claim responses so the daemon
|
||||
@@ -204,9 +205,32 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
||||
// with issue_id = "" once a task has no linked issue.
|
||||
ChatSessionID: uuidToString(t.ChatSessionID),
|
||||
AutopilotRunID: uuidToString(t.AutopilotRunID),
|
||||
Kind: computeTaskKind(t),
|
||||
}
|
||||
}
|
||||
|
||||
// computeTaskKind picks the source-discriminator string the activity UI uses
|
||||
// to choose how to render a task row. Computed from the existing FK shape so
|
||||
// no extra DB lookup is needed: chat / autopilot / comment-on-issue (any
|
||||
// triggered task with both an issue_id and trigger_comment_id) / quick_create
|
||||
// (no linked source — the agent is creating the issue itself) / direct
|
||||
// (assignee-driven task on an existing issue).
|
||||
func computeTaskKind(t db.AgentTaskQueue) string {
|
||||
if uuidToString(t.ChatSessionID) != "" {
|
||||
return "chat"
|
||||
}
|
||||
if uuidToString(t.AutopilotRunID) != "" {
|
||||
return "autopilot"
|
||||
}
|
||||
if uuidToString(t.IssueID) == "" {
|
||||
return "quick_create"
|
||||
}
|
||||
if uuidToString(t.TriggerCommentID) != "" {
|
||||
return "comment"
|
||||
}
|
||||
return "direct"
|
||||
}
|
||||
|
||||
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := h.resolveWorkspaceID(r)
|
||||
member, ok := h.workspaceMember(w, r, workspaceID)
|
||||
|
||||
@@ -404,6 +404,39 @@ func (h *Handler) isReplyToMemberThread(ctx context.Context, parent *db.Comment,
|
||||
return true // Reply to member thread without agent participation — suppress
|
||||
}
|
||||
|
||||
// shouldInheritParentMentions decides whether a reply with no explicit
|
||||
// mentions should inherit the parent (thread root) comment's mentions.
|
||||
//
|
||||
// Inheritance lets a member who started a thread by @mentioning an agent
|
||||
// continue the conversation with that agent without re-typing the mention
|
||||
// on every follow-up reply.
|
||||
//
|
||||
// It is intentionally narrow:
|
||||
//
|
||||
// - Only when the reply contains zero mentions of its own. Any explicit
|
||||
// mention in the reply is a deliberate choice about who to involve.
|
||||
// - Only when the reply author is a member. Agent-authored replies must
|
||||
// never inherit, otherwise an agent posting in a thread whose root
|
||||
// mentioned another agent would re-trigger that agent and create a loop.
|
||||
// - Only when the parent author is a member. When an agent authors a
|
||||
// comment that @mentions another agent, it is typically a one-shot
|
||||
// delegation (e.g. an agent posting a PR completion that @mentions a
|
||||
// reviewer agent). Subsequent member follow-ups in the same thread are
|
||||
// directed at the assignee, not at the delegated agent — inheriting
|
||||
// would re-trigger the delegated agent on every plain reply.
|
||||
func shouldInheritParentMentions(parentComment *db.Comment, replyMentions []util.Mention, replyAuthorType string) bool {
|
||||
if parentComment == nil {
|
||||
return false
|
||||
}
|
||||
if len(replyMentions) > 0 {
|
||||
return false
|
||||
}
|
||||
if replyAuthorType == "agent" {
|
||||
return false
|
||||
}
|
||||
return parentComment.AuthorType == "member"
|
||||
}
|
||||
|
||||
// enqueueMentionedAgentTasks parses @agent mentions from comment content and
|
||||
// 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
|
||||
@@ -419,17 +452,7 @@ func (h *Handler) isReplyToMemberThread(ctx context.Context, parent *db.Comment,
|
||||
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, inherit mentions from the parent comment
|
||||
// so that agents mentioned in the thread root are triggered by replies —
|
||||
// but only when the reply contains no mentions at all (a plain follow-up).
|
||||
// If the reply explicitly @mentions anyone (agents or members), the user
|
||||
// is making a deliberate choice about who to involve; don't auto-inherit.
|
||||
//
|
||||
// CRITICAL: agent-authored replies must NOT inherit parent mentions.
|
||||
// Otherwise an agent posting "No reply needed" in a thread whose root
|
||||
// mentioned another agent would re-trigger that agent, creating a loop.
|
||||
// Explicit @mentions in the agent's own comment still work for delegation.
|
||||
if parentComment != nil && len(mentions) == 0 && authorType != "agent" {
|
||||
if shouldInheritParentMentions(parentComment, mentions, authorType) {
|
||||
mentions = util.ParseMentions(parentComment.Content)
|
||||
}
|
||||
for _, m := range mentions {
|
||||
|
||||
@@ -2116,6 +2116,98 @@ func TestAgentReplyDoesNotInheritParentMentions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemberReplyToAgentRootDoesNotInheritParentMentions is the regression
|
||||
// for MUL-1535. When an agent posts a comment that @mentions another agent
|
||||
// (e.g. J posting a PR completion that @mentions a reviewer agent), a later
|
||||
// member reply in the same thread with no explicit mentions must NOT inherit
|
||||
// the @reviewer mention. The reviewer was a one-shot delegation; subsequent
|
||||
// member follow-ups are directed at the assignee, not the reviewer.
|
||||
func TestMemberReplyToAgentRootDoesNotInheritParentMentions(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
jAgent := createHandlerTestAgent(t, "J", nil)
|
||||
reviewerAgent := createHandlerTestAgent(t, "Reviewer", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "PR review delegation no-leak test",
|
||||
"status": "todo",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var issue IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&issue)
|
||||
issueID := issue.ID
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
countTasks := func(agentID string) int {
|
||||
var n int
|
||||
err := testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
||||
issueID, agentID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count tasks: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 1. Agent J posts a PR-completion comment that @mentions Reviewer for review.
|
||||
// This is a deliberate handoff and must enqueue a task for Reviewer.
|
||||
w = httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": fmt.Sprintf("PR ready. [@Reviewer](mention://agent/%s) please review this.", reviewerAgent),
|
||||
})
|
||||
r = withURLParam(r, "id", issueID)
|
||||
r.Header.Set("X-Agent-ID", jAgent)
|
||||
testHandler.CreateComment(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("J PR completion: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var rootComment CommentResponse
|
||||
json.NewDecoder(w.Body).Decode(&rootComment)
|
||||
if got := countTasks(reviewerAgent); got != 1 {
|
||||
t.Fatalf("expected 1 task for Reviewer after explicit mention, got %d", got)
|
||||
}
|
||||
|
||||
// Cancel reviewer's task so it's free to be re-triggered if the bug returns.
|
||||
if _, err := testPool.Exec(ctx,
|
||||
`UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1 AND agent_id = $2`,
|
||||
issueID, reviewerAgent,
|
||||
); err != nil {
|
||||
t.Fatalf("cancel reviewer task: %v", err)
|
||||
}
|
||||
|
||||
// 2. Member posts a plain follow-up reply under J's PR comment, with no
|
||||
// explicit mentions. The pre-fix code path inherited mentions from the
|
||||
// parent regardless of the parent author, which re-triggered Reviewer.
|
||||
// With the fix, the reply must NOT inherit because the parent was
|
||||
// authored by an agent.
|
||||
w = httptest.NewRecorder()
|
||||
r = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "How do I test this after merging?",
|
||||
"parent_id": rootComment.ID,
|
||||
})
|
||||
r = withURLParam(r, "id", issueID)
|
||||
testHandler.CreateComment(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("member follow-up: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := countTasks(reviewerAgent); got != 0 {
|
||||
t.Fatalf("expected 0 tasks for Reviewer after plain member reply (no inheritance from agent root), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentExplicitMentionStillTriggers documents the boundary the structural
|
||||
// fix preserves: suppressing implicit parent-mention inheritance for agent
|
||||
// authors does NOT block deliberate handoffs. An agent that explicitly
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
@@ -214,6 +215,53 @@ func TestIsReplyToMemberThread(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// shouldInheritParentMentions
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func TestShouldInheritParentMentions(t *testing.T) {
|
||||
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID), Content: "thread starter"}
|
||||
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID), Content: "agent thread starter"}
|
||||
someMention := []util.Mention{{Type: "agent", ID: otherAgentID}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
parent *db.Comment
|
||||
replyMentions []util.Mention
|
||||
replyAuthorType string
|
||||
want bool
|
||||
}{
|
||||
{"nil parent → false", nil, nil, "member", false},
|
||||
{"reply has explicit mentions → false", memberParent, someMention, "member", false},
|
||||
{"agent-authored reply, member parent → false (loop guard)", memberParent, nil, "agent", false},
|
||||
{"member reply, agent parent → false (parent author guard)", agentParent, nil, "member", false},
|
||||
{"member reply, member parent, no mentions → true (intended use)", memberParent, nil, "member", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldInheritParentMentions(tt.parent, tt.replyMentions, tt.replyAuthorType)
|
||||
if got != tt.want {
|
||||
t.Errorf("shouldInheritParentMentions() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the case from MUL-1535: J posts a PR completion comment
|
||||
// that @mentions GPT-Boy for review; later a member posts a plain follow-up
|
||||
// reply asking the assignee a question. GPT-Boy must NOT be re-triggered.
|
||||
func TestShouldInheritParentMentions_AgentReviewDelegationDoesNotLeak(t *testing.T) {
|
||||
jPRCompletion := &db.Comment{
|
||||
AuthorType: "agent",
|
||||
AuthorID: testUUID(agentAssigneeID),
|
||||
Content: fmt.Sprintf("PR ready. [@GPT-Boy](mention://agent/%s) please review this.", otherAgentID),
|
||||
}
|
||||
if got := shouldInheritParentMentions(jPRCompletion, nil, "member"); got {
|
||||
t.Fatal("member follow-up to an agent's PR-review delegation must not inherit the @reviewer mention")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Combined trigger decision (simulates the full on_comment check)
|
||||
// -------------------------------------------------------------------
|
||||
@@ -267,4 +315,3 @@ func TestOnCommentTriggerDecision(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1347,6 +1347,22 @@ func (s *TaskService) notifyQuickCreateCompleted(ctx context.Context, task db.Ag
|
||||
s.notifyQuickCreateFailed(ctx, task, qc, "agent finished without creating an issue")
|
||||
return
|
||||
}
|
||||
|
||||
// Link the new issue back to this task so subsequent reads of the task
|
||||
// (Activity tab, Recent work, etc.) render it as a normal issue task
|
||||
// (kind = "direct") instead of staying on the "Creating issue" active-
|
||||
// wording label. Best-effort: a write failure here doesn't block the
|
||||
// inbox notification, which is the more important signal to the user.
|
||||
if err := s.Queries.LinkTaskToIssue(ctx, db.LinkTaskToIssueParams{
|
||||
ID: task.ID,
|
||||
IssueID: issue.ID,
|
||||
}); err != nil {
|
||||
slog.Warn("quick-create completion: link task→issue failed",
|
||||
"task_id", util.UUIDToString(task.ID),
|
||||
"issue_id", util.UUIDToString(issue.ID),
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
prefix := s.getIssuePrefix(workspaceID)
|
||||
identifier := fmt.Sprintf("%s-%d", prefix, issue.Number)
|
||||
details, _ := json.Marshal(map[string]any{
|
||||
|
||||
@@ -620,7 +620,7 @@ func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams
|
||||
const createQuickCreateTask = `-- name: CreateQuickCreateTask :one
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, context)
|
||||
VALUES ($1, $2, NULL, 'queued', $3, $4)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, last_heartbeat_at
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, last_heartbeat_at, trigger_summary
|
||||
`
|
||||
|
||||
type CreateQuickCreateTaskParams struct {
|
||||
@@ -665,6 +665,7 @@ func (q *Queries) CreateQuickCreateTask(ctx context.Context, arg CreateQuickCrea
|
||||
&i.ParentTaskID,
|
||||
&i.FailureReason,
|
||||
&i.LastHeartbeatAt,
|
||||
&i.TriggerSummary,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1144,6 +1145,27 @@ func (q *Queries) HasPendingTaskForIssueAndAgent(ctx context.Context, arg HasPen
|
||||
return has_pending, err
|
||||
}
|
||||
|
||||
const linkTaskToIssue = `-- name: LinkTaskToIssue :exec
|
||||
UPDATE agent_task_queue
|
||||
SET issue_id = $2
|
||||
WHERE id = $1 AND issue_id IS NULL
|
||||
`
|
||||
|
||||
type LinkTaskToIssueParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
}
|
||||
|
||||
// Attaches the issue a quick-create task produced back to the task row, once
|
||||
// the agent has finished and the issue exists. Guarded by `issue_id IS NULL`
|
||||
// so this never overwrites an issue id that was set at task creation (only
|
||||
// quick-create tasks land here unset). Fixes the activity row staying on
|
||||
// "Creating issue" forever after completion.
|
||||
func (q *Queries) LinkTaskToIssue(ctx context.Context, arg LinkTaskToIssueParams) error {
|
||||
_, err := q.db.Exec(ctx, linkTaskToIssue, arg.ID, arg.IssueID)
|
||||
return err
|
||||
}
|
||||
|
||||
const listActiveTasksByIssue = `-- name: ListActiveTasksByIssue :many
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, last_heartbeat_at, trigger_summary FROM agent_task_queue
|
||||
WHERE issue_id = $1 AND status IN ('dispatched', 'running')
|
||||
|
||||
@@ -77,6 +77,16 @@ INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority,
|
||||
VALUES ($1, $2, NULL, 'queued', $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: LinkTaskToIssue :exec
|
||||
-- Attaches the issue a quick-create task produced back to the task row, once
|
||||
-- the agent has finished and the issue exists. Guarded by `issue_id IS NULL`
|
||||
-- so this never overwrites an issue id that was set at task creation (only
|
||||
-- quick-create tasks land here unset). Fixes the activity row staying on
|
||||
-- "Creating issue" forever after completion.
|
||||
UPDATE agent_task_queue
|
||||
SET issue_id = $2
|
||||
WHERE id = $1 AND issue_id IS NULL;
|
||||
|
||||
-- name: CreateRetryTask :one
|
||||
-- Clones a parent task into a fresh queued attempt. Carries forward the
|
||||
-- agent's resume context (session_id/work_dir) so the child can continue
|
||||
|
||||
Reference in New Issue
Block a user