Files
multica/server/internal/daemon/execenv/runtime_config_issue_status_test.go
Bohan Jiangandmultica-agent 43b0571f99 MUL-7821: feat(cli): mark duplicates with issue status/update --duplicate-of (#8979)
* feat(cli): mark duplicates from issue status and issue update (MUL-7821)

Add --duplicate-of to `multica issue status` and `multica issue update`.
It resolves an issue key or UUID and sends duplicate_of_issue_id, which
the server already accepts (MUL-7349). The CLI rejects a status other than
cancelled, and description or attachment changes, before any request or
upload. It fails when the response has no duplicate_of field, so a server
older than v0.5.2 cannot silently drop the mark.

Closes #8974

Co-authored-by: multica-agent <[email protected]>

* docs: teach agents to mark duplicates with --duplicate-of (MUL-7821)

Add the command to the agent runtime brief, the multica-platform issues
reference, CLI_AND_DAEMON.md and the CLI docs tables, so agents record the
mark instead of cancelling a duplicate and explaining it in a comment.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-09-29 23:27:27 -07:00

198 lines
8.7 KiB
Go

package execenv
import (
"strings"
"testing"
"github.com/multica-ai/multica/server/internal/issuestatus"
)
// builtInStatusLine is the status bullet for workspaces without custom
// statuses, including deployments behind an old server.
const builtInStatusLine = "- `multica issue status <id> <status>` — flip status (todo / in_progress / in_review / done / blocked / backlog / cancelled).\n"
// catalogBridgeBullet distinguishes workflow keys from lifecycle categories;
// it must appear exactly when a catalog is present.
const catalogBridgeBullet = "- The workflow rules above refer to exact built-in status keys, not categories. Custom statuses share lifecycle semantics only, not built-in automation behavior.\n"
func TestBriefStatusCatalogAbsentUsesBuiltInLine(t *testing.T) {
t.Parallel()
base := TaskContextForEnv{IssueID: "issue-1", AgentID: "a-1", AgentName: "Eve"}
out := buildMetaSkillContent("claude", base)
if !strings.Contains(out, builtInStatusLine) {
t.Fatalf("brief without a catalog must list the built-in statuses\n---\n%s", out)
}
if strings.Contains(out, catalogBridgeBullet) {
t.Errorf("brief without a catalog must not carry the catalog bridge bullet")
}
withEmpty := base
withEmpty.IssueStatuses = []IssueStatusForEnv{}
if got := buildMetaSkillContent("claude", withEmpty); got != out {
t.Errorf("empty catalog must render byte-identical to absent catalog:\n%s", firstBriefDiff(out, got))
}
}
// TestBriefTeachesDuplicateMark pins the duplicate-mark bullet with and
// without a status catalog, so agents cancelling a duplicate record the mark.
func TestBriefTeachesDuplicateMark(t *testing.T) {
t.Parallel()
base := TaskContextForEnv{IssueID: "issue-1", AgentID: "a-1", AgentName: "Eve"}
withCatalog := base
withCatalog.IssueStatuses = []IssueStatusForEnv{{Key: "later", Name: "Later", Category: "backlog"}}
for name, ctx := range map[string]TaskContextForEnv{"no catalog": base, "catalog": withCatalog} {
out := buildMetaSkillContent("claude", ctx)
if got := strings.Count(out, duplicateOfCommandLine); got != 1 {
t.Errorf("%s: duplicate-mark bullet appears %d times, want 1\n---\n%s", name, got, out)
}
}
}
func TestBriefStatusCatalogRendered(t *testing.T) {
t.Parallel()
ctx := TaskContextForEnv{
IssueID: "issue-1", AgentID: "a-1", AgentName: "Eve",
IssueStatuses: []IssueStatusForEnv{
{Key: "later", Name: "Later", Category: "backlog", Description: "Deferred on purpose"},
{Key: "rework", Name: "Rework", Category: "todo"},
{Key: "human_review", Name: "Human Review", Category: "in_review", Description: "Awaiting human acceptance"},
},
}
out := buildMetaSkillContent("claude", ctx)
if strings.Contains(out, builtInStatusLine) {
t.Errorf("catalog brief must replace the built-in seven-value enumeration")
}
for _, want := range []string{
"- `multica issue status <id> <status>` — flip status. Available statuses by lifecycle category:\n",
" - unstarted category: `backlog`, `todo` (built-in), `later` (Later — Deferred on purpose), `rework` (Rework)\n",
" - done category: `done` (built-in)\n",
" - started category: `in_progress`, `in_review`, `blocked` (built-in), `human_review` (Human Review — Awaiting human acceptance)\n",
" - closed category: `cancelled` (built-in)\n",
catalogBridgeBullet,
} {
if !strings.Contains(out, want) {
t.Errorf("catalog brief missing %q\n---\n%s", want, out)
}
}
if strings.Contains(out, "custom statuses not listed") || strings.Contains(out, "Custom statuses omitted") {
t.Errorf("no truncation disclosure may appear when nothing was omitted")
}
}
// TestBriefStatusCatalogSanitizesAndDiscloses pins the two safety properties:
// user-authored name/description cannot inject markdown structure into the
// trusted brief, and a key that fails the code-token guard drops its entry
// entirely instead of rendering mangled.
func TestBriefStatusCatalogSanitizesAndDiscloses(t *testing.T) {
t.Parallel()
ctx := TaskContextForEnv{
IssueID: "issue-1", AgentID: "a-1", AgentName: "Eve",
IssueStatuses: []IssueStatusForEnv{
{Key: "qa", Name: "QA *bold*\n# Heading", Category: "in_review", Description: "line1\nline2 [x]"},
{Key: "bad key!", Name: "Evil", Category: "todo", Description: "must be dropped"},
},
IssueStatusesOmitted: 4,
}
out := buildMetaSkillContent("claude", ctx)
for _, want := range []string{
` - started category: ` + "`in_progress`, `in_review`, `blocked`" + ` (built-in), ` + "`qa`" + ` (QA \*bold\* # Heading — line1 line2 \[x\])` + "\n",
" - unstarted category: `backlog`, `todo` (built-in)\n",
" - …and 4 more custom statuses not listed; an invalid status errors with the full valid list.\n",
} {
if !strings.Contains(out, want) {
t.Errorf("catalog brief missing %q\n---\n%s", want, out)
}
}
if strings.Contains(out, "Evil") || strings.Contains(out, "bad key") {
t.Errorf("entry with an invalid key must be dropped entirely\n---\n%s", out)
}
}
func TestBriefStatusCatalogMixedVersions(t *testing.T) {
t.Parallel()
stored := []IssueStatusForEnv{
{Key: "later", Name: "Later", Category: "unstarted", Description: "Deferred on purpose"},
{Key: "awaiting_response", Name: "Awaiting response", Category: "started", Description: "Waiting for a reply"},
{Key: "accepted", Name: "Accepted", Category: "done", Description: "Review complete"},
{Key: "withdrawn", Name: "Withdrawn", Category: "closed", Description: "No longer needed"},
}
wire := append([]IssueStatusForEnv(nil), stored...)
for i := range wire {
wire[i].Category = issuestatus.WireCategory(wire[i].Key, wire[i].Category)
}
for _, tc := range []struct {
name string
entries []IssueStatusForEnv
render func(*strings.Builder, TaskContextForEnv)
legacyRaw bool
}{
{"stored categories with old daemon reproduce omission", stored, writeLegacyIssueStatusCommand, true},
{"stored categories with new daemon", stored, writeIssueStatusCommand, false},
{"wire categories with old daemon", wire, writeLegacyIssueStatusCommand, false},
{"wire categories with new daemon", wire, writeIssueStatusCommand, false},
} {
t.Run(tc.name, func(t *testing.T) {
var b strings.Builder
tc.render(&b, TaskContextForEnv{IssueStatuses: tc.entries})
for _, entry := range stored {
want := "`" + entry.Key + "` (" + entry.Name + " — " + entry.Description + ")"
count := 1
if tc.legacyRaw && entry.Category != "done" {
count = 0
}
if got := strings.Count(b.String(), want); got != count {
t.Errorf("%q occurs %d times, want %d in:\n%s", want, got, count, b.String())
}
}
})
}
var storedBrief, wireBrief strings.Builder
writeIssueStatusCommand(&storedBrief, TaskContextForEnv{IssueStatuses: stored})
writeIssueStatusCommand(&wireBrief, TaskContextForEnv{IssueStatuses: wire})
if storedBrief.String() != wireBrief.String() {
t.Errorf("wire mapping must preserve the new daemon's grouping and output")
}
// Previously emitted wire categories must all remain readable, including
// the three spellings not produced for customs by today's WireCategory.
for _, category := range legacyStatusCategoryOrder {
var b strings.Builder
writeIssueStatusCommand(&b, TaskContextForEnv{IssueStatuses: []IssueStatusForEnv{
{Key: "custom", Name: "Custom", Category: category, Description: "Legacy payload"},
}})
if strings.Count(b.String(), "`custom` (Custom — Legacy payload)") != 1 {
t.Errorf("legacy category %s not rendered exactly once: %s", category, b.String())
}
}
}
func TestBriefStatusCatalogUnknownCategoriesDisclosed(t *testing.T) {
t.Parallel()
for _, onlyUnknown := range []bool{false, true} {
entries := []IssueStatusForEnv{
{Key: "future_one", Name: "DO_NOT_RENDER_NAME", Category: "future\n# INJECT", Description: "DO_NOT_RENDER_DESCRIPTION"},
{Key: "future_two", Name: "Hidden", Category: ""},
{Key: "bad key!", Name: "INVALID_KEY_NAME", Category: "future"},
}
if !onlyUnknown {
entries = append(entries, IssueStatusForEnv{Key: "qa", Name: "QA *bold*\n# Heading", Category: "started", Description: "line1\nline2 [x]"})
}
out := buildMetaSkillContent("claude", TaskContextForEnv{IssueStatuses: entries, IssueStatusesOmitted: 4})
for _, want := range []string{
" - Custom statuses omitted due to unrecognized categories: 2.\n",
" - …and 4 more custom statuses not listed; an invalid status errors with the full valid list.\n",
} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in:\n%s", want, out)
}
}
for _, unwanted := range []string{"future_one", "future_two", "INJECT", "DO_NOT_RENDER", "INVALID_KEY_NAME"} {
if strings.Contains(out, unwanted) {
t.Errorf("unknown/invalid entry leaked %q", unwanted)
}
}
if !onlyUnknown && !strings.Contains(out, "`qa` (QA \\*bold\\* # Heading — line1 line2 \\[x\\])") {
t.Errorf("known status must retain sanitization: %s", out)
}
}
}