Files
krow_backend/go-api/internal/runtime/smalltalk_test.go
Aravind 98025f3980 Recognise smalltalk that is addressed by name
"Thank you Owliver" was answered with a six-section operational briefing:
decision backlog, screening backlog, two at-risk roles, shift coverage,
overtime, withdrawals, five numbered next steps and three policy citations.
Tools were called and the corpus was retrieved, for a message that said thanks.

The set held "thank you" and it held "hi owliver". It did not hold the two
together, because the name variants had been written out by hand for the
greetings and never for the thanks or the farewells. That is the failure mode
of enumerating a cross product: one half gets maintained and the other half
silently does not, and nothing points at the gap.

So the name comes off once, in stripVocative, and the set holds each phrase
exactly once. "Thanks Owliver", "Owliver hi" and "Good night Owliver" all
reduce to a row that already existed. The three hand-written "hi owliver" rows
are gone; "owliver" alone stays, since that is somebody getting the agent's
attention rather than a phrase with a name attached.

Stripped only at an end and only as a whole word. "ask owliver to check the
rota" and "hi owliver which positions are at risk" keep their tools and their
evidence — a message that asks for something is not smalltalk however politely
it opens. Both directions are tested.

Also adds the thanks nobody had written down yet: thankyou, thx, tysm, thank
you so much, much appreciated, perfect thanks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 15:44:21 +05:30

211 lines
7.0 KiB
Go

