478 lines
18 KiB
Go
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")
|
|
}
|
|
}
|