mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* 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]>
301 lines
8.0 KiB
Go
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))
|
|
}
|
|
}
|