Files
multica/server/internal/handler/comment_reply_authz_handler_test.go
Bohan Jiang d38da27ed4 MUL-5619: fix(cli): surface the server's 409 message instead of the generic conflict template (#6267)
* fix(cli): show the server's conflict message instead of the generic 409 template

Every 409 this API returns is a deterministic refusal that names its own fix
("a skill with this name already exists", "set parent_id (--parent) to <id>").
The CLI replaced all of them with a template that says the opposite — that the
state changed underneath you and you should re-fetch and retry. Agents took the
retry hint literally: GH #6264 reports 15+ identical retries over 10 minutes
followed by hours spent chasing an optimistic-concurrency theory that never
existed, and GH #5948 is a second user misdiagnosing the same way. MUL-4417 had
already written the useful message server-side; it just never reached anyone.

Route 409 through the same server-message extraction 400/422 already uses, so
roughly forty hand-written conflict messages across skills, agents, runtimes,
labels, projects and comments become visible by default. A body we cannot
recognize still falls back to the template, so this never dumps a raw response.

extractServerMessage now prefers prose over a bare identifier, because a few
endpoints put a stable code in "error" and the sentence in "message".

MUL-5619

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): stop telling a wrong --parent that it posted a top-level comment

The reply guard returns one message for two different mistakes. A resumed
session that carries a previous turn's --parent forward (GH #6264) did not ask
for a top-level comment, but is told it did — which sends it looking for a
new-thread opt-in (GH #5383) instead of correcting the parent it already passed.

Split the copy: name the rejected parent when one was supplied, and keep the
existing top-level wording for the parentless case. Both still point at the
trigger comment to use.

MUL-5619

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtime): return 500, not a 409 echo, when the update store fails

InitiateUpdate answered every UpdateStore.Create failure with a 409 carrying
err.Error(). The in-memory store only ever returns errUpdateInProgress, so this
looked safe — but the Redis store also wraps infrastructure failures as
"reserve active update: <dial error>" and "persist update request: <error>".

Surfacing 409 bodies in the CLI turns that into a user-visible leak of internal
addresses, and labels an outage as a conflict the caller could fix by retrying.
Classify instead: errUpdateInProgress keeps its 409 and its actionable message,
everything else is logged and answered with a 500 and fixed copy.

Also pins the prose-over-machine-code preference for validation bodies, which
the shared extractor applies to 400/422 as well as 409. Only the issue-table
endpoints are shaped that way and none is reachable from the CLI today, but the
change is intentional and should fail loudly if reverted.

MUL-5619

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 13:35:04 +08:00

146 lines
5.4 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestCreateComment_TriggeredTaskRejectsTopLevelComment exercises the full
// CreateComment handler path (not just taskCoversReplyParent) for the trap
// reported in MUL-4417 / GH #5266: a comment-triggered task that posts a
// parentless, top-level comment on its own issue is rejected with a 409 whose
// message names the trigger comment and states that top-level comments are not
// allowed. Pinning the message here keeps it from silently drifting away from
// the behavior the CLI help now documents.
func TestCreateComment_TriggeredTaskRejectsTopLevelComment(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newRunningSquadLeaderTaskFixture(t)
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+fx.IssueID+"/comments", map[string]any{
"content": "dispatching a squad from this task",
})
r = withURLParam(r, "id", fx.IssueID)
r.Header.Set("X-Agent-ID", fx.LeaderID)
r.Header.Set("X-Task-ID", fx.TaskID)
testHandler.CreateComment(w, r)
if w.Code != http.StatusConflict {
t.Fatalf("CreateComment top-level: expected 409, got %d: %s", w.Code, w.Body.String())
}
if got := countAgentCommentsForIssue(t, fx.IssueID, fx.LeaderID); got != 0 {
t.Fatalf("expected rejected top-level comment not to be stored, got %d", got)
}
var body map[string]any
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode error response: %v", err)
}
msg, _ := body["error"].(string)
// Pin the three semantic pieces without locking the exact wording: why it
// was rejected, the comment to reply under, and the actionable fix. The last
// one guards against the guidance being dropped in a future edit.
for _, want := range []string{
"top-level comments", // reason
fx.TriggerCommentID, // the comment to reply under
"parent_id (--parent)", // actionable fix
} {
if !strings.Contains(msg, want) {
t.Fatalf("409 message should contain %q, got %q", want, msg)
}
}
}
// TestCreateComment_TriggeredTaskAllowsReplyUnderTrigger is the positive half:
// the same task replying under its trigger comment succeeds, proving the guard
// rejects only the top-level case and does not lock the whole issue for
// comments (MUL-4417 / GH #5266).
func TestCreateComment_TriggeredTaskAllowsReplyUnderTrigger(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newRunningSquadLeaderTaskFixture(t)
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+fx.IssueID+"/comments", map[string]any{
"content": "replying under the trigger comment",
"parent_id": fx.TriggerCommentID,
})
r = withURLParam(r, "id", fx.IssueID)
r.Header.Set("X-Agent-ID", fx.LeaderID)
r.Header.Set("X-Task-ID", fx.TaskID)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment reply-under-trigger: expected 201, got %d: %s", w.Code, w.Body.String())
}
}
// TestCreateComment_TriggeredTaskRejectsForeignParent covers the resumed-session
// drift in GH #6264: the task passes a --parent that is a real comment on its
// own issue but not one this run was given to answer. The refusal must name
// both the parent it rejected and the parent to use — and must NOT say a
// top-level comment was attempted, since that wording sent agents looking for a
// new-thread opt-in instead of correcting the --parent they already passed.
func TestCreateComment_TriggeredTaskRejectsForeignParent(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newRunningSquadLeaderTaskFixture(t)
// Must be a real comment on the same issue: a nonexistent id, or one from
// another issue, is refused earlier with a 400 and never reaches this guard.
var foreignParentID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type)
VALUES ($1, $2, 'member', $3, 'an earlier thread this task never owned', 'comment')
RETURNING id
`, fx.IssueID, testWorkspaceID, testUserID).Scan(&foreignParentID); err != nil {
t.Fatalf("create foreign parent comment: %v", err)
}
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+fx.IssueID+"/comments", map[string]any{
"content": "posting under a parent carried over from a previous turn",
"parent_id": foreignParentID,
})
r = withURLParam(r, "id", fx.IssueID)
r.Header.Set("X-Agent-ID", fx.LeaderID)
r.Header.Set("X-Task-ID", fx.TaskID)
testHandler.CreateComment(w, r)
if w.Code != http.StatusConflict {
t.Fatalf("CreateComment foreign parent: expected 409, got %d: %s", w.Code, w.Body.String())
}
if got := countAgentCommentsForIssue(t, fx.IssueID, fx.LeaderID); got != 0 {
t.Fatalf("expected rejected comment not to be stored, got %d", got)
}
var body map[string]any
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode error response: %v", err)
}
msg, _ := body["error"].(string)
for _, want := range []string{
foreignParentID, // the parent that was refused
fx.TriggerCommentID, // the parent to use instead
"parent_id (--parent)", // actionable fix
} {
if !strings.Contains(msg, want) {
t.Fatalf("409 message should contain %q, got %q", want, msg)
}
}
if strings.Contains(msg, "top-level") {
t.Fatalf("409 message must not claim a top-level comment was attempted, got %q", msg)
}
}