package runtime // Tests for system-prompt assembly (US-021, #40): the base instruction, the // environment block, and — the acceptance-critical part — the general-to- // specific ordering of AGENTS.md injection from a root directory down to the // working directory. AGENTS.md layout is faked via PromptConfig.ReadFile so the // ordering is asserted without touching disk. import ( "os" "path/filepath" "strings" "testing" "time" ) // fixedTime is a deterministic clock for the environment block. func fixedTime() time.Time { return time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) } // TestBuildSystemPromptBaseAndEnv verifies the base instruction and environment // block (cwd, OS, date) are present, with no AGENTS.md when none exist. func TestBuildSystemPromptBaseAndEnv(t *testing.T) { got, err := BuildSystemPrompt(PromptConfig{ WorkingDir: "/work/proj", Now: fixedTime, ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist }, }) if err != nil { t.Fatalf("BuildSystemPrompt: %v", err) } if !strings.HasPrefix(got, DefaultBaseInstruction) { t.Errorf("prompt should start with the default base instruction, got:\n%s", got) } if !strings.Contains(got, "Working directory: /work/proj") { t.Errorf("environment block missing working directory:\n%s", got) } if !strings.Contains(got, "Date: 2026-07-10") { t.Errorf("environment block missing date:\n%s", got) } if strings.Contains(got, "Project instructions") { t.Errorf("no AGENTS.md exists, but prompt injected one:\n%s", got) } } // TestBuildSystemPromptAdvertisesTaskFanout verifies the base instruction tells // the model about the generic task tool: that it dispatches an independent // sub-agent (delegation) and that multiple task calls in one message run in // parallel (fan-out, US-008/#458). func TestBuildSystemPromptAdvertisesTaskFanout(t *testing.T) { got, err := BuildSystemPrompt(PromptConfig{ WorkingDir: "/work/proj", Now: fixedTime, ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist }, }) if err != nil { t.Fatalf("BuildSystemPrompt: %v", err) } lower := strings.ToLower(got) if !strings.Contains(lower, "task tool") { t.Errorf("prompt should advertise the task tool:\n%s", got) } if !strings.Contains(lower, "sub-agent") || !strings.Contains(lower, "independent") { t.Errorf("prompt should describe the task tool as an independent sub-agent (delegation):\n%s", got) } if !strings.Contains(lower, "parallel") { t.Errorf("prompt should state that multiple task calls run in parallel (fan-out):\n%s", got) } } // TestBuildSystemPromptAGENTSOrdering is the acceptance-critical test: with an // AGENTS.md at the root and at a nested working directory, the root's content // must appear BEFORE the nested one (general → specific). func TestBuildSystemPromptAGENTSOrdering(t *testing.T) { root := filepath.Clean("/repo") mid := filepath.Join(root, "services") wd := filepath.Join(mid, "api") files := map[string]string{ filepath.Join(root, agentsFileName): "ROOT CONVENTIONS", filepath.Join(mid, agentsFileName): "SERVICES CONVENTIONS", filepath.Join(wd, agentsFileName): "API CONVENTIONS", } got, err := BuildSystemPrompt(PromptConfig{ WorkingDir: wd, Root: root, Now: fixedTime, ReadFile: func(path string) ([]byte, error) { if c, ok := files[path]; ok { return []byte(c), nil } return nil, os.ErrNotExist }, }) if err != nil { t.Fatalf("BuildSystemPrompt: %v", err) } iRoot := strings.Index(got, "ROOT CONVENTIONS") iMid := strings.Index(got, "SERVICES CONVENTIONS") iAPI := strings.Index(got, "API CONVENTIONS") if iRoot < 0 || iMid < 0 || iAPI < 0 { t.Fatalf("all three AGENTS.md must be injected, got:\n%s", got) } if !(iRoot < iMid && iMid < iAPI) { t.Errorf("AGENTS.md must be ordered general→specific (root= iAPI { t.Errorf("present AGENTS.md must stay ordered root block is // appended after the base/env/append layers when the read tool is available. func TestBuildSystemPromptInjectsSkills(t *testing.T) { skills := []*Skill{ {Frontmatter: SkillFrontmatter{Name: "weather", Description: "get weather"}, Path: "/skills/weather.md"}, {Frontmatter: SkillFrontmatter{Name: "secret", Description: "hidden", DisableModelInvocation: true}, Path: "/skills/secret.md"}, } got, err := BuildSystemPrompt(PromptConfig{ WorkingDir: "/work/proj", Now: fixedTime, ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist }, Skills: skills, ReadToolAvailable: true, }) if err != nil { t.Fatalf("BuildSystemPrompt: %v", err) } if !strings.Contains(got, "") || !strings.Contains(got, "weather") { t.Errorf("skills block must be injected, got:\n%s", got) } if strings.Contains(got, "secret") { t.Errorf("disable-model-invocation skill must not appear, got:\n%s", got) } iEnv := strings.Index(got, "Working directory") iSkills := strings.Index(got, "") if !(iEnv < iSkills) { t.Errorf("skills block must come after env block, env=%d skills=%d", iEnv, iSkills) } } // TestBuildSystemPromptNoSkillsWithoutReadTool verifies skills are NOT injected // when the read tool is unavailable, and that a skill-free prompt is unchanged. func TestBuildSystemPromptNoSkillsWithoutReadTool(t *testing.T) { skills := []*Skill{{Frontmatter: SkillFrontmatter{Name: "weather", Description: "d"}, Path: "/s/weather.md"}} withTool, _ := BuildSystemPrompt(PromptConfig{WorkingDir: "/w", Now: fixedTime, ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist }, Skills: skills, ReadToolAvailable: false}) if strings.Contains(withTool, "available_skills") { t.Errorf("no read tool → no skills block, got:\n%s", withTool) } // A prompt with no skills at all must equal one with read tool but empty list. bare, _ := BuildSystemPrompt(PromptConfig{WorkingDir: "/w", Now: fixedTime, ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist }}) withReadNoSkills, _ := BuildSystemPrompt(PromptConfig{WorkingDir: "/w", Now: fixedTime, ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist }, ReadToolAvailable: true}) if bare != withReadNoSkills { t.Errorf("empty skill list must not alter the prompt even with read tool:\n%q\nvs\n%q", bare, withReadNoSkills) } } // TestDisableModelInvocationCoexistence verifies the #305 coexistence contract: // a skill with disable-model-invocation:true is STILL exposed as a /skill-name // slash command (body expansion + $ARGUMENTS), while being EXCLUDED from the // prompt injection. The two invocation paths are independent. func TestDisableModelInvocationCoexistence(t *testing.T) { disabled := &Skill{ Frontmatter: SkillFrontmatter{Name: "secret", Description: "hidden", DisableModelInvocation: true}, Path: "/skills/secret.md", Body: "Do the secret thing with $ARGUMENTS.", } enabled := &Skill{ Frontmatter: SkillFrontmatter{Name: "weather", Description: "get weather"}, Path: "/skills/weather.md", Body: "Report the weather.", } skills := []*Skill{disabled, enabled} // 1. The disabled skill must be excluded from the model-facing prompt block, // while the enabled one appears. block := FormatSkillsForPrompt(skills) if strings.Contains(block, "secret") { t.Errorf("disable-model-invocation skill must not appear in , got:\n%s", block) } if !strings.Contains(block, "weather") { t.Errorf("model-invocable skill must appear in , got:\n%s", block) } // 2. The disabled skill must still be invocable via its /skill-name command, // with $ARGUMENTS substitution intact (behavior identical to an enabled one). cmd := disabled.SlashCommand() if cmd.Name != "secret" { t.Errorf("disabled skill slash name = %q, want secret", cmd.Name) } if cmd.Expand == nil { t.Fatal("disabled skill must expose a prompt command (Expand != nil)") } if got := cmd.Expand("now"); got != "Do the secret thing with now." { t.Errorf("Expand(now) = %q, want $ARGUMENTS substituted", got) } }