Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
8f6c5d2ac9 fix(cli): gate squad assignee resolution behind an allowed-kinds set (MUL-2165)
The earlier MUL-2165 fix taught resolveAssignee / resolveAssigneeByID to also
return (squad, ...), but those helpers are shared. Project lead and issue
subscriber callers were still using them, and their target schemas reject
squads — project.lead_type has a DB CHECK constraint
(server/migrations/034_projects.up.sql:10) and the subscriber handler's
isWorkspaceEntity switch only knows member/agent
(server/internal/handler/handler.go:414). So
`multica project create --lead "<SquadName>"` and
`multica issue subscriber add --user "<SquadName>"` would resolve to
(squad, ...) and surface as a 500/403 server-side instead of a clean
CLI-side resolution error.

Thread an assigneeKinds set through the resolver and the pickAssigneeFromFlags
helper. Issue create/update/assign/list pass `issueAssigneeKinds` (all three);
project lead and subscriber pass `memberOrAgentKinds`. The squads fetch is
skipped entirely when not allowed, and the not-found / no-match error wording
adapts to the allowed kinds so it never mentions a type the caller cannot use.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 22:30:32 +08:00
Jiang Bohan
64555c9aa0 fix(cli): resolve squad assignees in issue create/update/assign (MUL-2165)
The CLI assignee resolver only searched workspace members and agents, so a
quick-create input like "assign to <SquadName>" silently fell through to
"Unrecognized assignee: <SquadName>" in the issue description — even though
squads are first-class assignees server-side and the prompt's whole point was
to route the work for the user.

Extend resolveAssignee / resolveAssigneeByID to also fetch /api/squads, teach
the actor display lookup to render squad names in table output, update the
quick-create prompt and runtime-config command listing to mention
`multica squad list` alongside members and agents, and lock in the new
behavior with tests.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 22:14:33 +08:00
7 changed files with 475 additions and 90 deletions

View File

@@ -442,8 +442,10 @@ type actorDisplayLookup struct {
type actorDisplayLookupState struct {
members map[string]string
agents map[string]string
squads map[string]string
membersLoaded bool
agentsLoaded bool
squadsLoaded bool
}
func loadActorDisplayLookup(ctx context.Context, client *cli.APIClient) actorDisplayLookup {
@@ -493,6 +495,25 @@ func (l actorDisplayLookup) loadAgents() {
}
}
func (l actorDisplayLookup) loadSquads() {
if l.state == nil || l.state.squadsLoaded {
return
}
l.state.squadsLoaded = true
l.state.squads = map[string]string{}
if l.client == nil || l.client.WorkspaceID == "" {
return
}
var squads []map[string]any
if err := l.client.GetJSON(l.ctx, "/api/squads", &squads); err == nil {
for _, s := range squads {
if id := strVal(s, "id"); id != "" {
l.state.squads[id] = strVal(s, "name")
}
}
}
}
func (l actorDisplayLookup) actor(actorType, id string) string {
if actorType == "" || id == "" {
return ""
@@ -512,6 +533,13 @@ func (l actorDisplayLookup) actor(actorType, id string) string {
return "agent:" + name
}
}
case "squad":
l.loadSquads()
if l.state != nil && l.state.squads != nil {
if name := l.state.squads[id]; name != "" {
return "squad:" + name
}
}
}
return actorType + ":" + id
}

View File

