Files
2026-08-14 23:41:57 +08:00

316 lines
12 KiB
Go

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<mid<api), got positions root=%d mid=%d api=%d", iRoot, iMid, iAPI)
}
}
// TestBuildSystemPromptSkipsMissingIntermediate verifies a missing intermediate
// AGENTS.md is skipped without breaking the ordering of the present ones.
func TestBuildSystemPromptSkipsMissingIntermediate(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 ONLY",
filepath.Join(wd, agentsFileName): "API ONLY",
// no AGENTS.md at mid
}
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 ONLY")
iAPI := strings.Index(got, "API ONLY")
if iRoot < 0 || iAPI < 0 || iRoot >= iAPI {
t.Errorf("present AGENTS.md must stay ordered root<api, got root=%d api=%d in:\n%s", iRoot, iAPI, got)
}
}
// TestBuildSystemPromptNoRootOnlyWorkingDir verifies that with no Root, only the
// working directory's own AGENTS.md is considered (no ancestor walk).
func TestBuildSystemPromptNoRootOnlyWorkingDir(t *testing.T) {
wd := filepath.Clean("/repo/services/api")
ancestor := filepath.Join(filepath.Dir(wd), agentsFileName)
files := map[string]string{
filepath.Join(wd, agentsFileName): "WD ONLY",
ancestor: "ANCESTOR (should NOT appear)",
}
got, err := BuildSystemPrompt(PromptConfig{
WorkingDir: wd, 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)
}
if !strings.Contains(got, "WD ONLY") {
t.Errorf("working-dir AGENTS.md must be injected:\n%s", got)
}
if strings.Contains(got, "ANCESTOR") {
t.Errorf("with no Root, ancestor AGENTS.md must not be walked:\n%s", got)
}
}
// TestBuildSystemPromptReadErrorSurfaces verifies a present-but-unreadable
// AGENTS.md (a non-not-exist I/O error) is reported rather than silently
// dropped.
func TestBuildSystemPromptReadErrorSurfaces(t *testing.T) {
wd := filepath.Clean("/repo")
_, err := BuildSystemPrompt(PromptConfig{
WorkingDir: wd, Now: fixedTime,
ReadFile: func(string) ([]byte, error) { return nil, os.ErrPermission },
})
if err == nil {
t.Fatal("an unreadable AGENTS.md must surface an error, got nil")
}
}
// TestBuildSystemPromptBaseInstructionOverride verifies a non-empty
// BaseInstruction replaces the default coding-assistant prompt (mirrors pi's
// --system-prompt) while the environment block still follows it.
func TestBuildSystemPromptBaseInstructionOverride(t *testing.T) {
got, err := BuildSystemPrompt(PromptConfig{
BaseInstruction: "You are a haiku poet.",
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, "You are a haiku poet.") {
t.Errorf("custom base instruction should lead the prompt, got:\n%s", got)
}
if strings.Contains(got, DefaultBaseInstruction) {
t.Errorf("default base instruction must not appear when overridden:\n%s", got)
}
if !strings.Contains(got, "Working directory: /work/proj") {
t.Errorf("environment block must still follow the custom base:\n%s", got)
}
}
// TestBuildSystemPromptAppendInstructions verifies --append-system-prompt
// entries are layered onto the end of the prompt in order, after the base
// instruction and environment block, with empty entries skipped.
func TestBuildSystemPromptAppendInstructions(t *testing.T) {
got, err := BuildSystemPrompt(PromptConfig{
WorkingDir: "/work/proj",
Now: fixedTime,
AppendInstructions: []string{"FIRST APPEND", " ", "SECOND APPEND"},
ReadFile: func(string) ([]byte, error) { return nil, os.ErrNotExist },
})
if err != nil {
t.Fatalf("BuildSystemPrompt: %v", err)
}
iEnv := strings.Index(got, "Working directory")
iFirst := strings.Index(got, "FIRST APPEND")
iSecond := strings.Index(got, "SECOND APPEND")
if iFirst < 0 || iSecond < 0 {
t.Fatalf("both appended instructions must be present, got:\n%s", got)
}
if !(iEnv < iFirst && iFirst < iSecond) {
t.Errorf("appends must follow the env block and keep order (env<first<second), got env=%d first=%d second=%d", iEnv, iFirst, iSecond)
}
}
// TestBuildSystemPromptInjectsSkills verifies the <available_skills> 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, "<available_skills>") || !strings.Contains(got, "<name>weather</name>") {
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, "<available_skills>")
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
// <available_skills> 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 <available_skills>, got:\n%s", block)
}
if !strings.Contains(block, "<name>weather</name>") {
t.Errorf("model-invocable skill must appear in <available_skills>, 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)
}
}