Files
multica/server/internal/daemon/execenv/runtime_config_kind_test.go
Eve 38aa864e70 MUL-3560: refactor buildMetaSkillContent into kind-driven dispatcher
This is the structural prep PR for the runtime-brief diet roadmap on
MUL-3560 — split out from the per-section compression work so the
refactor risk and the content-gating risk land separately.

Output is byte-for-byte identical to the pre-refactor monolithic
builder for every existing test fixture. All existing tests pass
unchanged. The follow-up PR (0.6) will start gating sections per the
Section × Kind matrix from Eve's design comment on MUL-3560 (drop
Mentions / Comment Formatting / Issue Metadata / Sub-issue out of
quick-create, drop Comment Formatting / Mentions / Attachments out of
autopilot and chat, etc.), with negative assertions added alongside
each removal so each kind's brief contract becomes machine-checked.

What changed:

- runtime_config_kind.go (new): taskKind enum + classifyTask helper.
  Five kinds, mutually exclusive in practice; the documented
  precedence rule keeps the tiebreak deterministic even if a future
  caller breaks the mutex by accident. hasIssueContext is the
  predicate Issue Metadata and Sub-issue Creation gate on.
- runtime_config_sections.go (new): every section of the brief is
  extracted into its own writeXxx helper, preserving byte sequences
  exactly. Helpers with internal conditions keep those conditions;
  helpers the dispatcher always wants to call are unconditional.
- runtime_config.go: buildMetaSkillContent collapses from a
  ~450-line monolith to a ~70-line dispatcher — prelude, conditional
  context sections, a 5-way kind switch over Workflow bodies, and
  trailing sections. The matrix of "which kind gets which section"
  is now readable in one screen.
- runtime_config_kind_test.go (new): TestClassifyTask covers all 5
  kinds plus tiebreak cases; TestTaskKindHasIssueContext pins the
  predicate semantics; TestBuildMetaSkillContentKindMatrix is the
  structural canary that locks today's per-kind section set so any
  later PR that drops a section from a kind must update its
  expectations in lockstep.

Verification:

- go build ./...                                                      ok
- go vet ./internal/daemon/...                                        ok
- go test ./internal/daemon/... ./internal/handler/...                ok
- go test ./internal/daemon/execenv -run TestClassifyTask -v         pass
- go test ./internal/daemon/execenv -run TestTaskKindHasIssueContext pass
- go test ./internal/daemon/execenv -run TestBuildMetaSkillContent   pass

No production behaviour change in this PR. No new logging, no new
char-bucket fields — those are in PR #4439 and compose cleanly with
this refactor.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 14:33:42 +08:00

288 lines
7.4 KiB
Go

