feat(daemon): route coalesced replies per root thread (MUL-4348)

When a busy agent coalesces multiple @mentions into one run, the run used
a single --parent (the newest trigger), so questions raised in separate
root threads were answered in one merged comment while the other threads
were left unanswered.

Group the trigger + coalesced comments by root thread server-side in the
prompt builder (commentReplyThreads). When the run spans >=2 distinct
threads, emit a per-thread reply plan (BuildMultiThreadCommentReplyInstructions)
that instructs one reply per thread with the exact --parent, explicitly
overriding the general 'one comment per run' rule. Multiple @mentions from
the SAME thread collapse to a single group upstream, so same-thread
follow-ups keep the ordinary single --parent=trigger path and can never be
split into duplicate replies. Single-thread / non-coalesced runs are
unchanged.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Eve
2026-07-10 14:36:49 +08:00
parent f7ca045fb1
commit f6b56e28ae
3 changed files with 274 additions and 1 deletions

View File

@@ -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 <thread-parent-from-the-list-above> --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 <thread-parent-from-the-list-above> --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,
)
}

View File

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

View File

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