Capture prompts from identified lists

This commit is contained in:
2026-11-02 03:56:23 +00:00
parent 8e8f61f53f
commit c3ce15a4ab
4 changed files with 46 additions and 34 deletions

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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,
}

View File

@@ -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"},
}}}