Compare commits

..

6 Commits

Author SHA1 Message Date
Jiang Bohan
4ed5f3ce50 fix(quick-create): bound dialog height + scroll editor when content overflows
Pasting a screenshot into the agent-create prompt expanded the editor
unbounded, which dragged DialogContent past the viewport since the agent
mode className had no max-height. Manual mode was unaffected because
manualDialogContentClass pins `!h-96`.

- Cap agent-mode DialogContent at `!max-h-[80vh]` (width stays
  `!max-w-xl`); short prompts still render compact, tall content stops
  at 80% of the viewport.
- Switch the editor wrapper to `flex-1 min-h-[140px] overflow-y-auto`
  so it absorbs the remaining vertical space inside the now-bounded
  DialogContent and scrolls internally instead of pushing the dialog.
2026-04-29 16:40:16 +08:00
Bohan Jiang
40a984c997 feat(quick-create): default assignee to picker agent when user didn't name one (#1836)
* feat(quick-create): default assignee to picker agent when user didn't name one

The quick-create prompt previously told the agent to OMIT --assignee
when the user's input didn't name a person. That left almost every
quick-created issue unassigned, which doesn't match user intent — the
user opened quick-create with a specific agent picked, so that agent
is the obvious owner.

Both prompt surfaces (BuildPrompt for the dispatched message, plus
the workflow block in injected CLAUDE.md / AGENTS.md) now instruct
the agent: if the input doesn't name an assignee, pass
`--assignee "<your name>"`. The picker agent's name is interpolated
into the prompt at task-build time so the agent has a literal value
to use rather than guessing its own name. The "explicitly named
assignee → resolve via members" branch is unchanged.

* refactor(execenv): drop duplicated quick-create field rules from CLAUDE.md/AGENTS.md

The quick-create field rules (title / description / priority / assignee
fallback / project / status) lived in two places — the per-turn user
message built by BuildPrompt, and the workflow block injected into
CLAUDE.md / AGENTS.md by buildMetaSkillContent. Same content, two
sources, easy to update one and forget the other (the assignee-default
change in this PR had to touch both).

Quick-create is one-shot, so the per-turn user message is always
present and is the natural single source of truth. The injected
file's quick-create section now keeps only the hard guardrails:
"do exactly one issue create, no issue get / status / comment add,
exit on CLI error". Those guardrails stay in BOTH surfaces because
they're the safety net for providers that don't propagate the user
message into resumed-session context.

renderQuickCreateContext (issue_context.md) was already
guardrails-only — no change needed there.
2026-04-29 16:08:08 +08:00
Bohan Jiang
9ccaf18479 fix(comment): don't inherit parent @mentions from agent-authored roots (#1833)
* fix(comment): don't inherit parent @mentions when parent author is an agent

When an agent posts a comment that @mentions another agent (typically a
one-shot delegation, e.g. a PR-completion comment that asks a reviewer
agent to review), member follow-up replies in the same thread were
auto-inheriting that mention and re-triggering the reviewer on every
plain question. Same root cause: the inheritance branch only required
the reply to have no mentions, not that the parent was member-authored.

Tighten the guard: only inherit when the parent (thread root) is
authored by a member. Member-rooted threads still inherit so a member
who started by @mentioning an agent can keep replying without re-typing.
Agent-authored roots are treated as one-shot — explicit @mentions in
later comments still trigger normally.

Extracted the decision into shouldInheritParentMentions for direct unit
testing, and added an end-to-end regression
(TestMemberReplyToAgentRootDoesNotInheritParentMentions) that reproduces
MUL-1535: J posts a PR completion @mentioning Reviewer; a member's
plain follow-up must not re-enqueue Reviewer.

* chore(comment): gofmt trigger_test.go
2026-04-29 15:54:24 +08:00
Bohan Jiang
866b901943 fix(desktop): use themed Toaster wrapper instead of bare sonner (#1835)
#1831 fixed the Toaster wrapper to follow next-themes' resolvedTheme,
but the desktop renderer was importing `Toaster` directly from `sonner`
and never going through the wrapper. So the success toast still rendered
light on a dark UI. Switch the import to `@multica/ui/components/ui/sonner`
to match the web app and pick up the theme + icon overrides.
2026-04-29 15:53:51 +08:00
Bohan Jiang
9baa72cc68 fix: polish quick-create UX (kind labeling, dark toast, placeholder) (#1831)
* fix: polish quick-create UX (kind labeling, dark toast, placeholder)

Three small fixes shaken out from using the agent-create flow:

- AgentTaskResponse now carries a `kind` discriminator
  ("comment" | "autopilot" | "chat" | "quick_create" | "direct"), computed
  from the existing FK shape with no extra DB access. The Activity row
  uses it to label quick-create tasks as "Creating issue" instead of
  falling through to the generic "Untracked" — once the agent finishes
  and the new issue is linked, the row transitions to the normal
  identifier+title display.

- Sonner Toaster reads `resolvedTheme` instead of `theme`, so toasts
  follow the actual dark/light state. Forwarding "system" let sonner
  pick its own answer from `prefers-color-scheme`, which in the Electron
  renderer can disagree with next-themes' `html.dark` class — the toast
  rendered light on a dark UI.

- Agent-create placeholder rephrased to a more conversational example
  with a project reference: "let Bohan fix the inbox loading slowness
  in the Web project". Drops the priority hint (priority isn't widely
  used) and matches how people actually instruct the agent.

* fix(quick-create): link new issue back to task on completion

Addresses the review on PR #1831: completed quick-create tasks were
left with issue_id=NULL forever, so the activity row stayed on
"Creating issue" instead of transitioning to the normal MUL-XXX +
title rendering once the agent finished.

- Server: notifyQuickCreateCompleted now writes the resolved issue id
  back to agent_task_queue.issue_id via a new LinkTaskToIssue query
  (guarded by `issue_id IS NULL` so it only ever fills the unset
  quick-create case). Best-effort: a write failure logs but doesn't
  block the inbox notification.
- Frontend: defensive wording fallback — kind=quick_create rows in
  terminal status (completed/failed/cancelled) now render as
  "Quick create" instead of the active "Creating issue" label,
  covering rows whose link write failed or whose agent never
  produced an issue at all.
2026-04-29 15:40:59 +08:00
Bohan Jiang
576304519b docs(execenv): expose label/subscriber CLI + complete create/update flag list (#1830)
The agent-facing CLAUDE.md/AGENTS.md injected by InjectRuntimeConfig was
missing every doorway to non-core issue properties:

- `multica issue label list/add/remove` — the only way to label a newly
  created issue from the agent. Without it, agents either give up
  ("no command for that, please add it manually") or hallucinate flag
  names like `multica issue create --label foo` and fail.
- `multica issue subscriber list/add/remove` — same story for the
  subscribe-on-behalf flow.
- `multica label list/create` — agents need to discover existing label
  ids before they can attach one (we don't auto-create labels here).
- `issue create` flag list dropped `--project`, `--due-date`,
  `--attachment` even though the CLI has supported them for a while.
- `issue update` flag list dropped `--status`, `--assignee`,
  `--project`, `--due-date`, `--parent`, leaving agents thinking they
  could only edit title/description/priority via update.

Also splits `issue status` from `issue update` in the doc so the agent
sees the shortcut, and notes the `issue create` body intentionally
does NOT accept labels/subscribers (use the post-create commands).
2026-04-29 15:29:03 +08:00
15 changed files with 311 additions and 43 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 != "" {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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