Compare commits

...

3 Commits

Author SHA1 Message Date
Jiang Bohan
a7cadb49d8 docs(cli): cover --assignee-id / --to-id in user docs and quick-create prompt
Follow-up to the --*-id flag rollout: surface the new flags everywhere the
old ones are documented so users (and agents) can discover them.

- assigning-issues.{mdx,zh.mdx}: the page explicitly calls out the
  duplicate-name footgun ("first one listed wins, so rename before
  assigning") — replace that workaround with a --to-id <uuid> example
- cloud-quickstart.{mdx,zh.mdx}: add a --to-id hint after the substring-
  match callout so first-time users learn about the strict path
- internal/daemon/prompt.go (quick-create injected prompt):
  - default-to-self: pass --assignee-id <task.Agent.ID> instead of
    --assignee <name>; the picker agent's UUID is already in scope and
    UUID matching is unambiguous in workspaces with overlapping agent
    names (J / Cursor - J / Pi - J etc.)
  - user-named: tell the agent to prefer --assignee-id <uuid> using the
    user_id/id from the JSON it already fetched; --assignee <name> stays
    a fallback for unambiguous workspaces

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 13:11:02 +08:00
Jiang Bohan
bd00736c37 fix(cli): detect assignee flag presence via Changed, not value-emptiness
`pickAssigneeFromFlags` previously branched on `flag value != ""`, so
explicitly passing an empty UUID silently routed through the "no flag set"
path:

  multica issue list --assignee-id ""        # listed every issue
  multica issue create --assignee-id ""      # created an unassigned issue
  multica issue subscriber add --user-id ""  # subscribed the caller

This is exactly the failure mode the strict-UUID flag was added to prevent —
a script interpolating `--assignee-id "$MAYBE_UUID"` against a missing env
var should fail loudly, not silently degrade to a different operation.

Switch the picker (and the assign-command top-level guard) to use
`Flags().Changed`, so an explicit empty value reaches `resolveAssigneeByID`
/ `resolveAssignee` and surfaces a clear "expected a canonical UUID" /
"no member or agent found matching" error.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 13:02:52 +08:00
Jiang Bohan
7973fdb4c0 feat(cli): add --assignee-id / --to-id / --user-id for unambiguous targeting
`multica issue {create,update,list}`, `issue assign`, and `issue subscriber
{add,remove}` accepted only fuzzy name matching, which fails in workspaces
where one user's name is a substring of another (e.g. agent "J" vs
"Cursor - J" / member "Jiayuan"). #1642 added UUID acceptance through the
existing flags, but there was still no explicit path that signals "this is a
UUID, not a name" — important for scripts that read IDs from
`multica workspace members --output json`.

Adds an `-id`-suffixed counterpart for every assignee-taking flag:

- `issue list`     : --assignee-id
- `issue create`   : --assignee-id
- `issue update`   : --assignee-id
- `issue assign`   : --to-id
- `issue subscriber {add,remove}` : --user-id

The new flags route through `resolveAssigneeByID`, a strict resolver that
requires a canonical UUID and fails with a clear error when the entity is
not in the workspace (no name fallback). A shared `pickAssigneeFromFlags`
helper enforces mutual exclusion between the name and id flags so a script
that accidentally sets both never silently applies one over the other.

Refs MUL-1254.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 12:54:17 +08:00
10 changed files with 371 additions and 49 deletions

View File

@@ -305,10 +305,11 @@ multica workspace members <workspace-id>
multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
### Get Issue
@@ -321,9 +322,10 @@ multica issue get <id> --output json
```bash
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
### Update Issue
@@ -335,9 +337,12 @@ multica issue update <id> --title "New title" --priority urgent
```bash
multica issue assign <id> --to "Lambda"
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue assign <id> --unassign
```
Pass `--to-id <uuid>` to assign by canonical UUID (mutually exclusive with `--to`); useful when names overlap across members and agents.
### Change Status
```bash

