first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
package runtime
// Tests for the layered configuration system (US-023, #42): the precedence
// order (default < global < project < env/CLI), per-provider credential merge,
// and the hard-error paths for malformed files and invalid field values.
import (
"os"
"path/filepath"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/hooks"
)
// ptr is a helper for building pointer-valued config-layer fields in tests.
func ptr[T any](v T) *T { return &v }
// TestResolveConfigPrecedence is the acceptance-critical test: a field set in a
// higher layer overrides the same field in every lower layer, and the winner is
// always the highest layer that set it.
func TestResolveConfigPrecedence(t *testing.T) {
def := DefaultConfigLayer()
global := &ConfigLayer{Model: ptr("global/model"), ThinkingLevel: ptr("low")}
project := &ConfigLayer{Model: ptr("project/model")}
env := &ConfigLayer{Model: ptr("env/model"), Provider: ptr("bedrock")}
cfg, err := ResolveConfig(&def, global, project, env)
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
// Model set in all four layers → env (highest) wins.
if cfg.Model != "env/model" {
t.Errorf("Model = %q, want env/model (highest layer wins)", cfg.Model)
}
// Provider set only in env → env value.
if cfg.Provider != "bedrock" {
t.Errorf("Provider = %q, want bedrock", cfg.Provider)
}
// ThinkingLevel set in global only (not project/env) → global value shows through.
if cfg.ThinkingLevel != agentcore.ThinkingLow {
t.Errorf("ThinkingLevel = %q, want low (from global, lower layers don't set it)", cfg.ThinkingLevel)
}
// ToolExecutionMode set only in default → default shows through.
if cfg.ToolExecutionMode != agentcore.ToolExecutionParallel {
t.Errorf("ToolExecutionMode = %q, want parallel (default)", cfg.ToolExecutionMode)
}
}
// TestResolveConfigCredentialMerge verifies credentials merge per-provider: a
// higher layer overrides one provider's key while a lower layer's other-provider
// key is retained.
func TestResolveConfigCredentialMerge(t *testing.T) {
def := DefaultConfigLayer()
global := &ConfigLayer{Credentials: map[string]string{"openrouter": "or-low", "ollama": "ol-key"}}
project := &ConfigLayer{Credentials: map[string]string{"openrouter": "or-high"}}
cfg, err := ResolveConfig(&def, global, project)
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
if cfg.Credentials["openrouter"] != "or-high" {
t.Errorf("openrouter key = %q, want or-high (project overrides global)", cfg.Credentials["openrouter"])
}
if cfg.Credentials["ollama"] != "ol-key" {
t.Errorf("ollama key = %q, want ol-key (retained from global)", cfg.Credentials["ollama"])
}
}
// TestResolveConfigInvalidValues verifies invalid field values are hard errors.
func TestResolveConfigInvalidValues(t *testing.T) {
def := DefaultConfigLayer()
cases := []struct {
name string
layer *ConfigLayer
}{
{"bad mode", &ConfigLayer{ToolExecutionMode: ptr("concurrent")}},
{"bad thinking", &ConfigLayer{ThinkingLevel: ptr("ultra")}},
{"empty model", &ConfigLayer{Model: ptr("")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := ResolveConfig(&def, tc.layer); err == nil {
t.Errorf("%s must be a hard error, got nil", tc.name)
}
})
}
}
// TestLoadConfigLayerMissingAndMalformed verifies a missing file is not an error
// (nil layer) while a malformed file is.
func TestLoadConfigLayerMissingAndMalformed(t *testing.T) {
dir := t.TempDir()
// Missing file → nil, nil.
layer, err := LoadConfigLayer(filepath.Join(dir, "nope.json"))
if err != nil || layer != nil {
t.Errorf("missing file: got (%v, %v), want (nil, nil)", layer, err)
}
// Malformed file → error.
bad := filepath.Join(dir, "bad.json")
if err := os.WriteFile(bad, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadConfigLayer(bad); err == nil {
t.Error("malformed config file must return an error")
}
// Well-formed file → decoded layer.
good := filepath.Join(dir, "good.json")
if err := os.WriteFile(good, []byte(`{"model":"m","provider":"p"}`), 0o600); err != nil {
t.Fatal(err)
}
layer, err = LoadConfigLayer(good)
if err != nil {
t.Fatalf("good file: %v", err)
}
if layer == nil || layer.Model == nil || *layer.Model != "m" {
t.Errorf("good file decoded incorrectly: %+v", layer)
}
}
// TestEnvConfigLayer verifies env vars map to the right fields and unset vars
// leave fields nil.
func TestEnvConfigLayer(t *testing.T) {
env := map[string]string{
"PIGO_MODEL": "env/m",
"PIGO_THINKING_LEVEL": "high",
"PIGO_TOOL_EXECUTION_MODE": "sequential",
}
layer := EnvConfigLayer(func(k string) string { return env[k] })
if layer.Model == nil || *layer.Model != "env/m" {
t.Errorf("PIGO_MODEL not captured: %+v", layer.Model)
}
if layer.ThinkingLevel == nil || *layer.ThinkingLevel != "high" {
t.Errorf("PIGO_THINKING_LEVEL not captured: %+v", layer.ThinkingLevel)
}
if layer.ToolExecutionMode == nil || *layer.ToolExecutionMode != "sequential" {
t.Errorf("PIGO_TOOL_EXECUTION_MODE not captured: %+v", layer.ToolExecutionMode)
}
// Unset var → nil field.
if layer.Provider != nil {
t.Errorf("unset PIGO_PROVIDER should leave Provider nil, got %v", *layer.Provider)
}
}
// TestResolveConfigDefaultsAlone verifies the default layer alone yields a valid
// config.
func TestResolveConfigDefaultsAlone(t *testing.T) {
def := DefaultConfigLayer()
cfg, err := ResolveConfig(&def)
if err != nil {
t.Fatalf("default-only config must be valid: %v", err)
}
if cfg.Model == "" || cfg.ToolExecutionMode == "" || cfg.ThinkingLevel == "" {
t.Errorf("default config incomplete: %+v", cfg)
}
}
// TestResolveConfigHooksAppendMerge verifies hooks are append-merged per event
// type across layers (FR-2) in ascending order, not overridden like scalars.
func TestResolveConfigHooksAppendMerge(t *testing.T) {
def := DefaultConfigLayer()
global := &ConfigLayer{Hooks: hooks.HookSet{
"PreToolUse": {{Matcher: "*", Hooks: []hooks.HookConfig{{Command: "global-pre"}}}},
"Stop": {{Matcher: "", Hooks: []hooks.HookConfig{{Command: "global-stop"}}}},
}}
project := &ConfigLayer{Hooks: hooks.HookSet{
"PreToolUse": {{Matcher: "bash", Hooks: []hooks.HookConfig{{Command: "project-pre"}}}},
}}
cfg, err := ResolveConfig(&def, global, project)
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
pre := cfg.Hooks["PreToolUse"]
if len(pre) != 2 {
t.Fatalf("PreToolUse matchers = %d, want 2 (global+project appended)", len(pre))
}
// Ascending layer order: global before project.
if pre[0].Hooks[0].Command != "global-pre" || pre[1].Hooks[0].Command != "project-pre" {
t.Errorf("PreToolUse order wrong: %q, %q", pre[0].Hooks[0].Command, pre[1].Hooks[0].Command)
}
if len(cfg.Hooks["Stop"]) != 1 {
t.Errorf("Stop matchers = %d, want 1 (only global set it)", len(cfg.Hooks["Stop"]))
}
}
// TestResolveConfigHooksNilWhenAbsent verifies the no-hooks path leaves
// cfg.Hooks nil (FR-18), so downstream can cheaply skip hook dispatch.
func TestResolveConfigHooksNilWhenAbsent(t *testing.T) {
def := DefaultConfigLayer()
cfg, err := ResolveConfig(&def)
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
if cfg.Hooks != nil {
t.Errorf("Hooks = %v, want nil when no layer defines hooks", cfg.Hooks)
}
// An empty (but non-nil) hook map in a layer must not allocate cfg.Hooks.
empty := &ConfigLayer{Hooks: hooks.HookSet{"PreToolUse": {}}}
cfg2, err := ResolveConfig(&def, empty)
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
if cfg2.Hooks != nil {
t.Errorf("Hooks = %v, want nil when layer's event has no matchers", cfg2.Hooks)
}
}
// TestLoadConfigLayerHooks verifies a layer's hooks decode from JSON, including
// per-hook timeout and matcher fields.
func TestLoadConfigLayerHooks(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
body := `{
"model": "x/y",
"hooks": {
"PreToolUse": [
{"matcher": "bash", "hooks": [{"type": "command", "command": "echo hi", "timeout": 5}]}
]
}
}`
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
layer, err := LoadConfigLayer(path)
if err != nil {
t.Fatalf("LoadConfigLayer: %v", err)
}
pre := layer.Hooks["PreToolUse"]
if len(pre) != 1 || pre[0].Matcher != "bash" {
t.Fatalf("unexpected matchers: %+v", pre)
}
h := pre[0].Hooks[0]
if h.Command != "echo hi" || h.TimeoutSeconds() != 5 {
t.Errorf("unexpected hook: cmd=%q timeout=%d", h.Command, h.TimeoutSeconds())
}
}