package agent import ( "context" "encoding/json" "errors" "fmt" "strings" "testing" "gitea.stevedudenhoeffer.com/steve/majordomo/llm" "gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake" "gitea.stevedudenhoeffer.com/steve/pansy/internal/config" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) // scriptedRunner wires a Runner to a fake provider so the whole loop — tools, // change-set scoping, loop guards — is exercised with no live model. func scriptedRunner(t *testing.T, svc *service.Service, steps ...fake.Step) *Runner { t.Helper() p := fake.New("fake") for _, s := range steps { p.Enqueue("m", s) } model, err := p.Model("m") if err != nil { t.Fatalf("fake model: %v", err) } return &Runner{svc: svc, model: model} } // toolCall scripts one model turn that calls a tool. func toolCall(name string, args any) fake.Step { raw, _ := json.Marshal(args) return fake.ReplyWith(llm.Response{ FinishReason: llm.FinishToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: name, Arguments: raw}}, }) } // TestTurnIsOneChangeSet is the acceptance criterion the whole "act freely" // posture rests on: a turn that clears a bed and replants it — one object edit // and many planting inserts — has to undo as ONE action, not thirteen. func TestTurnIsOneChangeSet(t *testing.T) { ctx := context.Background() svc, owner := newAgentTestService(t) g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000}) if err != nil { t.Fatalf("garden: %v", err) } garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄") cucumber := mustPlant(t, svc, owner, "Cucumber", 45, "🥒") bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{ Kind: domain.KindBed, Name: "Garlic bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400, }) if err != nil { t.Fatalf("bed: %v", err) } if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil { t.Fatalf("seed garlic: %v", err) } before, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0) if err != nil { t.Fatalf("history: %v", err) } r := scriptedRunner(t, svc, toolCall("clear_object", map[string]any{"objectId": bed.ID}), toolCall("fill_region", map[string]any{"objectId": bed.ID, "region": "all", "plantId": cucumber.ID}), fake.Reply("Cleared the garlic and replanted the bed with cucumbers."), ) turn, err := r.Run(ctx, owner, g.ID, "change the garlic bed to cucumbers this year", nil, nil) if err != nil { t.Fatalf("Run: %v", err) } if turn.ChangeSetID == nil { t.Fatal("a turn that changed things produced no change set") } if !strings.Contains(turn.Reply, "cucumbers") { t.Errorf("reply = %q", turn.Reply) } // Exactly ONE new change set, whatever the model did inside the turn. after, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0) if err != nil { t.Fatalf("history: %v", err) } if len(after)-len(before) != 1 { t.Fatalf("turn produced %d change sets, want exactly 1", len(after)-len(before)) } cs := after[0] if cs.Source != domain.SourceAgent { t.Errorf("source = %q, want agent", cs.Source) } if cs.Summary != "change the garlic bed to cucumbers this year" { t.Errorf("summary = %q, want the user's own words", cs.Summary) } // The bed really is cucumbers now. full, _ := svc.GardenFull(ctx, owner, g.ID, nil) if len(full.Plantings) == 0 { t.Fatal("bed ended up empty") } for _, p := range full.Plantings { if p.PlantID != cucumber.ID { t.Errorf("unexpected plant %d still in the bed", p.PlantID) } } // And one undo puts it back. if _, conflicts, err := svc.RevertChangeSet(ctx, owner, *turn.ChangeSetID, domain.SourceUI); err != nil || len(conflicts) != 0 { t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts) } full, _ = svc.GardenFull(ctx, owner, g.ID, nil) for _, p := range full.Plantings { if p.PlantID != garlic.ID { t.Errorf("after undo the bed holds plant %d, want the garlic back", p.PlantID) } } } // TestViewerGetsAnExplainableRefusal — the ACL story only works if the model can // narrate the refusal, so a tool denial has to reach it as a tool RESULT it can // read, not as a 500 that ends the run. func TestViewerGetsAnExplainableRefusal(t *testing.T) { ctx := context.Background() svc, owner := newAgentTestService(t) viewer, err := svc.Register(ctx, service.RegisterInput{Email: "v@example.com", DisplayName: "V", Password: "password123"}) if err != nil { t.Fatalf("register: %v", err) } g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000}) if err != nil { t.Fatalf("garden: %v", err) } bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{ Kind: domain.KindBed, XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400, }) if err != nil { t.Fatalf("bed: %v", err) } if _, err := svc.AddShare(ctx, owner, g.ID, "v@example.com", domain.RoleViewer); err != nil { t.Fatalf("share: %v", err) } // A viewer can't open a change set at all, so the turn is refused up front — // before any model call — and the API turns that into a plain explanation. r := scriptedRunner(t, svc, fake.Reply("unused")) _, err = r.Run(ctx, viewer.ID, g.ID, "plant garlic in that bed", nil, nil) if !errors.Is(err, domain.ErrForbidden) { t.Fatalf("viewer turn err = %v, want ErrForbidden", err) } // And at the tool layer, a refusal comes back as a readable tool result // rather than killing the run. box := NewToolbox(svc, viewer.ID) raw, _ := json.Marshal(map[string]any{"objectId": bed.ID}) res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "clear_object", Arguments: raw}) if !res.IsError { t.Fatal("a viewer's clear_object succeeded") } if res.Content == "" { t.Error("the refusal carried no text for the model to explain") } } // TestRunStopsAtTheStepCap — a model that gets wedged should stop on its own, // and what it managed to do must still be recorded and undoable. func TestRunStopsAtTheStepCap(t *testing.T) { ctx := context.Background() svc, owner := newAgentTestService(t) g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000}) if err != nil { t.Fatalf("garden: %v", err) } // More describe_garden calls than the cap allows, forever. steps := make([]fake.Step, 0, maxSteps+4) for i := 0; i < maxSteps+4; i++ { steps = append(steps, toolCall("describe_garden", map[string]any{"gardenId": g.ID})) } r := scriptedRunner(t, svc, steps...) turn, err := r.Run(ctx, owner, g.ID, "look at the garden", nil, nil) if err != nil { t.Fatalf("a capped run should end cleanly, got %v", err) } if !turn.Truncated { t.Error("turn.Truncated = false; the run hit the cap and should say so") } if turn.Reply == "" { t.Error("a capped run said nothing; silence reads as a hang") } // It only read, so there is nothing to undo — and no empty change set either. if turn.ChangeSetID != nil { t.Errorf("a read-only turn produced change set %d", *turn.ChangeSetID) } } // TestReadOnlyTurnWritesNoChangeSet — asking a question must not litter the // history with empty entries. func TestReadOnlyTurnWritesNoChangeSet(t *testing.T) { ctx := context.Background() svc, owner := newAgentTestService(t) g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000}) if err != nil { t.Fatalf("garden: %v", err) } before, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0) r := scriptedRunner(t, svc, toolCall("describe_garden", map[string]any{"gardenId": g.ID}), fake.Reply("It's empty — nothing planted yet."), ) turn, err := r.Run(ctx, owner, g.ID, "what's in the garden?", nil, nil) if err != nil { t.Fatalf("Run: %v", err) } if turn.ChangeSetID != nil { t.Errorf("a question produced change set %d", *turn.ChangeSetID) } after, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0) if len(after) != len(before) { t.Errorf("history grew by %d for a read-only turn", len(after)-len(before)) } } // TestNewRunnerNeedsConfiguration — an instance with no key must not get a // half-built runner; the caller treats the error as "no assistant" and carries on. func TestNewRunnerNeedsConfiguration(t *testing.T) { svc, _ := newAgentTestService(t) for _, cfg := range []*config.Config{ {Agent: config.AgentConfig{Enabled: false, OllamaCloudAPIKey: "k", Model: "ollama-cloud/x"}}, {Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "", Model: "ollama-cloud/x"}}, {Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "k", Model: ""}}, } { if _, err := NewRunner(svc, cfg); err == nil { t.Errorf("NewRunner accepted %+v", cfg.Agent) } } // A model spec naming a provider that doesn't exist is a configuration // error, not a panic at first use. if _, err := NewRunner(svc, &config.Config{Agent: config.AgentConfig{ Enabled: true, OllamaCloudAPIKey: "k", Model: "nonesuch/model", }}); err == nil { t.Error("NewRunner accepted an unresolvable model spec") } } // TestTurnSummaryFitsAHistoryRow — the user's own words are the most useful // label for a change set, but a paragraph would wreck the list. func TestTurnSummaryFitsAHistoryRow(t *testing.T) { if got := turnSummary(" change the garlic bed\n to cucumbers "); got != "change the garlic bed to cucumbers" { t.Errorf("turnSummary = %q", got) } long := turnSummary(strings.Repeat("plant garlic ", 40)) if len(long) > 130 || !strings.HasSuffix(long, "…") { t.Errorf("long summary = %q (%d chars)", long, len(long)) } } // TestSystemPromptStatesTheCompassConvention — -y being north is not guessable, // and a model that assumes otherwise plants the wrong end of the bed. func TestSystemPromptStatesTheCompassConvention(t *testing.T) { p := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitImperial}) for _, want := range []string{"NORTH", "-y", "centimeters", "Plot", "version"} { if !strings.Contains(p, want) { t.Errorf("system prompt is missing %q:\n%s", want, p) } } if !strings.Contains(p, fmt.Sprintf("%.0f", 500.0)) { t.Error("system prompt doesn't state the garden's size") } }