mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
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>
363 lines
12 KiB
Go
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
|