mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user