Files
multica/server/internal/handler/squad_briefing_test.go
YikaJ fcb370edfd fix(squads): align parent issue status with agent-managed model (MUL-5156) (#5758)
* fix(squads): align parent issue status ownership with agent-managed model

Squad leaders now open assigned parents to in_progress on first dispatch, keep them there while members work, and only move to in_review when overall completion is confirmed—matching ordinary agent status semantics without server auto-flips.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(squads): scope leader parent-status ownership to squad-assigned issues

Review follow-up on the parent-status alignment change. Two boundaries were
left ambiguous, both of which the change's own premise ("don't make the model
resolve a contradiction in the prompt") argues should be closed in-place.

1. Status ownership was granted too widely. The leader briefing is injected on
   every leader path, keyed off is_leader_task — including the MUL-3724 case
   where an issue is assigned to a plain agent and a squad was merely
   @mentioned for help. The unqualified "Own the parent issue status"
   responsibility therefore also reached guest leaders, who could push another
   assignee's in-flight issue to in_review.

   buildSquadLeaderBriefing now takes ownsIssueStatus and selects between two
   variants of responsibility 6: the grant only when the issue's assignee is
   this squad, otherwise an explicit "do NOT change this issue's status".
   Quick-create passes false — no issue exists on that turn. Everything else in
   the protocol (roster, delegation, evaluation) is unchanged for both.

2. The comment-triggered path still contradicted itself. The runtime brief says
   "do not change status unless the comment explicitly asks", and a member's
   delivery comment never asks. Squads that dispatch by @mention create no
   child issues, so no child-done system comment exists to carry the explicit
   ask either — that parent would sit in in_progress indefinitely.
   writeWorkflowComment now names the protocol responsibility as the one
   exception for squad leaders. It is safe to state unconditionally because the
   grant is only present in the instructions when the server decided this squad
   owns the issue; for a guest leader the sentence has nothing to activate.

Tests: two composition tests assemble both halves (server-side briefing +
daemon-side CLAUDE.md) for one real scenario each, since asserting each half
alone is how the original contradiction shipped. Plus execenv coverage that the
carve-out appears only for leaders and the ordinary-agent rule stays absolute.

Docs and the multica-squads skill / source map record the narrower contract.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-22 20:33:15 +08:00

500 lines
18 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"
)
// TestSquadOperatingProtocolOwnsParentStatus locks the parent-issue status
// contract: first dispatch moves todo→in_progress and stays there; only a
// later confirmation of overall completion may advance to in_review; done is
// left to humans / integrations.
func TestSquadOperatingProtocolOwnsParentStatus(t *testing.T) {
protocol := squadOperatingProtocolFor(true)
compact := strings.Join(strings.Fields(protocol), " ")
for _, want := range []string{
"Own the parent issue status",
"move the parent to `in_progress`",
"successful dispatch is not completion",
"multica issue status <issue-id> in_review",
"Leave `done` to a human reviewer",
} {
if !strings.Contains(compact, want) {
t.Errorf("expected squad operating protocol to contain %q\n--- protocol ---\n%s", want, protocol)
}
}
}
// TestSquadOperatingProtocolScopesParentStatusOwnership is the guard for the
// MUL-5156 review finding: the briefing is injected on every leader path,
// including an @squad mention on an issue assigned to someone else. Status
// ownership must not ride along — a guest leader gets an explicit prohibition
// instead of the grant, so the model never has to infer the boundary.
func TestSquadOperatingProtocolScopesParentStatusOwnership(t *testing.T) {
guest := squadOperatingProtocolFor(false)
compactGuest := strings.Join(strings.Fields(guest), " ")
for _, want := range []string{
"Do NOT change this issue's status",
"not assigned to your squad",
"never run `multica issue status` on it",
} {
if !strings.Contains(compactGuest, want) {
t.Errorf("expected guest-leader protocol to contain %q\n--- protocol ---\n%s", want, guest)
}
}
// The grant must be entirely absent — not merely qualified.
for _, forbidden := range []string{
"Own the parent issue status",
"multica issue status <issue-id> in_review",
} {
if strings.Contains(compactGuest, forbidden) {
t.Errorf("guest-leader protocol must not contain status grant %q\n--- protocol ---\n%s", forbidden, guest)
}
}
// Everything that is not the status responsibility is identical, so a
// guest leader still coordinates, delegates, and records activity.
owner := squadOperatingProtocolFor(true)
for _, shared := range []string{
"## Squad Operating Protocol",
"Delegate by @mention",
"Record your evaluation",
"Stop after dispatching",
"Never both for the same work.",
} {
if !strings.Contains(owner, shared) || !strings.Contains(guest, shared) {
t.Errorf("expected %q in both protocol variants", shared)
}
}
// The IsSquadLeader marker the daemon greps for must survive both ways,
// or a guest leader silently loses its no_action / silent-exit behavior.
if !strings.Contains(guest, "## Squad Operating Protocol") {
t.Error("guest-leader protocol lost the header the daemon keys IsSquadLeader off")
}
}
// 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) {
protocol := squadOperatingProtocolFor(true)
compact := strings.Join(strings.Fields(protocol), " ")
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, protocol)
}
}
}
// 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, true)
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, true)
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, true)
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, true)
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, true)
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