Files
2026-08-29 17:02:35 +02:00

478 lines
18 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"homehub/internal/ollama"
"homehub/internal/rag"
"homehub/internal/store"
)
func toolTestApp(t *testing.T, serverURL string) *app {
t.Helper()
db, err := store.New(filepath.Join(t.TempDir(), "store.json"))
if err != nil {
t.Fatal(err)
}
idx, err := rag.New(filepath.Join(t.TempDir(), "rag.json"))
if err != nil {
t.Fatal(err)
}
cfg := defaultContextConfig()
cfg.AgentMaxSteps = 6
ai := ollama.New(serverURL, "fake-model", "fake-embed")
ai.ConfigureGeneration(cfg.chatOptions(), cfg.plannerOptions())
a := &app{db: db, ai: ai, rag: idx, timezone: berlin(t), context: cfg}
a.tools = newToolRegistry(a)
a.skills = newSkillRegistry(a, a.tools, filepath.Join(t.TempDir(), "skills"))
return a
}
func writeOllamaTurn(w http.ResponseWriter, msg map[string]any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"message": msg})
}
func TestNativeToolAgentCreatesMealPlanSymbolically(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["tools"]; ok {
if calls.Add(1) == 1 {
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "mealplan_create", "arguments": map[string]any{"title": "Pommes Currywurst", "when": map[string]any{"kind": "weekday", "weekday": "sunday"}, "meal": "lunch"}}}}})
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Erledigt."})
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "{}"})
}))
defer srv.Close()
a := toolTestApp(t, srv.URL)
out, err := a.runToolAgent(context.Background(), "Sonntag wären Pommes Currywurst toll. Also zu Mittag")
if err != nil {
t.Fatal(err)
}
if len(out.Results) == 0 || !out.Results[0].Success || !out.Results[0].Mutated {
t.Fatalf("results=%+v", out.Results)
}
st := a.db.Snapshot()
if len(st.MealPlans) != 1 {
t.Fatalf("meal plans=%d", len(st.MealPlans))
}
m := st.MealPlans[0]
if m.Meal != "lunch" || m.Title != "Pommes Currywurst" {
t.Fatalf("meal=%+v", m)
}
d, err := time.ParseInLocation("2006-01-02", m.Date, a.timezone)
if err != nil {
t.Fatal(err)
}
if d.Weekday() != time.Sunday {
t.Fatalf("date=%s weekday=%v", m.Date, d.Weekday())
}
if len(st.Tasks) != 0 {
t.Fatalf("unexpected tasks=%+v", st.Tasks)
}
}
func TestToolAgentCanSearchRecipeThenPlanAndSyncGroceries(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["tools"]; ok {
switch calls.Add(1) {
case 1:
writeOllamaTurn(w, map[string]any{"role": "assistant", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "recipes_search", "arguments": map[string]any{"query": "Nudeln Tomatensauce"}}}}})
case 2:
writeOllamaTurn(w, map[string]any{"role": "assistant", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "mealplan_create", "arguments": map[string]any{"title": "Nudeln mit Tomatensauce", "recipe_id": "r1", "when": map[string]any{"kind": "relative", "offset_days": 1}, "meal": "dinner", "servings": 4}}}}})
default:
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Geplant."})
}
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "{}"})
}))
defer srv.Close()
a := toolTestApp(t, srv.URL)
_ = a.db.Update(func(s *store.State) error {
s.Recipes = []store.Recipe{{ID: "r1", Title: "Nudeln mit Tomatensauce", Servings: 2, Ingredients: []store.Ingredient{{Name: "Nudeln", Quantity: "200", Unit: "g"}, {Name: "Tomatensauce", Quantity: "100", Unit: "ml"}}, CreatedAt: store.Now()}}
return nil
})
out, err := a.runToolAgent(context.Background(), "Plane morgen Nudeln mit Tomatensauce zum Abendessen für vier Personen")
if err != nil {
t.Fatal(err)
}
if len(out.Results) < 2 {
t.Fatalf("expected search + create, got %+v", out.Results)
}
st := a.db.Snapshot()
if len(st.MealPlans) != 1 || st.MealPlans[0].RecipeID != "r1" || st.MealPlans[0].Servings != 4 {
t.Fatalf("meal=%+v", st.MealPlans)
}
if len(st.Groceries) < 2 {
t.Fatalf("expected recipe groceries, got %+v", st.Groceries)
}
}
func TestAgentJSONSelectorFallbackStillUsesRegistry(t *testing.T) {
var native atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["tools"]; ok {
if native.Add(1) == 1 {
// Model incorrectly answers in prose instead of a native tool call.
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Ich habe den Termin eingetragen."})
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "OK"})
return
}
// JSON selector fallback chooses a registered tool with symbolic time.
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": `{"tool":"calendar_create","arguments":{"title":"Zahnarzt","when":{"kind":"relative","offset_days":1,"time":"15:30"}},"confidence":0.99}`})
}))
defer srv.Close()
a := toolTestApp(t, srv.URL)
out, err := a.runToolAgent(context.Background(), "Trage morgen Zahnarzt um 15:30 Uhr in den Kalender ein")
if err != nil {
t.Fatal(err)
}
if len(out.Results) != 1 || out.Results[0].Tool != "calendar_create" || !out.Results[0].Mutated {
t.Fatalf("out=%+v", out)
}
st := a.db.Snapshot()
if len(st.Events) != 1 {
t.Fatalf("events=%+v", st.Events)
}
tm, err := time.Parse(time.RFC3339, st.Events[0].Start)
if err != nil {
t.Fatal(err)
}
if tm.In(a.timezone).Hour() != 15 || tm.In(a.timezone).Minute() != 30 {
t.Fatalf("start=%s", st.Events[0].Start)
}
if out.Reply == "Ich habe den Termin eingetragen." {
t.Fatal("uncommitted prose must not be trusted")
}
}
func TestUnknownToolNeverMutatesStore(t *testing.T) {
a := toolTestApp(t, "http://127.0.0.1:1")
res := a.tools.Execute(context.Background(), "destroy_everything", map[string]any{})
if res.Success || res.Error == nil || res.Error.Code != "UNKNOWN_TOOL" {
t.Fatalf("res=%+v", res)
}
st := a.db.Snapshot()
if len(st.Tasks)+len(st.Events)+len(st.MealPlans) != 0 {
t.Fatalf("state mutated: %+v", st)
}
}
func TestWorkflowPlannerDecomposesWeeklyDinnerPlanIntoSubtasks(t *testing.T) {
var nativeCalls atomic.Int32
var jsonCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["tools"]; ok {
if nativeCalls.Add(1) == 1 {
// Native model asks an unnecessary clarification instead of using tools.
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Welche Gerichte soll ich planen?"})
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Plan erledigt."})
return
}
jsonCalls.Add(1)
steps := []map[string]any{}
titles := []string{"Gemüsepfanne mit Reis", "Nudeln mit Tomatensauce", "Ofenkartoffeln mit Kräuterquark", "Linsencurry", "Wraps mit Gemüse", "Pizza Margherita", "Kartoffelsuppe"}
for i, title := range titles {
steps = append(steps, map[string]any{
"id": fmt.Sprintf("s%d", i+1),
"task": fmt.Sprintf("Abendessen Tag %d planen", i+1),
"tool": "mealplan_create",
"arguments": map[string]any{
"title": title,
"when": map[string]any{"kind": "relative", "offset_days": i},
"meal": "dinner",
},
})
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": mustJSON(map[string]any{"goal": "7 Abendessen planen", "steps": steps, "confidence": 0.96})})
}))
defer srv.Close()
a := toolTestApp(t, srv.URL)
out, err := a.runToolAgent(context.Background(), "erstelle einen essensplan für die ganze woche aber nur abendessen")
if err != nil {
t.Fatal(err)
}
if out.Plan == nil || len(out.Plan.Steps) != 7 {
t.Fatalf("plan=%+v", out.Plan)
}
if len(out.Results) != 7 {
t.Fatalf("expected 7 tool results, got %d: %+v", len(out.Results), out.Results)
}
st := a.db.Snapshot()
if len(st.MealPlans) != 7 {
t.Fatalf("expected 7 meal plans, got %+v", st.MealPlans)
}
seen := map[string]bool{}
for _, m := range st.MealPlans {
if m.Meal != "dinner" {
t.Fatalf("unexpected meal=%+v", m)
}
seen[m.Date] = true
}
if len(seen) != 7 {
t.Fatalf("expected seven distinct dates, got %+v", st.MealPlans)
}
if jsonCalls.Load() != 1 {
t.Fatalf("expected one workflow planner call, got %d", jsonCalls.Load())
}
}
func mustJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
func TestWeeklyPlanRecipeFirstUsesRecipeBookAndBuildsGroceries(t *testing.T) {
var nativeCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["tools"]; ok {
if nativeCalls.Add(1) == 1 {
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Ich würde verschiedene Gerichte planen."})
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Erledigt."})
return
}
steps := []map[string]any{}
for i := 0; i < 7; i++ {
steps = append(steps, map[string]any{
"id": fmt.Sprintf("s%d", i+1), "task": "Abendessen planen", "tool": "mealplan_create",
"arguments": map[string]any{"title": fmt.Sprintf("Erfundenes Gericht %d", i+1), "when": map[string]any{"kind": "relative", "offset_days": i}, "meal": "dinner"},
})
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": mustJSON(map[string]any{"goal": "Wochenplan", "steps": steps, "confidence": 0.95})})
}))
defer srv.Close()
a := toolTestApp(t, srv.URL)
_ = a.db.Update(func(s *store.State) error {
s.Recipes = []store.Recipe{{ID: "r1", Title: "Nudeln mit Tomatensauce", Servings: 2, Ingredients: []store.Ingredient{{Name: "Nudeln", Quantity: "150", Unit: "g"}, {Name: "Tomatensauce", Quantity: "50", Unit: "ml"}}, CreatedAt: store.Now()}}
return nil
})
out, err := a.runToolAgent(context.Background(), "erstelle einen essensplan für die ganze woche aber nur abendessen")
if err != nil {
t.Fatal(err)
}
if out.Plan == nil || len(out.Plan.Steps) != 7 {
t.Fatalf("plan=%+v", out.Plan)
}
st := a.db.Snapshot()
if len(st.MealPlans) != 7 {
t.Fatalf("meal plans=%+v", st.MealPlans)
}
for _, m := range st.MealPlans {
if m.RecipeID != "r1" || m.Title != "Nudeln mit Tomatensauce" {
t.Fatalf("recipe-first failed: %+v", m)
}
}
var noodles, sauce *store.GroceryItem
for i := range st.Groceries {
g := &st.Groceries[i]
if g.Name == "Nudeln" {
noodles = g
}
if g.Name == "Tomatensauce" {
sauce = g
}
}
if noodles == nil || noodles.Quantity != "1050" {
t.Fatalf("noodles=%+v groceries=%+v", noodles, st.Groceries)
}
if sauce == nil || sauce.Quantity != "350" {
t.Fatalf("sauce=%+v groceries=%+v", sauce, st.Groceries)
}
}
func TestRecipesUpdateRefreshesLinkedGroceries(t *testing.T) {
var nativeCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["tools"]; ok {
if nativeCalls.Add(1) == 1 {
writeOllamaTurn(w, map[string]any{"role": "assistant", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "recipes_update", "arguments": map[string]any{"id": "r1", "ingredients": []any{map[string]any{"name": "Nudeln", "quantity": "300", "unit": "g"}, map[string]any{"name": "Tomatensauce", "quantity": "120", "unit": "ml"}}}}}}})
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Aktualisiert."})
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": `{}`})
}))
defer srv.Close()
a := toolTestApp(t, srv.URL)
today := time.Now().In(a.timezone).Format("2006-01-02")
_ = a.db.Update(func(s *store.State) error {
s.Recipes = []store.Recipe{{ID: "r1", Title: "Nudeln mit Tomatensauce", Servings: 2, Ingredients: []store.Ingredient{{Name: "Nudeln", Quantity: "150", Unit: "g"}}, CreatedAt: store.Now()}}
s.MealPlans = []store.MealPlan{{ID: "m1", Date: today, Meal: "dinner", RecipeID: "r1", Title: "Nudeln mit Tomatensauce", Servings: 2, CreatedAt: store.Now()}}
return nil
})
a.runHomeAutomations(context.Background(), "test-initial")
out, err := a.runToolAgent(context.Background(), "Ändere beim Rezept Nudeln mit Tomatensauce die Zutaten auf 300 g Nudeln und 120 ml Tomatensauce")
if err != nil {
t.Fatal(err)
}
if len(out.Results) == 0 || out.Results[0].Tool != "recipes_update" || !out.Results[0].Mutated {
t.Fatalf("out=%+v", out)
}
st := a.db.Snapshot()
if len(st.Recipes) != 1 || len(st.Recipes[0].Ingredients) != 2 {
t.Fatalf("recipe=%+v", st.Recipes)
}
found := map[string]string{}
for _, g := range st.Groceries {
found[g.Name] = g.Quantity
}
if found["Nudeln"] != "300" || found["Tomatensauce"] != "120" {
t.Fatalf("groceries=%+v", st.Groceries)
}
}
func TestGroceriesSyncMealplanReportsUnlinkedMeals(t *testing.T) {
a := toolTestApp(t, "http://127.0.0.1:1")
today := time.Now().In(a.timezone).Format("2006-01-02")
_ = a.db.Update(func(s *store.State) error {
s.MealPlans = []store.MealPlan{{ID: "m1", Date: today, Meal: "dinner", Title: "Freies Gericht", Servings: 2, CreatedAt: store.Now()}}
return nil
})
res := a.tools.Execute(context.Background(), "groceries_sync_mealplan", map[string]any{"days": 14})
if !res.Success {
t.Fatalf("res=%+v", res)
}
b, _ := json.Marshal(res.Data)
var data struct {
Missing []map[string]any `json:"missing_recipe_meals"`
}
if err := json.Unmarshal(b, &data); err != nil {
t.Fatal(err)
}
if len(data.Missing) != 1 {
t.Fatalf("data=%s", string(b))
}
}
func TestCalendarDeleteConfirmationUsesExactListedEventNotStaleRecentEntity(t *testing.T) {
var toolCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["tools"]; ok {
switch toolCalls.Add(1) {
case 1:
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "calendar_list", "arguments": map[string]any{"query": "Friseur"}}}}})
default:
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Ich habe den Termin Friseur gefunden. Soll ich den Termin löschen?"})
}
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": `{}`})
}))
defer srv.Close()
a := toolTestApp(t, srv.URL)
_ = a.db.Update(func(s *store.State) error {
s.Events = []store.CalendarEvent{{ID: "event_friseur", Title: "Friseur", Start: "2026-08-28T09:15:00+02:00", Source: "jarvis-tool", CreatedAt: store.Now(), UpdatedAt: store.Now()}}
s.Reminders = []store.Reminder{{ID: "rem_friseur", Title: "Termin: Friseur", At: "2026-08-28T08:45:00+02:00", LinkedType: "event", LinkedID: "event_friseur", Source: "calendar:auto", CreatedAt: store.Now(), UpdatedAt: store.Now()}}
return nil
})
// Reproduce the real bug: a stale recent mutation points to a meal-plan ID.
a.recent = &recentMutation{Kind: "meal_plan", ID: "meal_stale", At: time.Now()}
first, err := a.runToolAgent(context.Background(), "im kalender Friseur")
if err != nil {
t.Fatal(err)
}
if first.Mode != "clarification" {
t.Fatalf("first mode=%s reply=%q", first.Mode, first.Reply)
}
if a.confirm == nil || a.confirm.Tool != "calendar_delete" || fmt.Sprint(a.confirm.Arguments["id"]) != "event_friseur" {
t.Fatalf("pending confirmation=%+v", a.confirm)
}
callsBeforeConfirm := toolCalls.Load()
second, err := a.runToolAgent(context.Background(), "ja")
if err != nil {
t.Fatal(err)
}
if toolCalls.Load() != callsBeforeConfirm {
t.Fatalf("confirmation must not call LLM again; before=%d after=%d", callsBeforeConfirm, toolCalls.Load())
}
if len(second.Results) != 1 || second.Results[0].Tool != "calendar_delete" || !second.Results[0].Success || !second.Results[0].Mutated {
t.Fatalf("second=%+v", second)
}
st := a.db.Snapshot()
if len(st.Events) != 0 {
t.Fatalf("event not deleted: %+v", st.Events)
}
if len(st.Reminders) != 0 {
t.Fatalf("linked reminder not deleted: %+v", st.Reminders)
}
}
func TestFailedWriteCannotBeReportedAsSuccessByModel(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["tools"]; ok {
if calls.Add(1) == 1 {
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "calendar_delete", "arguments": map[string]any{"id": "meal_stale"}}}}})
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Der Termin wurde aus deinem Kalender entfernt."})
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": `{}`})
}))
defer srv.Close()
a := toolTestApp(t, srv.URL)
_ = a.db.Update(func(s *store.State) error {
s.Events = []store.CalendarEvent{{ID: "event_friseur", Title: "Friseur", Start: "2026-08-28T09:15:00+02:00", CreatedAt: store.Now(), UpdatedAt: store.Now()}}
return nil
})
out, err := a.runToolAgent(context.Background(), "lösche Friseur aus dem Kalender")
if err != nil {
t.Fatal(err)
}
if out.Mode != "tool-error" || !strings.Contains(out.Reply, "Keine Änderung gespeichert") {
t.Fatalf("out=%+v", out)
}
if strings.Contains(out.Reply, "wurde aus deinem Kalender entfernt") {
t.Fatalf("model success hallucination leaked through: %q", out.Reply)
}
if len(a.db.Snapshot().Events) != 1 {
t.Fatal("failed delete must not mutate state")
}
}