View File

@@ -32,9 +32,10 @@ The command-line equivalent:
```bash
multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` takes a member username or an agent name. Giving agents memorable names makes this step smoother — if multiple agents share a name in the workspace, the first one listed wins, so rename before assigning.
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace members --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
Unassign:

View File

@@ -32,9 +32,10 @@ import { Callout } from "fumadocs-ui/components/callout";
```bash
multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` 后跟成员用户名或智能体名字。给智能体起个好记的名字会让这一步顺很多——工作区里重名的会按列出顺序选第一个,建议先改名再分配
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`UUID 来自 `multica workspace members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥
取消分配:

View File

@@ -221,10 +221,11 @@ multica workspace members <workspace-id>
multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
### Get Issue
@@ -237,9 +238,10 @@ multica issue get <id> --output json
```bash
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID例如来自 `multica workspace members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
### Update Issue
@@ -251,9 +253,12 @@ multica issue update <id> --title "New title" --priority urgent
```bash
multica issue assign <id> --to "Lambda"
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue assign <id> --unassign
```
`--to-id <uuid>`(与 `--to` 互斥)按 UUID 精确分配;适合重名 workspace 下脚本化场景。
### Change Status
```bash

View File

@@ -99,7 +99,7 @@ Assign the issue to the agent you just created — click its avatar in the web U
multica issue assign MUL-1 --to my-agent-name
```
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it.
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace members --output json`.
**What happens next from the daemon**:

View File

@@ -99,7 +99,7 @@ multica issue create --title "给 README 加一段 ASCII 架构图"
multica issue assign MUL-1 --to my-agent-name
```
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
**接下来守护进程会**

View File

