mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
deec62235e
* wip(decision): conventions set (TASK-3119 U1a) * chore(eval): executable-conventions eval through the production question path (TASK-3119 U0) Measures, before any chip exists, how often the decision provider says a hand-labelled item breaks a convention at the 0.9 threshold: per convention precision and recall (all cases and real-only), the false 'Breaks' rate of a behaviour convention, and the calls and tokens spent. State is decision.BuildItemState, the question a decision.Noul worded as the U1 contract says, the model decision.DefaultModel. The hand labels were posted to TASK-3119 before any score existed; the trail comment carrying them is dropped from every state, and the dry run refuses a state that still carries them. Reads TYPESAFE_API_KEY only. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(eval): -links and -only for the conventions eval (TASK-3119 U1a) -links builds each state as the conventions set does, with the item's links (from the dump: the parent link only; blocking links are not in pad item show's JSON). -only runs one convention. Used for the lead's U1a gate: CONVE-1286 rerun with links. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(decision): liveness before EACH split call (TASK-3119 U1a, codex r1) A split set makes several provider calls; the liveness check ran once before them, so an item or workspace deleted between calls was still sent in the later ones. It now runs immediately before every call. A test deletes the item from inside the first call and the second is not sent; checking only once fails it. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(decision): decision_check has no default; allow-list the eval's decode (TASK-3119 U1a) A default "on" was written into every new convention: imports gained a decision_check field (artifact import parity) and an activated built-in no longer matched its seed (TASK-3462 would read every one as diverged). Absent already reads as on, so the field carries no default, in the template and both migrations. The conventions eval's readJSON is a raw decode the reserved-field guard counts; allow-listed with its reason, like the attention harness. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
758 lines
29 KiB
Go
758 lines
29 KiB
Go
package decision
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// fake stands up an httptest server speaking the provider's protocol and
|
|
// returns a provider pointed at it.
|
|
//
|
|
// handler receives the decoded request so a test can assert on the WIRE SHAPE
|
|
// the provider produced, not merely on what it decoded back — the two are
|
|
// different claims and only the first catches an encoder that agrees with its
|
|
// own decoder.
|
|
type fake struct {
|
|
t *testing.T
|
|
srv *httptest.Server
|
|
requests []wireRequest
|
|
// raw bodies as received, for tests asserting on criteria shape where
|
|
// the typed wireRequest would have normalised it away.
|
|
rawBodies []string
|
|
slept []time.Duration
|
|
// onRequest, when set, runs as each request arrives, before the answer:
|
|
// a test can change the store while a call is in flight.
|
|
onRequest func()
|
|
}
|
|
|
|
func newFake(t *testing.T, handler func(f *fake, req wireRequest, raw []byte, w http.ResponseWriter)) (*typesafeProvider, *fake) {
|
|
t.Helper()
|
|
f := &fake{t: t}
|
|
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
|
|
t.Errorf("Authorization header = %q, want Bearer test-key", got)
|
|
}
|
|
if got := r.Header.Get("Content-Type"); got != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", got)
|
|
}
|
|
// io.ReadAll, not a single Read into a ContentLength-sized slice: a
|
|
// single Read may return fewer bytes, which would truncate the body
|
|
// and make every request-shape assertion below fail for the wrong
|
|
// reason.
|
|
raw, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Errorf("fake could not read request body: %v", err)
|
|
}
|
|
var req wireRequest
|
|
if err := json.Unmarshal(raw, &req); err != nil {
|
|
t.Errorf("fake could not decode request: %v (body %q)", err, string(raw))
|
|
}
|
|
f.requests = append(f.requests, req)
|
|
f.rawBodies = append(f.rawBodies, string(raw))
|
|
if f.onRequest != nil {
|
|
f.onRequest()
|
|
}
|
|
handler(f, req, raw, w)
|
|
}))
|
|
t.Cleanup(f.srv.Close)
|
|
|
|
p := newTypesafe("test-key", "jev-1.13.0")
|
|
p.setEndpoint(f.srv.URL)
|
|
// Record the requested waits without spending wall clock, but keep the
|
|
// context's own semantics: a cancelled context must still abort the wait,
|
|
// or a test double would hide the very behaviour it is standing in for.
|
|
p.sleep = func(ctx context.Context, d time.Duration) error {
|
|
f.slept = append(f.slept, d)
|
|
return ctx.Err()
|
|
}
|
|
return p, f
|
|
}
|
|
|
|
func ok(body string) func(*fake, wireRequest, []byte, http.ResponseWriter) {
|
|
return func(_ *fake, _ wireRequest, _ []byte, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(body))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shape round-trip, one test per primitive.
|
|
//
|
|
// Every response body below is the VERBATIM JSON recorded from
|
|
// api.typesafe.ai/v1/systemone against jev-1.13.0 (choice and noul from the
|
|
// 760-call eval run, score from this unit's probe — receipts on TASK-3116's
|
|
// trail). They are pinned as literals rather than produced by marshalling our
|
|
// own wire structs, so a test cannot pass by agreeing with a wrong encoder.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const recordedChoiceResponse = `{"model":"jev-1.13.0","answers":{"severity":{"type":"choice","choice":"high","confidence":0.63,"probabilities":{"high":0.72,"critical":0.05,"medium":0.22,"low":0.01}}},"usage":{"input_tokens":1138,"output_tokens":165}}`
|
|
|
|
const recordedNoulResponse = `{"model":"jev-1.13.0","answers":{"needs_human_decision":{"type":"noul","noul":0.92}},"usage":{"input_tokens":1224,"output_tokens":41}}`
|
|
|
|
const recordedScoreResponse = `{"model":"jev-1.13.0","answers":{"severity":{"type":"score","score":0.14,"confidence":0.88,"legend":{"0":"cosmetic only","1":"annoying but harmless","2":"a real defect in normal use","3":"breaks a core flow","4":"data loss or security exposure"},"probabilities":{"0":0.86,"1":0.14,"2":0.0,"3":0.0,"4":0.0}}},"usage":{"input_tokens":372,"output_tokens":17}}`
|
|
|
|
func TestAskChoiceRoundTrip(t *testing.T) {
|
|
p, f := newFake(t, ok(recordedChoiceResponse))
|
|
|
|
q := Choice("How severe is this bug?", map[string]string{
|
|
"low": "cosmetic",
|
|
"medium": "a real defect",
|
|
"high": "breaks a core flow",
|
|
"critical": "data loss",
|
|
})
|
|
answers, usage, err := p.Ask(context.Background(), "a bug report", map[string]Question{"severity": q})
|
|
if err != nil {
|
|
t.Fatalf("Ask: %v", err)
|
|
}
|
|
|
|
// Request shape: choice criteria is an OBJECT of option -> description.
|
|
raw := f.rawBodies[0]
|
|
if !strings.Contains(raw, `"type":"choice"`) {
|
|
t.Errorf("request did not carry type choice: %s", raw)
|
|
}
|
|
if !strings.Contains(raw, `"criteria":{`) {
|
|
t.Errorf("choice criteria must serialise as an object, got: %s", raw)
|
|
}
|
|
if f.requests[0].Model != "jev-1.13.0" {
|
|
t.Errorf("model = %q, want the pinned jev-1.13.0", f.requests[0].Model)
|
|
}
|
|
|
|
a := answers["severity"]
|
|
if a.Kind != KindChoice {
|
|
t.Errorf("Kind = %q, want %q", a.Kind, KindChoice)
|
|
}
|
|
if a.Choice != "high" {
|
|
t.Errorf("Choice = %q, want high", a.Choice)
|
|
}
|
|
if a.Confidence == nil {
|
|
t.Fatal("Confidence is nil; a choice answer carries one")
|
|
}
|
|
if *a.Confidence != 0.63 {
|
|
t.Errorf("Confidence = %v, want 0.63", *a.Confidence)
|
|
}
|
|
if got := a.Probabilities["high"]; got != 0.72 {
|
|
t.Errorf("Probabilities[high] = %v, want 0.72", got)
|
|
}
|
|
// Choice probabilities are keyed by OPTION NAME, never by index. A
|
|
// consumer written for score's vocabulary must not find a key here.
|
|
if _, hasIndexKey := a.Probabilities["0"]; hasIndexKey {
|
|
t.Error(`choice probabilities must not be keyed by level index`)
|
|
}
|
|
if a.Legend != nil {
|
|
t.Errorf("Legend = %v, want nil for a choice answer", a.Legend)
|
|
}
|
|
if usage.InputTokens != 1138 || usage.OutputTokens != 165 {
|
|
t.Errorf("Usage = %+v, want 1138/165", usage)
|
|
}
|
|
if usage.StateTruncated {
|
|
t.Error("StateTruncated is true for a short state")
|
|
}
|
|
}
|
|
|
|
func TestAskNoulRoundTripCarriesNoConfidence(t *testing.T) {
|
|
p, f := newFake(t, ok(recordedNoulResponse))
|
|
|
|
q := Noul("The item is waiting on a human's judgment.",
|
|
"a human must decide", "an agent can proceed")
|
|
answers, _, err := p.Ask(context.Background(), "an item body", map[string]Question{"needs_human_decision": q})
|
|
if err != nil {
|
|
t.Fatalf("Ask: %v", err)
|
|
}
|
|
|
|
raw := f.rawBodies[0]
|
|
if !strings.Contains(raw, `"type":"noul"`) {
|
|
t.Errorf("request did not carry type noul: %s", raw)
|
|
}
|
|
if !strings.Contains(raw, `"true":"a human must decide"`) {
|
|
t.Errorf("noul criteria must carry the true/false pair: %s", raw)
|
|
}
|
|
|
|
a := answers["needs_human_decision"]
|
|
if a.Kind != KindNoul {
|
|
t.Errorf("Kind = %q, want %q", a.Kind, KindNoul)
|
|
}
|
|
if a.Noul != 0.92 {
|
|
t.Errorf("Noul = %v, want 0.92", a.Noul)
|
|
}
|
|
|
|
// THE point of the pointer. Asserting only "Confidence is nil" would
|
|
// also pass if the field were a float64 that happened to be
|
|
// unset — which is exactly the bug — so assert the CONSEQUENCE a caller
|
|
// would see: the banding default must be the caller's, not 0.0.
|
|
if a.Confidence != nil {
|
|
t.Errorf("Confidence = %v, want nil: a noul answer carries none", *a.Confidence)
|
|
}
|
|
if got := a.ConfidenceOr(1.0); got != 1.0 {
|
|
t.Errorf("ConfidenceOr(1.0) = %v, want the caller's default 1.0; a zero here "+
|
|
"means an absent confidence is being read as minimum confidence", got)
|
|
}
|
|
if a.Probabilities != nil {
|
|
t.Errorf("Probabilities = %v, want nil for a noul answer", a.Probabilities)
|
|
}
|
|
}
|
|
|
|
func TestAskScoreRoundTripAndScoreIsNotALevelIndex(t *testing.T) {
|
|
p, f := newFake(t, ok(recordedScoreResponse))
|
|
|
|
levels := []string{
|
|
"cosmetic only", "annoying but harmless", "a real defect in normal use",
|
|
"breaks a core flow", "data loss or security exposure",
|
|
}
|
|
answers, _, err := p.Ask(context.Background(), "a bug report", map[string]Question{
|
|
"severity": Score("Rate how severe this bug is.", levels),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Ask: %v", err)
|
|
}
|
|
|
|
// Request shape: score criteria is an ORDERED ARRAY, not a map. This is
|
|
// the one request-shape difference that a map would silently survive
|
|
// locally and fail against the live API.
|
|
raw := f.rawBodies[0]
|
|
if !strings.Contains(raw, `"criteria":["cosmetic only","annoying but harmless"`) {
|
|
t.Errorf("score criteria must serialise as an ordered array in the given order, got: %s", raw)
|
|
}
|
|
|
|
a := answers["severity"]
|
|
if a.Kind != KindScore {
|
|
t.Errorf("Kind = %q, want %q", a.Kind, KindScore)
|
|
}
|
|
if a.Score != 0.14 {
|
|
t.Errorf("Score = %v, want 0.14", a.Score)
|
|
}
|
|
if a.Legend["4"] != "data loss or security exposure" {
|
|
t.Errorf("Legend[4] = %q, want the highest level", a.Legend["4"])
|
|
}
|
|
if got := a.Probabilities["0"]; got != 0.86 {
|
|
t.Errorf("Probabilities[0] = %v, want 0.86", got)
|
|
}
|
|
|
|
// Score is the probability-weighted MEAN over level indices, so the
|
|
// picked level comes from the argmax and NOT from the number.
|
|
idx, name, okTop := a.TopLevel()
|
|
if !okTop {
|
|
t.Fatal("TopLevel() reported no level for a score answer")
|
|
}
|
|
if idx != 0 || name != "cosmetic only" {
|
|
t.Errorf("TopLevel() = (%d, %q), want (0, cosmetic only)", idx, name)
|
|
}
|
|
// The counterfactual that makes the assertion above mean something: the
|
|
// mass here sits on index 0, and 0.14 rounds to 0 too, so this case
|
|
// alone cannot distinguish argmax from rounding. Assert on a
|
|
// distribution where they DISAGREE.
|
|
skewed := Answer{
|
|
Kind: KindScore,
|
|
Score: 2.6, // 0.4*2 + 0.6*3
|
|
Legend: map[string]string{"0": "a", "1": "b", "2": "c", "3": "d"},
|
|
Probabilities: map[string]float64{"0": 0, "1": 0, "2": 0.4, "3": 0.6},
|
|
}
|
|
if idx, name, _ := skewed.TopLevel(); idx != 3 || name != "d" {
|
|
t.Errorf("TopLevel() on a skewed distribution = (%d, %q), want (3, d); "+
|
|
"int-casting Score would give 2 here", idx, name)
|
|
}
|
|
}
|
|
|
|
func TestTopLevelTiesResolveToLowestIndexAndRejectOtherKinds(t *testing.T) {
|
|
tie := Answer{
|
|
Kind: KindScore,
|
|
Legend: map[string]string{"0": "low", "1": "high"},
|
|
Probabilities: map[string]float64{"0": 0.5, "1": 0.5},
|
|
}
|
|
// Run repeatedly: a map-order-dependent implementation passes this
|
|
// intermittently, so one iteration would not discriminate.
|
|
for i := 0; i < 50; i++ {
|
|
if idx, name, _ := tie.TopLevel(); idx != 0 || name != "low" {
|
|
t.Fatalf("iteration %d: TopLevel() = (%d, %q), want the lowest index (0, low)", i, idx, name)
|
|
}
|
|
}
|
|
if _, _, okTop := (Answer{Kind: KindChoice, Probabilities: map[string]float64{"a": 1}}).TopLevel(); okTop {
|
|
t.Error("TopLevel() reported ok for a choice answer")
|
|
}
|
|
if _, _, okTop := (Answer{Kind: KindScore}).TopLevel(); okTop {
|
|
t.Error("TopLevel() reported ok for a score answer with no probabilities")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Errors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestOversizedStateSurfacesTypedError(t *testing.T) {
|
|
// The body is the one measured from the live API.
|
|
p, _ := newFake(t, func(_ *fake, _ wireRequest, _ []byte, w http.ResponseWriter) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"detail":{"error_type":"max_tokens_exceeded"}}`))
|
|
})
|
|
|
|
_, _, err := p.Ask(context.Background(), "s", map[string]Question{
|
|
"q": Noul("a statement", "", ""),
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Ask succeeded on a 400 max_tokens_exceeded")
|
|
}
|
|
if !errors.Is(err, ErrMaxTokensExceeded) {
|
|
t.Errorf("errors.Is(err, ErrMaxTokensExceeded) = false for a measured budget refusal; err = %v", err)
|
|
}
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("err is not an *APIError: %v", err)
|
|
}
|
|
if apiErr.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("StatusCode = %d, want 400 (the MEASURED status; the contract said 422)", apiErr.StatusCode)
|
|
}
|
|
if apiErr.ErrorType != "max_tokens_exceeded" {
|
|
t.Errorf("ErrorType = %q, want max_tokens_exceeded", apiErr.ErrorType)
|
|
}
|
|
}
|
|
|
|
// TestFitStateRefusesOversizedQuestionsBeforeSending pins ErrRequestTooLarge
|
|
// (TASK-3120, lead review F2): a Choice question set whose options alone
|
|
// exceed maxRequestTokens is refused by fitState BEFORE any HTTP call, with
|
|
// an error matching errors.Is(err, ErrRequestTooLarge) — distinct from
|
|
// ErrMaxTokensExceeded, which is the PROVIDER's own after-the-fact refusal
|
|
// of a request fitState let through. The fake fails the test if it is ever
|
|
// called, since that would mean the pre-send guard let an oversized request
|
|
// reach the network.
|
|
//
|
|
// The option count (254, plus one option's-worth of budget consumed by
|
|
// "none" in a real caller) and per-option description length (~350 chars —
|
|
// title + a near-cap PlaybookSummary + trigger + invocation_slug) mirror
|
|
// what the playbook-match endpoint could plausibly build from a workspace
|
|
// with a couple hundred active playbooks and realistic bodies: this is the
|
|
// SAME scenario F2 named, reproduced directly against the budget check
|
|
// rather than through the server's fixtures.
|
|
func TestFitStateRefusesOversizedQuestionsBeforeSending(t *testing.T) {
|
|
p, _ := newFake(t, func(*fake, wireRequest, []byte, http.ResponseWriter) {
|
|
t.Fatal("provider was called for a request fitState should have refused before sending")
|
|
})
|
|
|
|
opts := make(map[string]string, 254)
|
|
for i := 0; i < 254; i++ {
|
|
opts[fmt.Sprintf("PLAYB-%d", i)] = fmt.Sprintf(
|
|
"Playbook number %d: %s (trigger: on-release; invoke via slug \"playbook-%d\")",
|
|
i, strings.Repeat("word ", 55), i)
|
|
}
|
|
q := Choice("Given the text, which of these does it ask for?", opts)
|
|
if err := q.Validate(); err != nil {
|
|
t.Fatalf("fixture question does not validate: %v", err)
|
|
}
|
|
|
|
_, _, err := p.Ask(context.Background(), "some free text", map[string]Question{"match": q})
|
|
if err == nil {
|
|
t.Fatal("Ask succeeded on a question set that should have busted the pre-send token budget")
|
|
}
|
|
if !errors.Is(err, ErrRequestTooLarge) {
|
|
t.Errorf("errors.Is(err, ErrRequestTooLarge) = false; err = %v", err)
|
|
}
|
|
if errors.Is(err, ErrMaxTokensExceeded) {
|
|
t.Error("a PRE-SEND refusal matched ErrMaxTokensExceeded, which is the provider's own after-the-fact refusal — the two must stay distinguishable")
|
|
}
|
|
}
|
|
|
|
func Test422SurfacesBodyAndIsNotABudgetError(t *testing.T) {
|
|
const body = `{"detail":[{"loc":["body","questions","q","criteria"],"msg":"at least 2 levels required"}]}`
|
|
p, _ := newFake(t, func(_ *fake, _ wireRequest, _ []byte, w http.ResponseWriter) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_, _ = w.Write([]byte(body))
|
|
})
|
|
|
|
_, _, err := p.Ask(context.Background(), "s", map[string]Question{"q": Noul("a statement", "", "")})
|
|
if err == nil {
|
|
t.Fatal("Ask succeeded on a 422")
|
|
}
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("err is not an *APIError: %v", err)
|
|
}
|
|
if apiErr.Body != body {
|
|
t.Errorf("Body = %q, want the provider's body verbatim %q", apiErr.Body, body)
|
|
}
|
|
if !strings.Contains(err.Error(), "at least 2 levels required") {
|
|
t.Errorf("Error() dropped the provider's message, which names the offending question: %q", err.Error())
|
|
}
|
|
// Control leg: 422 must NOT match the budget sentinel, or the typed
|
|
// error above would be meaningless.
|
|
if errors.Is(err, ErrMaxTokensExceeded) {
|
|
t.Error("a 422 validation failure matched ErrMaxTokensExceeded")
|
|
}
|
|
}
|
|
|
|
func TestRetriesTransientThenSucceeds(t *testing.T) {
|
|
calls := 0
|
|
p, f := newFake(t, func(_ *fake, _ wireRequest, _ []byte, w http.ResponseWriter) {
|
|
calls++
|
|
switch calls {
|
|
case 1:
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"detail":{"error_type":"rate_limited"}}`))
|
|
case 2:
|
|
w.WriteHeader(529)
|
|
_, _ = w.Write([]byte(`{"detail":{"error_type":"overloaded"}}`))
|
|
default:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(recordedNoulResponse))
|
|
}
|
|
})
|
|
|
|
answers, _, err := p.Ask(context.Background(), "s", map[string]Question{
|
|
"needs_human_decision": Noul("a statement", "", ""),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Ask: %v", err)
|
|
}
|
|
if calls != 3 {
|
|
t.Errorf("provider was called %d times, want 3 (429, 529, then 200)", calls)
|
|
}
|
|
if answers["needs_human_decision"].Noul != 0.92 {
|
|
t.Errorf("answer not returned after a successful retry: %+v", answers)
|
|
}
|
|
// Assert the backoff GREW, which is what distinguishes exponential
|
|
// backoff from a fixed sleep — the end state (a success) is identical
|
|
// either way.
|
|
if len(f.slept) != 2 {
|
|
t.Fatalf("slept %d times, want 2: %v", len(f.slept), f.slept)
|
|
}
|
|
if f.slept[0] != initialBackoff {
|
|
t.Errorf("first backoff = %v, want %v", f.slept[0], initialBackoff)
|
|
}
|
|
if f.slept[1] <= f.slept[0] {
|
|
t.Errorf("backoff did not grow: %v then %v", f.slept[0], f.slept[1])
|
|
}
|
|
}
|
|
|
|
func TestRetryAfterHeaderOverridesBackoffAndIsBounded(t *testing.T) {
|
|
calls := 0
|
|
p, f := newFake(t, func(_ *fake, _ wireRequest, _ []byte, w http.ResponseWriter) {
|
|
calls++
|
|
if calls == 1 {
|
|
w.Header().Set("Retry-After", "3")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(recordedNoulResponse))
|
|
})
|
|
if _, _, err := p.Ask(context.Background(), "s", map[string]Question{
|
|
"needs_human_decision": Noul("a statement", "", ""),
|
|
}); err != nil {
|
|
t.Fatalf("Ask: %v", err)
|
|
}
|
|
if len(f.slept) != 1 || f.slept[0] != 3*time.Second {
|
|
t.Errorf("slept %v, want one 3s wait from the Retry-After header", f.slept)
|
|
}
|
|
|
|
if got := parseRetryAfter("99999"); got != maxRetryAfter {
|
|
t.Errorf("parseRetryAfter(99999) = %v, want it clamped to %v", got, maxRetryAfter)
|
|
}
|
|
for _, bad := range []string{"", "0", "-5", "Wed, 21 Oct 2015 07:28:00 GMT", "soon"} {
|
|
if got := parseRetryAfter(bad); got != 0 {
|
|
t.Errorf("parseRetryAfter(%q) = %v, want 0 so the normal backoff applies", bad, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGivesUpAfterMaxAttempts(t *testing.T) {
|
|
calls := 0
|
|
p, _ := newFake(t, func(_ *fake, _ wireRequest, _ []byte, w http.ResponseWriter) {
|
|
calls++
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
})
|
|
_, _, err := p.Ask(context.Background(), "s", map[string]Question{"q": Noul("a statement", "", "")})
|
|
if err == nil {
|
|
t.Fatal("Ask succeeded against a provider that only ever 429s")
|
|
}
|
|
if calls != maxAttempts {
|
|
t.Errorf("provider was called %d times, want maxAttempts=%d", calls, maxAttempts)
|
|
}
|
|
}
|
|
|
|
func TestPartialAnswerSetIsAnError(t *testing.T) {
|
|
// Two questions asked, one answered. A partial map would leave the
|
|
// caller unable to tell "no opinion" from "half the call failed".
|
|
p, _ := newFake(t, ok(recordedNoulResponse))
|
|
_, _, err := p.Ask(context.Background(), "s", map[string]Question{
|
|
"needs_human_decision": Noul("a statement", "", ""),
|
|
"blocked": Noul("another statement", "", ""),
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Ask succeeded with one of two questions unanswered")
|
|
}
|
|
if !strings.Contains(err.Error(), "blocked") {
|
|
t.Errorf("error does not name the unanswered question: %v", err)
|
|
}
|
|
// The KIND check would also refuse this call — a missing answer decodes
|
|
// with an empty Kind — and would also name the key, so the two
|
|
// assertions above cannot tell which check fired. The diagnosis is the
|
|
// difference a caller sees: "no answer" versus "answered with a ",
|
|
// the second naming a kind the provider never sent.
|
|
if !strings.Contains(err.Error(), "no answer") {
|
|
t.Errorf("error does not say the question went unanswered: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Truncation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestOversizedStringStateIsTruncatedAndFlagged(t *testing.T) {
|
|
p, f := newFake(t, ok(recordedNoulResponse))
|
|
|
|
// Multi-byte runes so a naive byte cut would produce invalid UTF-8.
|
|
big := strings.Repeat("état ", 40000)
|
|
if len(big) <= int(float64(maxRequestTokens)*charsPerToken) {
|
|
t.Fatalf("test state is not actually over budget (%d chars)", len(big))
|
|
}
|
|
|
|
_, usage, err := p.Ask(context.Background(), big, map[string]Question{
|
|
"needs_human_decision": Noul("a statement", "", ""),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Ask: %v", err)
|
|
}
|
|
if !usage.StateTruncated {
|
|
t.Error("StateTruncated is false after an over-budget state was shortened")
|
|
}
|
|
|
|
sent, okStr := f.requests[0].State.(string)
|
|
if !okStr {
|
|
t.Fatalf("state arrived as %T, want string", f.requests[0].State)
|
|
}
|
|
if len(sent) >= len(big) {
|
|
t.Errorf("state was not shortened: sent %d chars of %d", len(sent), len(big))
|
|
}
|
|
if !strings.HasPrefix(big, sent) {
|
|
t.Error("truncated state is not a prefix of the original")
|
|
}
|
|
// The cut must leave room for the questions too, since the measured
|
|
// budget covers state AND questions together.
|
|
if len(sent) > int(float64(maxRequestTokens)*charsPerToken) {
|
|
t.Errorf("truncated state (%d chars) exceeds the whole-request char budget", len(sent))
|
|
}
|
|
}
|
|
|
|
// TestBudgetConstantsStayInsideTheMeasurement guards the two constants that no
|
|
// behavioural test can check, because their correctness is a fact about the
|
|
// LIVE provider rather than about this code. Without this, someone raising
|
|
// either one to "use more of the window" gets a fully green suite and a
|
|
// production 400.
|
|
//
|
|
// Both figures come from the bisection recorded on TASK-3116's trail.
|
|
func TestBudgetConstantsStayInsideTheMeasurement(t *testing.T) {
|
|
// Largest request the live API ACCEPTED, by its own usage.input_tokens.
|
|
const measuredAccepted = 32476
|
|
// Smallest request it REFUSED with max_tokens_exceeded (predicted from
|
|
// the calibrated tokens-per-record; the refusal itself is measured).
|
|
const measuredRefused = 33600
|
|
|
|
if maxRequestTokens > measuredAccepted {
|
|
t.Errorf("maxRequestTokens = %d, which is above the largest request measured to be "+
|
|
"ACCEPTED (%d). Raising it past the measurement needs a new bisection, not a guess.",
|
|
maxRequestTokens, measuredAccepted)
|
|
}
|
|
if maxRequestTokens >= measuredRefused {
|
|
t.Errorf("maxRequestTokens = %d, at or above a request measured to be REFUSED (%d)",
|
|
maxRequestTokens, measuredRefused)
|
|
}
|
|
|
|
// charsPerToken must UNDER-estimate characters per token so that the
|
|
// token estimate over-estimates and truncation stays conservative. The
|
|
// bisection measured 2.80 chars/token on the densest content tried.
|
|
const measuredCharsPerToken = 2.80
|
|
if charsPerToken > measuredCharsPerToken {
|
|
t.Errorf("charsPerToken = %v, above the measured %v: truncation would under-estimate "+
|
|
"tokens and send over-budget requests", charsPerToken, measuredCharsPerToken)
|
|
}
|
|
}
|
|
|
|
// TestFitStateCutsOnRuneBoundaries sweeps the cut OFFSET, which is the whole
|
|
// point: the offset is a function of how large the questions are, so a single
|
|
// fixed state and question produce a single fixed offset. An earlier version of
|
|
// this assertion lived inside the truncation test above with one offset, and a
|
|
// mutant that deleted the rune-boundary loop entirely SURVIVED it — that one
|
|
// offset happened to land on a boundary, so the green could not go red.
|
|
//
|
|
// Varying the instructions length by one byte at a time shifts the offset by
|
|
// one, so across eight iterations over two-byte runes the cut lands mid-rune
|
|
// several times. The mutant dies on those.
|
|
func TestFitStateCutsOnRuneBoundaries(t *testing.T) {
|
|
p := newTypesafe("test-key", "jev-1.13.0")
|
|
|
|
// Why 64 offsets and three rune widths, rather than a handful: a cut
|
|
// that splits a rune leaves a lone lead byte, which json.Marshal
|
|
// replaces with a six-byte \ufffd — so a mid-rune cut often FAILS the
|
|
// size check on its own and the loop retries, landing wherever the
|
|
// retry's arithmetic puts it. That second mechanism hides the defect at
|
|
// most offsets. Measured with the boundary loop deleted (review round 2):
|
|
// invalid UTF-8 escaped at 18/64 offsets for 2-byte runes, 29/64 for
|
|
// 3-byte and 43/64 for 4-byte, while the previous 8-offset sweep over
|
|
// 2-byte runes caught NONE.
|
|
sawTruncation := false
|
|
for _, r := range []string{"é", "€", "😀"} {
|
|
big := strings.Repeat(r, 400000)
|
|
for extra := 0; extra < 64; extra++ {
|
|
questions := map[string]wireQuestion{
|
|
"q": {Type: "noul", Instructions: "i" + strings.Repeat("x", extra)},
|
|
}
|
|
got, truncated, err := p.fitState(big, questions)
|
|
if err != nil {
|
|
t.Fatalf("%q extra=%d: fitState: %v", r, extra, err)
|
|
}
|
|
if !truncated {
|
|
t.Fatalf("%q extra=%d: state of %d bytes was not truncated", r, extra, len(big))
|
|
}
|
|
sawTruncation = true
|
|
|
|
s, okStr := got.(string)
|
|
if !okStr {
|
|
t.Fatalf("%q extra=%d: fitState returned %T, want string", r, extra, got)
|
|
}
|
|
if !utf8.ValidString(s) {
|
|
t.Errorf("%q extra=%d: truncated state is not valid UTF-8 — the cut split a rune "+
|
|
"at offset %d", r, extra, len(s))
|
|
}
|
|
if !strings.HasPrefix(big, s) {
|
|
t.Errorf("%q extra=%d: truncated state is not a prefix of the original", r, extra)
|
|
}
|
|
}
|
|
}
|
|
// Precondition: if nothing truncated, every assertion above was vacuous.
|
|
if !sawTruncation {
|
|
t.Fatal("no iteration truncated; the test asserted nothing")
|
|
}
|
|
}
|
|
|
|
func TestShortStateIsNotTruncated(t *testing.T) {
|
|
p, f := newFake(t, ok(recordedNoulResponse))
|
|
_, usage, err := p.Ask(context.Background(), "a short body", map[string]Question{
|
|
"needs_human_decision": Noul("a statement", "", ""),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Ask: %v", err)
|
|
}
|
|
if usage.StateTruncated {
|
|
t.Error("StateTruncated is true for a state well inside the budget")
|
|
}
|
|
if got := f.requests[0].State; got != "a short body" {
|
|
t.Errorf("state = %v, want it passed through verbatim", got)
|
|
}
|
|
}
|
|
|
|
func TestStructuredStateIsNeverTruncated(t *testing.T) {
|
|
// Cutting marshalled JSON at a byte boundary produces invalid JSON, and
|
|
// dropping fields would change what is being asked about. A structured
|
|
// state that does not fit gets the provider's own refusal instead.
|
|
p, f := newFake(t, ok(recordedNoulResponse))
|
|
state := map[string]any{"body": strings.Repeat("x", 200000)}
|
|
|
|
_, usage, err := p.Ask(context.Background(), state, map[string]Question{
|
|
"needs_human_decision": Noul("a statement", "", ""),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Ask: %v", err)
|
|
}
|
|
if usage.StateTruncated {
|
|
t.Error("StateTruncated is true for a structured state, which is never cut")
|
|
}
|
|
m, okMap := f.requests[0].State.(map[string]any)
|
|
if !okMap {
|
|
t.Fatalf("state arrived as %T, want a JSON object", f.requests[0].State)
|
|
}
|
|
if s, _ := m["body"].(string); len(s) != 200000 {
|
|
t.Errorf("structured state body is %d chars, want the full 200000 — it must not be cut", len(s))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Question validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestQuestionValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
q Question
|
|
wantErr string
|
|
}{
|
|
{"choice ok", Choice("i", map[string]string{"a": "x", "b": "y"}), ""},
|
|
{"score ok", Score("i", []string{"a", "b"}), ""},
|
|
{"noul ok with criteria", Noul("i", "t", "f"), ""},
|
|
{"noul ok bare", Noul("i", "", ""), ""},
|
|
|
|
{"empty instructions", Choice(" ", map[string]string{"a": "x", "b": "y"}), "instructions are empty"},
|
|
{"choice one option", Choice("i", map[string]string{"a": "x"}), "at least 2 options"},
|
|
{"choice empty option name", Choice("i", map[string]string{"": "x", "b": "y"}), "empty option name"},
|
|
{"choice empty description", Choice("i", map[string]string{"a": " ", "b": "y"}), "empty description"},
|
|
{"score one level", Score("i", []string{"a"}), "at least 2 levels"},
|
|
{"score empty level", Score("i", []string{"a", " "}), "level 1 is empty"},
|
|
{"unknown kind", Question{Kind: "guess", Instructions: "i"}, "unknown question kind"},
|
|
|
|
// Cross-kind criteria. Each of these is a caller who meant a
|
|
// different constructor; silently dropping the stray field would
|
|
// send a question they did not write.
|
|
{"choice with levels", Question{Kind: KindChoice, Instructions: "i",
|
|
Options: map[string]string{"a": "x", "b": "y"}, Levels: []string{"l1", "l2"}},
|
|
"another kind's criteria"},
|
|
{"score with options", Question{Kind: KindScore, Instructions: "i",
|
|
Levels: []string{"a", "b"}, Options: map[string]string{"o": "d"}},
|
|
"another kind's criteria"},
|
|
{"noul with options", Question{Kind: KindNoul, Instructions: "i",
|
|
Options: map[string]string{"a": "x"}}, "another kind's criteria"},
|
|
{"noul half-described", Question{Kind: KindNoul, Instructions: "i", TrueDesc: "t"},
|
|
"only one of true/false"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := tt.q.Validate()
|
|
if tt.wantErr == "" {
|
|
if err != nil {
|
|
t.Fatalf("Validate() = %v, want nil", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("Validate() = nil, want an error containing %q", tt.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Errorf("Validate() = %q, want it to contain %q", err.Error(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAskRejectsBadInputBeforeCallingProvider(t *testing.T) {
|
|
called := false
|
|
p, _ := newFake(t, func(_ *fake, _ wireRequest, _ []byte, w http.ResponseWriter) {
|
|
called = true
|
|
_, _ = w.Write([]byte(recordedNoulResponse))
|
|
})
|
|
|
|
if _, _, err := p.Ask(context.Background(), "s", nil); err == nil {
|
|
t.Error("Ask succeeded with no questions")
|
|
}
|
|
if _, _, err := p.Ask(context.Background(), "s", map[string]Question{"": Noul("i", "", "")}); err == nil {
|
|
t.Error("Ask succeeded with an empty question key")
|
|
}
|
|
if _, _, err := p.Ask(context.Background(), "s", map[string]Question{
|
|
"q": Choice("i", map[string]string{"only": "one"}),
|
|
}); err == nil {
|
|
t.Error("Ask succeeded with an invalid choice question")
|
|
} else if !strings.Contains(err.Error(), `question "q"`) {
|
|
t.Errorf("error does not name the offending question key: %v", err)
|
|
}
|
|
|
|
// The whole point of validating first: no request was ever sent.
|
|
if called {
|
|
t.Error("provider was called despite invalid input")
|
|
}
|
|
}
|