Files
multica/server/internal/daemon/execenv/skill_visibility_test.go
49098deb6f MUL-6984 refactor(prompt): give each duplicated instruction one owner (#7971)
* refactor(prompt): give each duplicated instruction one owner (MUL-6984)

Four cases where the same data or rule was rendered two or three times into
one context window, following the sibling-block removal in #7937. Each is
resolved by picking the surface that owns it and pointing the others there —
no behavior change beyond what an agent stops reading twice.

The rule used to pick the owner is the one MUL-5377 already set for issue
tasks: the runtime brief holds what is true of this KIND of task (stable, and
it is the prompt-cache prefix), the per-turn user message holds what is true
of this RUN, and the workdir sidecar holds nothing. Autopilot, quick-create
and squad-leader paths had never been cleaned up against it.

Autopilot run data (run id, autopilot id, title, source, trigger payload,
instructions) was rendered in the brief, the per-turn message, and the
sidecar. Now per-turn only; the brief keeps the run-only framing and the
shared issue-command guard. This also settles a contract violation: those are
per-run values, and the brief promises not to carry any. MUL-5696 already
caught the two copies drifting on the guard sentence and fixed that one line
with a shared constant. The payload is now capped at 8 KB with an explicit
truncation notice — it is written by whatever system fired the trigger, so
nothing here bounds it, and a clipped JSON object read as a whole one is
worse than a short one.

The squad leader no_action rule was written four times: the Squad Operating
Protocol, the per-turn prompt, brief workflow step 4, and brief ## Output.
The four had already drifted — only some carried the MUL-6622 escape hatch
that keeps a FAILED `squad activity` call from ending the turn in silence.
The protocol is now the single statement (it is the only document sent
exclusively to leaders); the two brief sites and the per-turn reply imperative
carry the EXCEPTION to their own imperative and point back at it.

Quick-create's five run/output rules were in the brief's Workflow, the
brief's ## Output, and the per-turn "Output format" block; the
--description-file /tmp guidance was in the brief's Available Commands and
per-turn. The brief owns both now — its own comment says these guardrails
must hold "even if the user message is missing" — and per-turn renders only
the field values the modal picked.

.agent_context/issue_context.md is deleted outright. Nothing read it: the
rendered CLAUDE.md never mentioned .agent_context, no per-turn message
pointed at it, and no provider discovers it. Its writer's own comment said
"the runtime brief already carries every fact this file would". With it gone
the directory is created only when the default provider needs
.agent_context/skills, so a local_directory task no longer leaves an empty
managed directory in the user's checkout.

Compatibility: prompt text only. No wire field, CLI flag, or DB column
changes, so no server/daemon version coupling. Tests follow the text to its
new owner rather than being deleted: the protocol's no_action rule is now
pinned in internal/handler, the quick-create rules in the brief's own suite,
and the payload cap has its own coverage including the rune-boundary cut.

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

* fix(prompt): drop the autopilot payload cap, pin the moved invariant (MUL-6984)

Addresses review on #7971.

The 8 KB trigger-payload cap is removed. Its rationale was wrong twice over:
webhook ingress already bounds a body at 256 KiB
(handler.maxWebhookBodyBytes), so the payload was never unbounded from
Multica's side; and because de-duplication made the per-turn message the
payload's ONLY rendering, truncating there dropped task input with no way
back — GET /api/autopilots/{id}/runs/{runId} returns the full payload but no
CLI command exposes it, and `autopilot runs` lists runs without payloads. A
legitimate 9 KB webhook would have silently lost fields the autopilot
instructions might name, and the "JSON is incomplete" notice only prevents
misreading, it does not recover anything. The cap belongs with an agent-facing
read-back command and a threshold taken from real payload sizes, not as a side
effect of removing duplicates. Rendering it once instead of twice is still a
strict improvement over the pre-PR behavior.

TestBriefByteIdenticalAcrossRunsForEveryKind could not see the invariant this
PR actually moves: its autopilot row pins run-1 / ap-1 and every variant
mutates only the generic resume / initiator / connected-app fields, so putting
the title, source, payload or description back in the brief left it green.
TestAutopilotBriefByteIdenticalAcrossRunScopedFields adds a second and third
run that vary all of them — including a different autopilot id — asserts the
brief is byte-identical, and asserts the brief contains none of those values.
That second half matters: byte-identity alone also holds if the values reach
no surface at all. Verified against a deliberately reintroduced field, where
it fails as intended. TestBuildPromptAutopilotRunOnly gains the source and
payload assertions so the per-turn message is pinned as their sole owner.

Contract comments that still described the deleted sidecar are corrected:
agent.MinHandoffCLIVersion (the gate always measured the opening prompt — no
provider read the sidecar), the handoff_note field docs on both sides of the
claim wire, the claim handler's note, TaskContextForEnv.HandoffNote, and
errPathPreExists's namespace list. The Reuse path keeps its issue_context.md
mentions, now labelled as cleanup of manifests written by older builds rather
than of anything this code still writes.

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

* refactor(squad): keep the protocol's ownership note out of the prompt (MUL-6984)

Review nit on #7971. The Squad Operating Protocol ended with a paragraph
saying it is the single statement of the no_action rule and that the rule
changes here — true, and worth writing down, but addressed to whoever
maintains the text. That const is sent to the model on every leader turn, so
the leader paid ~40 tokens per turn for a sentence it cannot act on.

Moved to the const's doc comment, next to the other constraints a maintainer
reads before editing it. The skill side already records the same ownership in
multica-squads/references/squad-source-map.md.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 18:57:35 +08:00

301 lines
8.0 KiB
Go

package execenv
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSkillModelInvocationVisibility(t *testing.T) {
t.Parallel()
cases := []struct {
name string
content string
want bool
}{
{
name: "no frontmatter is visible",
content: "body",
want: true,
},
{
name: "disable true is hidden",
content: `---
name: hidden
disable-model-invocation: true
---
body`,
want: false,
},
{
name: "disable quoted true is hidden",
content: `---
name: hidden
disable-model-invocation: "true"
---
body`,
want: false,
},
{
name: "disable false is visible",
content: `---
name: visible
disable-model-invocation: false
---
body`,
want: true,
},
{
name: "user invocable false is still visible",
content: `---
name: visible
user-invocable: false
---
body`,
want: true,
},
{
name: "invalid frontmatter is visible",
content: `---
name: visible
description: bad: value
disable-model-invocation: true
---
body`,
want: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := skillModelInvocationVisible(SkillContextForEnv{Content: tc.content})
if got != tc.want {
t.Errorf("skillModelInvocationVisible = %v, want %v", got, tc.want)
}
})
}
}
func TestRenderedSkillListsHideDisableModelInvocationSkills(t *testing.T) {
t.Parallel()
ctx := TaskContextForEnv{
IssueID: "issue-1",
QuickCreatePrompt: "create something",
AutopilotRunID: "run-1",
AgentName: "Eve",
AgentID: "eve-1",
AgentSkills: []SkillContextForEnv{
{
Name: "Visible Skill",
Description: "visible description",
Content: `---
name: visible-skill
---
Visible body.`,
},
{
Name: "Hidden Skill",
Description: "hidden description",
Content: `---
name: hidden-skill
disable-model-invocation: true
---
Hidden body.`,
},
},
}
// The brief is now the only Multica-rendered skill listing, for every kind.
for _, kind := range []TaskContextForEnv{
{IssueID: ctx.IssueID, AgentName: ctx.AgentName, AgentID: ctx.AgentID, AgentSkills: ctx.AgentSkills},
{QuickCreatePrompt: ctx.QuickCreatePrompt, AgentName: ctx.AgentName, AgentID: ctx.AgentID, AgentSkills: ctx.AgentSkills},
{AutopilotRunID: ctx.AutopilotRunID, AgentName: ctx.AgentName, AgentID: ctx.AgentID, AgentSkills: ctx.AgentSkills},
{ChatSessionID: "c-1", AgentName: ctx.AgentName, AgentID: ctx.AgentID, AgentSkills: ctx.AgentSkills},
} {
out := buildMetaSkillContent("codex", kind)
// Listings carry the on-disk slug, not the display name (MUL-5529).
if !strings.Contains(out, "visible-skill") {
t.Errorf("brief missing visible skill:\n%s", out)
}
if strings.Contains(out, "Visible Skill") {
t.Errorf("brief lists the display name instead of the slug:\n%s", out)
}
if strings.Contains(out, "hidden-skill") || strings.Contains(out, "hidden description") {
t.Errorf("brief advertised disable-model-invocation skill:\n%s", out)
}
}
}
// sanitizeSkillName is not injective — "A B" and "A-B" both reduce to "a-b" —
// so a listing built from it alone names two skills identically while
// writeSkillFiles puts the second in `a-b-multica`. The second skill then has
// no invocable name and the model is silently pointed at the first. This needs
// no user-installed skill and no local_directory: an ordinary task with two
// such skills bound reproduces it in a clean workdir.
//
// The assertion is set equality between what is listed and what exists on
// disk, so any future drift between the two allocators fails here.
func TestSkillListingNamesMatchDirectoriesOnBatchSlugCollision(t *testing.T) {
t.Parallel()
skills := []SkillContextForEnv{
{Name: "A B", Content: "---\nname: one\n---\n\nfirst"},
{Name: "A-B", Content: "---\nname: two\n---\n\nsecond"},
{Name: "a.b", Content: "---\nname: three\n---\n\nthird"},
}
dir := t.TempDir()
if err := writeSkillFiles(dir, skills, nil); err != nil {
t.Fatalf("writeSkillFiles failed: %v", err)
}
onDisk := map[string]struct{}{}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read skills dir: %v", err)
}
for _, e := range entries {
onDisk[e.Name()] = struct{}{}
if _, err := os.Stat(filepath.Join(dir, e.Name(), "SKILL.md")); err != nil {
t.Errorf("no SKILL.md under %s: %v", e.Name(), err)
}
}
listed := map[string]struct{}{}
for _, s := range modelVisibleSkills(skills) {
if _, dup := listed[s.Name]; dup {
t.Errorf("listing repeats the name %q, so one skill has no invocable name", s.Name)
}
listed[s.Name] = struct{}{}
}
if len(listed) != len(skills) {
t.Errorf("listed %d distinct names for %d skills: %v", len(listed), len(skills), listed)
}
for name := range listed {
if _, ok := onDisk[name]; !ok {
t.Errorf("listing advertises %q but no such directory exists; on disk: %v", name, onDisk)
}
}
for name := range onDisk {
if _, ok := listed[name]; !ok {
t.Errorf("directory %q was written but never listed; listed: %v", name, listed)
}
}
}
// Hidden skills still occupy a directory, so they must still consume a slug.
// Allocating over the filtered list instead of the full batch would shift every
// later suffix and desynchronize the listing from disk.
func TestBatchSlugAllocationCountsHiddenSkills(t *testing.T) {
t.Parallel()
skills := []SkillContextForEnv{
{Name: "A B", Content: "---\nname: hidden\ndisable-model-invocation: true\n---\n\nhidden"},
{Name: "A-B", Content: "---\nname: visible\n---\n\nvisible"},
}
dir := t.TempDir()
if err := writeSkillFiles(dir, skills, nil); err != nil {
t.Fatalf("writeSkillFiles failed: %v", err)
}
visible := modelVisibleSkills(skills)
if len(visible) != 1 {
t.Fatalf("expected 1 visible skill, got %d", len(visible))
}
// The hidden skill took `a-b`, so the visible one must be listed — and
// written — as `a-b-multica`.
if visible[0].Name != "a-b-multica" {
t.Errorf("visible skill listed as %q, want %q", visible[0].Name, "a-b-multica")
}
if _, err := os.Stat(filepath.Join(dir, "a-b-multica", "SKILL.md")); err != nil {
t.Errorf("listed name has no matching directory: %v", err)
}
}
func TestRenderedSkillListsOmitSectionWhenOnlyDisabledSkills(t *testing.T) {
t.Parallel()
ctx := TaskContextForEnv{
IssueID: "issue-1",
AgentSkills: []SkillContextForEnv{
{
Name: "Hidden Skill",
Content: `---
name: hidden-skill
disable-model-invocation: true
---
Hidden body.`,
},
},
}
runtimeConfig := buildMetaSkillContent("codex", ctx)
if strings.Contains(runtimeConfig, "## Skills") {
t.Errorf("runtime config should omit Skills section when every skill disables model invocation:\n%s", runtimeConfig)
}
}
func TestWriteContextFilesHydratesDisableModelInvocationSkillButDoesNotAdvertiseIt(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "issue-1",
AgentSkills: []SkillContextForEnv{
{
Name: "Visible Skill",
Content: `---
name: visible-skill
---
Visible body.`,
},
{
Name: "Hidden Skill",
Content: `---
name: hidden-skill
disable-model-invocation: true
---
Hidden body.`,
},
},
}
if err := writeContextFiles(dir, "", ctx, nil); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
// The sidecar brief is gone (MUL-6984): nothing pointed at
// .agent_context/issue_context.md, so it was a third copy of facts the
// runtime brief and per-turn message already carry. Prepare must not
// resurrect it — a workdir-local file no surface names is exactly how a
// stale duplicate goes unnoticed.
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "issue_context.md")); !os.IsNotExist(err) {
t.Fatalf("writeContextFiles wrote a sidecar brief; stat err = %v, want not-exist", err)
}
hiddenSkill, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "hidden-skill", "SKILL.md"))
if err != nil {
t.Fatalf("disabled skill should still be hydrated on disk: %v", err)
}
if !strings.Contains(string(hiddenSkill), "disable-model-invocation: true") {
t.Errorf("hidden skill frontmatter should be preserved on disk:\n%s", string(hiddenSkill))
}
}