Files
multica/server/internal/handler/squad_briefing_test.go
LinYushen 3692b6a862 fix(squad): inject leader briefing by task flag, not issue assignee (MUL-3730) (#4606)
* fix(squad): inject leader briefing by task flag, not issue assignee

Key squad-leader briefing injection off task.IsLeaderTask + task.SquadID
instead of issue.AssigneeType=='squad'. The old gate missed the most common
path — an @squad mention in a comment on an issue assigned to a plain agent
(MUL-3724) — so the leader booted with zero squad context and did the work
itself instead of orchestrating.

- migration 127: add agent_task_queue.squad_id (no FK) + partial index
- sqlc: CreateAgentTask stamps squad_id; CreateRetryTask inherits it
- service: thread squadID through EnqueueTaskForSquadLeader(+WithHandoff),
  enqueueMentionTask, and the rerun path; all 5 call sites pass the squad id
- daemon claim: unified injection keyed on leader-task + squad_id, with a
  defensive leader-identity re-check; quick-create block retained (it serves
  issue-less tasks and sets resp.SquadID/SquadName)
- briefing: strengthen leader Operating Protocol opening
- tests: claim-time injection (comment-mention/non-leader/null-squad),
  squad_id enqueue stamping, retry inheritance; existing fixture updated

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

* test+docs(squad): dangling squad_id regression + clarify quick-create path

Address review nits on #4606:
- Add TestClaim_LeaderTaskWithDanglingSquadID_NoBriefing: squad hard-deleted
  after enqueue leaves task.squad_id dangling (no FK); claim still 200 and
  skips injection via the err!=nil guard. This is the load-bearing contract
  for dropping the FK.
- Rewrite the daemon.go injection comment to state quick-create does NOT use
  the is_leader_task/squad_id columns — it routes squad via the context JSON
  branch (qc.SquadID) and must not be folded into the column-based path.

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

---------

Co-authored-by: 魏和尚 <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 16:01:33 +08:00

429 lines
15 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)
}
}
// assignSkillToAgent creates a workspace skill and attaches it to the agent.
func assignSkillToAgent(t *testing.T, agentID, skillName string) {
t.Helper()
ctx := context.Background()
var skillID string
if err := testPool.QueryRow(ctx, `
INSERT INTO skill (workspace_id, name, description, content, created_by)
VALUES ($1, $2, '', '', $3)
RETURNING id
`, testWorkspaceID, skillName, testUserID).Scan(&skillID); err != nil {
t.Fatalf("create skill %s: %v", skillName, err)
}
t.Cleanup(func() {
if _, err := testPool.Exec(ctx, `DELETE FROM agent_skill WHERE agent_id = $1 AND skill_id = $2`, agentID, skillID); err != nil {
t.Errorf("cleanup agent skill %s/%s: %v", agentID, skillName, err)
}
if _, err := testPool.Exec(ctx, `DELETE FROM skill WHERE id = $1`, skillID); err != nil {
t.Errorf("cleanup skill %s: %v", skillName, err)
}
})
if _, err := testPool.Exec(ctx,
`INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)`,
agentID, skillID,
); err != nil {
t.Fatalf("assign skill %s to agent: %v", skillName, err)
}
}
// TestBuildSquadLeaderBriefing_MemberSkillsInRoster locks in the delegation
// fix: an agent member's assigned skills appear in the leader roster so the
// leader can route by capability. Agents with no skills get an explicit
// marker; human members never carry a skills segment.
func TestBuildSquadLeaderBriefing_MemberSkillsInRoster(t *testing.T) {
ctx := context.Background()
leaderID, _ := seededLeaderAgent(t)
squad := seedSquadForBriefing(t, leaderID, "Skilled Squad", "")
skilled := createHandlerTestAgent(t, "Skilled Bot", []byte("[]"))
addAgentMember(t, squad.ID, skilled, "backend")
// ListAgentSkillNamesByAgentIDs orders by name ASC → "polars" before "stat…".
assignSkillToAgent(t, skilled, "polars")
assignSkillToAgent(t, skilled, "statistical-analysis")
plain := createHandlerTestAgent(t, "Plain Bot", []byte("[]"))
addAgentMember(t, squad.ID, plain, "")
memberRowID, userID, userName := seededHumanMember(t)
_ = memberRowID
addHumanMember(t, squad.ID, userID, "reviewer")
out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad)
if !strings.Contains(out, "skills: polars, statistical-analysis") {
t.Errorf("expected skilled member skills in roster, got:\n%s", out)
}
if !strings.Contains(out, "Plain Bot — agent — no skills assigned") {
t.Errorf("expected no-skills marker for skill-less agent, got:\n%s", out)
}
if strings.Contains(out, userName+" — member (human), role: \"reviewer\" — skills:") ||
strings.Contains(out, userName+" — member (human), role: \"reviewer\" — no skills") {
t.Errorf("human member must not render a skills segment, got:\n%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, is_leader_task, squad_id)
VALUES ($1, $2, $3, 'queued', 0,
($1::uuid = (SELECT leader_id FROM squad WHERE id = $4::uuid)),
$4::uuid)
RETURNING id
`, agentID, runtimeID, issueID, squadID).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