From c3ce15a4abba1c05758dc4990da91dff9714fbe0 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 03:56:23 +0000 Subject: [PATCH] Capture prompts from identified lists --- docs/roadmap/implementation.md | 8 +++++++ internal/configuration/load.go | 10 ++++---- internal/configuration/prompts.go | 32 ++++++++++---------------- internal/configuration/prompts_test.go | 30 ++++++++++++++++-------- 4 files changed, 46 insertions(+), 34 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 1405dbe..21b0755 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -330,6 +330,14 @@ Validation: owning prompt tests prove correct content and input metadata after inheritance/replacement, including file edits not changing a constructed engine. Do not normalize a whole list solely to update a field and lose its origins. +Completion: prompt capture, normalization, input presence, and materialization +now use effective list indexes and full runtime IDs, preserving the shared lazy +origin snapshot. Converted native, explicit-context, inherited-import, and +replacement content fixtures. Focused execution is deferred because older +loader tests still index resource maps and reference removed prompt metadata; +their conversion is assigned to later work. Formatting and `git diff --check` +passed. + ## Stage 7: Preserve Inspection And Sensitive-Value Handling Scope: `internal/configuration/inspection.go`, configuration inspection/diff diff --git a/internal/configuration/load.go b/internal/configuration/load.go index 3fec27e..71e6b8c 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -323,11 +323,11 @@ type promptInputPresence struct { description fieldState } -func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) { - for _, id := range sortedKeys(prompts) { - inputs := prompts[id].Inputs +func capturePromptMetadata(prompts []promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) { + for promptIndex, prompt := range prompts { + inputs := prompt.Inputs states := make([]promptInputPresence, len(inputs)) - base := basePath.Key(id).Key("inputs") + base := basePath.Index(promptIndex).Key("inputs") for index := range inputs { path := base.Index(index) states[index] = promptInputPresence{ @@ -335,7 +335,7 @@ func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.P description: captureFieldState(path.Key("description"), resolution), } } - destination[indexedResourceID(namespace, id)] = states + destination[indexedResourceID(namespace, prompt.ID)] = states } } diff --git a/internal/configuration/prompts.go b/internal/configuration/prompts.go index b9bb9a1..4db79b1 100644 --- a/internal/configuration/prompts.go +++ b/internal/configuration/prompts.go @@ -28,10 +28,10 @@ type promptEntry struct { messages []*template.Template } -func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, resolution *configkit.Resolution) { - for _, id := range sortedKeys(prompts) { - definition := prompts[id] - base := basePath.Key(id) +func resolvePrompts(prompts []promptConfig, basePath configkit.Path, resolution *configkit.Resolution) { + for index := range prompts { + definition := &prompts[index] + base := basePath.Index(index) for _, field := range []struct { name string value **string @@ -43,14 +43,12 @@ func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, re normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value)) } } - inputsChanged := false for index := range definition.Inputs { input := &definition.Inputs[index] inputPath := base.Key("inputs").Index(index) normalizedName := strings.TrimSpace(input.Name) if normalizedName != input.Name { configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalizedName, "normalize input metadata") - inputsChanged = true } for _, field := range []struct { name string @@ -63,21 +61,16 @@ func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, re normalized := strings.TrimSpace(**field.value) if normalized != **field.value { configkit.Normalize(resolution, inputPath.Key(field.name), field.value, &normalized, "normalize input metadata") - inputsChanged = true } } } } - if inputsChanged { - resolution.RecordNormalization(base.Key("inputs"), "normalize input metadata") - } // Normalize message metadata on execution-owned copies below. ConfigKit // records sequence mutations as whole-value replacements, which would // discard the effective source contexts of unchanged content_file fields. if definition.Output != nil && definition.Output.SchemaPath != nil { normalizeString(&definition.Output.SchemaPath, base.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*definition.Output.SchemaPath)) } - prompts[id] = definition } } @@ -92,14 +85,13 @@ func (state *loaded) materializePrompts() error { return metadata.Lookup(path) } for _, resources := range resourceSets(&state.result.Config) { - for _, id := range sortedKeys(resources.prompts) { - promptBase := resources.bases.prompts.Key(id) - if strings.TrimSpace(id) == "" { + for index, raw := range resources.prompts { + promptBase := resources.bases.prompts.Index(index) + if strings.TrimSpace(raw.ID) == "" { return fmt.Errorf("%s: prompt ID must not be blank", promptBase) } - fullID := indexedResourceID(resources.namespace, id) - raw := resources.prompts[id] - entry, err := state.materializePrompt(fullID, raw.Version, raw, promptBase, lookupOrigin) + fullID := indexedResourceID(resources.namespace, raw.ID) + entry, err := state.materializePrompt(fullID, raw, promptBase, lookupOrigin) if err != nil { return err } @@ -109,11 +101,11 @@ func (state *loaded) materializePrompts() error { return nil } -func (state *loaded) materializePrompt(id string, version *string, raw promptConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) { +func (state *loaded) materializePrompt(id string, raw promptConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) { if len(raw.Messages) == 0 { return nil, fmt.Errorf("%s: at least one message is required", base.Key("messages")) } - definition := domain.PromptDefinition{ID: id, Version: stringPointerValue(version)} + definition := domain.PromptDefinition{ID: id} if raw.DefaultProfile != nil { if strings.TrimSpace(*raw.DefaultProfile) == "" { return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile")) @@ -242,7 +234,7 @@ func (state *loaded) materializePrompt(id string, version *string, raw promptCon entry.definition = definition entry.hash = hash entry.inspection = domain.PromptInspection{ - PromptID: id, PromptVersion: stringPointerValue(version), PromptHash: hash, + PromptID: id, PromptHash: hash, DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...), OutputContract: contract, } diff --git a/internal/configuration/prompts_test.go b/internal/configuration/prompts_test.go index ad38447..cd035e6 100644 --- a/internal/configuration/prompts_test.go +++ b/internal/configuration/prompts_test.go @@ -16,10 +16,11 @@ import ( func TestLoadCapturesInheritedImportedContent(t *testing.T) { files := fstest.MapFS{ - "app/main.yaml": {Data: []byte("config:\n imports:\n - path: ../catalog/base.yaml\n required: true\n - path: ../other/definition.yaml\n required: true\ncollections:\n docs:\n prompts:\n child:\n extends: base\n")}, - "catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n base:\n version: \"v1\"\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")}, + "app/main.yaml": {Data: []byte("config:\n imports:\n - path: ../catalog/base.yaml\n required: true\n - path: ../other/definition.yaml\n required: true\ncollections:\n docs:\n prompts:\n - id: child\n extends: {id: base}\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")}, + "app/body.md": {Data: []byte("Replacement {{.name}}")}, + "catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n - id: base\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")}, "catalog/body.md": {Data: []byte("Captured {{.name}}")}, - "other/definition.yaml": {Data: []byte("collections:\n other:\n prompts:\n child:\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")}, + "other/definition.yaml": {Data: []byte("collections:\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")}, "other/body.md": {Data: []byte("Other collection {{.name}}")}, } namespace := configkit.NewFilesystem("embedded", files) @@ -45,7 +46,16 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) { if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" { t.Fatalf("message metadata was not normalized: %+v", message) } - field, ok := state.result.Metadata().Lookup(configkit.Keys("collections", "docs", "prompts", "child", "messages").Index(0).Key("content_file")) + childIndex := -1 + for index, item := range state.result.Config.Collections["docs"].Prompts { + if item.ID == "child" { + childIndex = index + } + } + if childIndex < 0 { + t.Fatal("inherited prompt missing from effective list") + } + field, ok := state.result.Metadata().Lookup(configkit.Keys("collections", "docs", "prompts").Index(childIndex).Key("messages").Index(0).Key("content_file")) if !ok || len(field.Origin.Locations) != 1 { t.Fatalf("content origin was lost during normalization: %+v", field) } @@ -59,10 +69,11 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) { t.Fatalf("content origin = %q, want catalog/base.yaml", originPath) } other, otherInspection, err := state.prompt("other/child") - if err != nil || other.Templates[0].Content != "Other collection {{.name}}" { + if err != nil || other.Templates[0].Content != "Replacement {{.name}}" { t.Fatalf("other collection did not retain its source context: %+v, %v", other, err) } files["catalog/body.md"].Data = []byte("Changed") + files["app/body.md"].Data = []byte("Changed replacement") files["other/body.md"].Data = []byte("Changed other collection") again, againInspection, err := state.prompt("docs/child") if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash { @@ -74,7 +85,7 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) { t.Fatalf("caller mutation escaped into index: %+v, %v", third, err) } otherAgain, otherAgainInspection, err := state.prompt("other/child") - if err != nil || otherAgain.Templates[0].Content != "Other collection {{.name}}" || otherAgainInspection.PromptHash != otherInspection.PromptHash { + if err != nil || otherAgain.Templates[0].Content != "Replacement {{.name}}" || otherAgainInspection.PromptHash != otherInspection.PromptHash { t.Fatalf("other captured definition changed after source mutation: %+v, %+v, %v", otherAgain, otherAgainInspection, err) } } @@ -97,7 +108,7 @@ func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) { t.Fatalf("mapping context or handle ownership failed: %+v, opens=%d closes=%d, %v", definition, filesystem.contentOpened, filesystem.contentClosed, err) } _, err = load(mapping("contextless", promptWithFile("body.md")), nil) - if err == nil || !strings.Contains(err.Error(), "/prompts/example/messages/0/content_file") { + if err == nil || !strings.Contains(err.Error(), "/prompts/0/messages/0/content_file") { t.Fatalf("expected semantic path for contextless resource, got %v", err) } } @@ -106,7 +117,7 @@ func TestLoadCapturesNativeContentFile(t *testing.T) { root := t.TempDir() definitionPath := filepath.Join(root, "definition.yaml") contentPath := filepath.Join(root, "body.md") - if err := os.WriteFile(definitionPath, []byte("prompts:\n example:\n version: \"v1\"\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil { + if err := os.WriteFile(definitionPath, []byte("prompts:\n - id: example\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(contentPath, []byte("Native body"), 0o600); err != nil { @@ -466,7 +477,8 @@ func TestInputMetadataPresenceDoesNotChangePromptHashOrInspection(t *testing.T) } func promptWithFile(name string) map[string]any { - return map[string]any{"prompts": map[string]any{"example": map[string]any{ + return map[string]any{"prompts": []any{map[string]any{ + "id": "example", "messages": []any{map[string]any{"role": "user", "content_file": name}}, "output": map[string]any{"format": "text", "validation_mode": "basic"}, }}}