119 lines
5.3 KiB
Go
119 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
ck "gitea.maximumdirect.net/eric/ConfigKit"
|
|
pk "gitea.maximumdirect.net/eric/promptkit"
|
|
)
|
|
|
|
type step struct {
|
|
status int
|
|
content string
|
|
usage any
|
|
}
|
|
|
|
func testRuntime(t *testing.T, transport http.RoundTripper, overrides ...ck.Override) *Runtime {
|
|
t.Helper()
|
|
t.Setenv("AUDITA_PROTOTYPE_KEY", "synthetic-test-secret")
|
|
loaded, err := loadConfig(ck.File("config.yaml"), overrides...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r, err := newRuntime(loaded.Config, ".", &http.Client{Transport: transport})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
func intOverride(field string, value int) ck.Override {
|
|
return ck.Override{Path: ck.Keys("llm", field), Value: value, Flag: "--" + field}
|
|
}
|
|
|
|
func TestWorkflowBoundaries(t *testing.T) {
|
|
zero := map[string]any{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
for _, tc := range []struct {
|
|
name string
|
|
steps []step
|
|
retries, repairs int
|
|
accepted, rejected, warnings int
|
|
attempts, retryCount, repairCount uint64
|
|
cause error
|
|
}{
|
|
{name: "repair both roles", steps: []step{{200, "bad", zero}, {200, validProposal, zero}, {200, "bad", nil}, {200, validAssessment, zero}}, repairs: 2, accepted: 1, attempts: 4, repairCount: 2},
|
|
{name: "proposal repair exhausted", steps: []step{{200, "bad", nil}, {200, "bad", nil}, {200, "bad", nil}}, repairs: 2, warnings: 1, attempts: 3, repairCount: 2},
|
|
{name: "assessment repair exhausted", steps: []step{{200, validProposal, nil}, {200, "bad", nil}, {200, "bad", nil}}, repairs: 1, rejected: 1, warnings: 1, attempts: 3, repairCount: 1},
|
|
{name: "domain rejects missing span", steps: []step{{200, strings.Replace(validProposal, "hello world", "absent span", 1), nil}}, repairs: 2, rejected: 1, attempts: 1},
|
|
{name: "domain rejects missing decision", steps: []step{{200, validProposal, nil}, {200, `{"validations":[]}`, nil}}, repairs: 2, rejected: 1, warnings: 1, attempts: 2},
|
|
{name: "domain rejects duplicate decision", steps: []step{{200, validProposal, nil}, {200, `{"validations":[{"correction_index":0,"approved":true,"confidence":1,"reason":"ok"},{"correction_index":0,"approved":true,"confidence":1,"reason":"ok"}]}`, nil}}, repairs: 2, rejected: 1, warnings: 1, attempts: 2},
|
|
{name: "assessment semantic rejection", steps: []step{{200, validProposal, nil}, {200, strings.Replace(validAssessment, `"approved":true`, `"approved":false`, 1), nil}}, repairs: 2, rejected: 1, attempts: 2},
|
|
{name: "429 recovery", steps: []step{{429, "", nil}, {200, validProposal, zero}, {200, validAssessment, zero}}, retries: 1, accepted: 1, attempts: 3, retryCount: 1},
|
|
{name: "explicit zero retries", steps: []step{{503, "", nil}}, repairs: 2, attempts: 1, cause: pk.ErrLLMGenerate},
|
|
{name: "retry exhaustion after completed proposal", steps: []step{{200, validProposal, zero}, {503, "", nil}, {503, "", nil}}, retries: 1, repairs: 2, attempts: 3, retryCount: 1, cause: pk.ErrRetriesExhausted},
|
|
{name: "retry and repair budgets", steps: []step{{503, "", nil}, {200, "bad", zero}, {503, "", nil}, {200, validProposal, zero}, {200, validAssessment, zero}}, retries: 1, repairs: 1, accepted: 1, attempts: 5, retryCount: 2, repairCount: 1},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var mu sync.Mutex
|
|
count := 0
|
|
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if req.Header.Get("Authorization") != "Bearer synthetic-test-secret" {
|
|
t.Error("wrong credential")
|
|
}
|
|
var body struct {
|
|
Messages []struct {
|
|
Content string `json:"content"`
|
|
} `json:"messages"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body.Messages) < 2 || !strings.Contains(body.Messages[1].Content, "Prompt hardening policy") {
|
|
t.Error("real prompt hardening missing")
|
|
}
|
|
if count >= len(tc.steps) {
|
|
t.Error("unexpected extra provider call")
|
|
return providerResponse(400, "", nil), nil
|
|
}
|
|
s := tc.steps[count]
|
|
count++
|
|
return providerResponse(s.status, s.content, s.usage), nil
|
|
})
|
|
r := testRuntime(t, transport, intOverride("transient_retries", tc.retries), intOverride("repair_attempts", tc.repairs))
|
|
recorder := &Recorder{}
|
|
out, err := r.workflow(context.Background(), recorder.observer)
|
|
if tc.cause != nil {
|
|
if !errors.Is(err, tc.cause) {
|
|
t.Fatalf("error %v does not match %v", err, tc.cause)
|
|
}
|
|
} else if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Accepted != tc.accepted || out.Rejected != tc.rejected || len(out.Warnings) != tc.warnings {
|
|
t.Fatalf("result %+v", out)
|
|
}
|
|
a, rt, rp := recorder.totals()
|
|
if a != tc.attempts || rt != tc.retryCount || rp != tc.repairCount || count != len(tc.steps) {
|
|
t.Fatalf("counts %d/%d/%d; dispatches %d", a, rt, rp, count)
|
|
}
|
|
records := recorder.snapshot()
|
|
serialized, _ := json.Marshal(records)
|
|
if strings.Contains(string(serialized), "synthetic-test-secret") || strings.Contains(string(serialized), "hello world") {
|
|
t.Fatal("sensitive observation")
|
|
}
|
|
for _, o := range records {
|
|
if o.Kind == string(pk.ExecutionAttempt) && o.StatusCode == 503 && o.Usage.Total.Known {
|
|
t.Fatal("invented known usage")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|