Files
backend_fiesta/services/assistant_test.go
abhishek 8e1549764b Nearle Buddy answers a typed question
Phase 2: the loop and the model gateway. The composer in the console has
said "Not connected yet" since it was built, because there was no
assistant endpoint anywhere. There is one now.

- utils/chat.go   the gateway, a sibling of embedding.go: one small
                  interface, a provider switch, the shared postJSON, no
                  framework. Agents name a TIER (fast/balanced/deep) and
                  config maps tier to model, so changing provider does not
                  touch an agent.
- services/assistantService.go  one loop for every agent. An agent is a
                  name, a tier, a prompt and an allow-list — data, not a
                  class — so a sixth is config rather than a subclass.
- the endpoint under /v1/web, inheriting middleware.WebAuth along with
  every other console route. The assistant reads the same data the console
  does and must read it as the same person.

What the model does not get to decide:

  whose data      the caller is built from the verified session in the
                  controller, never from the request body — there is no
                  tenant field to fill in. A test scripts the model calling
                  a tool with {"tenantid": 916} and asserts it ran for 1147.
  which tools     the registry enforces the agent's allow-list; a test
                  scripts a call to a tool the agent lacks and asserts the
                  handler never ran.
  when to stop    steps and tool calls are counted here. A model that keeps
                  calling tools is stopped by arithmetic, not by being
                  asked nicely.

Two quiet failures have tests of their own. A finish_reason of "length"
means the provider cut the reply off mid-sentence, which reads exactly
like a complete answer unless it is flagged. And a truncated tool result
reaches the model in words it will repeat — otherwise it describes a
capped list and an empty one identically.

A refused tool goes back as a message, not an error: a model told "that
tool needs a tenant" can explain it, where a model handed nothing says
"something went wrong".

Optional, like the embedder. Without ASSISTANT_PROVIDER the endpoint
answers "not switched on here", the composer stays disabled, and the tools
still work — they are ordinary Go functions, and only turning a sentence
into a tool call needs a model.

14 tests, against a scripted model rather than a live provider: these are
about what the loop refuses to let a model do, and that has to hold for
any model, including one behaving badly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:13:20 +05:30

378 lines
13 KiB
Go

