Centralize message role invariants

This commit is contained in:
2026-08-26 02:07:15 +00:00
parent 8745d256bd
commit 5064cf833d
8 changed files with 292 additions and 36 deletions

View File

@@ -254,20 +254,27 @@ func normalizePromptDefinitionWithContent(raw *promptDefinitionFile, readContent
templates := make([]domain.PromptMessageTemplate, 0, len(raw.Messages))
for i, msg := range raw.Messages {
role := strings.TrimSpace(msg.Role)
if role == "" {
return nil, fmt.Errorf("message %d role is required", i)
role, err := domain.NormalizeMessageRole(msg.Role)
if err != nil {
return nil, fmt.Errorf("message %d role: %w", i, err)
}
hasContent := strings.TrimSpace(msg.Content) != ""
hasContentFile := strings.TrimSpace(msg.ContentFile) != ""
if hasContent == hasContentFile {
return nil, fmt.Errorf("message %d (%s) must set exactly one of content or content_file", i, role)
return nil, fmt.Errorf("message %d must set exactly one of content or content_file", i)
}
cacheControl, err := normalizeCacheControl(msg.CacheControl)
var rawCacheControl *domain.CacheControl
if msg.CacheControl != nil {
rawCacheControl = &domain.CacheControl{
Type: domain.CacheControlType(msg.CacheControl.Type),
TTL: msg.CacheControl.TTL,
}
}
cacheControl, err := domain.NormalizeCacheControl(rawCacheControl)
if err != nil {
return nil, fmt.Errorf("message %d (%s) cache_control: %w", i, role, err)
return nil, fmt.Errorf("message %d cache_control: %w", i, err)
}
templateContent := msg.Content
@@ -275,7 +282,7 @@ func normalizePromptDefinitionWithContent(raw *promptDefinitionFile, readContent
if hasContentFile {
body, resolvedPath, err := readContentFile(msg.ContentFile)
if err != nil {
return nil, fmt.Errorf("prompt %q message %d (%s): failed to read content_file %q: %w", id, i, role, msg.ContentFile, err)
return nil, fmt.Errorf("prompt %q message %d: failed to read content_file %q: %w", id, i, msg.ContentFile, err)
}
templateContent = body
resolvedContentFile = resolvedPath
@@ -319,27 +326,3 @@ func normalizePromptDefinitionWithContent(raw *promptDefinitionFile, readContent
Validation: outputContract,
}, nil
}
func normalizeCacheControl(raw *cacheControlFile) (*domain.CacheControl, error) {
if raw == nil {
return nil, nil
}
cacheType := strings.TrimSpace(raw.Type)
if cacheType == "" {
return nil, errors.New("type is required")
}
if domain.CacheControlType(cacheType) != domain.CacheControlEphemeral {
return nil, fmt.Errorf("unsupported type %q", cacheType)
}
ttl := strings.TrimSpace(raw.TTL)
if ttl != "" && ttl != "1h" {
return nil, fmt.Errorf("unsupported ttl %q", ttl)
}
return &domain.CacheControl{
Type: domain.CacheControlType(cacheType),
TTL: ttl,
}, nil
}

View File

@@ -1087,6 +1087,48 @@ output:
}
}
func TestPromptDefinitionMessageRoleNormalization(t *testing.T) {
const invalidRole = "consumer-private-role"
repo := NewFSRepository(fstest.MapFS{
"canonical.yaml": {Data: []byte(`
id: canonical
version: "1"
messages:
- role: " \u2003SyStEm\u2003 "
content: test
output:
format: text
validation_mode: none
`)},
"invalid.yaml": {Data: []byte(`
id: invalid-role
version: "1"
messages:
- role: consumer-private-role
content: test
output:
format: text
validation_mode: none
`)},
}, ".")
definition, err := repo.GetPromptDefinition(context.Background(), "canonical", "")
if err != nil {
t.Fatalf("GetPromptDefinition() error = %v", err)
}
if got := definition.Templates[0].Role; got != domain.RoleSystem {
t.Fatalf("normalized role = %q, want %q", got, domain.RoleSystem)
}
_, err = repo.GetPromptDefinition(context.Background(), "invalid-role", "")
if !errors.Is(err, ErrInvalidPromptDefinition) {
t.Fatalf("expected ErrInvalidPromptDefinition, got %v", err)
}
if strings.Contains(err.Error(), invalidRole) {
t.Fatalf("invalid prompt error exposed the supplied role: %v", err)
}
}
func assertCacheControl(t *testing.T, got *domain.CacheControl, wantType domain.CacheControlType, wantTTL string) {
t.Helper()
if got == nil {