305 lines
10 KiB
Go
305 lines
10 KiB
Go
package services
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"nearle/services/tools"
|
|
"nearle/utils"
|
|
)
|
|
|
|
// realRegistry builds every tool the server registers, exactly as the facade
|
|
// does — with nil services, because nothing here calls a handler.
|
|
//
|
|
// Replaces a hand-written list of tool names that went stale twice: once when
|
|
// the help corpus arrived and once when the first write did. Both times the
|
|
// shipped agents were correct and the TEST was wrong, which is the worst way
|
|
// round — a check that has to be remembered is a check that will not be.
|
|
func realRegistry(t *testing.T) *tools.Registry {
|
|
t.Helper()
|
|
|
|
corpus, err := tools.LoadHelp()
|
|
if err != nil {
|
|
t.Fatalf("loading the help corpus: %v", err)
|
|
}
|
|
|
|
r := tools.New(nil)
|
|
for _, tool := range []tools.Tool{
|
|
tools.StuckOrders(nil, nil),
|
|
tools.DeliveryProgress(nil),
|
|
tools.BranchPerformance(nil),
|
|
tools.PendingApprovals(nil, nil),
|
|
tools.LowStock(nil),
|
|
tools.TillsNotSyncing(nil),
|
|
tools.SalesByChannel(nil, nil, nil),
|
|
tools.Help(corpus),
|
|
tools.ApproveStockRequest(nil, nil),
|
|
} {
|
|
if err := r.Register(tool); err != nil {
|
|
t.Fatalf("registering %s: %v", tool.Name, err)
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// knownTool asks the real registry, so an agent naming a tool that does not
|
|
// exist fails here and not on somebody's deployment.
|
|
func knownTool(name string) bool { return liveRegistry.Has(name) }
|
|
|
|
// liveRegistry is built once, in TestMain, because knownTool has no *testing.T.
|
|
var liveRegistry *tools.Registry
|
|
|
|
func TestMain(m *testing.M) {
|
|
fake := &testing.T{}
|
|
liveRegistry = realRegistry(fake)
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// writeAgents drops YAML into a temporary directory and loads it.
|
|
func writeAgents(t *testing.T, files map[string]string) (map[string]Agent, error) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for name, body := range files {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
|
t.Fatalf("writing %s: %v", name, err)
|
|
}
|
|
}
|
|
return LoadAgents(dir, knownTool)
|
|
}
|
|
|
|
/* ── The agents that ship ──────────────────────────────────────────────── */
|
|
|
|
func TestTheEmbeddedAgentsLoad(t *testing.T) {
|
|
// The defaults are compiled in so a binary always has working agents and a
|
|
// deployment cannot be broken by a missing directory.
|
|
agents, err := LoadAgents("", knownTool)
|
|
if err != nil {
|
|
t.Fatalf("the shipped agents do not load: %v", err)
|
|
}
|
|
for _, want := range []string{"orders", "inventory", "shopfloor"} {
|
|
if _, ok := agents[want]; !ok {
|
|
t.Fatalf("%q is missing: %v", want, AgentNames(agents))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEveryShippedAgentNamesOnlyRealTools(t *testing.T) {
|
|
// The check that makes a typo a startup failure rather than an agent that
|
|
// silently cannot do one of the things it claims.
|
|
if _, err := LoadAgents("", knownTool); err != nil {
|
|
t.Fatalf("a shipped agent names a tool that does not exist: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAddingAnAgentTakesNoCodeChange(t *testing.T) {
|
|
// Phase 3's whole point, stated as a test.
|
|
agents, err := writeAgents(t, map[string]string{
|
|
"sixth.yaml": "name: sixth\ntier: fast\ntools:\n - low_stock\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("loading: %v", err)
|
|
}
|
|
if _, ok := agents["sixth"]; !ok {
|
|
t.Fatal("a new file did not become an agent")
|
|
}
|
|
}
|
|
|
|
/* ── The shared prompt ─────────────────────────────────────────────────── */
|
|
|
|
func TestEveryAgentInheritsTheRulesThatMustNotDrift(t *testing.T) {
|
|
// Repeating "say when a result was truncated" in three files is three
|
|
// chances for one of them to lose it.
|
|
agents, err := LoadAgents("", knownTool)
|
|
if err != nil {
|
|
t.Fatalf("loading: %v", err)
|
|
}
|
|
for name, agent := range agents {
|
|
if !strings.Contains(agent.System, "truncated") {
|
|
t.Fatalf("agent %q was not told about truncated results", name)
|
|
}
|
|
if !strings.Contains(agent.System, "never estimate") {
|
|
t.Fatalf("agent %q was not told to answer only from tools", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnAgentsOwnWordsAreAddedNotSubstituted(t *testing.T) {
|
|
agents, err := writeAgents(t, map[string]string{
|
|
"one.yaml": "name: one\ntools:\n - low_stock\nsystem: |\n Mind the gap.\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("loading: %v", err)
|
|
}
|
|
system := agents["one"].System
|
|
if !strings.Contains(system, "Mind the gap.") {
|
|
t.Fatal("the agent's own words were dropped")
|
|
}
|
|
if !strings.Contains(system, "never estimate") {
|
|
t.Fatal("the agent's own words replaced the shared rules")
|
|
}
|
|
}
|
|
|
|
/* ── What must not load quietly ────────────────────────────────────────── */
|
|
|
|
func TestATypoInAToolNameStopsTheServer(t *testing.T) {
|
|
// Invisible at runtime otherwise: the agent never calls it, the model says
|
|
// it cannot look something up, and everything reports healthy.
|
|
_, err := writeAgents(t, map[string]string{
|
|
"one.yaml": "name: one\ntools:\n - stcuk_orders\n",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("an agent naming a tool that does not exist was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "stcuk_orders") {
|
|
t.Fatalf("the error does not name the typo: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnAgentWithNoToolsIsRefused(t *testing.T) {
|
|
// It could only answer from its prompt, which is the one thing this
|
|
// assistant is built not to do.
|
|
if _, err := writeAgents(t, map[string]string{"one.yaml": "name: one\ntier: fast\n"}); err == nil {
|
|
t.Fatal("an agent with no tools was accepted")
|
|
}
|
|
}
|
|
|
|
func TestAnUnknownTierIsRefusedRatherThanFallingBack(t *testing.T) {
|
|
// The gateway falls back to the balanced model for anything it does not
|
|
// recognise, so a "deep" agent with a typo would run on the cheap model for
|
|
// months with nothing to show for it.
|
|
if _, err := writeAgents(t, map[string]string{
|
|
"one.yaml": "name: one\ntier: thorough\ntools:\n - low_stock\n",
|
|
}); err == nil {
|
|
t.Fatal("an unknown tier was accepted")
|
|
}
|
|
}
|
|
|
|
func TestAFileThatDoesNotNameItsAgentIsRefused(t *testing.T) {
|
|
// Named by contents, not filename: a file renamed on deploy would otherwise
|
|
// silently become a different agent.
|
|
if _, err := writeAgents(t, map[string]string{
|
|
"one.yaml": "tier: fast\ntools:\n - low_stock\n",
|
|
}); err == nil {
|
|
t.Fatal("a file with no agent name was accepted")
|
|
}
|
|
}
|
|
|
|
func TestTwoAgentsCannotShareAName(t *testing.T) {
|
|
// One of them is being ignored, and which depends on directory order.
|
|
_, err := writeAgents(t, map[string]string{
|
|
"a.yaml": "name: same\ntools:\n - low_stock\n",
|
|
"b.yaml": "name: same\ntools:\n - till_status\n",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("two agents took the same name")
|
|
}
|
|
}
|
|
|
|
func TestBrokenYAMLIsRefusedWithTheFileNamed(t *testing.T) {
|
|
_, err := writeAgents(t, map[string]string{
|
|
"one.yaml": "name: one\n tools: [\n",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("unparseable YAML was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "one.yaml") {
|
|
t.Fatalf("the error does not say which file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnEmptyDirectoryIsRefusedNotTreatedAsNoAgents(t *testing.T) {
|
|
// Zero agents means every question answers "no assistant called that",
|
|
// which reads as a bug in the console rather than a missing config.
|
|
if _, err := LoadAgents(t.TempDir(), knownTool); err == nil {
|
|
t.Fatal("an empty agents directory was accepted")
|
|
}
|
|
}
|
|
|
|
func TestAMissingDirectoryIsAnError(t *testing.T) {
|
|
if _, err := LoadAgents(filepath.Join(t.TempDir(), "nope"), knownTool); err == nil {
|
|
t.Fatal("a missing agents directory was accepted")
|
|
}
|
|
}
|
|
|
|
/* ── Defaults ──────────────────────────────────────────────────────────── */
|
|
|
|
func TestAnAgentThatNamesNoLimitsGetsSensibleOnes(t *testing.T) {
|
|
// Zero would mean a loop that runs no steps at all and answers nothing.
|
|
agents, err := writeAgents(t, map[string]string{
|
|
"one.yaml": "name: one\ntools:\n - low_stock\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("loading: %v", err)
|
|
}
|
|
agent := agents["one"]
|
|
if agent.MaxSteps <= 0 || agent.MaxToolCalls <= 0 {
|
|
t.Fatalf("limits are unusable: %+v", agent)
|
|
}
|
|
if agent.Tier != utils.TierBalanced {
|
|
t.Fatalf("an agent with no tier got %q", agent.Tier)
|
|
}
|
|
}
|
|
|
|
func TestTheDiskDirectoryReplacesTheEmbeddedAgentsRatherThanMerging(t *testing.T) {
|
|
// Merging would leave a deployment guessing which half of an agent it is
|
|
// running.
|
|
agents, err := writeAgents(t, map[string]string{
|
|
"only.yaml": "name: only\ntools:\n - low_stock\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("loading: %v", err)
|
|
}
|
|
if len(agents) != 1 {
|
|
t.Fatalf("the embedded agents leaked in: %v", AgentNames(agents))
|
|
}
|
|
}
|
|
|
|
func TestAgentNamesAreSortedSoTwoDeploymentsLogTheSameLine(t *testing.T) {
|
|
names := AgentNames(map[string]Agent{"zulu": {}, "alpha": {}, "mike": {}})
|
|
if names[0] != "alpha" || names[2] != "zulu" {
|
|
t.Fatalf("not sorted: %v", names)
|
|
}
|
|
}
|
|
|
|
// The platform agent may only hold tools a caller with no tenant can run.
|
|
//
|
|
// Nearle's own staff are `issuperadmin`: they read across every merchant and
|
|
// belong to none, so `tools.Caller.Tenantid` is zero and `satisfies` refuses
|
|
// anything declaring RequiresTenant or RequiresBranch. An agent holding those
|
|
// would accept every question and answer "pick a shop first" to all of them,
|
|
// which reads as a broken assistant rather than a scoped one.
|
|
//
|
|
// Asserted rather than trusted, because adding a tool to a YAML file is one
|
|
// line and the failure it produces is invisible until somebody in the platform
|
|
// workspace asks a question.
|
|
func TestThePlatformAgentOnlyHoldsToolsThatNeedNoTenant(t *testing.T) {
|
|
registry := realRegistry(t)
|
|
|
|
agents, err := LoadAgents("", registry.Has)
|
|
if err != nil {
|
|
t.Fatalf("agents: %v", err)
|
|
}
|
|
|
|
platform, ok := agents["platform"]
|
|
if !ok {
|
|
t.Fatal("no platform agent — Nearle Admin has no assistant at all")
|
|
}
|
|
if len(platform.Tools) == 0 {
|
|
t.Fatal("the platform agent holds no tools")
|
|
}
|
|
|
|
for _, name := range platform.Tools {
|
|
tool, ok := registry.Tool(name)
|
|
if !ok {
|
|
t.Fatalf("platform names %q, which is not in the registry", name)
|
|
}
|
|
if tool.Needs != tools.RequiresNothing {
|
|
t.Fatalf(
|
|
"platform holds %q, which needs a shop — a platform account has none, so every "+
|
|
"question using it is refused", name)
|
|
}
|
|
}
|
|
}
|