package runtime
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/krow/krow-backend/go-api/internal/gateway"
"github.com/krow/krow-backend/go-api/internal/tools"
)
// TestIsSmalltalkGreetings covers what the fix is for: the messages that were
// costing a full operational turn.
func TestIsSmalltalkGreetings(t *testing.T) {
for _, q := range []string{
"hi", "Hi", "HI", "hi!", "hi.", " hi ", "hi :)", "hi 👋",
"hello", "Hello!", "hey", "Hey there", "hi there",
"good morning", "Good Morning!", "good evening",
"thanks", "Thank you", "thank-you", "thank you",
"bye", "Goodbye", "good night",
"how are you", "How's it going?",
} {
if !isSmalltalk(q) {
t.Errorf("isSmalltalk(%q) = false, want true", q)
}
}
}
// TestIsSmalltalkRealQuestions is the half that matters for correctness. A
// false positive answers a real operational question with a greeting, so
// anything carrying a request must fall through — including the ones that
// merely START with a greeting.
func TestIsSmalltalkRealQuestions(t *testing.T) {
for _, q := range []string{
"", " ",
"how many open positions?",
"what happened today",
"hi, how many open positions?",
"hello there, which shifts are uncovered?",
"hey can you check the screening backlog",
"thanks — now show me the overtime report",
"good morning, what needs attention right now?",
"say hi to the new starters",
"how are you handling the uncovered shifts",
"bye week coverage",
} {
if isSmalltalk(q) {
t.Errorf("isSmalltalk(%q) = true, want false", q)
}
}
}
func TestNormaliseSmalltalk(t *testing.T) {
cases := map[string]string{
"Hi!": "hi",
" HELLO ": "hello",
"thank-you": "thank you",
"thank you": "thank you",
"How's it go?": "hows it go",
"👋": "",
"shift 12": "shift",
}
for in, want := range cases {
if got := normaliseSmalltalk(in); got != want {
t.Errorf("normaliseSmalltalk(%q) = %q, want %q", in, got, want)
}
}
}
// TestSmalltalkSendsNoToolsAndSkipsRetrieval is the fix as the user meets it:
// "hi" reaches the model as "hi", with nothing attached.
func TestSmalltalkSendsNoToolsAndSkipsRetrieval(t *testing.T) {
var toolCalls int
reg := tools.NewRegistry()
reg.MustRegister(countingTool("activity_breakdown", &toolCalls))
ret := &scriptedRetriever{results: onePassage("Staff must arrive fifteen minutes early.")}
gw := &scriptedGateway{}
agent := knowledgeAgent()
agent.Tools = []string{"activity_breakdown"}
exec := NewModelExecutor(gw, &MemorySink{}, reg).WithRetriever(ret)
res, err := exec.ExecuteAgent(context.Background(), agent, testInput("Hi"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Termination != TerminationCompleted {
t.Fatalf("Termination = %q, want Completed", res.Termination)
}
if ret.calls != 0 {
t.Errorf("retriever was called %d times for a greeting; want 0", ret.calls)
}
if len(gw.seen) != 1 {
t.Fatalf("a greeting took %d model calls, want 1", len(gw.seen))
}
req := gw.seen[0]
if len(req.Tools) != 0 {
t.Errorf("greeting carried %d tool definitions, want 0", len(req.Tools))
}
if req.Messages[0].Text != "Hi" {
t.Errorf("model saw %q, want the bare greeting", req.Messages[0].Text)
}
if !strings.Contains(req.System, "greeted you") {
t.Error("the smalltalk directive did not reach the system prompt")
}
if req.MaxOutputTokens != smalltalkMaxOutputTokens {
t.Errorf("MaxOutputTokens = %d, want the smalltalk cap %d",
req.MaxOutputTokens, smalltalkMaxOutputTokens)
}
}
// TestOperationalQuestionKeepsToolsAndRetrieval is the guard on the fix above.
// The cheap path must not swallow a question that needs evidence.
func TestOperationalQuestionKeepsToolsAndRetrieval(t *testing.T) {
var toolCalls int
reg := tools.NewRegistry()
reg.MustRegister(countingTool("activity_breakdown", &toolCalls))
ret := &scriptedRetriever{results: onePassage("Staff must arrive fifteen minutes early.")}
gw := &scriptedGateway{}
agent := knowledgeAgent()
agent.Tools = []string{"activity_breakdown"}
exec := NewModelExecutor(gw, &MemorySink{}, reg).WithRetriever(ret)
if _, err := exec.ExecuteAgent(
context.Background(), agent, testInput("hi, how many open positions?"),
); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ret.calls != 1 {
t.Errorf("retriever called %d times for a real question, want 1", ret.calls)
}
if len(gw.seen[0].Tools) == 0 {
t.Error("a real question was sent with no tools")
}
}
// TestCatalogueWithheldOnceToolBudgetIsSpent covers the other half of the cost
// work: the synthesis turn at the end of a tool-using run is sent without a
// catalogue the model is no longer permitted to use.
func TestCatalogueWithheldOnceToolBudgetIsSpent(t *testing.T) {
var toolCalls int
reg := tools.NewRegistry()
reg.MustRegister(countingTool("activity_breakdown", &toolCalls))
gw := &scriptedGateway{steps: []*gateway.Response{{
ToolCalls: []gateway.ToolCall{{ID: "call_1", Name: "activity_breakdown", Input: json.RawMessage(`{}`)}},
StopReason: "tool_use", Model: "fake-model",
}}}
agent := testAgent()
agent.Tools = []string{"activity_breakdown"}
exec := NewModelExecutor(gw, &MemorySink{}, reg)
res, err := exec.executeWithLimits(context.Background(), agent, testInput("what happened today"),
Limits{MaxSteps: 4, MaxToolCalls: 1, MaxTokens: 100_000, Deadline: 30 * time.Second})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Termination != TerminationCompleted {
t.Fatalf("Termination = %q, want Completed", res.Termination)
}
if len(gw.seen) < 2 {
t.Fatalf("expected at least 2 model calls, got %d", len(gw.seen))
}
if len(gw.seen[0].Tools) == 0 {
t.Error("the first call must offer the catalogue")
}
if n := len(gw.seen[len(gw.seen)-1].Tools); n != 0 {
t.Errorf("the final call carried %d tool definitions; the tool budget was spent", n)
}
}
// The case from production: "Thank you Owliver" answered with a six-section
// operational briefing — tools, retrieval, citations, next steps — because the
// set held "thank you" and "hi owliver" but not the two together.
func TestSmalltalkSurvivesBeingAddressedByName(t *testing.T) {
for _, q := range []string{
"Thank you Owliver", "thanks owliver", "Thanks, Owliver!",
"Owliver hi", "hi owliver", "Hello Owliver",
"Good morning Owliver", "good night owliver", "bye owliver",
"owliver", "Owliver?",
} {
if !isSmalltalk(q) {
t.Errorf("isSmalltalk(%q) = false; a greeting addressed by name is still a greeting", q)
}
}
}
// The name comes off only as a vocative at an end. A real question that
// mentions the agent is still a real question.
func TestAQuestionMentioningTheNameIsNotSmalltalk(t *testing.T) {
for _, q := range []string{
"ask owliver to check the rota",
"owliver how many shifts are uncovered",
"thanks owliver now show me the backlog",
"is owliver working",
"hi owliver which positions are at risk",
} {
if isSmalltalk(q) {
t.Errorf("isSmalltalk(%q) = true; this asks for something and must keep its tools", q)
}
}
}