From ea31e7128022f500eb5ea7e9b56baff4ec3e3ff2 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 6 Nov 2026 15:28:45 +0000 Subject: [PATCH] Test typed sources through configuration composition --- docs/roadmap/implementation.md | 4 ++ prompt_sources_contract_test.go | 94 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 prompt_sources_contract_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 074864d..8877e02 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -494,6 +494,10 @@ the helper itself neither accesses files nor resolves inheritance. ## Stage 10: Verify Typed Sources Through Real Composition +**Completed:** Real engine composition tests cover inherited backend and prompt +records, layered completion, snapshots, explicit values, relative inherited file +content using ConfigKit's filesystem context, and deferred final validation. + **Scope:** Focused source-helper contract tests, not a second ConfigKit suite. 1. Extend existing backend-source tests with a real inherited child, preserving diff --git a/prompt_sources_contract_test.go b/prompt_sources_contract_test.go new file mode 100644 index 0000000..af9a9a5 --- /dev/null +++ b/prompt_sources_contract_test.go @@ -0,0 +1,94 @@ +package promptkit_test + +import ( + "context" + "errors" + "testing" + "testing/fstest" + + "go.maximumdirect.net/configkit" + "go.maximumdirect.net/promptkit" +) + +func TestTypedSourcesComposeAndCaptureValues(t *testing.T) { + endpoint, childEndpoint, base := "https://base.test/v1", "https://child.test/v1", "base" + limit, queue := 2, 0 + backendSource := promptkit.BackendSource("backends", []promptkit.BackendConfig{{ID: "base", Endpoint: &endpoint, ConcurrencyLimit: &limit}, {ID: "child", Extends: &base, Endpoint: &childEndpoint, QueueCapacity: &queue}}) + role, content, secondContent, empty, cacheType := "user", "hello {{.name}}", "second", "", "ephemeral" + format, validation, description, profileID := "text", "none", "description", "local" + required, repairs := false, 0 + prompts := []promptkit.PromptConfig{ + {ID: "base", DefaultProfile: &profileID, Description: &description, Inputs: []promptkit.PromptInputConfig{{Name: "input", Required: &required, Description: &empty}}, Messages: []promptkit.PromptMessageConfig{{Role: &role, Content: &content, CacheControl: &promptkit.PromptCacheControlConfig{Type: &cacheType, TTL: &empty}}, {Role: &role, Content: &secondContent}}, Output: &promptkit.PromptOutputConfig{Format: &format, ValidationMode: &validation, RepairAttempts: &repairs}}, + {ID: "child", Extends: &base}, + {ID: "layered"}, + {ID: "empty-inputs", Inputs: []promptkit.PromptInputConfig{}, Messages: []promptkit.PromptMessageConfig{{Role: &role, Content: &content}}, Output: &promptkit.PromptOutputConfig{Format: &format, ValidationMode: &validation}}, + {ID: "omitted-inputs", Messages: []promptkit.PromptMessageConfig{{Role: &role, Content: &content}}, Output: &promptkit.PromptOutputConfig{Format: &format, ValidationMode: &validation}}, + } + promptSource := promptkit.PromptSource("typed prompts", prompts) + endpoint, childEndpoint, role, content, description = "changed", "changed", "changed", "changed", "changed" + prompts[0].Inputs[0].Name = "changed" + prompts[0].Messages[1].Content = &empty + prompts[1].ID = "changed" + profileEndpoint, model := "https://profile.test/v1", "model" + main := configkit.Input{Source: configkit.Mapping("completion", map[string]any{"prompts": []any{map[string]any{"id": "layered", "messages": []any{map[string]any{"role": "user", "content": "layer"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}}})} + profileSource := promptkit.ProfileSource("profiles", []promptkit.ProfileConfig{{ID: "local", Endpoint: &profileEndpoint, Model: &model}}) + underlays := []configkit.Underlay{{Inputs: []configkit.Input{{Source: backendSource}, {Source: promptSource}, {Source: profileSource}}}} + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: underlays, Main: &main}}, promptkit.WithBundledOpenRouter(false)) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + backend, err := engine.InspectBackend(ctx, "child") + if err != nil || backend.BackendID != "child" || backend.Endpoint != "https://child.test/v1" || backend.ConcurrencyLimit == nil || *backend.ConcurrencyLimit != 2 || backend.QueueCapacity == nil || *backend.QueueCapacity != 0 { + t.Fatalf("backend = %+v, %v", backend, err) + } + prompt, err := engine.InspectPrompt(ctx, "child") + if err != nil { + t.Fatal(err) + } + if prompt.DefaultProfileID == nil || *prompt.DefaultProfileID != "local" || prompt.Description == nil || *prompt.Description != "description" || prompt.Inputs == nil || len(*prompt.Inputs) != 1 || (*prompt.Inputs)[0].Name != "input" || (*prompt.Inputs)[0].Required == nil || *(*prompt.Inputs)[0].Required || (*prompt.Inputs)[0].Description == nil || *(*prompt.Inputs)[0].Description != "" || len(prompt.Messages) != 2 || prompt.Messages[0].Content != "hello {{.name}}" || prompt.Messages[1].Content != "second" || prompt.Messages[0].CacheControl.TTL == nil || *prompt.Messages[0].CacheControl.TTL != "" || prompt.OutputContract.RepairAttempts == nil || *prompt.OutputContract.RepairAttempts != 0 { + t.Fatalf("prompt = %+v", prompt) + } + prepared, err := engine.Prepare(ctx, promptkit.RunRequest{PromptID: "child", Vars: map[string]string{"name": "reader"}}) + if err != nil || prepared.Messages[0].Content != "hello reader" { + t.Fatalf("preparation = %+v, %v", prepared, err) + } + for _, id := range []string{"layered", "empty-inputs", "omitted-inputs"} { + result, err := engine.InspectPrompt(ctx, id) + if err != nil { + t.Fatal(err) + } + if id == "empty-inputs" && (result.Inputs == nil || len(*result.Inputs) != 0) { + t.Fatalf("empty inputs = %+v", result.Inputs) + } + if id == "omitted-inputs" && result.Inputs != nil { + t.Fatalf("omitted inputs = %+v", result.Inputs) + } + } +} + +func TestTypedPromptSourceUsesExplicitFilesystemContext(t *testing.T) { + role, path, format, validation, parent := "user", "body.md", "text", "none", "base" + source := promptkit.PromptSource("file prompts", []promptkit.PromptConfig{{ID: "base", Messages: []promptkit.PromptMessageConfig{{Role: &role, ContentFile: &path}}, Output: &promptkit.PromptOutputConfig{Format: &format, ValidationMode: &validation}}, {ID: "child", Extends: &parent}}) + files := fstest.MapFS{"templates/body.md": {Data: []byte("captured {{.value}}")}} + filesystem := configkit.NewFilesystem("fixture", files) + input := configkit.Input{Source: source, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "templates"}} + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}) + if err != nil { + t.Fatal(err) + } + files["templates/body.md"].Data = []byte("changed") + prompt, err := engine.InspectPrompt(context.Background(), "child") + if err != nil || prompt.Messages[0].Content != "captured {{.value}}" { + t.Fatalf("file prompt = %+v, %v", prompt, err) + } +} + +func TestTypedPromptSourceDefersValidation(t *testing.T) { + role, body, path, format, validation := "user", "body", "also-file", "text", "none" + source := promptkit.PromptSource("invalid", []promptkit.PromptConfig{{ID: "bad", Messages: []promptkit.PromptMessageConfig{{Role: &role, Content: &body, ContentFile: &path}}, Output: &promptkit.PromptOutputConfig{Format: &format, ValidationMode: &validation}}}) + _, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: source}}}) + if !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Fatalf("invalid final source: %v", err) + } +}