diff --git a/server/internal/daemon/execenv/reply_instructions.go b/server/internal/daemon/execenv/reply_instructions.go index 58cdb3fb1a..9af2aca97f 100644 --- a/server/internal/daemon/execenv/reply_instructions.go +++ b/server/internal/daemon/execenv/reply_instructions.go @@ -199,3 +199,66 @@ func buildCommentReplyInstructionsSlim(provider, issueID, triggerCommentID strin issueID, triggerCommentID, ) } + +// ThreadReplyTarget is one root-thread group a coalesced run must answer. +// ThreadID labels the conversation (its root comment id); ParentID is the exact +// `--parent` the agent must pass so its reply lands inside that thread. +type ThreadReplyTarget struct { + ThreadID string + ParentID string +} + +// BuildMultiThreadCommentReplyInstructions is the reply cookbook for a run whose +// coalesced comments span MORE THAN ONE root thread (MUL-4348). It deliberately +// overrides the general "post exactly one comment per run" guidance for this +// specific run: three unrelated questions raised in three separate threads must +// land as three in-thread answers, not one merged blob posted under a single +// thread (or as a stray root comment). +// +// The grouping is computed server-side, so same-thread follow-ups never reach +// here — they collapse to a single target upstream and take the ordinary +// single-parent path. That is why the agent is told, unconditionally, to post +// exactly one reply per listed thread and never more than one reply in the same +// thread: the "multiple @mentions in one thread" case is already consolidated +// before this instruction is emitted, so a per-thread fan-out cannot split it. +// +// Returns "" for fewer than two targets; callers keep the single-parent path. +func BuildMultiThreadCommentReplyInstructions(issueID string, targets []ThreadReplyTarget) string { + if issueID == "" || len(targets) < 2 { + return "" + } + + targetLines := "" + for _, tgt := range targets { + targetLines += fmt.Sprintf("- thread %s → reply with `--parent %s`\n", tgt.ThreadID, tgt.ParentID) + } + + // File-hygiene guidance mirrors buildCommentReplyInstructionsSlim, but the + // agent must use a DISTINCT body file per thread so one reply's content can + // never leak into another's. + var cookbook string + if runtimeGOOS == "windows" { + cookbook = fmt.Sprintf( + "For EACH thread above, write that reply's body to its own UTF-8 file with your file-write tool, then post it with `--content-file` (do NOT use inline `--content` or a `--content-stdin` HEREDOC — see ## Comment Formatting above for why). Use one file per thread and remove each after posting:\n\n"+ + " multica issue comment add %s --parent --content-file ./reply-1.md\n"+ + " Remove-Item ./reply-1.md\n\n", + issueID, + ) + } else { + cookbook = fmt.Sprintf( + "For EACH thread above, write that reply's body to its own UTF-8 file with your file-write tool, then post it with `--content-file` (do NOT use inline `--content` or a `--content-stdin` HEREDOC — see ## Comment Formatting above for why). Use one file per thread and remove each after posting:\n\n"+ + " multica issue comment add %s --parent --content-file ./reply-1.md\n"+ + " rm ./reply-1.md\n\n", + issueID, + ) + } + + return fmt.Sprintf( + "This run coalesced comments from %d DISTINCT threads. Post ONE reply per thread — %d replies in total — each threaded under its own conversation. This OVERRIDES the general \"post exactly one comment per run\" guidance: for THIS run multiple replies are required and correct. Do NOT merge separate threads into a single comment, and do NOT post more than one reply in the same thread.\n\n"+ + "Reply targets (use the exact `--parent` for each — do NOT reuse `--parent` values from previous turns in this session):\n"+ + "%s\n"+ + "%s"+ + "Do NOT write literal `\\n` escapes to simulate line breaks; each file preserves real newlines.\n", + len(targets), len(targets), targetLines, cookbook, + ) +} diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go index c683702d64..930e589998 100644 --- a/server/internal/daemon/prompt.go +++ b/server/internal/daemon/prompt.go @@ -220,10 +220,76 @@ func buildCommentPrompt(task Task, provider string) string { } else { fmt.Fprintf(&b, "Read the discussion: `multica issue comment list %s --recent 10 --output json` (resolved threads come back folded — `--full` to expand).\n\n", task.IssueID) } - b.WriteString(execenv.BuildCommentReplyInstructions(provider, task.IssueID, task.TriggerCommentID)) + // Reply routing. When this run coalesced comments spanning MORE THAN ONE + // root thread, answer each thread in its own thread instead of dumping one + // merged comment (MUL-4348). Same-thread follow-ups collapse to a single + // group upstream, so they keep the ordinary single-parent path below and can + // never be split into duplicate replies. + if targets := commentReplyThreads(task); len(targets) >= 2 { + b.WriteString(execenv.BuildMultiThreadCommentReplyInstructions(task.IssueID, targets)) + } else { + b.WriteString(execenv.BuildCommentReplyInstructions(provider, task.IssueID, task.TriggerCommentID)) + } return b.String() } +// commentReplyThreads groups this run's trigger + coalesced comments by their +// root thread, in first-seen order (coalesced comments oldest-first, the newest +// trigger last). A run that coalesced several @mentions from the SAME thread +// yields a single group, so same-thread follow-ups get exactly one consolidated +// reply and can never be split into duplicates; comments from different root +// threads yield one group each so the agent replies inside each thread instead +// of merging them into one blob (MUL-4348). +// +// The trigger's own thread replies under the trigger comment itself, matching +// the long-standing single-thread behaviour (--parent = trigger comment id); +// every other thread replies under its root comment id. Returns nil when there +// is no trigger or only a single distinct thread — the caller then keeps the +// existing single-parent reply path unchanged. +func commentReplyThreads(task Task) []execenv.ThreadReplyTarget { + if task.TriggerCommentID == "" { + return nil + } + // A comment with no explicit thread id is a root comment: it is its own + // thread, so fall back to the comment id itself as the thread key. + threadKey := func(threadID, commentID string) string { + if threadID != "" { + return threadID + } + return commentID + } + + order := make([]string, 0, len(task.CoalescedComments)+1) + parentByThread := make(map[string]string, len(task.CoalescedComments)+1) + note := func(threadID, parentID string) { + if _, ok := parentByThread[threadID]; !ok { + order = append(order, threadID) + } + parentByThread[threadID] = parentID + } + + // Coalesced (older) comments first: each replies under its own thread root. + for _, cc := range task.CoalescedComments { + tid := threadKey(cc.ThreadID, cc.ID) + if _, ok := parentByThread[tid]; ok { + continue // thread already represented; keep its first parent + } + note(tid, tid) + } + // The newest trigger last: its thread replies under the trigger comment, + // overriding any coalesced comment that shared the trigger's thread. + note(threadKey(task.TriggerThreadID, task.TriggerCommentID), task.TriggerCommentID) + + if len(order) <= 1 { + return nil + } + targets := make([]execenv.ThreadReplyTarget, 0, len(order)) + for _, tid := range order { + targets = append(targets, execenv.ThreadReplyTarget{ThreadID: tid, ParentID: parentByThread[tid]}) + } + return targets +} + // buildChatPrompt constructs a prompt for interactive chat tasks. func buildChatPrompt(task Task) string { // Proactive self-introduction: the agent was just created and is opening the diff --git a/server/internal/daemon/prompt_test.go b/server/internal/daemon/prompt_test.go index 9b60ede261..9ed01d7f7f 100644 --- a/server/internal/daemon/prompt_test.go +++ b/server/internal/daemon/prompt_test.go @@ -676,3 +676,147 @@ func TestBuildCommentPromptCoalescedIDsOnlyFallback(t *testing.T) { } } } + +// TestCommentReplyThreadsGrouping pins the server-side grouping that drives +// per-thread reply routing (MUL-4348). The invariants: +// - three distinct root threads → three targets, each replying to its own +// thread (the trigger's thread replies under the trigger comment itself). +// - multiple coalesced follow-ups in the SAME thread → a single group, so the +// caller keeps the single-parent path and the reply is never duplicated. +// - no coalesced comments (ordinary single comment) → nil. +func TestCommentReplyThreadsGrouping(t *testing.T) { + t.Run("three distinct root threads fan out", func(t *testing.T) { + task := Task{ + TriggerCommentID: "c3", + TriggerThreadID: "c3", // a root comment is its own thread + CoalescedComments: []CoalescedCommentData{ + {ID: "c1", ThreadID: "c1", Content: "背一首宋词"}, + {ID: "c2", ThreadID: "c2", Content: "毛泽东诗词背一首"}, + }, + } + targets := commentReplyThreads(task) + if len(targets) != 3 { + t.Fatalf("want 3 targets, got %d: %+v", len(targets), targets) + } + wantParent := map[string]string{"c1": "c1", "c2": "c2", "c3": "c3"} + for _, tgt := range targets { + if wantParent[tgt.ThreadID] != tgt.ParentID { + t.Errorf("thread %s: parent = %s, want %s", tgt.ThreadID, tgt.ParentID, wantParent[tgt.ThreadID]) + } + } + }) + + t.Run("same-thread follow-ups consolidate to a single group", func(t *testing.T) { + task := Task{ + TriggerCommentID: "c3", + TriggerThreadID: "thread-A", + CoalescedComments: []CoalescedCommentData{ + {ID: "c1", ThreadID: "thread-A", Content: "追问 1"}, + {ID: "c2", ThreadID: "thread-A", Content: "追问 2"}, + }, + } + if targets := commentReplyThreads(task); targets != nil { + t.Fatalf("same-thread follow-ups must not fan out; got %d targets: %+v", len(targets), targets) + } + }) + + t.Run("mixed: trigger thread plus one other thread", func(t *testing.T) { + task := Task{ + TriggerCommentID: "c3", + TriggerThreadID: "thread-A", + CoalescedComments: []CoalescedCommentData{ + {ID: "c1", ThreadID: "thread-A", Content: "same-thread follow-up"}, + {ID: "c2", ThreadID: "thread-B", Content: "other thread"}, + }, + } + targets := commentReplyThreads(task) + if len(targets) != 2 { + t.Fatalf("want 2 targets (thread-A, thread-B), got %d: %+v", len(targets), targets) + } + got := map[string]string{} + for _, tgt := range targets { + got[tgt.ThreadID] = tgt.ParentID + } + // The trigger's own thread replies under the trigger comment, not its root. + if got["thread-A"] != "c3" { + t.Errorf("trigger thread parent = %q, want c3 (the trigger comment)", got["thread-A"]) + } + if got["thread-B"] != "thread-B" { + t.Errorf("other thread parent = %q, want its root thread-B", got["thread-B"]) + } + }) + + t.Run("no coalesced comments → nil", func(t *testing.T) { + task := Task{TriggerCommentID: "c1", TriggerThreadID: "thread-A"} + if targets := commentReplyThreads(task); targets != nil { + t.Fatalf("ordinary single-comment run must not fan out; got %+v", targets) + } + }) +} + +// TestBuildCommentPromptCrossThreadFansOutReplies is the end-to-end prompt +// assertion for the screenshot scenario: three separate root comments coalesced +// into one run must produce a per-thread reply plan (one reply per thread), +// explicitly overriding the "one comment per run" rule, instead of the single +// --parent cookbook. +func TestBuildCommentPromptCrossThreadFansOutReplies(t *testing.T) { + task := Task{ + IssueID: "issue-xthread-2", + TriggerCommentID: "c3", + TriggerThreadID: "c3", + TriggerCommentContent: "莎士比亚名言来一句", + TriggerAuthorType: "member", + CoalescedCommentIDs: []string{"c1", "c2"}, + CoalescedComments: []CoalescedCommentData{ + {ID: "c1", ThreadID: "c1", AuthorType: "member", AuthorName: "Yushen", Content: "背一首宋词", CreatedAt: "2026-07-10T01:00:00Z"}, + {ID: "c2", ThreadID: "c2", AuthorType: "member", AuthorName: "Yushen", Content: "毛泽东诗词背一首", CreatedAt: "2026-07-10T02:00:00Z"}, + }, + } + out := BuildPrompt(task, "claude") + + for _, want := range []string{ + "3 DISTINCT threads", + "Post ONE reply per thread", + "OVERRIDES", + "--parent c1", + "--parent c2", + "--parent c3", + } { + if !strings.Contains(out, want) { + t.Errorf("cross-thread prompt must contain %q, got:\n%s", want, out) + } + } + // The single-parent cookbook must NOT be used when fanning out. + if strings.Contains(out, "always use the trigger comment ID below") { + t.Errorf("cross-thread prompt must not emit the single-parent reply cookbook, got:\n%s", out) + } +} + +// TestBuildCommentPromptSameThreadKeepsSingleReply pins the hard requirement: +// multiple @mentions coalesced from the SAME thread must keep the ordinary +// single-parent reply path (one reply, under the trigger comment) and must NOT +// trigger the multi-thread fan-out. +func TestBuildCommentPromptSameThreadKeepsSingleReply(t *testing.T) { + task := Task{ + IssueID: "issue-samethread-1", + TriggerCommentID: "c3", + TriggerThreadID: "thread-A", + TriggerCommentContent: "追问 3", + TriggerAuthorType: "member", + CoalescedCommentIDs: []string{"c1", "c2"}, + CoalescedComments: []CoalescedCommentData{ + {ID: "c1", ThreadID: "thread-A", AuthorType: "member", AuthorName: "Yushen", Content: "追问 1", CreatedAt: "2026-07-10T01:00:00Z"}, + {ID: "c2", ThreadID: "thread-A", AuthorType: "member", AuthorName: "Yushen", Content: "追问 2", CreatedAt: "2026-07-10T02:00:00Z"}, + }, + } + out := BuildPrompt(task, "claude") + + if strings.Contains(out, "DISTINCT threads") { + t.Errorf("same-thread coalescing must not emit the multi-thread fan-out block, got:\n%s", out) + } + // The single-parent cookbook is used, threading the one reply under the + // trigger comment. + if !strings.Contains(out, "--parent c3 --content-file ./reply.md") { + t.Errorf("same-thread run must keep the single --parent=trigger reply cookbook, got:\n%s", out) + } +}