agent
This commit is contained in:
272
services/tools/conformance_test.go
Normal file
272
services/tools/conformance_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nearle/models"
|
||||
)
|
||||
|
||||
// The sweep: every tool that ships, held to the same rules.
|
||||
//
|
||||
// Phase 5's measure is that a merchant's question cannot return another
|
||||
// merchant's row. The per-tool tests each prove it for one tool; this proves it
|
||||
// for all of them at once, and — more usefully — for the eighth tool somebody
|
||||
// adds in a hurry next month. A tool that forgets appears here as a failure
|
||||
// rather than as a support ticket.
|
||||
|
||||
// shipped is one real tool plus a way to ask what tenant its service was given.
|
||||
//
|
||||
// The recorders differ per tool because the services differ, and writing them
|
||||
// out is the honest version: a generic mechanism would need every service to
|
||||
// share an interface they have no reason to share.
|
||||
type shipped struct {
|
||||
tool Tool
|
||||
// askedTenant reports the tenant the underlying service was called with,
|
||||
// or -1 when the tool never reached its service.
|
||||
askedTenant func() int
|
||||
}
|
||||
|
||||
func shippedTools(t *testing.T) []Tool {
|
||||
t.Helper()
|
||||
out := make([]Tool, 0, 8)
|
||||
for _, s := range shippedWithRecorders(t) {
|
||||
out = append(out, s.tool)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func shippedWithRecorders(t *testing.T) []shipped {
|
||||
t.Helper()
|
||||
|
||||
corpus, err := LoadHelp()
|
||||
if err != nil {
|
||||
t.Fatalf("loading help: %v", err)
|
||||
}
|
||||
|
||||
deliveries1 := &fakeDeliveries{}
|
||||
deliveries2 := &fakeDeliveries{}
|
||||
branches := &fakeBranches{}
|
||||
approvals := &fakeApprovals{}
|
||||
stocks := &fakeStocks{}
|
||||
tills := &fakeTills{}
|
||||
revenue := &fakeRevenue{}
|
||||
counter := &fakeCounter{}
|
||||
// Carries a pending request so the write resolves rather than refusing —
|
||||
// the sweep is about scope and shape, not about an empty inbox.
|
||||
writes := &writingApprovals{}
|
||||
writes.rows = []models.StockRequest{pendingRequest(41, 12, "Rice", "R Mart")}
|
||||
|
||||
fixed := func() time.Time { return time.Date(2026, 9, 23, 12, 0, 0, 0, time.Local) }
|
||||
|
||||
return []shipped{
|
||||
{StuckOrders(deliveries1, fixed), func() int { return deliveries1.last.Tenantid }},
|
||||
{DeliveryProgress(deliveries2), func() int { return deliveries2.last.Tenantid }},
|
||||
{BranchPerformance(branches), func() int { return branches.seen }},
|
||||
{PendingApprovals(approvals, fixed), func() int { return approvals.seenTenant }},
|
||||
{LowStock(stocks), func() int { return atoiOr(stocks.seenTenant, -1) }},
|
||||
{TillsNotSyncing(tills), func() int { return -1 }}, // presence is keyed by branch, not tenant
|
||||
{SalesByChannel(revenue, counter, fixed), func() int { return revenue.seenTid }},
|
||||
// Help reaches no service and holds no shop data — it is the one tool
|
||||
// with RequiresNothing, and the sweep checks that separately below.
|
||||
{Help(corpus), func() int { return -1 }},
|
||||
// The write. Included so it is held to every rule the reads are, and so
|
||||
// a second write added later cannot quietly skip the sweep.
|
||||
{ApproveStockRequest(writes, writes), func() int { return writes.seenTenant }},
|
||||
}
|
||||
}
|
||||
|
||||
func atoiOr(text string, fallback int) int {
|
||||
n := 0
|
||||
if text == "" {
|
||||
return fallback
|
||||
}
|
||||
for _, r := range text {
|
||||
if r < '0' || r > '9' {
|
||||
return fallback
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/* ── The property phase 5 is measured on ───────────────────────────────── */
|
||||
|
||||
func TestNoToolReadsATenantOtherThanTheCallers(t *testing.T) {
|
||||
t.Setenv("POS_TOKEN_SECRET", cardSecret)
|
||||
// The model is handed every argument it could possibly want, including the
|
||||
// one it must never be able to use. Each tool's service must still have been
|
||||
// asked only about the caller's own shop.
|
||||
const mine = 1147
|
||||
const theirs = 916
|
||||
|
||||
caller := Caller{Userid: 904, Tenantid: mine, Locationid: 1172}
|
||||
poison := map[string]any{
|
||||
"tenantid": theirs, "tenant_id": theirs, "locationid": 9999,
|
||||
"store_id": 9999, "partnerid": theirs, "customerid": theirs,
|
||||
"minutes_waiting": 10, "at_or_below": 5, "days": 7,
|
||||
"question": "how do I add a cashier", "area": "people", "requestid": 41,
|
||||
}
|
||||
|
||||
for _, s := range shippedWithRecorders(t) {
|
||||
r := New(nil)
|
||||
if err := r.Register(s.tool); err != nil {
|
||||
t.Fatalf("registering %s: %v", s.tool.Name, err)
|
||||
}
|
||||
agent := Agent{Name: "sweep", Tools: []string{s.tool.Name}}
|
||||
|
||||
if _, err := r.Call(context.Background(), agent, s.tool.Name, poison, caller); err != nil {
|
||||
t.Fatalf("%s refused a legitimate caller: %v", s.tool.Name, err)
|
||||
}
|
||||
|
||||
if asked := s.askedTenant(); asked != -1 && asked != mine {
|
||||
t.Fatalf("%s read tenant %d for a caller from %d", s.tool.Name, asked, mine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoToolAcceptsAnArgumentThatChoosesWhoseDataIsRead(t *testing.T) {
|
||||
// Structural, not behavioural: the schema must not offer the field at all,
|
||||
// so there is nothing for a model to be argued into filling in. Register
|
||||
// enforces it, and this is the sweep over everything that ships.
|
||||
r := New(nil)
|
||||
for _, tool := range shippedTools(t) {
|
||||
if err := r.Register(tool); err != nil {
|
||||
t.Fatalf("%s: %v", tool.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryTenantScopedToolRefusesACallerWithNoShop(t *testing.T) {
|
||||
// Including staff. A platform account carries no tenant, and every one of
|
||||
// these would otherwise run with a zero tenant and return whatever that
|
||||
// means to the query underneath.
|
||||
staff := Caller{Userid: 12, Superadmin: true}
|
||||
|
||||
for _, s := range shippedWithRecorders(t) {
|
||||
if s.tool.Needs == RequiresNothing {
|
||||
continue
|
||||
}
|
||||
r := New(nil)
|
||||
if err := r.Register(s.tool); err != nil {
|
||||
t.Fatalf("registering %s: %v", s.tool.Name, err)
|
||||
}
|
||||
_, err := r.Call(context.Background(), Agent{Name: "sweep", Tools: []string{s.tool.Name}},
|
||||
s.tool.Name, map[string]any{"question": "x", "requestid": 41}, staff)
|
||||
if !errors.Is(err, ErrNoTenant) {
|
||||
t.Fatalf("%s answered a caller with no shop: %v", s.tool.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryBranchScopedToolRefusesAnAllBranchesCaller(t *testing.T) {
|
||||
// A tool that only exists per outlet must say so rather than answering for
|
||||
// whichever branch a zero happens to mean.
|
||||
admin := Caller{Userid: 904, Tenantid: 1147}
|
||||
|
||||
found := 0
|
||||
for _, s := range shippedWithRecorders(t) {
|
||||
if s.tool.Needs != RequiresBranch {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
r := New(nil)
|
||||
_ = r.Register(s.tool)
|
||||
_, err := r.Call(context.Background(), Agent{Name: "sweep", Tools: []string{s.tool.Name}},
|
||||
s.tool.Name, map[string]any{"days": 7}, admin)
|
||||
if !errors.Is(err, ErrNoTenant) {
|
||||
t.Fatalf("%s answered without a branch: %v", s.tool.Name, err)
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Fatal("no branch-scoped tools were exercised, so this proves nothing")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Things every tool owes the model and the reader ───────────────────── */
|
||||
|
||||
func TestEveryToolTellsTheModelWhenToUseIt(t *testing.T) {
|
||||
// A model chooses between tools by their descriptions alone. A vague one
|
||||
// produces a model that calls the wrong tool and explains the wrong number
|
||||
// with total confidence.
|
||||
for _, tool := range shippedTools(t) {
|
||||
if len(tool.Description) < 60 {
|
||||
t.Fatalf("%s has a description too thin to choose by: %q", tool.Name, tool.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoToolWritesWhenTheModelAsksForIt(t *testing.T) {
|
||||
// Every write must have been built through WriteTool, which is the only way
|
||||
// to get a propose/execute pair — and Register refuses a ScopeWrite tool
|
||||
// without one. A write whose Handler wrote would execute on a model's say
|
||||
// so, with nobody asked.
|
||||
r := New(nil)
|
||||
writes := 0
|
||||
for _, tool := range shippedTools(t) {
|
||||
if err := r.Register(tool); err != nil {
|
||||
t.Fatalf("%s: %v", tool.Name, err)
|
||||
}
|
||||
if tool.Scope == ScopeWrite {
|
||||
writes++
|
||||
if tool.propose == nil || tool.execute == nil {
|
||||
t.Fatalf("%s is a write with no approval path", tool.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if writes == 0 {
|
||||
t.Fatal("no write tools were exercised, so this proves nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSweepItselfChangesNothing(t *testing.T) {
|
||||
t.Setenv("POS_TOKEN_SECRET", cardSecret)
|
||||
// Every other test in this file calls every tool. If a write ever executed
|
||||
// on Call, this is the test that notices — and it notices for tools added
|
||||
// after today.
|
||||
writes := &writingApprovals{}
|
||||
writes.rows = []models.StockRequest{pendingRequest(41, 12, "Rice", "R Mart")}
|
||||
|
||||
r := New(nil)
|
||||
tool := ApproveStockRequest(writes, writes)
|
||||
_ = r.Register(tool)
|
||||
|
||||
_, err := r.Call(context.Background(), Agent{Name: "sweep", Tools: []string{tool.Name}},
|
||||
tool.Name, map[string]any{"requestid": 41}, Caller{Userid: 904, Tenantid: 1147})
|
||||
if err != nil {
|
||||
t.Fatalf("proposing: %v", err)
|
||||
}
|
||||
if len(writes.wrote) != 0 {
|
||||
t.Fatalf("calling a write tool wrote to the database: %v", writes.wrote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryToolNamesWhereItsRowsCanBeChecked(t *testing.T) {
|
||||
// Buddy states conclusions in sentences. The only honest way to present that
|
||||
// is beside a link to the page holding the same rows.
|
||||
caller := Caller{Userid: 904, Tenantid: 1147, Locationid: 1172}
|
||||
|
||||
for _, s := range shippedWithRecorders(t) {
|
||||
if s.tool.Needs == RequiresNothing {
|
||||
continue // the help corpus is not a page of rows
|
||||
}
|
||||
if s.tool.Scope == ScopeWrite {
|
||||
continue // a write answers with a card, not with rows to check
|
||||
}
|
||||
r := New(nil)
|
||||
_ = r.Register(s.tool)
|
||||
result, err := r.Call(context.Background(), Agent{Name: "sweep", Tools: []string{s.tool.Name}},
|
||||
s.tool.Name, map[string]any{"days": 7}, caller)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", s.tool.Name, err)
|
||||
}
|
||||
if result.Source == "" {
|
||||
t.Fatalf("%s answers with nowhere to check it", s.tool.Name)
|
||||
}
|
||||
if result.Scope == "" {
|
||||
t.Fatalf("%s does not say what its answer covered", s.tool.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user