fix(daemon): reply under the specific mentioning comment per thread (MUL-4348 review nit #1)

Non-trigger threads previously replied under the thread root, while the
trigger's thread replied under the trigger comment — asymmetric, and it put
the answer at the top of the thread instead of next to the actual question
when the mention was a mid-thread reply. Reply under the NEWEST triggering
comment in each thread instead (inputs are chronological, so last-write-wins
per thread), making every thread consistent and nesting each answer beside
its question.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Eve
2026-07-10 14:58:03 +08:00
parent b1ff9f9edd
commit 55db58567e
2 changed files with 46 additions and 15 deletions

View File

@@ -241,11 +241,14 @@ func buildCommentPrompt(task Task, provider string) string {
// 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.
// The reply for each thread targets the NEWEST comment that triggered this run
// in that thread (coalesced comments arrive oldest-first and the trigger is the
// newest overall, so a simple last-write-wins yields the newest per thread).
// That nests the answer next to the most recent question in the thread rather
// than at the thread root, and makes the trigger's own thread (--parent =
// trigger comment) consistent with every other thread instead of a special
// case. 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
@@ -261,6 +264,9 @@ func commentReplyThreads(task Task) []execenv.ThreadReplyTarget {
order := make([]string, 0, len(task.CoalescedComments)+1)
parentByThread := make(map[string]string, len(task.CoalescedComments)+1)
// note records first-seen order but lets the newest comment win the reply
// target: inputs are chronological (coalesced oldest-first, trigger last),
// so the last write for a thread is its newest triggering comment.
note := func(threadID, parentID string) {
if _, ok := parentByThread[threadID]; !ok {
order = append(order, threadID)
@@ -268,16 +274,14 @@ func commentReplyThreads(task Task) []execenv.ThreadReplyTarget {
parentByThread[threadID] = parentID
}
// Coalesced (older) comments first: each replies under its own thread root.
// Coalesced (older) comments first: reply under the specific comment that
// mentioned the agent, not the thread root, so a mid-thread mention gets its
// answer next to the question.
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)
note(threadKey(cc.ThreadID, cc.ID), cc.ID)
}
// The newest trigger last: its thread replies under the trigger comment,
// overriding any coalesced comment that shared the trigger's thread.
// The newest trigger last: it always wins its own thread's reply target,
// overriding any earlier coalesced comment that shared the trigger's thread.
note(threadKey(task.TriggerThreadID, task.TriggerCommentID), task.TriggerCommentID)
if len(order) <= 1 {

View File

@@ -741,8 +741,11 @@ func TestCommentReplyThreadsGrouping(t *testing.T) {
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"])
// The other thread replies under the specific comment that mentioned the
// agent (a mid-thread reply), not the thread root — fixes the placement
// asymmetry from the first cut.
if got["thread-B"] != "c2" {
t.Errorf("other thread parent = %q, want c2 (the specific mentioning comment)", got["thread-B"])
}
})
@@ -752,6 +755,30 @@ func TestCommentReplyThreadsGrouping(t *testing.T) {
t.Fatalf("ordinary single-comment run must not fan out; got %+v", targets)
}
})
t.Run("non-trigger thread replies under its newest mention, not root", func(t *testing.T) {
// Two mid-thread mentions in thread-B (oldest c1, newer c2); the reply
// should target the newest specific comment (c2), not the root thread-B.
task := Task{
TriggerCommentID: "c9",
TriggerThreadID: "thread-A",
CoalescedComments: []CoalescedCommentData{
{ID: "c1", ThreadID: "thread-B", Content: "older mention", CreatedAt: "2026-07-10T01:00:00Z"},
{ID: "c2", ThreadID: "thread-B", Content: "newer mention", CreatedAt: "2026-07-10T02:00:00Z"},
},
}
targets := commentReplyThreads(task)
got := map[string]string{}
for _, tgt := range targets {
got[tgt.ThreadID] = tgt.ParentID
}
if got["thread-B"] != "c2" {
t.Errorf("thread-B parent = %q, want newest mention c2 (not root)", got["thread-B"])
}
if got["thread-A"] != "c9" {
t.Errorf("trigger thread parent = %q, want trigger c9", got["thread-A"])
}
})
}
// TestBuildCommentPromptCrossThreadFansOutReplies is the end-to-end prompt