@@ -208,7 +208,8 @@ func init() {
issueListCmd.Flags().String("output", "table", "Output format: table or json")
issueListCmd.Flags().String("status", "", "Filter by status")
issueListCmd.Flags().String("priority", "", "Filter by priority")
issueListCmd.Flags().String("assignee", "", "Filter by assignee name")
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("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)")
@@ -222,7 +223,8 @@ func init() {
issueCreateCmd.Flags().Bool("description-stdin", false, "Read issue description from stdin (preserves multi-line content verbatim)")
issueCreateCmd.Flags().String("status", "", "Issue status")
issueCreateCmd.Flags().String("priority", "", "Issue priority")
issueCreateCmd.Flags().String("assignee", "", "Assignee name (member or agent)")
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("parent", "", "Parent issue ID")
issueCreateCmd.Flags().String("project", "", "Project ID")
issueCreateCmd.Flags().String("due-date", "", "Due date (RFC3339 format)")
@@ -235,7 +237,8 @@ func init() {
issueUpdateCmd.Flags().Bool("description-stdin", false, "Read new description from stdin (preserves multi-line content verbatim)")
issueUpdateCmd.Flags().String("status", "", "New status")
issueUpdateCmd.Flags().String("priority", "", "New priority")
issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member or agent)")
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("project", "", "Project ID")
issueUpdateCmd.Flags().String("due-date", "", "New due date (RFC3339 format)")
issueUpdateCmd.Flags().String("parent", "", "Parent issue ID (use --parent \"\" to clear)")
@@ -245,7 +248,8 @@ func init() {
issueStatusCmd.Flags().String("output", "table", "Output format: table or json")
// issue assign
issueAssignCmd.Flags().String("to", "", "Assignee name (member or agent)")
issueAssignCmd.Flags().String("to", "", "Assignee name (member or agent; fuzzy match)")
issueAssignCmd.Flags().String("to-id", "", "Assignee UUID (mutually exclusive with --to)")
issueAssignCmd.Flags().Bool("unassign", false, "Remove current assignee")
issueAssignCmd.Flags().String("output", "json", "Output format: table or json")
@@ -281,11 +285,13 @@ func init() {
issueSubscriberListCmd.Flags().String("output", "table", "Output format: table or json")
// issue subscriber add
issueSubscriberAddCmd.Flags().String("user", "", "Member or agent name to subscribe (defaults to the caller)")
issueSubscriberAddCmd.Flags().String("user", "", "Member or agent name to subscribe (fuzzy match; defaults to the caller)")
issueSubscriberAddCmd.Flags().String("user-id", "", "Member or agent UUID to subscribe (mutually exclusive with --user)")
issueSubscriberAddCmd.Flags().String("output", "json", "Output format: table or json")
// issue subscriber remove
issueSubscriberRemoveCmd.Flags().String("user", "", "Member or agent name to unsubscribe (defaults to the caller)")
issueSubscriberRemoveCmd.Flags().String("user", "", "Member or agent name to unsubscribe (fuzzy match; defaults to the caller)")
issueSubscriberRemoveCmd.Flags().String("user-id", "", "Member or agent UUID to unsubscribe (mutually exclusive with --user)")
issueSubscriberRemoveCmd.Flags().String("output", "json", "Output format: table or json")
}
@@ -319,11 +325,11 @@ func runIssueList(cmd *cobra.Command, _ []string) error {
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
params.Set("limit", fmt.Sprintf("%d", v))
}
if v, _ := cmd.Flags().GetString("assignee"); v != "" {
_, aID, resolveErr := resolveAssignee(ctx, client, v)
if resolveErr != nil {
return fmt.Errorf("resolve assignee: %w", resolveErr)
}
_, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
if resolveErr != nil {
return fmt.Errorf("resolve assignee: %w", resolveErr)
}
if hasAssignee {
params.Set("assignee_id", aID)
}
if v, _ := cmd.Flags().GetInt("offset"); v > 0 {
@@ -476,11 +482,11 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error {
if v, _ := cmd.Flags().GetString("due-date"); v != "" {
body["due_date"] = v
}
if v, _ := cmd.Flags().GetString("assignee"); v != "" {
aType, aID, resolveErr := resolveAssignee(ctx, client, v)
if resolveErr != nil {
return fmt.Errorf("resolve assignee: %w", resolveErr)
}
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
if resolveErr != nil {
return fmt.Errorf("resolve assignee: %w", resolveErr)
}
if hasAssignee {
body["assignee_type"] = aType
body["assignee_id"] = aID
}
@@ -599,14 +605,15 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error {
v, _ := cmd.Flags().GetString("due-date")
body["due_date"] = v
}
if cmd.Flags().Changed("assignee") {
v, _ := cmd.Flags().GetString("assignee")
aType, aID, resolveErr := resolveAssignee(ctx, client, v)
if cmd.Flags().Changed("assignee") || cmd.Flags().Changed("assignee-id") {
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
if resolveErr != nil {
return fmt.Errorf("resolve assignee: %w", resolveErr)
}
body["assignee_type"] = aType
body["assignee_id"] = aID
if hasAssignee {
body["assignee_type"] = aType
body["assignee_id"] = aID
}
}
if cmd.Flags().Changed("parent") {
v, _ := cmd.Flags().GetString("parent")
@@ -645,12 +652,14 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error {
func runIssueAssign(cmd *cobra.Command, args []string) error {
toName, _ := cmd.Flags().GetString("to")
unassign, _ := cmd.Flags().GetBool("unassign")
toNameSet := cmd.Flags().Changed("to")
toIDSet := cmd.Flags().Changed("to-id")
if toName == "" && !unassign {
return fmt.Errorf("provide --to <name> or --unassign")
if !toNameSet && !toIDSet && !unassign {
return fmt.Errorf("provide --to <name>, --to-id <uuid>, or --unassign")
}
if toName != "" && unassign {
return fmt.Errorf("--to and --unassign are mutually exclusive")
if (toNameSet || toIDSet) && unassign {
return fmt.Errorf("--to/--to-id and --unassign are mutually exclusive")
}
client, err := newAPIClient(cmd)
@@ -662,16 +671,20 @@ func runIssueAssign(cmd *cobra.Command, args []string) error {
defer cancel()
body := map[string]any{}
displayTarget := toName
if unassign {
body["assignee_type"] = nil
body["assignee_id"] = nil
} else {
aType, aID, resolveErr := resolveAssignee(ctx, client, toName)
aType, aID, _, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "to", "to-id")
if resolveErr != nil {
return fmt.Errorf("resolve assignee: %w", resolveErr)
}
body["assignee_type"] = aType
body["assignee_id"] = aID
if displayTarget == "" {
displayTarget = truncateID(aID)
}
}
var result map[string]any
@@ -682,7 +695,7 @@ func runIssueAssign(cmd *cobra.Command, args []string) error {
if unassign {
fmt.Fprintf(os.Stderr, "Issue %s unassigned.\n", truncateID(args[0]))
} else {
fmt.Fprintf(os.Stderr, "Issue %s assigned to %s.\n", truncateID(args[0]), toName)
fmt.Fprintf(os.Stderr, "Issue %s assigned to %s.\n", truncateID(args[0]), displayTarget)
}
output, _ := cmd.Flags().GetString("output")
@@ -1145,11 +1158,11 @@ func runIssueSubscriberMutation(cmd *cobra.Command, issueID, action string) erro
body := map[string]any{}
userName, _ := cmd.Flags().GetString("user")
if userName != "" {
uType, uID, resolveErr := resolveAssignee(ctx, client, userName)
if resolveErr != nil {
return fmt.Errorf("resolve user: %w", resolveErr)
}
uType, uID, hasUser, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "user", "user-id")
if resolveErr != nil {
return fmt.Errorf("resolve user: %w", resolveErr)
}
if hasUser {
body["user_type"] = uType
body["user_id"] = uID
}
@@ -1163,6 +1176,8 @@ func runIssueSubscriberMutation(cmd *cobra.Command, issueID, action string) erro
target := "caller"
if userName != "" {
target = userName
} else if hasUser {
target = truncateID(uID)
}
if action == "subscribe" {
fmt.Fprintf(os.Stderr, "Subscribed %s to issue %s.\n", target, truncateID(issueID))
@@ -1269,6 +1284,83 @@ func ambiguousAssigneeError(input string, matches []assigneeMatch) error {
return fmt.Errorf("ambiguous assignee %q; matches:\n%s", input, strings.Join(parts, "\n"))
}
// 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) {
if client.WorkspaceID == "" {
return "", "", fmt.Errorf("workspace ID is required to resolve assignees; use --workspace-id or set MULTICA_WORKSPACE_ID")
}
input := strings.TrimSpace(id)
if !uuidRegexp.MatchString(input) {
return "", "", fmt.Errorf("expected a canonical UUID, got %q", id)
}
var members []map[string]any
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)
if memberErr != nil && agentErr != nil {
return "", "", fmt.Errorf("failed to resolve assignee: %v; %v", memberErr, agentErr)
}
for _, m := range members {
if strings.EqualFold(strVal(m, "user_id"), input) {
return "member", strVal(m, "user_id"), nil
}
}
for _, a := range agents {
if strings.EqualFold(strVal(a, "id"), input) {
return "agent", strVal(a, "id"), nil
}
}
return "", "", fmt.Errorf("no member or agent found with ID %q", 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.
//
// 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) {
nameSet := cmd.Flags().Changed(nameFlag)
idSet := cmd.Flags().Changed(idFlag)
if nameSet && idSet {
return "", "", false, fmt.Errorf("--%s and --%s are mutually exclusive", nameFlag, idFlag)
}
if idSet {
idVal, _ := cmd.Flags().GetString(idFlag)
t, i, err := resolveAssigneeByID(ctx, client, idVal)
if err != nil {
return "", "", true, err
}
return t, i, true, nil
}
if nameSet {
name, _ := cmd.Flags().GetString(nameFlag)
t, i, err := resolveAssignee(ctx, client, name)
if err != nil {
return "", "", true, err
}
return t, i, true, nil
}
return "", "", false, nil
}
func formatAssignee(issue map[string]any) string {
aType := strVal(issue, "assignee_type")
aID := strVal(issue, "assignee_id")

View File

@@ -358,6 +358,220 @@ func TestResolveAssigneeByID(t *testing.T) {
})
}
// TestResolveAssigneeByIDStrict covers the strict UUID resolver that backs
// --assignee-id / --to-id / --user-id. Unlike resolveAssignee it must reject
// non-UUID inputs (no name fallback) and surface a clear error when the UUID
// is well-formed but not present in the workspace.
func TestResolveAssigneeByIDStrict(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"},
{"id": "192b9cca-2222-2222-2222-222222222222", "name": "Open Claw - J"},
}
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)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
ctx := context.Background()
t.Run("full UUID resolves the right agent in a substring-collision workspace", func(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")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "agent" || aID != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
t.Errorf("got (%q, %q), want agent J", aType, aID)
}
})
t.Run("uppercase UUID is normalized", func(t *testing.T) {
aType, aID, err := resolveAssigneeByID(ctx, client, "5FB87AC7-23B5-4A7A-81FA-ED295A54545D")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "agent" || aID != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
t.Errorf("got (%q, %q), want agent J", aType, aID)
}
})
t.Run("UUID resolves a member", func(t *testing.T) {
aType, aID, err := resolveAssigneeByID(ctx, client, "aaaaaaaa-1111-1111-1111-111111111111")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "member" || aID != "aaaaaaaa-1111-1111-1111-111111111111" {
t.Errorf("got (%q, %q), want Alice", aType, aID)
}
})
t.Run("non-UUID input is rejected without name fallback", func(t *testing.T) {
_, _, err := resolveAssigneeByID(ctx, client, "Alice")
if err == nil {
t.Fatal("expected error for non-UUID input")
}
if !strings.Contains(err.Error(), "UUID") {
t.Errorf("expected UUID error, got: %v", err)
}
})
t.Run("UUID prefix (ShortID) is rejected — strict mode requires canonical form", func(t *testing.T) {
_, _, err := resolveAssigneeByID(ctx, client, "5fb87ac7")
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")
if err == nil {
t.Fatal("expected error for missing entity")
}
if !strings.Contains(err.Error(), "no member or agent") {
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")
if err == nil {
t.Fatal("expected error for missing workspace ID")
}
})
}
// TestPickAssigneeFromFlags covers the flag-pair picker that backs every
// assignee-taking command. The mutual-exclusion guard is the load-bearing
// piece — silently preferring one side would let a buggy script set both
// flags and assign the wrong entity.
func TestPickAssigneeFromFlags(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"},
}
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)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
ctx := context.Background()
newCmd := func() *cobra.Command {
c := &cobra.Command{Use: "test"}
c.Flags().String("assignee", "", "")
c.Flags().String("assignee-id", "", "")
return c
}
t.Run("neither flag set returns hasValue=false", func(t *testing.T) {
_, _, has, err := pickAssigneeFromFlags(ctx, client, newCmd(), "assignee", "assignee-id")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if has {
t.Errorf("expected hasValue=false")
}
})
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")
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)
}
})
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")
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)
}
})
t.Run("both flags set is rejected", func(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")
if err == nil {
t.Fatal("expected mutually-exclusive error")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected mutually-exclusive error, got: %v", err)
}
})
// Explicit-empty regression: a script that interpolates an empty env var
// into `--assignee-id "$MAYBE_UUID"` must NOT silently route through the
// "no flag set" branch — that would defeat the whole point of the strict
// UUID flag (issue list returning everything, create leaving the issue
// unassigned, subscriber add subscribing the caller). Detection is via
// Flags().Changed, so an explicit empty string surfaces as a UUID error.
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")
if err == nil {
t.Fatal("expected UUID error for explicit empty assignee-id")
}
if !has {
t.Errorf("expected hasValue=true so caller treats this as a real attempt, not a no-op")
}
if !strings.Contains(err.Error(), "UUID") {
t.Errorf("expected UUID-shaped error, got: %v", err)
}
})
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")
if err == nil {
t.Fatal("expected resolver error for explicit empty assignee")
}
if !has {
t.Errorf("expected hasValue=true so caller treats this as a real attempt, not a no-op")
}
})
t.Run("explicit empty on both flags is mutually exclusive (set wins over value)", func(t *testing.T) {
c := newCmd()
_ = c.Flags().Set("assignee", "")
_ = c.Flags().Set("assignee-id", "")
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected mutually-exclusive error, got: %v", err)
}
})
}
func TestIssueSubscriberList(t *testing.T) {
subscribersResp := []map[string]any{
{

View File

@@ -105,7 +105,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("**Always use `--output json` for all read commands** to get structured data with full IDs.\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] [--limit N] [--offset N] --output json` — List issues in workspace (default limit: 50; JSON output includes `total`, `has_more` — use offset to paginate when `has_more` is true)\n")
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X | --assignee-id <uuid>] [--limit N] [--offset N] --output json` — List issues in workspace (default limit: 50; 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 comment list <issue-id> [--limit N] [--offset N] [--since <RFC3339>] --output json` — List comments on an issue (supports pagination; includes id, parent_id for threading)\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")
@@ -122,14 +122,14 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica autopilot runs <id> [--limit N] --output json` — List execution history for an autopilot\n\n")
b.WriteString("### Write\n")
b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--status X] [--assignee X] [--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] [--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 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>` — Assign an issue to a member or agent by name (use `--unassign` to remove assignee)\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 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>]` — Subscribe a member or agent to issue updates (defaults to the caller when `--user` is omitted)\n")
b.WriteString("- `multica issue subscriber remove <issue-id> [--user <name>]` — Unsubscribe a member or agent\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")
b.WriteString("- `multica issue subscriber remove <issue-id> [--user <name>|--user-id <uuid>]` — Unsubscribe a member or agent\n")
b.WriteString("- `multica issue comment add <issue-id> --content-stdin [--parent <comment-id>] [--attachment <path>]` — Post a comment. Agent-authored comments should always pipe content via stdin, even for short single-line replies. Use `--parent` to reply to a specific comment; `--attachment` may be repeated.\n")
b.WriteString(" - **For comment content, you MUST pipe via stdin; this is mandatory for multi-line content (anything with line breaks, paragraphs, code blocks, backticks, or quotes).** Do not use inline `--content` and do not write `\\n` escapes. Use a HEREDOC instead:\n")
b.WriteString("\n")

View File

@@ -65,15 +65,19 @@ 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 find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — 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` (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")
agentID := ""
agentName := ""
if task.Agent != nil {
agentID = task.Agent.ID
agentName = task.Agent.Name
}
if agentName != "" {
if agentID != "" {
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to YOURSELF: pass `--assignee-id %q` (your agent UUID). The picker agent is the expected owner because the user opened quick-create with you selected — never leave the issue unassigned. Use the UUID flag, not `--assignee <name>`, so the assignment is unambiguous even when other agents share part of your name.\n\n", agentID)
} else if agentName != "" {
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to YOURSELF: pass `--assignee %q`. The picker agent is the expected owner because the user opened quick-create with you selected — never leave the issue unassigned.\n\n", agentName)
} else {
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee <your agent name>`. Never leave the issue unassigned.\n\n")
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee-id <your agent UUID>` (preferred) or `--assignee <your agent name>`. Never leave the issue unassigned.\n\n")
}
// fields to omit