diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go index 930e589998..8b910844b2 100644 --- a/server/internal/daemon/prompt.go +++ b/server/internal/daemon/prompt.go @@ -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 { diff --git a/server/internal/daemon/prompt_test.go b/server/internal/daemon/prompt_test.go index 9ed01d7f7f..dc068a3c51 100644 --- a/server/internal/daemon/prompt_test.go +++ b/server/internal/daemon/prompt_test.go @@ -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