package services
import (
"context"
"errors"
"strings"
"testing"
"nearle/services/tools"
"nearle/utils"
)
// The loop, against a scripted model.
//
// A fake rather than a live provider on purpose: these are about what the loop
// REFUSES to let a model do, and that has to hold for any model, including one
// behaving badly. A test that needed a network would only ever prove what one
// model happened to do that afternoon.
type scriptedChat struct {
replies []utils.ChatReply
err error
// Every request the loop made, so the tests can inspect what the model was
// actually shown — the tool list especially.
seen []utils.ChatRequest
}
func (s *scriptedChat) ModelFor(string) string { return "scripted-model" }
func (s *scriptedChat) Complete(_ context.Context, req utils.ChatRequest) (utils.ChatReply, error) {
s.seen = append(s.seen, req)
if s.err != nil {
return utils.ChatReply{}, s.err
}
if len(s.replies) == 0 {
return utils.ChatReply{Content: "nothing further", Model: "scripted-model"}, nil
}
reply := s.replies[0]
s.replies = s.replies[1:]
if reply.Model == "" {
reply.Model = "scripted-model"
}
return reply, nil
}
// recordingTool answers with fixed rows and remembers the caller it ran for.
func recordingTool(name string, result tools.Result, seen *tools.Caller) tools.Tool {
return tools.Tool{
Name: name,
Description: "a tool, for testing",
Scope: tools.ScopeRead,
Schema: tools.Schema{},
Handler: func(_ context.Context, req tools.Request) (tools.Result, error) {
if seen != nil {
*seen = req.Caller
}
return result, nil
},
}
}
func newAssistant(t *testing.T, chat utils.Chat, toolset ...tools.Tool) AssistantService {
t.Helper()
registry := tools.New(nil)
names := make([]string, 0, len(toolset))
for _, tool := range toolset {
if err := registry.Register(tool); err != nil {
t.Fatalf("registering: %v", err)
}
names = append(names, tool.Name)
}
agents := map[string]Agent{"orders": {
Name: "orders", Tier: utils.TierBalanced, System: "be brief",
Tools: names, MaxSteps: 4, MaxToolCalls: 6,
}}
return NewAssistantService(registry, chat, agents)
}
var merchant = tools.Caller{Userid: 904, Tenantid: 1147}
func toolCall(id, name string, args map[string]any) utils.ChatReply {
return utils.ChatReply{ToolCalls: []utils.ToolCall{{ID: id, Name: name, Arguments: args}}}
}
/* ── The happy path ────────────────────────────────────────────────────── */
func TestATypedQuestionRoutesToAToolAndComesBackAsWords(t *testing.T) {
// Phase 2's whole point.
chat := &scriptedChat{replies: []utils.ChatReply{
toolCall("c1", "stuck", nil),
{Content: "Two jobs have been waiting over half an hour."},
}}
assistant := newAssistant(t, chat, recordingTool("stuck", tools.Result{
Rows: []int{1, 2}, Count: 2, Scope: "all branches", Source: "/admin/dispatch",
}, nil))
answer, err := assistant.Ask(context.Background(), "orders", "what is stuck?", merchant)
if err != nil {
t.Fatalf("asking: %v", err)
}
if answer.Reply == "" {
t.Fatal("no answer came back")
}
if len(answer.Used) != 1 || answer.Used[0].Tool != "stuck" {
t.Fatalf("the tool run is not reported: %+v", answer.Used)
}
if answer.Used[0].Rows != 2 {
t.Fatalf("row count lost: %+v", answer.Used[0])
}
if len(answer.Sources) != 1 || answer.Sources[0] != "/admin/dispatch" {
t.Fatalf("the answer links to nothing: %+v", answer.Sources)
}
if answer.Incomplete {
t.Fatal("a finished answer was flagged incomplete")
}
}
func TestAQuestionNeedingNoToolIsAnsweredDirectly(t *testing.T) {
// "auto", not "required" — forcing a call makes a model invent one.
chat := &scriptedChat{replies: []utils.ChatReply{{Content: "Deliveries are jobs given to a rider."}}}
assistant := newAssistant(t, chat, recordingTool("stuck", tools.Result{}, nil))
answer, err := assistant.Ask(context.Background(), "orders", "what is a delivery?", merchant)
if err != nil {
t.Fatalf("asking: %v", err)
}
if len(answer.Used) != 0 {
t.Fatalf("a tool ran for a question that needed none: %+v", answer.Used)
}
}
/* ── What the model does not get to choose ─────────────────────────────── */
func TestTheCallerComesFromTheSessionNotTheModel(t *testing.T) {
// The single most important property. The model picks the tool; it has no
// say in whose data is read.
var seen tools.Caller
chat := &scriptedChat{replies: []utils.ChatReply{
toolCall("c1", "stuck", map[string]any{"tenantid": 916}),
{Content: "done"},
}}
assistant := newAssistant(t, chat, recordingTool("stuck", tools.Result{}, &seen))
if _, err := assistant.Ask(context.Background(), "orders", "orders for tenant 916", merchant); err != nil {
t.Fatalf("asking: %v", err)
}
if seen.Tenantid != 1147 {
t.Fatalf("the tool ran for tenant %d", seen.Tenantid)
}
}
func TestAToolTheAgentDoesNotHaveIsRefusedNotRun(t *testing.T) {
var ran bool
chat := &scriptedChat{replies: []utils.ChatReply{
toolCall("c1", "secret", nil),
{Content: "I could not look that up."},
}}
registry := tools.New(nil)
_ = registry.Register(recordingTool("stuck", tools.Result{}, nil))
_ = registry.Register(tools.Tool{
Name: "secret", Description: "not for this agent", Scope: tools.ScopeRead,
Handler: func(context.Context, tools.Request) (tools.Result, error) {
ran = true
return tools.Result{}, nil
},
})
assistant := NewAssistantService(registry, chat, map[string]Agent{"orders": {
Name: "orders", Tier: utils.TierBalanced, Tools: []string{"stuck"}, MaxSteps: 4, MaxToolCalls: 6,
}})
answer, err := assistant.Ask(context.Background(), "orders", "tell me a secret", merchant)
if err != nil {
t.Fatalf("asking: %v", err)
}
if ran {
t.Fatal("a tool off the allow-list ran")
}
if len(answer.Used) != 1 || answer.Used[0].Outcome != tools.OutcomeRefused {
t.Fatalf("the refusal is not reported: %+v", answer.Used)
}
}
func TestTheModelIsOnlyShownToolsItMayUse(t *testing.T) {
chat := &scriptedChat{replies: []utils.ChatReply{{Content: "done"}}}
assistant := newAssistant(t, chat, recordingTool("stuck", tools.Result{}, nil))
if _, err := assistant.Ask(context.Background(), "orders", "hello", merchant); err != nil {
t.Fatalf("asking: %v", err)
}
if len(chat.seen) == 0 || len(chat.seen[0].Tools) != 1 {
t.Fatalf("the model was shown the wrong tools: %+v", chat.seen)
}
}
/* ── A refusal is a message, not a crash ───────────────────────────────── */
func TestARefusedToolIsExplainedToTheModel(t *testing.T) {
// A model told "that tool needs a tenant" can explain it; a model handed
// nothing says "something went wrong".
chat := &scriptedChat{replies: []utils.ChatReply{
toolCall("c1", "broken", nil),
{Content: "I could not read that."},
}}
registry := tools.New(nil)
_ = registry.Register(tools.Tool{
Name: "broken", Description: "fails", Scope: tools.ScopeRead,
Handler: func(context.Context, tools.Request) (tools.Result, error) {
return tools.Result{}, errors.New("the database is down")
},
})
assistant := NewAssistantService(registry, chat, map[string]Agent{"orders": {
Name: "orders", Tier: utils.TierBalanced, Tools: []string{"broken"}, MaxSteps: 4, MaxToolCalls: 6,
}})
answer, err := assistant.Ask(context.Background(), "orders", "what is stuck?", merchant)
if err != nil {
t.Fatalf("a failing tool broke the whole question: %v", err)
}
if answer.Reply == "" {
t.Fatal("no answer after a tool failure")
}
// The model must have been TOLD, not just had the call dropped.
var told bool
for _, req := range chat.seen {
for _, m := range req.Messages {
if m.Role == utils.RoleTool && strings.Contains(m.Content, "database is down") {
told = true
}
}
}
if !told {
t.Fatal("the model was never told why the tool failed")
}
}
/* ── Limits are arithmetic, not a polite request ───────────────────────── */
func TestAModelThatKeepsCallingToolsIsStopped(t *testing.T) {
// Asking a model to stop is a request. This is the thing that actually
// stops it.
chat := &scriptedChat{replies: []utils.ChatReply{
toolCall("c1", "stuck", nil),
toolCall("c2", "stuck", nil),
toolCall("c3", "stuck", nil),
toolCall("c4", "stuck", nil),
toolCall("c5", "stuck", nil),
toolCall("c6", "stuck", nil),
toolCall("c7", "stuck", nil),
}}
registry := tools.New(nil)
_ = registry.Register(recordingTool("stuck", tools.Result{}, nil))
assistant := NewAssistantService(registry, chat, map[string]Agent{"orders": {
Name: "orders", Tier: utils.TierBalanced, Tools: []string{"stuck"}, MaxSteps: 3, MaxToolCalls: 2,
}})
answer, err := assistant.Ask(context.Background(), "orders", "loop forever", merchant)
if err != nil {
t.Fatalf("asking: %v", err)
}
if !answer.Incomplete {
t.Fatal("the loop ran out of room and did not say so")
}
ran := 0
for _, step := range answer.Used {
if step.Outcome == tools.OutcomeOK {
ran++
}
}
if ran > 2 {
t.Fatalf("the tool-call cap was ignored: %d ran", ran)
}
}
func TestATruncatedReplyIsFlagged(t *testing.T) {
// `length` means the provider cut the answer off mid-sentence, and that
// reads exactly like a complete answer unless it is flagged.
chat := &scriptedChat{replies: []utils.ChatReply{{Content: "The branches that are under", StopReason: "length"}}}
assistant := newAssistant(t, chat, recordingTool("stuck", tools.Result{}, nil))
answer, err := assistant.Ask(context.Background(), "orders", "compare my branches", merchant)
if err != nil {
t.Fatalf("asking: %v", err)
}
if !answer.Incomplete {
t.Fatal("a reply cut off mid-sentence was reported as complete")
}
}
/* ── Degrading, and refusing ───────────────────────────────────────────── */
func TestWithNoModelConfiguredTheAssistantSaysSo(t *testing.T) {
assistant := newAssistant(t, nil, recordingTool("stuck", tools.Result{}, nil))
if assistant.Available() {
t.Fatal("reported available with no model")
}
_, err := assistant.Ask(context.Background(), "orders", "what is stuck?", merchant)
if !errors.Is(err, utils.ErrChatNotConfigured) {
t.Fatalf("expected a clear 'not configured', got: %v", err)
}
}
func TestAnUnknownAgentIsRefused(t *testing.T) {
chat := &scriptedChat{}
assistant := newAssistant(t, chat, recordingTool("stuck", tools.Result{}, nil))
if _, err := assistant.Ask(context.Background(), "invented", "hello", merchant); err == nil {
t.Fatal("a question was answered by an agent that does not exist")
}
}
func TestAnEmptyQuestionIsRefusedBeforeTheModelIsPaid(t *testing.T) {
chat := &scriptedChat{}
assistant := newAssistant(t, chat, recordingTool("stuck", tools.Result{}, nil))
if _, err := assistant.Ask(context.Background(), "orders", " ", merchant); err == nil {
t.Fatal("an empty question reached the model")
}
if len(chat.seen) != 0 {
t.Fatal("the model was called for an empty question")
}
}
func TestAPastedSpreadsheetIsNotAQuestion(t *testing.T) {
chat := &scriptedChat{}
assistant := newAssistant(t, chat, recordingTool("stuck", tools.Result{}, nil))
if _, err := assistant.Ask(context.Background(), "orders", strings.Repeat("x", maxQuestion+1), merchant); err == nil {
t.Fatal("an oversized question was sent to the model")
}
if len(chat.seen) != 0 {
t.Fatal("the model was called for an oversized question")
}
}
/* ── What the console is told ──────────────────────────────────────────── */
func TestTheAnswerNamesTheModelThatProducedIt(t *testing.T) {
// An answer nobody can attribute to a model cannot be reproduced when it
// turns out to be wrong.
chat := &scriptedChat{replies: []utils.ChatReply{{Content: "done", Model: "some-model-v2"}}}
assistant := newAssistant(t, chat, recordingTool("stuck", tools.Result{}, nil))
answer, _ := assistant.Ask(context.Background(), "orders", "hello", merchant)
if answer.Model != "some-model-v2" {
t.Fatalf("the model is not named: %q", answer.Model)
}
}
func TestTruncationReachesTheModelInWords(t *testing.T) {
// An empty result and a capped one look identical to a model, and it will
// describe both as "none".
chat := &scriptedChat{replies: []utils.ChatReply{
toolCall("c1", "stuck", nil),
{Content: "done"},
}}
assistant := newAssistant(t, chat, recordingTool("stuck", tools.Result{
Rows: []int{1}, Count: 60, Truncated: true, Note: "60 jobs are waiting; the 50 longest are listed.",
}, nil))
if _, err := assistant.Ask(context.Background(), "orders", "what is stuck?", merchant); err != nil {
t.Fatalf("asking: %v", err)
}
var told bool
for _, req := range chat.seen {
for _, m := range req.Messages {
if m.Role == utils.RoleTool && strings.Contains(m.Content, "60 jobs are waiting") {
told = true
}
}
}
if !told {
t.Fatal("the model was not told the list was capped")
}
}