Files
multica/server/internal/handler/squad_briefing_test.go
Bohan Jiang 46a29b1ebb fix(squads): warn leader against double-triggering an agent (#3053)
Squad coordinators were both @mentioning an agent in the parent issue and
creating a todo child issue assigned to the same agent, causing the agent
to be triggered twice in parallel (mention dispatch + assignment dispatch).
The server has no cross-issue dedupe for this case — and adding one would
make @mention semantics context-dependent and unpredictable.

Fix is at the prompt level: tell the squad leader that a `todo` child
issue with an agent assignee already fires that agent, so they must pick
exactly one delegation path for any given piece of work — comment-based
@mention or todo child-issue assignment, never both.

Adds a focused regression test that locks in the new rule via narrow
substring checks (so harmless rewording stays free).

Fixes #3033

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 13:48:21 +08:00

363 lines
12 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TestSquadOperatingProtocolWarnsAgainstDualTrigger locks in the rule
// added for #3033: the protocol must tell the squad leader that a `todo`
// child issue with an agent assignee already fires that agent, so they
// must not also @mention the same agent on the parent issue for the
// same work. Asserts behavior, not exact wording — keep the substrings
// narrow so harmless rewording doesn't break the test.
func TestSquadOperatingProtocolWarnsAgainstDualTrigger(t *testing.T) {
compact := strings.Join(strings.Fields(squadOperatingProtocol), " ")
for _, want := range []string{
"--status todo` and an agent assignee already fires that agent automatically",
"Never both for the same work.",
} {
if !strings.Contains(compact, want) {
t.Errorf("expected squad operating protocol to contain %q\n--- protocol ---\n%s", want, squadOperatingProtocol)
}
}
}
// seedSquadForBriefing creates a squad with the seeded test agent as
// leader. Returns the loaded db.Squad and a cleanup-registered ID.
func seedSquadForBriefing(t *testing.T, leaderID string, name, instructions string) db.Squad {
t.Helper()
ctx := context.Background()
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id, instructions)
VALUES ($1, $2, '', $3, $4, $5)
RETURNING id
`, testWorkspaceID, name, leaderID, testUserID, instructions).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM squad WHERE id = $1`, squadID)
})
uuid := util.MustParseUUID(squadID)
squad, err := testHandler.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
ID: uuid,
WorkspaceID: util.MustParseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("load squad: %v", err)
}
return squad
}
func addAgentMember(t *testing.T, squadID pgtype.UUID, agentID, role string) {
t.Helper()
if _, err := testHandler.Queries.AddSquadMember(context.Background(), db.AddSquadMemberParams{
SquadID: squadID,
MemberType: "agent",
MemberID: util.MustParseUUID(agentID),
Role: role,
}); err != nil {
t.Fatalf("add agent member: %v", err)
}
}
func addHumanMember(t *testing.T, squadID pgtype.UUID, userID, role string) {
t.Helper()
if _, err := testHandler.Queries.AddSquadMember(context.Background(), db.AddSquadMemberParams{
SquadID: squadID,
MemberType: "member",
MemberID: util.MustParseUUID(userID),
Role: role,
}); err != nil {
t.Fatalf("add human member: %v", err)
}
}
// seededLeaderAgent loads the first seeded agent in the test workspace.
func seededLeaderAgent(t *testing.T) (id, name string) {
t.Helper()
if err := testPool.QueryRow(context.Background(), `
SELECT id, name FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1
`, testWorkspaceID).Scan(&id, &name); err != nil {
t.Fatalf("load seeded agent: %v", err)
}
return id, name
}
// seededHumanMember returns the (member_row_id, user_id, user_name) of the
// test fixture's human member in the workspace.
func seededHumanMember(t *testing.T) (memberID, userID, userName string) {
t.Helper()
if err := testPool.QueryRow(context.Background(), `
SELECT m.id, u.id, u.name
FROM member m JOIN "user" u ON u.id = m.user_id
WHERE m.workspace_id = $1 ORDER BY m.created_at ASC LIMIT 1
`, testWorkspaceID).Scan(&memberID, &userID, &userName); err != nil {
t.Fatalf("load seeded member: %v", err)
}
return
}
func TestBuildSquadLeaderBriefing_FullSquad(t *testing.T) {
ctx := context.Background()
leaderID, leaderName := seededLeaderAgent(t)
squad := seedSquadForBriefing(t, leaderID, "Full Squad", "Always write tests.")
helper1 := createHandlerTestAgent(t, "Helper One", []byte("[]"))
helper2 := createHandlerTestAgent(t, "Helper Two", []byte("[]"))
addAgentMember(t, squad.ID, helper1, "implementer")
addAgentMember(t, squad.ID, helper2, "")
memberRowID, userID, userName := seededHumanMember(t)
_ = memberRowID
addHumanMember(t, squad.ID, userID, "reviewer")
out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad)
for _, want := range []string{
"## Squad Operating Protocol",
"## Squad Roster",
"Leader (you):",
leaderName,
"## Squad Instructions (Full Squad)",
"Always write tests.",
"`[@Helper One](mention://agent/" + helper1 + ")`",
"`[@Helper Two](mention://agent/" + helper2 + ")`",
`role: "implementer"`,
`role: "reviewer"`,
"`[@" + userName + "](mention://member/" + userID + ")`",
} {
if !strings.Contains(out, want) {
t.Errorf("expected briefing to contain %q\n--- briefing ---\n%s", want, out)
}
}
// Helper Two has no role — must NOT render an empty role: "" segment.
if strings.Contains(out, `Helper Two — agent, role: ""`) {
t.Errorf("expected empty role to be omitted, got: %s", out)
}
}
func TestBuildSquadLeaderBriefing_OnlyLeader(t *testing.T) {
ctx := context.Background()
leaderID, _ := seededLeaderAgent(t)
squad := seedSquadForBriefing(t, leaderID, "Solo Squad", "")
out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad)
if !strings.Contains(out, "Members: (none — you are the only member of this squad)") {
t.Errorf("expected lone-leader fallback line, got:\n%s", out)
}
// No user instructions → no Squad Instructions section.
if strings.Contains(out, "## Squad Instructions") {
t.Errorf("expected no Squad Instructions section when empty, got:\n%s", out)
}
}
func TestBuildSquadLeaderBriefing_SkipsArchivedAgent(t *testing.T) {
ctx := context.Background()
leaderID, _ := seededLeaderAgent(t)
squad := seedSquadForBriefing(t, leaderID, "Archive Squad", "")
archived := createHandlerTestAgent(t, "Retired Bot", []byte("[]"))
addAgentMember(t, squad.ID, archived, "")
if _, err := testPool.Exec(ctx,
`UPDATE agent SET archived_at = now(), archived_by = $1 WHERE id = $2`,
testUserID, archived,
); err != nil {
t.Fatalf("archive agent: %v", err)
}
out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad)
if strings.Contains(out, "Retired Bot") {
t.Errorf("archived agent should not appear in roster:\n%s", out)
}
if strings.Contains(out, archived) {
t.Errorf("archived agent UUID should not appear in roster:\n%s", out)
}
}
// TestBuildSquadLeaderBriefing_MentionsRoundTrip is the contract test
// guaranteeing every emitted mention markdown string parses back through
// util.ParseMentions to its (type, id). If this ever breaks, the leader's
// dispatch comments will silently fail to trigger anyone.
func TestBuildSquadLeaderBriefing_MentionsRoundTrip(t *testing.T) {
ctx := context.Background()
leaderID, _ := seededLeaderAgent(t)
squad := seedSquadForBriefing(t, leaderID, "Mention Round Trip", "")
helper := createHandlerTestAgent(t, "Round Trip Bot", []byte("[]"))
addAgentMember(t, squad.ID, helper, "")
memberRowID, userID, _ := seededHumanMember(t)
_ = memberRowID
addHumanMember(t, squad.ID, userID, "")
out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad)
mentions := util.ParseMentions(out)
wantIDs := map[string]string{
leaderID: "agent",
helper: "agent",
userID: "member",
}
got := make(map[string]string, len(mentions))
for _, m := range mentions {
got[m.ID] = m.Type
}
for id, kind := range wantIDs {
if got[id] != kind {
t.Errorf("expected %s mention for id %s, got %q (all parsed: %#v)", kind, id, got[id], mentions)
}
}
}
// claimAndDecodeAgent runs ClaimTaskByRuntime for the given runtime and
// returns the agent block of the response. Fails the test on non-200.
func claimAndDecodeAgent(t *testing.T, runtimeID string) *TaskAgentData {
t.Helper()
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil, testWorkspaceID, "test-claim-squad-briefing")
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.ClaimTaskByRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ClaimTaskByRuntime: %d %s", w.Code, w.Body.String())
}
var resp struct {
Task *struct {
Agent *TaskAgentData `json:"agent"`
} `json:"task"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Task == nil || resp.Task.Agent == nil {
t.Fatalf("expected task.agent in response, got: %s", w.Body.String())
}
return resp.Task.Agent
}
// queueSquadIssueTaskFor creates an issue assigned to the squad and a queued
// task for the given (agentID, runtimeID). Returns the issue + task IDs.
func queueSquadIssueTaskFor(t *testing.T, squadID, agentID, runtimeID string, issueNumber int) (issueID, taskID string) {
t.Helper()
ctx := context.Background()
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_id, creator_type,
assignee_type, assignee_id, number, position
) VALUES ($1, 'Squad briefing claim test', 'todo', 'medium', $2, 'member',
'squad', $3, $4, 0)
RETURNING id
`, testWorkspaceID, testUserID, squadID, issueNumber).Scan(&issueID); err != nil {
t.Fatalf("create squad-assigned issue: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority)
VALUES ($1, $2, $3, 'queued', 0)
RETURNING id
`, agentID, runtimeID, issueID).Scan(&taskID); err != nil {
t.Fatalf("queue task: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
return
}
// TestClaimTask_LeaderGetsBriefing — when the squad leader claims a task on
// a squad-assigned issue, the response's agent.instructions must include
// the Operating Protocol + Roster + user instructions.
func TestClaimTask_LeaderGetsBriefing(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var leaderID, runtimeID string
if err := testPool.QueryRow(ctx,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID, &runtimeID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
squad := seedSquadForBriefing(t, leaderID, "Briefing Claim Squad", "Be terse.")
helper := createHandlerTestAgent(t, "Briefing Helper", []byte("[]"))
addAgentMember(t, squad.ID, helper, "implementer")
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), leaderID, runtimeID, 95001)
agent := claimAndDecodeAgent(t, runtimeID)
for _, want := range []string{
"## Squad Operating Protocol",
"## Squad Roster",
"Leader (you):",
"## Squad Instructions (Briefing Claim Squad)",
"Be terse.",
"`[@Briefing Helper](mention://agent/" + helper + ")`",
} {
if !strings.Contains(agent.Instructions, want) {
t.Errorf("expected agent.instructions to contain %q\n--- instructions ---\n%s", want, agent.Instructions)
}
}
}
// TestClaimTask_NonLeaderGetsNoBriefing — when a non-leader squad member
// claims a task on a squad-assigned issue, NO briefing is injected.
func TestClaimTask_NonLeaderGetsNoBriefing(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var leaderID string
if err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
squad := seedSquadForBriefing(t, leaderID, "Non-Leader Squad", "Squad guidance.")
// Create a second agent (NOT the leader) with its own runtime so the
// claim path picks its task without ambiguity.
helperID := createHandlerTestAgent(t, "Non Leader Helper", []byte("[]"))
addAgentMember(t, squad.ID, helperID, "")
var helperRuntime string
if err := testPool.QueryRow(ctx,
`SELECT runtime_id FROM agent WHERE id = $1`, helperID,
).Scan(&helperRuntime); err != nil {
t.Fatalf("get helper runtime: %v", err)
}
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), helperID, helperRuntime, 95002)
agent := claimAndDecodeAgent(t, helperRuntime)
for _, mustNot := range []string{
"Squad Operating Protocol",
"Squad Roster",
"Squad Instructions (Non-Leader Squad)",
} {
if strings.Contains(agent.Instructions, mustNot) {
t.Errorf("non-leader claim should NOT contain %q\n--- instructions ---\n%s", mustNot, agent.Instructions)
}
}
}
// Avoid "imported and not used: pgtype" if helpers above are the only users.
var _ pgtype.UUID