package execenv
import (
"strings"
"testing"
)
// TestClassifyTask pins the precedence rule documented on classifyTask:
//
// 1. chat wins
// 2. quick-create
// 3. autopilot run-only
// 4. comment-triggered
// 5. otherwise assignment-triggered
//
// The pre-refactor builder relied on the daemon never setting two of
// ChatSessionID / QuickCreatePrompt / AutopilotRunID at once, but did not
// document the tiebreak. Now that the tiebreak is a function with a fixed
// switch order, it deserves a test so any future call site that violates
// the mutex still lands on a deterministic kind instead of silently picking
// up the wrong workflow.
func TestClassifyTask(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ctx TaskContextForEnv
want taskKind
}{
{
name: "chat",
ctx: TaskContextForEnv{ChatSessionID: "chat-1"},
want: kindChat,
},
{
name: "quick-create",
ctx: TaskContextForEnv{QuickCreatePrompt: "draft an issue"},
want: kindQuickCreate,
},
{
name: "autopilot-run-only",
ctx: TaskContextForEnv{AutopilotRunID: "run-1"},
want: kindAutopilotRunOnly,
},
{
name: "comment-triggered",
ctx: TaskContextForEnv{
IssueID: "issue-1",
TriggerCommentID: "comment-1",
},
want: kindCommentTriggered,
},
{
name: "assignment-triggered",
ctx: TaskContextForEnv{IssueID: "issue-1"},
want: kindAssignmentTriggered,
},
{
name: "assignment-triggered-bare",
ctx: TaskContextForEnv{},
want: kindAssignmentTriggered,
},
// Tiebreak cases — two specific-kind flags set at once. The
// daemon never produces these, but if a future call site
// accidentally does, classifyTask must still return a
// deterministic kind chosen by the documented precedence.
{
name: "tiebreak-chat-beats-quick-create",
ctx: TaskContextForEnv{
ChatSessionID: "chat-1",
QuickCreatePrompt: "p",
},
want: kindChat,
},
{
name: "tiebreak-quick-create-beats-autopilot",
ctx: TaskContextForEnv{
QuickCreatePrompt: "p",
AutopilotRunID: "run-1",
},
want: kindQuickCreate,
},
{
name: "tiebreak-autopilot-beats-comment",
ctx: TaskContextForEnv{
AutopilotRunID: "run-1",
IssueID: "issue-1",
TriggerCommentID: "comment-1",
},
want: kindAutopilotRunOnly,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := classifyTask(tc.ctx); got != tc.want {
t.Errorf("classifyTask: got %d, want %d", got, tc.want)
}
})
}
}
// TestTaskKindHasIssueContext pins the predicate used to gate Issue Metadata
// and Sub-issue Creation. Equivalent to the pre-refactor scattered check
// `ChatSessionID == "" && QuickCreatePrompt == "" && AutopilotRunID == ""`;
// pulling it onto taskKind means the kind matrix decides — not three
// duplicated string compares in the builder.
func TestTaskKindHasIssueContext(t *testing.T) {
t.Parallel()
cases := []struct {
kind taskKind
want bool
}{
{kindCommentTriggered, true},
{kindAssignmentTriggered, true},
{kindAutopilotRunOnly, false},
{kindQuickCreate, false},
{kindChat, false},
}
for _, tc := range cases {
if got := tc.kind.hasIssueContext(); got != tc.want {
t.Errorf("kind=%d hasIssueContext: got %v, want %v", tc.kind, got, tc.want)
}
}
}
// TestBuildMetaSkillContentKindMatrix locks in which sections each kind
// emits today. This is the post-refactor structural canary for MUL-3560
// PR 0.5: any later PR that drops a section from a kind must update the
// negative expectations here in lockstep, and any PR that accidentally
// adds a section back fails this test.
//
// The fixtures here intentionally use the minimal context required to
// trigger each kind, with one repo and one skill so Repositories / Skills
// fire. They are NOT meant to exercise every conditional inside each
// section — that is covered by the dedicated per-section tests in the
// rest of this package.
func TestBuildMetaSkillContentKindMatrix(t *testing.T) {
t.Parallel()
baseRepo := []RepoContextForEnv{{URL: "https://example.com/x.git", Description: "x"}}
baseSkill := []SkillContextForEnv{{Name: "skill-x", Description: "x"}}
type sectionCheck struct {
heading string
// kinds that MUST contain this heading
mustHave map[taskKind]bool
// kinds that MUST NOT contain it (left implicit: any kind not
// in mustHave should not have it)
}
checks := []sectionCheck{
{
heading: "# Multica Agent Runtime",
mustHave: allKinds(),
},
{
heading: "## Background Task Safety",
mustHave: allKinds(),
},
// Identity is only rendered when AgentName/ID/Instructions are
// present; the matrix tests below provide AgentName for every
// kind so it should fire across the board.
{
heading: "## Agent Identity",
mustHave: allKinds(),
},
{
heading: "## Available Commands",
mustHave: allKinds(),
},
{
heading: "## Comment Formatting",
mustHave: allKinds(),
},
{
heading: "## Repositories",
mustHave: allKinds(),
},
{
heading: "## Issue Metadata",
mustHave: map[taskKind]bool{
kindCommentTriggered: true,
kindAssignmentTriggered: true,
},
},
{
heading: "## Instruction Precedence",
mustHave: map[taskKind]bool{
kindAssignmentTriggered: true,
},
},
{
heading: "### Workflow",
mustHave: allKinds(),
},
{
heading: "## Sub-issue Creation",
mustHave: map[taskKind]bool{
kindCommentTriggered: true,
kindAssignmentTriggered: true,
},
},
{
heading: "## Skills",
mustHave: allKinds(),
},
{
heading: "## Mentions",
mustHave: allKinds(),
},
{
heading: "## Attachments",
mustHave: allKinds(),
},
{
heading: "## Important: Always Use the `multica` CLI",
mustHave: allKinds(),
},
{
heading: "## Output",
mustHave: allKinds(),
},
}
fixtures := map[taskKind]TaskContextForEnv{
kindChat: {
ChatSessionID: "chat-1",
AgentName: "Agent X",
AgentID: "agent-x",
Repos: baseRepo,
AgentSkills: baseSkill,
},
kindQuickCreate: {
QuickCreatePrompt: "make an issue",
AgentName: "Agent X",
AgentID: "agent-x",
Repos: baseRepo,
AgentSkills: baseSkill,
},
kindAutopilotRunOnly: {
AutopilotRunID: "run-1",
AgentName: "Agent X",
AgentID: "agent-x",
Repos: baseRepo,
AgentSkills: baseSkill,
},
kindCommentTriggered: {
IssueID: "issue-1",
TriggerCommentID: "comment-1",
AgentName: "Agent X",
AgentID: "agent-x",
Repos: baseRepo,
AgentSkills: baseSkill,
},
kindAssignmentTriggered: {
IssueID: "issue-1",
AgentName: "Agent X",
AgentID: "agent-x",
Repos: baseRepo,
AgentSkills: baseSkill,
},
}
for kind, ctx := range fixtures {
out := buildMetaSkillContent("claude", ctx)
for _, c := range checks {
present := strings.Contains(out, c.heading)
want := c.mustHave[kind]
if want && !present {
t.Errorf("kind=%d: expected heading %q in brief", kind, c.heading)
}
if !want && present {
t.Errorf("kind=%d: heading %q should NOT be in brief (matrix gating regression)", kind, c.heading)
}
}
}
}
func allKinds() map[taskKind]bool {
return map[taskKind]bool{
kindCommentTriggered: true,
kindAssignmentTriggered: true,
kindAutopilotRunOnly: true,
kindQuickCreate: true,
kindChat: true,
}
}