@@ -113,7 +113,7 @@ var issueUpdateCmd = &cobra.Command{
var issueAssignCmd = &cobra.Command{
Use: "assign <id>",
Short: "Assign an issue to a member or agent",
Short: "Assign an issue to a member, agent, or squad",
Args: exactArgs(1),
RunE: runIssueAssign,
}
@@ -242,8 +242,8 @@ func init() {
issueListCmd.Flags().Bool("full-id", false, "Show full UUIDs in table output")
issueListCmd.Flags().String("status", "", "Filter by status")
issueListCmd.Flags().String("priority", "", "Filter by priority")
issueListCmd.Flags().String("assignee", "", "Filter by assignee name (member or agent; fuzzy match)")
issueListCmd.Flags().String("assignee-id", "", "Filter by assignee UUID (mutually exclusive with --assignee)")
issueListCmd.Flags().String("assignee", "", "Filter by assignee name (member, agent, or squad; fuzzy match)")
issueListCmd.Flags().String("assignee-id", "", "Filter by assignee UUID — member, agent, or squad (mutually exclusive with --assignee)")
issueListCmd.Flags().String("project", "", "Filter by project ID")
issueListCmd.Flags().Int("limit", 50, "Maximum number of issues to return")
issueListCmd.Flags().Int("offset", 0, "Number of issues to skip (for pagination)")
@@ -258,8 +258,8 @@ func init() {
issueCreateCmd.Flags().String("description-file", "", "Read issue description from a UTF-8 file (preserves multi-line content verbatim; use this on Windows when stdin piping mangles non-ASCII bytes)")
issueCreateCmd.Flags().String("status", "", "Issue status")
issueCreateCmd.Flags().String("priority", "", "Issue priority")
issueCreateCmd.Flags().String("assignee", "", "Assignee name (member or agent; fuzzy match)")
issueCreateCmd.Flags().String("assignee-id", "", "Assignee UUID (mutually exclusive with --assignee)")
issueCreateCmd.Flags().String("assignee", "", "Assignee name (member, agent, or squad; fuzzy match)")
issueCreateCmd.Flags().String("assignee-id", "", "Assignee UUID — member, agent, or squad (mutually exclusive with --assignee)")
issueCreateCmd.Flags().String("parent", "", "Parent issue ID")
issueCreateCmd.Flags().String("project", "", "Project ID")
issueCreateCmd.Flags().String("due-date", "", "Due date (RFC3339 format)")
@@ -273,8 +273,8 @@ func init() {
issueUpdateCmd.Flags().String("description-file", "", "Read new description from a UTF-8 file (preserves multi-line content verbatim; use this on Windows when stdin piping mangles non-ASCII bytes)")
issueUpdateCmd.Flags().String("status", "", "New status")
issueUpdateCmd.Flags().String("priority", "", "New priority")
issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member or agent; fuzzy match)")
issueUpdateCmd.Flags().String("assignee-id", "", "New assignee UUID (mutually exclusive with --assignee)")
issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member, agent, or squad; fuzzy match)")
issueUpdateCmd.Flags().String("assignee-id", "", "New assignee UUID — member, agent, or squad (mutually exclusive with --assignee)")
issueUpdateCmd.Flags().String("project", "", "Project ID")
issueUpdateCmd.Flags().String("due-date", "", "New due date (RFC3339 format)")
issueUpdateCmd.Flags().String("parent", "", "Parent issue ID (use --parent \"\" to clear)")
@@ -284,8 +284,8 @@ func init() {
issueStatusCmd.Flags().String("output", "table", "Output format: table or json")
// issue assign
issueAssignCmd.Flags().String("to", "", "Assignee name (member or agent; fuzzy match)")
issueAssignCmd.Flags().String("to-id", "", "Assignee UUID (mutually exclusive with --to)")
issueAssignCmd.Flags().String("to", "", "Assignee name (member, agent, or squad; fuzzy match)")
issueAssignCmd.Flags().String("to-id", "", "Assignee UUID — member, agent, or squad (mutually exclusive with --to)")
issueAssignCmd.Flags().Bool("unassign", false, "Remove current assignee")
issueAssignCmd.Flags().String("output", "json", "Output format: table or json")
@@ -362,7 +362,7 @@ func runIssueList(cmd *cobra.Command, _ []string) error {
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
params.Set("limit", fmt.Sprintf("%d", v))
}
_, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
_, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id", issueAssigneeKinds)
if resolveErr != nil {
return fmt.Errorf("resolve assignee: %w", resolveErr)
}
@@ -554,7 +554,7 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error {
if v, _ := cmd.Flags().GetString("due-date"); v != "" {
body["due_date"] = v
}
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id", issueAssigneeKinds)
if resolveErr != nil {
return fmt.Errorf("resolve assignee: %w", resolveErr)
}
@@ -691,7 +691,7 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error {
body["due_date"] = v
}
if cmd.Flags().Changed("assignee") || cmd.Flags().Changed("assignee-id") {
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id", issueAssigneeKinds)
if resolveErr != nil {
return fmt.Errorf("resolve assignee: %w", resolveErr)
}
@@ -770,7 +770,7 @@ func runIssueAssign(cmd *cobra.Command, args []string) error {
body["assignee_type"] = nil
body["assignee_id"] = nil
} else {
aType, aID, _, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "to", "to-id")
aType, aID, _, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "to", "to-id", issueAssigneeKinds)
if resolveErr != nil {
return fmt.Errorf("resolve assignee: %w", resolveErr)
}
@@ -1286,7 +1286,7 @@ func runIssueSubscriberMutation(cmd *cobra.Command, issueID, action string) erro
body := map[string]any{}
userName, _ := cmd.Flags().GetString("user")
uType, uID, hasUser, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "user", "user-id")
uType, uID, hasUser, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "user", "user-id", memberOrAgentKinds)
if resolveErr != nil {
return fmt.Errorf("resolve user: %w", resolveErr)
}
@@ -1325,19 +1325,58 @@ func runIssueSubscriberMutation(cmd *cobra.Command, issueID, action string) erro
// ---------------------------------------------------------------------------
type assigneeMatch struct {
Type string // "member" or "agent"
ID string // user_id for members, agent id for agents
Type string // "member", "agent", or "squad"
ID string // user_id for members, agent id for agents, squad id for squads
Name string
}
func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (string, string, error) {
// assigneeKinds is the set of entity types a given flag is allowed to resolve
// to. Issue assignees accept all three (`issueAssigneeKinds`), while
// project lead and issue subscribers are member-or-agent only
// (`memberOrAgentKinds`) — the DB CHECK on `project.lead_type` and the
// `isWorkspaceEntity` switch in the subscriber handler both reject `squad`,
// so resolving to (squad, ...) for those callers would surface as a 500 /
// 403 instead of a clean CLI-side resolution error (MUL-2165 follow-up).
type assigneeKinds struct {
member, agent, squad bool
}
var (
issueAssigneeKinds = assigneeKinds{member: true, agent: true, squad: true}
memberOrAgentKinds = assigneeKinds{member: true, agent: true}
)
func (k assigneeKinds) describe() string {
parts := make([]string, 0, 3)
if k.member {
parts = append(parts, "member")
}
if k.agent {
parts = append(parts, "agent")
}
if k.squad {
parts = append(parts, "squad")
}
switch len(parts) {
case 0:
return "<none>"
case 1:
return parts[0]
case 2:
return parts[0] + " or " + parts[1]
default:
return strings.Join(parts[:len(parts)-1], ", ") + ", or " + parts[len(parts)-1]
}
}
func resolveAssignee(ctx context.Context, client *cli.APIClient, name string, kinds assigneeKinds) (string, string, error) {
if client.WorkspaceID == "" {
return "", "", fmt.Errorf("workspace ID is required to resolve assignees; use --workspace-id or set MULTICA_WORKSPACE_ID")
}
input := strings.TrimSpace(name)
if input == "" {
return "", "", fmt.Errorf("no member or agent found matching %q", name)
return "", "", fmt.Errorf("no %s found matching %q", kinds.describe(), name)
}
inputLower := strings.ToLower(input)
@@ -1349,6 +1388,7 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (s
// 3. substringMatches — preserves the existing partial-name UX.
var idMatches, exactMatches, substringMatches []assigneeMatch
var errs []error
var fetchAttempts int
classify := func(entityType, id, displayName string) {
match := assigneeMatch{Type: entityType, ID: id, Name: displayName}
@@ -1366,29 +1406,61 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (s
}
// Search members.
var members []map[string]any
if err := client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members); err != nil {
errs = append(errs, fmt.Errorf("fetch members: %w", err))
} else {
for _, m := range members {
classify("member", strVal(m, "user_id"), strVal(m, "name"))
if kinds.member {
fetchAttempts++
var members []map[string]any
if err := client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members); err != nil {
errs = append(errs, fmt.Errorf("fetch members: %w", err))
} else {
for _, m := range members {
classify("member", strVal(m, "user_id"), strVal(m, "name"))
}
}
}
// Search agents.
var agents []map[string]any
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
if err := client.GetJSON(ctx, agentPath, &agents); err != nil {
errs = append(errs, fmt.Errorf("fetch agents: %w", err))
} else {
for _, a := range agents {
classify("agent", strVal(a, "id"), strVal(a, "name"))
if kinds.agent {
fetchAttempts++
var agents []map[string]any
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
if err := client.GetJSON(ctx, agentPath, &agents); err != nil {
errs = append(errs, fmt.Errorf("fetch agents: %w", err))
} else {
for _, a := range agents {
classify("agent", strVal(a, "id"), strVal(a, "name"))
}
}
}
// If both fetches failed, report the errors instead of a misleading "not found".
if len(errs) == 2 {
return "", "", fmt.Errorf("failed to resolve assignee: %v; %v", errs[0], errs[1])
// Search squads. The platform allows issues to be assigned to a squad
// (the leader agent then coordinates delegation), so squad names must
// resolve here too for issue-assignee callers — otherwise a user saying
// "assign to <SquadName>" silently falls through and the autopilot
// prompt emits "Unrecognized assignee: <SquadName>" (MUL-2165). Callers
// whose target schema is member-or-agent only (project lead, subscriber)
// must opt out via `kinds.squad = false`.
if kinds.squad {
fetchAttempts++
var squads []map[string]any
if err := client.GetJSON(ctx, "/api/squads", &squads); err != nil {
errs = append(errs, fmt.Errorf("fetch squads: %w", err))
} else {
for _, s := range squads {
if strVal(s, "archived_at") != "" {
continue
}
classify("squad", strVal(s, "id"), strVal(s, "name"))
}
}
}
// If every fetch failed, report the errors instead of a misleading "not found".
if fetchAttempts > 0 && len(errs) == fetchAttempts {
msgs := make([]string, len(errs))
for i, e := range errs {
msgs[i] = e.Error()
}
return "", "", fmt.Errorf("failed to resolve assignee: %s", strings.Join(msgs, "; "))
}
for _, bucket := range [][]assigneeMatch{idMatches, exactMatches, substringMatches} {
@@ -1401,7 +1473,7 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (s
return "", "", ambiguousAssigneeError(input, bucket)
}
}
return "", "", fmt.Errorf("no member or agent found matching %q", input)
return "", "", fmt.Errorf("no %s found matching %q", kinds.describe(), input)
}
func ambiguousAssigneeError(input string, matches []assigneeMatch) error {
@@ -1413,12 +1485,13 @@ func ambiguousAssigneeError(input string, matches []assigneeMatch) error {
}
// resolveAssigneeByID strictly resolves a canonical UUID to (assignee_type,
// assignee_id) by looking it up against the workspace's members and agents.
// It is the deterministic counterpart to resolveAssignee: callers that already
// hold a UUID (e.g. agents reading IDs from `multica workspace members
// --output json`) should use this instead of round-tripping through name
// matching, which can be ambiguous in workspaces with overlapping names.
func resolveAssigneeByID(ctx context.Context, client *cli.APIClient, id string) (string, string, error) {
// assignee_id) by looking it up against the workspace's members, agents, and
// (when allowed) squads. It is the deterministic counterpart to
// resolveAssignee: callers that already hold a UUID (e.g. agents reading IDs
// from `multica workspace members --output json`) should use this instead of
// round-tripping through name matching, which can be ambiguous in workspaces
// with overlapping names.
func resolveAssigneeByID(ctx context.Context, client *cli.APIClient, id string, kinds assigneeKinds) (string, string, error) {
if client.WorkspaceID == "" {
return "", "", fmt.Errorf("workspace ID is required to resolve assignees; use --workspace-id or set MULTICA_WORKSPACE_ID")
}
@@ -1428,14 +1501,40 @@ func resolveAssigneeByID(ctx context.Context, client *cli.APIClient, id string)
}
var members []map[string]any
memberErr := client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members)
var memberErr error
if kinds.member {
memberErr = client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members)
}
var agents []map[string]any
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
agentErr := client.GetJSON(ctx, agentPath, &agents)
var agentErr error
if kinds.agent {
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
agentErr = client.GetJSON(ctx, agentPath, &agents)
}
if memberErr != nil && agentErr != nil {
return "", "", fmt.Errorf("failed to resolve assignee: %v; %v", memberErr, agentErr)
var squads []map[string]any
var squadErr error
if kinds.squad {
squadErr = client.GetJSON(ctx, "/api/squads", &squads)
}
allFailed := true
hasFetch := false
for _, pair := range []struct {
enabled bool
err error
}{{kinds.member, memberErr}, {kinds.agent, agentErr}, {kinds.squad, squadErr}} {
if !pair.enabled {
continue
}
hasFetch = true
if pair.err == nil {
allFailed = false
}
}
if hasFetch && allFailed {
return "", "", fmt.Errorf("failed to resolve assignee: %v; %v; %v", memberErr, agentErr, squadErr)
}
for _, m := range members {
@@ -1448,23 +1547,29 @@ func resolveAssigneeByID(ctx context.Context, client *cli.APIClient, id string)
return "agent", strVal(a, "id"), nil
}
}
for _, s := range squads {
if strings.EqualFold(strVal(s, "id"), input) {
return "squad", strVal(s, "id"), nil
}
}
return "", "", fmt.Errorf("no member or agent found with ID %q", input)
return "", "", fmt.Errorf("no %s found with ID %q", kinds.describe(), input)
}
// pickAssigneeFromFlags reads a (name-flag, id-flag) pair off cmd and resolves
// it to (assignee_type, assignee_id). The third return reports whether either
// flag was *explicitly set*; callers use it to decide whether to write
// `assignee_*` into the request body. The two flags are mutually exclusive —
// passing both is rejected up-front so a script that accidentally sets both
// never silently applies one over the other.
// it to (assignee_type, assignee_id), restricted to the entity types in
// kinds. The third return reports whether either flag was *explicitly set*;
// callers use it to decide whether to write `assignee_*` into the request
// body. The two flags are mutually exclusive — passing both is rejected
// up-front so a script that accidentally sets both never silently applies one
// over the other.
//
// Presence is detected via Flags().Changed (not value-emptiness): a script
// that interpolates an empty env var (`--assignee-id "$MAYBE_UUID"`) must
// fail loudly through resolveAssignee/resolveAssigneeByID rather than silently
// degrade to "no filter / unassigned / subscribe caller", which would defeat
// the strict-UUID guarantee the new flags exist for.
func pickAssigneeFromFlags(ctx context.Context, client *cli.APIClient, cmd *cobra.Command, nameFlag, idFlag string) (string, string, bool, error) {
func pickAssigneeFromFlags(ctx context.Context, client *cli.APIClient, cmd *cobra.Command, nameFlag, idFlag string, kinds assigneeKinds) (string, string, bool, error) {
nameSet := cmd.Flags().Changed(nameFlag)
idSet := cmd.Flags().Changed(idFlag)
if nameSet && idSet {
@@ -1472,7 +1577,7 @@ func pickAssigneeFromFlags(ctx context.Context, client *cli.APIClient, cmd *cobr
}
if idSet {
idVal, _ := cmd.Flags().GetString(idFlag)
t, i, err := resolveAssigneeByID(ctx, client, idVal)
t, i, err := resolveAssigneeByID(ctx, client, idVal, kinds)
if err != nil {
return "", "", true, err
}
@@ -1480,7 +1585,7 @@ func pickAssigneeFromFlags(ctx context.Context, client *cli.APIClient, cmd *cobr
}
if nameSet {
name, _ := cmd.Flags().GetString(nameFlag)
t, i, err := resolveAssignee(ctx, client, name)
t, i, err := resolveAssignee(ctx, client, name, kinds)
if err != nil {
return "", "", true, err
}

View File

@@ -207,8 +207,10 @@ func TestFormatAssignee(t *testing.T) {
state: &actorDisplayLookupState{
members: map[string]string{"abcdefgh-1234": "Alice"},
agents: map[string]string{"xyz": "CodeBot"},
squads: map[string]string{"sq-1": "Super Human"},
membersLoaded: true,
agentsLoaded: true,
squadsLoaded: true,
},
}
tests := []struct {
@@ -221,6 +223,7 @@ func TestFormatAssignee(t *testing.T) {
{"no id", map[string]any{"assignee_type": "member"}, ""},
{"member", map[string]any{"assignee_type": "member", "assignee_id": "abcdefgh-1234"}, "member:Alice"},
{"agent", map[string]any{"assignee_type": "agent", "assignee_id": "xyz"}, "agent:CodeBot"},
{"squad", map[string]any{"assignee_type": "squad", "assignee_id": "sq-1"}, "squad:Super Human"},
{"unknown fallback", map[string]any{"assignee_type": "agent", "assignee_id": "missing"}, "agent:missing"},
}
for _, tt := range tests {
@@ -572,6 +575,9 @@ func TestResolveAssignee(t *testing.T) {
agentsResp := []map[string]any{
{"id": "agent-3333", "name": "CodeBot"},
}
squadsResp := []map[string]any{
{"id": "squad-4444", "name": "Super Human"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@@ -579,6 +585,8 @@ func TestResolveAssignee(t *testing.T) {
json.NewEncoder(w).Encode(membersResp)
case "/api/agents":
json.NewEncoder(w).Encode(agentsResp)
case "/api/squads":
json.NewEncoder(w).Encode(squadsResp)
default:
http.NotFound(w, r)
}
@@ -589,7 +597,7 @@ func TestResolveAssignee(t *testing.T) {
ctx := context.Background()
t.Run("exact match member", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "Alice Smith")
aType, aID, err := resolveAssignee(ctx, client, "Alice Smith", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -599,7 +607,7 @@ func TestResolveAssignee(t *testing.T) {
})
t.Run("case-insensitive substring", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "bob")
aType, aID, err := resolveAssignee(ctx, client, "bob", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -609,7 +617,7 @@ func TestResolveAssignee(t *testing.T) {
})
t.Run("match agent", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "codebot")
aType, aID, err := resolveAssignee(ctx, client, "codebot", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -618,8 +626,31 @@ func TestResolveAssignee(t *testing.T) {
}
})
// MUL-2165: squad names must resolve to (squad, <id>) so the autopilot
// quick-create prompt can route work to a squad (e.g. "Super Human")
// instead of falling through to "Unrecognized assignee".
t.Run("match squad by exact name", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "Super Human", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "squad" || aID != "squad-4444" {
t.Errorf("got (%q, %q), want (squad, squad-4444)", aType, aID)
}
})
t.Run("match squad by case-insensitive substring", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "super", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "squad" || aID != "squad-4444" {
t.Errorf("got (%q, %q), want (squad, squad-4444)", aType, aID)
}
})
t.Run("no match", func(t *testing.T) {
_, _, err := resolveAssignee(ctx, client, "nobody")
_, _, err := resolveAssignee(ctx, client, "nobody", issueAssigneeKinds)
if err == nil {
t.Fatal("expected error for no match")
}
@@ -628,7 +659,7 @@ func TestResolveAssignee(t *testing.T) {
t.Run("ambiguous", func(t *testing.T) {
// Both "Alice Smith" and "Bob Jones" contain a space — but let's use a broader query
// "e" matches "Alice Smith" and "Bob Jones" and "CodeBot"
_, _, err := resolveAssignee(ctx, client, "o")
_, _, err := resolveAssignee(ctx, client, "o", issueAssigneeKinds)
if err == nil {
t.Fatal("expected error for ambiguous match")
}
@@ -639,13 +670,92 @@ func TestResolveAssignee(t *testing.T) {
t.Run("missing workspace ID", func(t *testing.T) {
noWSClient := cli.NewAPIClient(srv.URL, "", "test-token")
_, _, err := resolveAssignee(ctx, noWSClient, "alice")
_, _, err := resolveAssignee(ctx, noWSClient, "alice", issueAssigneeKinds)
if err == nil {
t.Fatal("expected error for missing workspace ID")
}
})
}
// TestResolveAssigneeRespectsKinds covers the MUL-2165 follow-up: callers
// whose target schema is member-or-agent-only (project.lead_type DB CHECK
// at server/migrations/034_projects.up.sql:10, and the subscriber handler's
// isWorkspaceEntity switch at server/internal/handler/handler.go:414) must
// be able to opt out of squad resolution. Without this, "--lead <SquadName>"
// would return (squad, ...) and the request would 500/403 server-side
// instead of failing with a clean CLI-side resolution error.
func TestResolveAssigneeRespectsKinds(t *testing.T) {
membersResp := []map[string]any{
{"user_id": "user-1111", "name": "Alice"},
}
agentsResp := []map[string]any{
{"id": "agent-3333", "name": "CodeBot"},
}
squadsResp := []map[string]any{
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
}
var squadsHits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/workspaces/ws-1/members":
json.NewEncoder(w).Encode(membersResp)
case "/api/agents":
json.NewEncoder(w).Encode(agentsResp)
case "/api/squads":
squadsHits++
json.NewEncoder(w).Encode(squadsResp)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
ctx := context.Background()
t.Run("memberOrAgentKinds skips the /api/squads fetch entirely", func(t *testing.T) {
before := squadsHits
_, _, _ = resolveAssignee(ctx, client, "Alice", memberOrAgentKinds)
if squadsHits != before {
t.Errorf("expected memberOrAgentKinds to skip /api/squads, but it was called %d time(s)", squadsHits-before)
}
})
t.Run("memberOrAgentKinds rejects a squad name with a member-or-agent-only error", func(t *testing.T) {
_, _, err := resolveAssignee(ctx, client, "Super Human", memberOrAgentKinds)
if err == nil {
t.Fatal("expected resolution error for squad name under memberOrAgentKinds")
}
if !strings.Contains(err.Error(), "no member or agent") {
t.Errorf("expected member-or-agent error wording, got: %v", err)
}
if strings.Contains(err.Error(), "squad") {
t.Errorf("error must not mention squad when squads are not allowed, got: %v", err)
}
})
t.Run("memberOrAgentKinds rejects a squad UUID via the strict resolver", func(t *testing.T) {
_, _, err := resolveAssigneeByID(ctx, client, "ccccccc1-2222-3333-4444-555555555555", memberOrAgentKinds)
if err == nil {
t.Fatal("expected not-found error for squad UUID under memberOrAgentKinds")
}
if !strings.Contains(err.Error(), "no member or agent") {
t.Errorf("expected member-or-agent error wording, got: %v", err)
}
})
t.Run("issueAssigneeKinds still resolves the same squad name (control)", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "Super Human", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "squad" || aID != "ccccccc1-2222-3333-4444-555555555555" {
t.Errorf("got (%q, %q), want (squad, ccccccc1-...)", aType, aID)
}
})
}
// TestResolveAssigneeExactMatchWins covers the substring-collision scenario from
// multica-ai/multica#1620: when one name is a substring of another (e.g.
// "reviewer" vs "peer-reviewer"), an exact match on the shorter name must
@@ -661,6 +771,8 @@ func TestResolveAssigneeExactMatchWins(t *testing.T) {
json.NewEncoder(w).Encode([]map[string]any{})
case "/api/agents":
json.NewEncoder(w).Encode(agentsResp)
case "/api/squads":
json.NewEncoder(w).Encode([]map[string]any{})
default:
http.NotFound(w, r)
}
@@ -671,7 +783,7 @@ func TestResolveAssigneeExactMatchWins(t *testing.T) {
ctx := context.Background()
t.Run("exact shorter name resolves to shorter agent", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "reviewer")
aType, aID, err := resolveAssignee(ctx, client, "reviewer", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -681,7 +793,7 @@ func TestResolveAssigneeExactMatchWins(t *testing.T) {
})
t.Run("exact longer name still resolves unambiguously", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "peer-reviewer")
aType, aID, err := resolveAssignee(ctx, client, "peer-reviewer", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -691,7 +803,7 @@ func TestResolveAssigneeExactMatchWins(t *testing.T) {
})
t.Run("exact match is case-insensitive and tolerates whitespace", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, " Reviewer ")
aType, aID, err := resolveAssignee(ctx, client, " Reviewer ", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -703,7 +815,7 @@ func TestResolveAssigneeExactMatchWins(t *testing.T) {
t.Run("substring-only input falls back and stays ambiguous", func(t *testing.T) {
// "review" matches both agents via substring and neither via exact name,
// so the existing ambiguity error is preserved.
_, _, err := resolveAssignee(ctx, client, "review")
_, _, err := resolveAssignee(ctx, client, "review", issueAssigneeKinds)
if err == nil {
t.Fatal("expected error for ambiguous substring match")
}
@@ -724,12 +836,17 @@ func TestResolveAssigneeByID(t *testing.T) {
{"id": "f656eab8-1111-1111-1111-111111111111", "name": "reviewer"},
{"id": "9b0ff9a2-2222-2222-2222-222222222222", "name": "peer-reviewer"},
}
squadsResp := []map[string]any{
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/workspaces/ws-1/members":
json.NewEncoder(w).Encode(membersResp)
case "/api/agents":
json.NewEncoder(w).Encode(agentsResp)
case "/api/squads":
json.NewEncoder(w).Encode(squadsResp)
default:
http.NotFound(w, r)
}
@@ -740,7 +857,7 @@ func TestResolveAssigneeByID(t *testing.T) {
ctx := context.Background()
t.Run("full UUID resolves agent", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "f656eab8-1111-1111-1111-111111111111")
aType, aID, err := resolveAssignee(ctx, client, "f656eab8-1111-1111-1111-111111111111", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -750,7 +867,7 @@ func TestResolveAssigneeByID(t *testing.T) {
})
t.Run("8-char ShortID resolves agent", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "f656eab8")
aType, aID, err := resolveAssignee(ctx, client, "f656eab8", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -760,7 +877,7 @@ func TestResolveAssigneeByID(t *testing.T) {
})
t.Run("uppercase ShortID still resolves", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "F656EAB8")
aType, aID, err := resolveAssignee(ctx, client, "F656EAB8", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -770,7 +887,7 @@ func TestResolveAssigneeByID(t *testing.T) {
})
t.Run("ShortID resolves a member", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "aaaaaaaa")
aType, aID, err := resolveAssignee(ctx, client, "aaaaaaaa", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -792,12 +909,17 @@ func TestResolveAssigneeByIDStrict(t *testing.T) {
{"id": "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", "name": "J"},
{"id": "192b9cca-2222-2222-2222-222222222222", "name": "Open Claw - J"},
}
squadsResp := []map[string]any{
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/workspaces/ws-1/members":
json.NewEncoder(w).Encode(membersResp)
case "/api/agents":
json.NewEncoder(w).Encode(agentsResp)
case "/api/squads":
json.NewEncoder(w).Encode(squadsResp)
default:
http.NotFound(w, r)
}
@@ -811,7 +933,7 @@ func TestResolveAssigneeByIDStrict(t *testing.T) {
// This is the MUL-1254 scenario: agent "J" is unreachable by name
// because every other agent has "J" in it. UUID lookup must
// deterministically pick the right one.
aType, aID, err := resolveAssigneeByID(ctx, client, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d")
aType, aID, err := resolveAssigneeByID(ctx, client, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -821,7 +943,7 @@ func TestResolveAssigneeByIDStrict(t *testing.T) {
})
t.Run("uppercase UUID is normalized", func(t *testing.T) {
aType, aID, err := resolveAssigneeByID(ctx, client, "5FB87AC7-23B5-4A7A-81FA-ED295A54545D")
aType, aID, err := resolveAssigneeByID(ctx, client, "5FB87AC7-23B5-4A7A-81FA-ED295A54545D", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -831,7 +953,7 @@ func TestResolveAssigneeByIDStrict(t *testing.T) {
})
t.Run("UUID resolves a member", func(t *testing.T) {
aType, aID, err := resolveAssigneeByID(ctx, client, "aaaaaaaa-1111-1111-1111-111111111111")
aType, aID, err := resolveAssigneeByID(ctx, client, "aaaaaaaa-1111-1111-1111-111111111111", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -840,8 +962,21 @@ func TestResolveAssigneeByIDStrict(t *testing.T) {
}
})
// MUL-2165: --assignee-id <squad-uuid> must resolve to (squad, <id>) so
// scripts that read the squad list and pin its UUID can assign work to a
// squad in a single deterministic call.
t.Run("UUID resolves a squad", func(t *testing.T) {
aType, aID, err := resolveAssigneeByID(ctx, client, "ccccccc1-2222-3333-4444-555555555555", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "squad" || aID != "ccccccc1-2222-3333-4444-555555555555" {
t.Errorf("got (%q, %q), want squad Super Human", aType, aID)
}
})
t.Run("non-UUID input is rejected without name fallback", func(t *testing.T) {
_, _, err := resolveAssigneeByID(ctx, client, "Alice")
_, _, err := resolveAssigneeByID(ctx, client, "Alice", issueAssigneeKinds)
if err == nil {
t.Fatal("expected error for non-UUID input")
}
@@ -851,25 +986,25 @@ func TestResolveAssigneeByIDStrict(t *testing.T) {
})
t.Run("UUID prefix (ShortID) is rejected — strict mode requires canonical form", func(t *testing.T) {
_, _, err := resolveAssigneeByID(ctx, client, "5fb87ac7")
_, _, err := resolveAssigneeByID(ctx, client, "5fb87ac7", issueAssigneeKinds)
if err == nil {
t.Fatal("expected error for ShortID")
}
})
t.Run("well-formed UUID with no matching entity errors", func(t *testing.T) {
_, _, err := resolveAssigneeByID(ctx, client, "deadbeef-1111-1111-1111-111111111111")
_, _, err := resolveAssigneeByID(ctx, client, "deadbeef-1111-1111-1111-111111111111", issueAssigneeKinds)
if err == nil {
t.Fatal("expected error for missing entity")
}
if !strings.Contains(err.Error(), "no member or agent") {
if !strings.Contains(err.Error(), "no member, agent, or squad") {
t.Errorf("expected not-found error, got: %v", err)
}
})
t.Run("missing workspace ID", func(t *testing.T) {
noWSClient := cli.NewAPIClient(srv.URL, "", "test-token")
_, _, err := resolveAssigneeByID(ctx, noWSClient, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d")
_, _, err := resolveAssigneeByID(ctx, noWSClient, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", issueAssigneeKinds)
if err == nil {
t.Fatal("expected error for missing workspace ID")
}
@@ -893,6 +1028,8 @@ func TestPickAssigneeFromFlags(t *testing.T) {
json.NewEncoder(w).Encode(membersResp)
case "/api/agents":
json.NewEncoder(w).Encode(agentsResp)
case "/api/squads":
json.NewEncoder(w).Encode([]map[string]any{})
default:
http.NotFound(w, r)
}
@@ -910,7 +1047,7 @@ func TestPickAssigneeFromFlags(t *testing.T) {
}
t.Run("neither flag set returns hasValue=false", func(t *testing.T) {
_, _, has, err := pickAssigneeFromFlags(ctx, client, newCmd(), "assignee", "assignee-id")
_, _, has, err := pickAssigneeFromFlags(ctx, client, newCmd(), "assignee", "assignee-id", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
@@ -922,7 +1059,7 @@ func TestPickAssigneeFromFlags(t *testing.T) {
t.Run("name flag uses fuzzy resolver", func(t *testing.T) {
c := newCmd()
_ = c.Flags().Set("assignee", "Alice")
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
if err != nil || !has || typ != "member" || id != "aaaaaaaa-1111-1111-1111-111111111111" {
t.Errorf("got (%q, %q, %v, %v), want Alice", typ, id, has, err)
}
@@ -931,7 +1068,7 @@ func TestPickAssigneeFromFlags(t *testing.T) {
t.Run("id flag uses strict resolver", func(t *testing.T) {
c := newCmd()
_ = c.Flags().Set("assignee-id", "5fb87ac7-23b5-4a7a-81fa-ed295a54545d")
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
if err != nil || !has || typ != "agent" || id != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
t.Errorf("got (%q, %q, %v, %v), want agent J", typ, id, has, err)
}
@@ -941,7 +1078,7 @@ func TestPickAssigneeFromFlags(t *testing.T) {
c := newCmd()
_ = c.Flags().Set("assignee", "Alice")
_ = c.Flags().Set("assignee-id", "5fb87ac7-23b5-4a7a-81fa-ed295a54545d")
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
if err == nil {
t.Fatal("expected mutually-exclusive error")
}
@@ -959,7 +1096,7 @@ func TestPickAssigneeFromFlags(t *testing.T) {
t.Run("explicit empty --assignee-id surfaces as UUID error, not silent skip", func(t *testing.T) {
c := newCmd()
_ = c.Flags().Set("assignee-id", "")
_, _, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
_, _, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
if err == nil {
t.Fatal("expected UUID error for explicit empty assignee-id")
}
@@ -974,7 +1111,7 @@ func TestPickAssigneeFromFlags(t *testing.T) {
t.Run("explicit empty --assignee surfaces as not-found, not silent skip", func(t *testing.T) {
c := newCmd()
_ = c.Flags().Set("assignee", "")
_, _, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
_, _, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
if err == nil {
t.Fatal("expected resolver error for explicit empty assignee")
}
@@ -987,13 +1124,106 @@ func TestPickAssigneeFromFlags(t *testing.T) {
c := newCmd()
_ = c.Flags().Set("assignee", "")
_ = c.Flags().Set("assignee-id", "")
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected mutually-exclusive error, got: %v", err)
}
})
}
// TestPickAssigneeFromFlagsMemberOrAgentKinds is the call-site regression
// for the MUL-2165 follow-up. Subscriber add/remove and project lead pass
// memberOrAgentKinds because their target schema rejects squads
// (subscriber: server/internal/handler/handler.go:414;
// project: server/migrations/034_projects.up.sql:10). Without this gating,
// `multica issue subscriber add --user "<SquadName>"` or
// `multica project create --lead "<SquadName>"` would resolve to
// (squad, ...) and surface as a 500/403 server-side instead of a clean
// CLI-side resolution error.
func TestPickAssigneeFromFlagsMemberOrAgentKinds(t *testing.T) {
membersResp := []map[string]any{
{"user_id": "aaaaaaaa-1111-1111-1111-111111111111", "name": "Alice"},
}
agentsResp := []map[string]any{
{"id": "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", "name": "J"},
}
squadsResp := []map[string]any{
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
}
var squadsHits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/workspaces/ws-1/members":
json.NewEncoder(w).Encode(membersResp)
case "/api/agents":
json.NewEncoder(w).Encode(agentsResp)
case "/api/squads":
squadsHits++
json.NewEncoder(w).Encode(squadsResp)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
ctx := context.Background()
newCmd := func(nameFlag, idFlag string) *cobra.Command {
c := &cobra.Command{Use: "test"}
c.Flags().String(nameFlag, "", "")
c.Flags().String(idFlag, "", "")
return c
}
t.Run("subscriber --user with a squad name is rejected without hitting /api/squads", func(t *testing.T) {
before := squadsHits
c := newCmd("user", "user-id")
_ = c.Flags().Set("user", "Super Human")
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "user", "user-id", memberOrAgentKinds)
if err == nil {
t.Fatal("expected resolution error for squad name under memberOrAgentKinds")
}
if !strings.Contains(err.Error(), "no member or agent") {
t.Errorf("expected member-or-agent error wording, got: %v", err)
}
if squadsHits != before {
t.Errorf("memberOrAgentKinds must NOT fetch /api/squads, but it was called %d time(s)", squadsHits-before)
}
})
t.Run("subscriber --user-id with a squad UUID is rejected", func(t *testing.T) {
c := newCmd("user", "user-id")
_ = c.Flags().Set("user-id", "ccccccc1-2222-3333-4444-555555555555")
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "user", "user-id", memberOrAgentKinds)
if err == nil {
t.Fatal("expected not-found error for squad UUID under memberOrAgentKinds")
}
if !strings.Contains(err.Error(), "no member or agent") {
t.Errorf("expected member-or-agent error wording, got: %v", err)
}
})
t.Run("project --lead with a member name still resolves cleanly", func(t *testing.T) {
c := newCmd("lead", "lead-id")
_ = c.Flags().Set("lead", "Alice")
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "lead", "lead-id", memberOrAgentKinds)
if err != nil || !has || typ != "member" || id != "aaaaaaaa-1111-1111-1111-111111111111" {
t.Errorf("got (%q, %q, %v, %v), want member Alice", typ, id, has, err)
}
})
t.Run("project --lead with an agent name still resolves cleanly", func(t *testing.T) {
c := newCmd("lead", "lead-id")
_ = c.Flags().Set("lead", "J")
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "lead", "lead-id", memberOrAgentKinds)
if err != nil || !has || typ != "agent" || id != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
t.Errorf("got (%q, %q, %v, %v), want agent J", typ, id, has, err)
}
})
}
func TestIssueSubscriberList(t *testing.T) {
subscribersResp := []map[string]any{
{
@@ -1094,6 +1324,9 @@ func TestIssueSubscriberMutationBody(t *testing.T) {
case "/api/agents":
json.NewEncoder(w).Encode(tt.agents)
return
case "/api/squads":
json.NewEncoder(w).Encode([]map[string]any{})
return
}
gotPath = r.URL.Path
if r.Method != http.MethodPost {
@@ -1109,7 +1342,7 @@ func TestIssueSubscriberMutationBody(t *testing.T) {
body := map[string]any{}
if tt.user != "" {
uType, uID, err := resolveAssignee(ctx, client, tt.user)
uType, uID, err := resolveAssignee(ctx, client, tt.user, issueAssigneeKinds)
if err != nil {
t.Fatalf("resolveAssignee: %v", err)
}

View File

@@ -286,7 +286,7 @@ func runProjectCreate(cmd *cobra.Command, _ []string) error {
body["icon"] = v
}
if v, _ := cmd.Flags().GetString("lead"); v != "" {
aType, aID, resolveErr := resolveAssignee(ctx, client, v)
aType, aID, resolveErr := resolveAssignee(ctx, client, v, memberOrAgentKinds)
if resolveErr != nil {
return fmt.Errorf("resolve lead: %w", resolveErr)
}
@@ -368,7 +368,7 @@ func runProjectUpdate(cmd *cobra.Command, args []string) error {
}
if cmd.Flags().Changed("lead") {
v, _ := cmd.Flags().GetString("lead")
aType, aID, resolveErr := resolveAssignee(ctx, client, v)
aType, aID, resolveErr := resolveAssignee(ctx, client, v, memberOrAgentKinds)
if resolveErr != nil {
return fmt.Errorf("resolve lead: %w", resolveErr)
}

View File

@@ -112,7 +112,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("**Use `--output json` for structured data.** Human table output now prints routable issue keys (for example `MUL-123`) and short UUID prefixes for workspace resources; use `--full-id` on list commands when you need canonical UUIDs.\n\n")
b.WriteString("### Read\n")
b.WriteString("- `multica issue get <id> --output json` — Get full issue details (title, description, status, priority, assignee)\n")
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X | --assignee-id <uuid>] [--limit N] [--offset N] [--full-id] [--output json]` — List issues in workspace (default limit: 50; table output uses routable issue keys; JSON output includes `total`, `has_more` — use offset to paginate when `has_more` is true). Prefer `--assignee-id <uuid>` when scripting from `multica workspace members --output json` / `multica agent list --output json`.\n")
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X | --assignee-id <uuid>] [--limit N] [--offset N] [--full-id] [--output json]` — List issues in workspace (default limit: 50; table output uses routable issue keys; JSON output includes `total`, `has_more` — use offset to paginate when `has_more` is true). Prefer `--assignee-id <uuid>` when scripting from `multica workspace members --output json` / `multica agent list --output json` / `multica squad list --output json`.\n")
b.WriteString("- `multica issue comment list <issue-id> [--since <RFC3339>] --output json` — List all comments on an issue (server caps at 2000 rows). Use `--since` for incremental polling.\n")
b.WriteString("- `multica issue label list <issue-id> --output json` — List labels currently attached to an issue\n")
b.WriteString("- `multica issue subscriber list <issue-id> --output json` — List members/agents subscribed to an issue\n")
@@ -120,6 +120,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica workspace get --output json` — Get workspace details and context\n")
b.WriteString("- `multica workspace members [workspace-id] --output json` — List workspace members (user IDs, names, roles)\n")
b.WriteString("- `multica agent list --output json` — List agents in workspace\n")
b.WriteString("- `multica squad list --output json` — List squads in workspace (squads are first-class assignees — assigning an issue to a squad routes it to the squad leader, who then delegates)\n")
b.WriteString("- `multica repo checkout <url> [--ref <branch-or-sha>]` — Check out a repository into the working directory (creates a git worktree with a dedicated branch; use `--ref` for review/QA on a specific branch, tag, or commit)\n")
b.WriteString("- `multica issue runs <issue-id> [--full-id] --output json` — List all execution runs for an issue (status, timestamps, errors); table task IDs are short prefixes unless `--full-id` is set\n")
b.WriteString("- `multica issue run-messages <task-id> [--issue <issue-id>] [--since <seq>] --output json` — List messages for a specific execution run; full task UUIDs work directly, copied short task prefixes must be scoped with `--issue <issue-id>`\n")
@@ -134,7 +135,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--status X] [--assignee X | --assignee-id <uuid>] [--parent <issue-id>] [--project <project-id>] [--due-date <RFC3339>] [--attachment <path>]` — Create a new issue. `--attachment` may be repeated to upload multiple files; labels and subscribers are not accepted here, attach them after create with the commands below.\n")
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X] [--status X] [--assignee X | --assignee-id <uuid>] [--parent <issue-id>] [--project <project-id>] [--due-date <RFC3339>]` — Update one or more issue fields in a single call. Use `--parent \"\"` to clear the parent.\n")
b.WriteString("- `multica issue status <id> <status>` — Shortcut for `issue update --status` when you only need to flip status (todo, in_progress, in_review, done, blocked, backlog, cancelled)\n")
b.WriteString("- `multica issue assign <id> --to <name>|--to-id <uuid>` — Assign an issue to a member or agent. `--to <name>` does fuzzy name matching; pass `--to-id <uuid>` (mutually exclusive with `--to`) to assign by canonical UUID, e.g. when names overlap. Use `--unassign` to clear the assignee.\n")
b.WriteString("- `multica issue assign <id> --to <name>|--to-id <uuid>` — Assign an issue to a member, agent, or squad. `--to <name>` does fuzzy name matching; pass `--to-id <uuid>` (mutually exclusive with `--to`) to assign by canonical UUID, e.g. when names overlap. Use `--unassign` to clear the assignee.\n")
b.WriteString("- `multica issue label add <issue-id> <label-id>` — Attach a label to an issue (look up the label id via `multica label list`)\n")
b.WriteString("- `multica issue label remove <issue-id> <label-id>` — Detach a label from an issue\n")
b.WriteString("- `multica issue subscriber add <issue-id> [--user <name>|--user-id <uuid>]` — Subscribe a member or agent to issue updates (defaults to the caller when neither flag is set; the two flags are mutually exclusive)\n")

View File

@@ -65,7 +65,7 @@ func buildQuickCreatePrompt(task Task) string {
// assignee
b.WriteString("- **assignee**:\n")
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace members --output json` (and `multica agent list --output json` if it might be an agent) and find the matching entity by display name. On a clean unambiguous match, prefer `--assignee-id <uuid>` using the `user_id` (member) or `id` (agent) from that JSON — UUID matching is exact and robust to name collisions in workspaces with overlapping names. `--assignee <name>` (fuzzy) is acceptable as a fallback when names are unambiguous. On no match or ambiguous match, do NOT pass either flag — instead append a final line to the description: `Unrecognized assignee: X`.\n")
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace members --output json`, `multica agent list --output json`, and `multica squad list --output json` and find the matching entity by display name. Squads are first-class assignees too — a squad name (e.g. \"Super Human\") routes work to the squad leader, who then delegates. On a clean unambiguous match, prefer `--assignee-id <uuid>` using the `user_id` (member) or `id` (agent or squad) from that JSON — UUID matching is exact and robust to name collisions in workspaces with overlapping names. `--assignee <name>` (fuzzy) is acceptable as a fallback when names are unambiguous. On no match or ambiguous match, do NOT pass either flag — instead append a final line to the description: `Unrecognized assignee: X`.\n")
agentID := ""
agentName := ""
if task.Agent != nil {

View File

@@ -41,6 +41,24 @@ func TestBuildQuickCreatePromptRules(t *testing.T) {
}
}
// TestBuildQuickCreatePromptAssigneeIncludesSquads locks in the MUL-2165
// fix: the assignee-resolution rules must tell the agent to consult the
// squad list alongside members and agents. Before this, a quick-create
// input like "assign to <SquadName>" silently fell through to
// "Unrecognized assignee" because squads were never queried.
func TestBuildQuickCreatePromptAssigneeIncludesSquads(t *testing.T) {
out := buildQuickCreatePrompt(Task{QuickCreatePrompt: "fix the login button color"})
mustContain := []string{
"multica squad list",
"Squads are first-class assignees",
}
for _, s := range mustContain {
if !strings.Contains(out, s) {
t.Errorf("buildQuickCreatePrompt assignee block missing %q\n--- output ---\n%s", s, out)
}
}
}
// TestBuildQuickCreatePromptProjectPinning verifies that when the user
// pins a project in the quick-create modal, the prompt instructs the agent
// to pass `--project <uuid>` exactly. Without this, the agent would re-read