267 lines
11 KiB
Go
267 lines
11 KiB
Go
package promptexec
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
type fakeExecutor struct{}
|
||
|
||
func (fakeExecutor) InspectPrompt(context.Context, string, string) (PromptInspection, error) {
|
||
return PromptInspection{}, nil
|
||
}
|
||
|
||
func (fakeExecutor) InspectProfile(context.Context, string) (ProfileInspection, error) {
|
||
return ProfileInspection{}, nil
|
||
}
|
||
|
||
func (fakeExecutor) Execute(context.Context, ExecuteRequest, PreparationCallback) (*Execution, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
var _ Executor = fakeExecutor{}
|
||
|
||
type lifecycleExecutor struct {
|
||
providerCalled bool
|
||
operationalFailure error
|
||
validationRejected bool
|
||
}
|
||
|
||
func (executor *lifecycleExecutor) InspectPrompt(context.Context, string, string) (PromptInspection, error) {
|
||
return PromptInspection{}, nil
|
||
}
|
||
|
||
func (executor *lifecycleExecutor) InspectProfile(context.Context, string) (ProfileInspection, error) {
|
||
return ProfileInspection{}, nil
|
||
}
|
||
|
||
func (executor *lifecycleExecutor) Execute(_ context.Context, request ExecuteRequest, callback PreparationCallback) (*Execution, error) {
|
||
if executor.operationalFailure != nil {
|
||
return nil, executor.operationalFailure
|
||
}
|
||
preparation := Preparation{PromptID: request.PromptID, PromptVersion: request.PromptVersion, DataPackagePath: request.DataPackagePath}
|
||
var debug *PreparationDebug
|
||
if request.CaptureDebug {
|
||
debug = &PreparationDebug{RenderedMessages: []RenderedMessage{{Role: "user", Content: "sensitive rendered message"}}}
|
||
}
|
||
if callback != nil {
|
||
if err := callback(copyPreparation(preparation), copyPreparationDebug(debug)); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
executor.providerCalled = true
|
||
status := ValidationPassed
|
||
if executor.validationRejected {
|
||
status = ValidationFailed
|
||
}
|
||
result := Execution{PromptID: request.PromptID, PromptVersion: request.PromptVersion, DataPackagePath: request.DataPackagePath, Validation: Validation{Status: status}, RawOutput: []byte("generated content")}
|
||
if request.CaptureDebug {
|
||
result.Debug = &ExecutionDebug{RawOutput: []byte("generated content")}
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
func TestExecutorLifecycleFixtures(t *testing.T) {
|
||
request := ExecuteRequest{PromptID: "weather.daily_generated_text", PromptVersion: "1.0.0", DataPackagePath: "data_package.yaml"}
|
||
t.Run("callback failure prevents provider execution", func(t *testing.T) {
|
||
executor := &lifecycleExecutor{}
|
||
callbackError := errors.New("persistence failed")
|
||
result, err := executor.Execute(context.Background(), request, func(Preparation, *PreparationDebug) error { return callbackError })
|
||
if result != nil || !errors.Is(err, callbackError) || executor.providerCalled {
|
||
t.Fatalf("result/error/provider = %#v/%v/%t", result, err, executor.providerCalled)
|
||
}
|
||
})
|
||
t.Run("validation rejection is completed result", func(t *testing.T) {
|
||
executor := &lifecycleExecutor{validationRejected: true}
|
||
result, err := executor.Execute(context.Background(), request, nil)
|
||
if err != nil || result == nil || result.Validation.Status != ValidationFailed || !executor.providerCalled {
|
||
t.Fatalf("result/error/provider = %#v/%v/%t", result, err, executor.providerCalled)
|
||
}
|
||
})
|
||
t.Run("operational failure has no completed result", func(t *testing.T) {
|
||
failure := NewError(Generation, "generation failed", nil)
|
||
executor := &lifecycleExecutor{operationalFailure: failure}
|
||
result, err := executor.Execute(context.Background(), request, nil)
|
||
if result != nil || !errors.Is(err, failure) || executor.providerCalled {
|
||
t.Fatalf("result/error/provider = %#v/%v/%t", result, err, executor.providerCalled)
|
||
}
|
||
})
|
||
t.Run("debug requires explicit request", func(t *testing.T) {
|
||
executor := &lifecycleExecutor{}
|
||
var callbackDebug *PreparationDebug
|
||
result, err := executor.Execute(context.Background(), request, func(_ Preparation, debug *PreparationDebug) error {
|
||
callbackDebug = debug
|
||
return nil
|
||
})
|
||
if err != nil || callbackDebug != nil || result.Debug != nil {
|
||
t.Fatalf("debug = %#v/%#v, error = %v", callbackDebug, result.Debug, err)
|
||
}
|
||
request.CaptureDebug = true
|
||
result, err = executor.Execute(context.Background(), request, func(_ Preparation, debug *PreparationDebug) error {
|
||
callbackDebug = debug
|
||
return nil
|
||
})
|
||
if err != nil || callbackDebug == nil || result.Debug == nil {
|
||
t.Fatalf("debug = %#v/%#v, error = %v", callbackDebug, result.Debug, err)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestErrorCategoriesAndCapacityError(t *testing.T) {
|
||
categories := []ErrorCategory{
|
||
InvalidConfiguration,
|
||
InvalidRequest,
|
||
PromptNotFound,
|
||
PromptLoad,
|
||
ProfileNotFound,
|
||
ProfileLoad,
|
||
MissingCredential,
|
||
ArtifactLoad,
|
||
PromptRender,
|
||
Capacity,
|
||
Generation,
|
||
OperationalValidation,
|
||
ValidationRejected,
|
||
Canceled,
|
||
DeadlineExceeded,
|
||
}
|
||
cause := errors.New("dependency details must not become safe error text")
|
||
for _, category := range categories {
|
||
t.Run(string(category), func(t *testing.T) {
|
||
err := NewError(category, "safe workflow failure", cause)
|
||
if err.Category() != category || CategoryOf(err) != category {
|
||
t.Fatalf("category = %q/%q, want %q", err.Category(), CategoryOf(err), category)
|
||
}
|
||
if !errors.Is(err, cause) {
|
||
t.Fatal("errors.Is() = false, want preserved cause")
|
||
}
|
||
if strings.Contains(err.Error(), cause.Error()) {
|
||
t.Fatalf("error leaks cause: %q", err)
|
||
}
|
||
})
|
||
}
|
||
|
||
capacity := NewCapacityError("local", "safe capacity failure", cause)
|
||
if capacity.Category() != Capacity || CategoryOf(capacity) != Capacity || capacity.BackendID != "local" {
|
||
t.Fatalf("capacity error = %#v", capacity)
|
||
}
|
||
if !errors.Is(capacity, cause) {
|
||
t.Fatal("capacity error does not preserve cause")
|
||
}
|
||
}
|
||
|
||
func TestBoundDiagnosticAndErrorText(t *testing.T) {
|
||
longUTF8 := strings.Repeat("é", maxDiagnosticBytes)
|
||
values := make([]string, maxValidationDiagnostics+2)
|
||
for index := range values {
|
||
values[index] = longUTF8
|
||
}
|
||
values[0] = string([]byte{'a', 0xff, 'b'})
|
||
bounded := boundDiagnostics(values)
|
||
if len(bounded) != maxValidationDiagnostics {
|
||
t.Fatalf("diagnostics length = %d, want %d", len(bounded), maxValidationDiagnostics)
|
||
}
|
||
for index, value := range bounded {
|
||
if len(value) > maxDiagnosticBytes || !utf8.ValidString(value) {
|
||
t.Fatalf("diagnostic %d = %q, want valid UTF-8 within %d bytes", index, value, maxDiagnosticBytes)
|
||
}
|
||
}
|
||
if bounded[0] != "a<>b" {
|
||
t.Fatalf("invalid UTF-8 diagnostic = %q, want replacement", bounded[0])
|
||
}
|
||
validation := NewValidation(ValidationFailed, "json_schema", "daily.schema.json", values)
|
||
if len(validation.Diagnostics) != maxValidationDiagnostics || validation.Diagnostics[0] != "a<>b" {
|
||
t.Fatalf("validation = %#v, want bounded diagnostics", validation)
|
||
}
|
||
|
||
err := NewError(Generation, strings.Repeat("é", maxErrorMessageBytes), nil)
|
||
if len(err.Error()) > maxErrorMessageBytes || !utf8.ValidString(err.Error()) {
|
||
t.Fatalf("error = %q, want valid UTF-8 within %d bytes", err, maxErrorMessageBytes)
|
||
}
|
||
capacity := NewCapacityError(strings.Repeat("x", 300), strings.Repeat("é", maxErrorMessageBytes), nil)
|
||
if len(capacity.Error()) > maxErrorMessageBytes || !utf8.ValidString(capacity.Error()) {
|
||
t.Fatalf("capacity error = %q, want valid UTF-8 within %d bytes", capacity, maxErrorMessageBytes)
|
||
}
|
||
}
|
||
|
||
func TestContractCopiesMutableValues(t *testing.T) {
|
||
preparation := Preparation{InputHashes: map[string]string{"data_package": "input-hash"}}
|
||
preparationDebug := &PreparationDebug{
|
||
RenderedMessages: []RenderedMessage{{Role: "user", Content: "rendered input"}},
|
||
StructuredSchema: []byte("schema body"),
|
||
ParametersJSON: []byte(`{"temperature":0.2}`),
|
||
}
|
||
execution := Execution{
|
||
InputHashes: map[string]string{"data_package": "input-hash"},
|
||
Validation: Validation{Diagnostics: []string{"validation detail"}},
|
||
RawOutput: []byte("generated output"),
|
||
Debug: &ExecutionDebug{
|
||
RawOutput: []byte("provider output"),
|
||
ValidationDiagnostics: []string{"detailed validation"},
|
||
},
|
||
}
|
||
|
||
preparationCopy := copyPreparation(preparation)
|
||
debugCopy := copyPreparationDebug(preparationDebug)
|
||
executionCopy := copyExecution(execution)
|
||
preparation.InputHashes["data_package"] = "changed"
|
||
preparationDebug.RenderedMessages[0].Content = "changed"
|
||
preparationDebug.StructuredSchema[0] = 'x'
|
||
preparationDebug.ParametersJSON[0] = 'x'
|
||
execution.InputHashes["data_package"] = "changed"
|
||
execution.Validation.Diagnostics[0] = "changed"
|
||
execution.RawOutput[0] = 'x'
|
||
execution.Debug.RawOutput[0] = 'x'
|
||
execution.Debug.ValidationDiagnostics[0] = "changed"
|
||
|
||
if preparationCopy.InputHashes["data_package"] != "input-hash" {
|
||
t.Fatalf("preparation copy = %#v", preparationCopy)
|
||
}
|
||
if debugCopy.RenderedMessages[0].Content != "rendered input" || string(debugCopy.StructuredSchema) != "schema body" || string(debugCopy.ParametersJSON) != `{"temperature":0.2}` {
|
||
t.Fatalf("preparation debug copy = %#v", debugCopy)
|
||
}
|
||
if executionCopy.InputHashes["data_package"] != "input-hash" || executionCopy.Validation.Diagnostics[0] != "validation detail" || string(executionCopy.RawOutput) != "generated output" || string(executionCopy.Debug.RawOutput) != "provider output" || executionCopy.Debug.ValidationDiagnostics[0] != "detailed validation" {
|
||
t.Fatalf("execution copy = %#v", executionCopy)
|
||
}
|
||
}
|
||
|
||
func TestSafeContractValuesExcludeSensitiveFields(t *testing.T) {
|
||
preparation := Preparation{
|
||
PromptID: "weather.daily_generated_text",
|
||
PromptVersion: "1.0.0",
|
||
PromptHash: "prompt-hash",
|
||
RenderedPromptHash: "rendered-hash",
|
||
InputHashes: map[string]string{"data_package": "input-hash"},
|
||
ProfileID: "configured-profile",
|
||
BackendID: "local",
|
||
ModelName: "model-name",
|
||
Output: OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: "daily.generated_text.schema.json"},
|
||
DataPackagePath: "data-packages/daily/data_package.yaml",
|
||
}
|
||
execution := Execution{
|
||
RunID: "run-id",
|
||
PromptID: preparation.PromptID,
|
||
PromptVersion: preparation.PromptVersion,
|
||
PromptHash: preparation.PromptHash,
|
||
RenderedPromptHash: preparation.RenderedPromptHash,
|
||
InputHashes: copyStringMap(preparation.InputHashes),
|
||
ProfileID: preparation.ProfileID,
|
||
BackendID: preparation.BackendID,
|
||
ModelName: preparation.ModelName,
|
||
GeneratedHash: "generated-hash",
|
||
RawOutput: []byte("generated content"),
|
||
}
|
||
text := preparation.PromptID + preparation.PromptVersion + preparation.PromptHash + preparation.RenderedPromptHash + preparation.ProfileID + preparation.BackendID + preparation.ModelName + preparation.Output.SchemaPath + preparation.DataPackagePath + execution.RunID + execution.GeneratedHash
|
||
for _, unwanted := range []string{"https://provider.example", "API_KEY_ENV", "rendered message", "schema body", "input body", "provider response body", "full parameters"} {
|
||
if strings.Contains(text, unwanted) {
|
||
t.Fatalf("safe values contain %q: %s", unwanted, text)
|
||
}
|
||
}
|
||
if string(execution.RawOutput) != "generated content" {
|
||
t.Fatalf("raw output = %q, want generated content", execution.RawOutput)
|
||
}
|
||
}
|