diff --git a/CLAUDE.md b/CLAUDE.md index caa61b6..1b7aaa1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,24 @@ Conventions that follow from it: plantings. Fixing it per-call-site is how it came back, which is why the rule lives in `commitScope` where no caller can forget it. +- **The assistant's date comes from the client, never from the model.** + `POST /agent/chat` carries `today` (the browser's local day, same reason the + UI sends `plantedAt`); `Runner.Run` puts it in the system prompt and + `NewToolbox` stamps it on every dated tool default through `adapter.day`. A + new tool that takes a date defaults through `day()`, not `time.Now()`. Left to + guess, the live model dated journal entries a year back (2025) — the year it + remembered from training. +- **`describe_garden` is a summary, not a dump.** Plops are grouped per plant + (`service.DescribeGroup`: count, where, planted date, days to maturity) and + ids are listed only for groups of ≤ `maxListedPlops`; the first grid-filled + garden made the old per-plop describe ~450 entries on every turn. A tool that + needs individual ids uses `list_plantings`; bulk work takes (object, plant) + — `remove_plantings`, `ClearPlantings`. Don't add a tool that lists plops. +- **The assistant can't undo and must not pretend to.** Asked to "undo the + beets", the live model replied "Done!" and changed nothing. The prompt now + forbids claiming a change no tool made and points at the Undo button; keep + both rules when editing `systemPrompt`. + - **Request deadlines are extended through `responseController(c)`, never `http.NewResponseController(c.Writer)`.** A controller built in a handler can't reach the socket — the logging middleware wraps the writer — so every diff --git a/DESIGN.md b/DESIGN.md index f6e5cff..94fde36 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -72,7 +72,10 @@ POST /seed-lots/scan ← multipart image → a seed-packet proposal (re POST /seed-lots/from-packet ← confirmed proposal → a plant (new or existing) + a lot GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own) GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator -POST /agent/chat ← SSE: step events, then the finished turn (editor only) +POST /agent/chat ← SSE: step events, then the finished turn (editor only); + body {gardenId, message, today?} — today is the sender's LOCAL + date, told to the model and stamped on everything the turn + plants, removes or journals (server UTC day when omitted) GET,DELETE /gardens/:id/agent/history (the actor's own thread) GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config) GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off, vision model; @@ -146,7 +149,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac 7. **Sharing** — invite by email, roles, viewer read-only mode. 8. **Polish** — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging. 9. **Agent seam** — `ops.go` bulk ops + `internal/agent` DefineTool wrappers. -10. **Garden assistant** — majordomo in-process, Ollama Cloud, streaming chat. Each turn runs inside ONE change set (`source='agent'`), so a turn that clears a bed and replants it undoes as one action; that is what makes acting without a confirmation prompt defensible. Bounded by a step cap and a timeout — loop safety, not spend control. The `majordomo` build tag is gone: a tag that keeps the agent out of the binary only earns its keep if you'd ship a build without it, and the agent is the point. +10. **Garden assistant** — majordomo in-process, Ollama Cloud, streaming chat. Each turn runs inside ONE change set (`source='agent'`), so a turn that clears a bed and replants it undoes as one action; that is what makes acting without a confirmation prompt defensible. Bounded by a step cap and a timeout — loop safety, not spend control. The `majordomo` build tag is gone: a tag that keeps the agent out of the binary only earns its keep if you'd ship a build without it, and the agent is the point. What a day of live use added: the turn carries the gardener's **local day** (`today` in the chat body) into the prompt and every dated tool default, because the model's own idea of the date was a year stale and the server's is UTC; `describe_garden` **groups plops by plant** (count, where, planted date, days to maturity — `DescribeGroup`) and lists ids only for small groups, with `list_plantings` for the rest and `remove_plantings` to act on a whole group; `move_planting` relocates a plop (`MovePlanting`, within or across beds) keeping its planting date; `fill_region` takes an explicit local rectangle and a `seedLotId`; `update_plant`, `read_history` and `copy_garden` (the "" plan convention) round out what the model kept reaching for. A mutation on another garden inside a turn is recorded under THAT garden (`record` refuses to file revisions into a scope for a different garden), so undo always finds them where the person is looking. ## Deliberate v1 limits diff --git a/internal/agent/doc.go b/internal/agent/doc.go index bd9b0e2..2762bbd 100644 --- a/internal/agent/doc.go +++ b/internal/agent/doc.go @@ -29,8 +29,15 @@ // // # Unconfigured instances // -// With no API key the assistant is simply not offered: the chat route isn't -// registered and the capability isn't advertised — the same shape as OIDC -// 404ing when unconfigured. An instance without a key starts and serves the app -// exactly as it did before. +// With no API key the assistant is simply not offered: the chat route answers +// 503 and /capabilities says agent:false, so the UI never shows the tab. (The +// route is always registered — a Settings change can turn the assistant on +// without a restart, which a missing route couldn't do.) An instance without a +// key starts and serves the app exactly as it did before. +// +// # The gardener's day +// +// A turn carries the person's local date (from the client) into the prompt and +// every dated tool default. The model is never the source of a date: left to +// guess, it wrote the year it remembered from training. package agent diff --git a/internal/agent/runtime.go b/internal/agent/runtime.go index ad07606..2073737 100644 --- a/internal/agent/runtime.go +++ b/internal/agent/runtime.go @@ -32,6 +32,9 @@ const ( maxSameCallRepeats = 3 ) +// dateLayout is the YYYY-MM-DD form every date crosses the tool boundary in. +const dateLayout = "2006-01-02" + // Runner drives a model over pansy's toolbox. One per process; Run is safe to // call concurrently. type Runner struct { @@ -73,17 +76,31 @@ type Turn struct { Truncated bool `json:"truncated,omitempty"` } -// Run executes one turn against a garden, as actorID. +// Run executes one turn against a garden, as actorID, on the day it is where +// they are. +// +// today is the gardener's local date (YYYY-MM-DD) as the client reports it; it +// goes into the prompt, so the model knows what day it is, and to every tool, so +// what the turn plants, removes or journals is dated the day the person did it. +// Empty means "the service's UTC today" — the best a caller with no local clock +// (a bare API client) can do. The model itself must never be the source of the +// date: left to guess, the live one stamped a year it remembered from training. // // The whole turn runs inside ONE change set, so everything the model did undoes // together. That is what makes acting without a confirmation prompt defensible. // The scope is opened even for a turn that turns out to be a question — a change // set with no revisions is never written, so asking costs nothing. -func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message string, history []llm.Message, onStep func(agent.Step)) (*Turn, error) { +func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, today string, history []llm.Message, onStep func(agent.Step)) (*Turn, error) { message = strings.TrimSpace(message) if message == "" { return nil, domain.ErrInvalidInput } + today = strings.TrimSpace(today) + if today == "" { + today = time.Now().UTC().Format(dateLayout) + } else if _, err := time.Parse(dateLayout, today); err != nil { + return nil, fmt.Errorf("%w: today must be a YYYY-MM-DD date", domain.ErrInvalidInput) + } ctx, cancel := context.WithTimeout(ctx, runTimeout) defer cancel() @@ -109,8 +126,8 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message strin Summary: turnSummary(message), AgentRunID: &runID, }, func(ctx context.Context) error { - box := NewToolbox(r.svc, actorID) - a := agent.New(r.model, systemPrompt(garden), + box := NewToolbox(r.svc, actorID, today) + a := agent.New(r.model, systemPrompt(garden, today), agent.WithMaxSteps(maxSteps), agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats), ) @@ -198,35 +215,70 @@ func turnSummary(message string) string { return s } -// systemPrompt gives the model the conventions it cannot infer. +// systemPrompt gives the model the conventions it cannot infer, the day it is, +// and the rules of conduct the live instance showed it needs. // // The compass convention in particular is not guessable: -y is north because // screen y grows downward, and a model that assumes otherwise plants the south -// half when asked for the north one. -func systemPrompt(g *domain.Garden) string { - units := "metric — all measurements are centimeters" +// half when asked for the north one. The date is not guessable either — a model +// asked to backdate nothing still wrote the year it remembered from training — +// and the conduct rules each answer a thing the assistant actually did in live +// testing: reported a change it never made, narrated every planting into the +// journal, swapped four beds on an ambiguous sentence, and answered an imperial +// gardener in centimeters. +func systemPrompt(g *domain.Garden, today string) string { + units := "The gardener works in meters and centimeters; answer in those." + size := fmt.Sprintf("%.0f x %.0f cm", g.WidthCM, g.HeightCM) if g.UnitPref == domain.UnitImperial { - units = "imperial for display, but every measurement you send or receive is in CENTIMETERS" + units = "The gardener thinks in feet and inches. Convert what they say before calling a tool " + + "(1 ft = 30.48 cm, 1 in = 2.54 cm) and answer in feet and inches, never in centimeters." + size = fmt.Sprintf("%.1f x %.1f ft (%.0f x %.0f cm)", g.WidthCM/30.48, g.HeightCM/30.48, g.WidthCM, g.HeightCM) } return fmt.Sprintf(`You are pansy's garden assistant. You help plan and edit a real garden by calling tools. -The garden you are working on is %q (id %d), %.0f x %.0f cm. The user's units are %s. +The garden you are working on is %q (id %d), %s. Today is %s — the gardener's local date. +%s Conventions you cannot guess and must not assume: +- Every measurement a tool takes or returns is in CENTIMETERS. - Positions in a garden are centimeters from its top-left corner: x grows east, y grows SOUTH. - Inside an object (a bed), positions are relative to that object's CENTER, and -y is NORTH. So the north half of a bed is negative y. Getting this backwards plants the wrong end. - Objects and plantings are version-guarded. Use the version from describe_garden when editing. +- Dates are YYYY-MM-DD. Tools date what they plant, remove or journal as today unless you pass + a date; pass one only when the gardener says it happened on another day. How to work: -- Start from describe_garden to see what is actually there. Do not guess ids. -- Use find_plant to turn a plant name into an id. If it returns several candidates, - pick the one that matches what the user said, or ask them which they meant. -- To replant a bed with something else: clear_object, then fill_region with region "all". -- When a tool refuses (for example, the user only has view access to this garden), - explain what happened in plain words. Do not retry it. +- Start from describe_garden to see what is actually there. Do not guess ids. It groups each + bed's plantings by plant, with a count, a rough location and the planting date; a group lists + its plops one by one only when it is small. For the ids of a large group use list_plantings, + or act on the whole group at once with remove_plantings. +- Use find_plant to turn a plant name into an id. If it returns several candidates, pick the one + that matches what the user said, or ask them which they meant. +- To replant a bed with something else: clear_object, then fill_region with region "all". To take + one plant out of a mixed bed: remove_plantings. To relocate plants: move_planting, which keeps + their planting date — do not remove and replant them. +- fill_region in grid mode lays out individual plants at true spacing, which is what "so I can + plant from it" means; clump mode is a quick sketch. For an area no compass name describes (a + middle third, a strip along one edge) give fill_region a rectangle instead of placing plops by hand. +- A garden named "%s — " is this garden's plan for that year; copy_garden with that name + makes one. Never use a different real garden as a scratch space. +- When a tool refuses (for example, the user only has view access to this garden), explain what + happened in plain words. Do not retry it. -When you are done, say briefly what you changed — the user is watching the canvas -and wants to know what to look at. If you changed nothing, say that too.`, - g.Name, g.ID, g.WidthCM, g.HeightCM, units) +How to behave: +- Only claim what a tool actually did. If a tool failed, or there is no tool for what was asked, + say so plainly — never describe a change you did not make. +- You cannot undo. Every reply of yours that changed the garden has an "Undo this" button under + it, and the History panel can revert any change; point the gardener there, or offer to reverse + the change by hand with tools. +- When a request could mean materially different things — "swap the cucumbers and the melons" + with two beds of each — say what you would do and ask, rather than clearing beds on a guess. + When it is clear, just do it. +- The plan already records what was planted where and when. Write a journal entry only when the + gardener asks for one or tells you something that happened — weather, pests, a harvest, an + observation — not to narrate your own planting. +- The gardener is watching the canvas. When you are done, say briefly what you changed and where + to look; if you changed nothing, say that too.`, + g.Name, g.ID, size, today, units, g.Name) } diff --git a/internal/agent/runtime_test.go b/internal/agent/runtime_test.go index e452d10..55b3657 100644 --- a/internal/agent/runtime_test.go +++ b/internal/agent/runtime_test.go @@ -75,7 +75,7 @@ func TestTurnIsOneChangeSet(t *testing.T) { 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) + 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) } @@ -152,14 +152,14 @@ func TestViewerGetsAnExplainableRefusal(t *testing.T) { // 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) + _, 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) + 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 { @@ -187,7 +187,7 @@ func TestRunStopsAtTheStepCap(t *testing.T) { } r := scriptedRunner(t, svc, steps...) - turn, err := r.Run(ctx, owner, g.ID, "look at the garden", nil, nil) + 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) } @@ -218,7 +218,7 @@ func TestReadOnlyTurnWritesNoChangeSet(t *testing.T) { 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) + turn, err := r.Run(ctx, owner, g.ID, "what's in the garden?", "", nil, nil) if err != nil { t.Fatalf("Run: %v", err) } @@ -268,8 +268,8 @@ func TestTurnSummaryFitsAHistoryRow(t *testing.T) { // 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"} { + p := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitImperial}, "2026-08-22") + 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) } @@ -315,7 +315,7 @@ func TestPartialWorkSurvivesATimeout(t *testing.T) { time.Sleep(50 * time.Millisecond) cancel() }() - _, err = r.Run(cancelled, owner, g.ID, "move the bed", nil, nil) + _, err = r.Run(cancelled, owner, g.ID, "move the bed", "", nil, nil) if err == nil { t.Fatal("expected the turn to fail") } @@ -356,3 +356,147 @@ func TestTurnSummaryTrimsByRunes(t *testing.T) { t.Errorf("summary is %d runes, want it trimmed", n) } } + +// TestSystemPromptKnowsTheDayAndTheGardenersUnits — two things the live model +// got wrong for want of being told: it dated journal entries with the year it +// remembered from training, and answered a feet-and-inches gardener in +// centimeters. The conduct rules are checked by their load-bearing phrases. +func TestSystemPromptKnowsTheDayAndTheGardenersUnits(t *testing.T) { + imperial := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 731.52, HeightCM: 731.52, UnitPref: domain.UnitImperial}, "2026-08-22") + for _, want := range []string{ + "Today is 2026-08-22", + "feet and inches", + "24.0 x 24.0 ft", + "never describe a change you did not make", + "You cannot undo", + "Undo this", + "rather than clearing beds on a guess", + "not to narrate your own planting", + `"Plot — "`, + "remove_plantings", + "move_planting", + "list_plantings", + } { + if !strings.Contains(imperial, want) { + t.Errorf("imperial prompt is missing %q", want) + } + } + metric := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitMetric}, "2026-08-22") + if strings.Contains(metric, "feet and inches") { + t.Error("metric prompt tells the model to answer in feet and inches") + } + if !strings.Contains(metric, "500 x 400 cm") { + t.Error("metric prompt doesn't state the garden's size in cm") + } +} + +// TestRunRejectsAMalformedToday — the date reaches every tool as a default, so a +// bad one must stop the turn before the model runs, not fail its first fill. +func TestRunRejectsAMalformedToday(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) + } + r := scriptedRunner(t, svc, fake.Reply("unused")) + if _, err := r.Run(ctx, owner, g.ID, "hello", "yesterday", nil, nil); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("Run with today=%q: err = %v, want ErrInvalidInput", "yesterday", err) + } +} + +// TestTurnDatesItsWorkTheGardenersDay — what a turn plants is dated the day the +// gardener sent it, not the server's UTC day (which is tomorrow by nine in the +// evening in Ohio) and not a day the model chose. +func TestTurnDatesItsWorkTheGardenersDay(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, "🧄") + bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{ + Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 200, HeightCM: 200, + }) + if err != nil { + t.Fatalf("bed: %v", err) + } + r := scriptedRunner(t, svc, + toolCall("fill_region", map[string]any{"objectId": bed.ID, "region": "all", "plantId": garlic.ID}), + fake.Reply("Filled the bed with garlic."), + ) + if _, err := r.Run(ctx, owner, g.ID, "fill the bed with garlic", "2026-08-22", nil, nil); err != nil { + t.Fatalf("Run: %v", err) + } + full, err := svc.GardenFull(ctx, owner, g.ID, nil) + if err != nil { + t.Fatalf("GardenFull: %v", err) + } + if len(full.Plantings) == 0 { + t.Fatal("the turn planted nothing") + } + for _, p := range full.Plantings { + if p.PlantedAt == nil || *p.PlantedAt != "2026-08-22" { + t.Errorf("plop %d plantedAt = %v, want the gardener's day 2026-08-22", p.ID, p.PlantedAt) + } + } +} + +// TestTurnOnAnotherGardenFilesHistoryThere — a turn is scoped to one garden, but +// nothing stops the model from pointing a tool at an object in another garden +// the person can edit ("do the same in my other garden"). Those revisions must +// land in THAT garden's history, as the agent's work, where its undo can see +// them — not in the open scope, where undoing this turn would quietly revert +// rows in a garden the person isn't looking at. +func TestTurnOnAnotherGardenFilesHistoryThere(t *testing.T) { + ctx := context.Background() + svc, owner := newAgentTestService(t) + a, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "A", WidthCM: 2000, HeightCM: 2000}) + if err != nil { + t.Fatalf("garden A: %v", err) + } + b, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "B", WidthCM: 2000, HeightCM: 2000}) + if err != nil { + t.Fatalf("garden B: %v", err) + } + bedB, err := svc.CreateObject(ctx, owner, b.ID, service.ObjectInput{ + Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 200, HeightCM: 200, + }) + if err != nil { + t.Fatalf("bed: %v", err) + } + beforeA, _, _ := svc.GardenHistory(ctx, owner, a.ID, 0, 0) + beforeB, _, _ := svc.GardenHistory(ctx, owner, b.ID, 0, 0) + + r := scriptedRunner(t, svc, + toolCall("update_object", map[string]any{"objectId": bedB.ID, "version": bedB.Version, "name": "Renamed from A"}), + fake.Reply("Renamed the bed in B."), + ) + turn, err := r.Run(ctx, owner, a.ID, "rename the bed in my other garden", "", nil, nil) + if err != nil { + t.Fatalf("Run: %v", err) + } + if turn.ChangeSetID != nil { + t.Errorf("the turn on A produced change set %d, but it changed nothing in A", *turn.ChangeSetID) + } + afterA, _, _ := svc.GardenHistory(ctx, owner, a.ID, 0, 0) + if len(afterA) != len(beforeA) { + t.Errorf("A's history grew by %d for a change made in B", len(afterA)-len(beforeA)) + } + afterB, _, _ := svc.GardenHistory(ctx, owner, b.ID, 0, 0) + if len(afterB) != len(beforeB)+1 { + t.Fatalf("B's history grew by %d, want 1", len(afterB)-len(beforeB)) + } + if cs := afterB[0]; cs.Source != domain.SourceAgent || cs.AgentRunID == nil { + t.Errorf("B's entry = source %q, run %v; want the agent's, with its run id", cs.Source, cs.AgentRunID) + } + // And it undoes from B, where the person would look for it. + if _, conflicts, err := svc.RevertChangeSet(ctx, owner, afterB[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 { + t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts) + } + d, _ := svc.DescribeGarden(ctx, owner, b.ID) + if len(d.Objects) != 1 || d.Objects[0].Name != "Bed" { + t.Errorf("after undo B's bed is %+v, want its original name back", d.Objects) + } +} diff --git a/internal/agent/tools.go b/internal/agent/tools.go index a62d951..6a09c03 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -2,28 +2,45 @@ package agent import ( "context" + "fmt" + "strings" "gitea.stevedudenhoeffer.com/steve/majordomo/llm" + "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) // NewToolbox builds a majordomo toolbox over pansy's service layer, bound to a -// single acting user. Every tool call runs as actorID, so pansy's permission -// checks (requireGardenRole / objectForRole) apply unchanged. Construct one per -// authenticated agent session: +// single acting user and to the day it is where they are. Every tool call runs +// as actorID, so pansy's permission checks (requireGardenRole / objectForRole) +// apply unchanged. Construct one per authenticated agent session: // -// box := agent.NewToolbox(svc, session.UserID) +// box := agent.NewToolbox(svc, session.UserID, "2026-08-22") // agent.Run(ctx, model, box, "fill the NE corner with garlic") -func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox { - a := &adapter{svc: svc, actor: actorID} +// +// today (YYYY-MM-DD) is the date every tool stamps on what it plants, removes or +// journals unless the model passes one — the gardener's local day, from the +// client, because the server's UTC day is tomorrow by nine in the evening in +// Ohio. Empty falls back to the service's UTC today. +func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox { + a := &adapter{svc: svc, actor: actorID, today: strings.TrimSpace(today)} return llm.NewToolbox("pansy", llm.DefineTool("list_gardens", "List the gardens the user can see (owned and shared), with the user's role on each.", a.listGardens), llm.DefineTool("describe_garden", - "Summarize a garden: its dimensions, objects (with sizes/positions/version), and each object's active plantings with a rough compass location.", + "Summarize a garden: its dimensions, objects (with sizes/positions/version), and each "+ + "object's active plantings grouped by plant — how many, roughly where, when they went in, "+ + "and days to maturity when known. A small group lists its plops individually (id + "+ + "version, for move_planting/remove_planting); a large one (a grid-filled bed) does not — "+ + "use list_plantings for those ids, or act on the whole group with remove_plantings.", a.describeGarden), + llm.DefineTool("list_plantings", + "List one object's active plops one by one, each with its id, version, location, count and "+ + "planting date — the detail describe_garden leaves out for a large group. Narrow to one "+ + "plant with plantId. Use it only when you need to address individual plops.", + a.listPlantings), llm.DefineTool("create_object", "Add an object (bed, grow_bag, container, in_ground, tree, path, structure) to a garden, positioned by its center in garden cm.", a.createObject), @@ -31,16 +48,38 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox { "Move an object to a new center position (garden cm). Needs the object's current version from describe_garden.", a.moveObject), llm.DefineTool("place_planting", - "Place one plop of a plant inside a plantable object, positioned in the object's LOCAL frame (0,0 = object center, -y = north).", + "Place one plop of a plant inside a plantable object, positioned in the object's LOCAL frame "+ + "(0,0 = object center, -y = north). Omit radiusCm for a single plant (it defaults to half "+ + "the plant's spacing); a larger radius is a clump, whose plant count is derived from its "+ + "area unless you pass count. Dated today unless plantedAt says otherwise.", a.placePlanting), llm.DefineTool("fill_region", "Fill part of a plantable object with one plant, hex-packed at the plant's spacing. "+ - "region is a compass name, not coordinates: nw|ne|sw|se for the quarter corners, "+ - "north|south|east|west (or top|bottom|left|right) for halves, or all for the whole thing. "+ - "North is the top of the garden. Example: to replant a whole bed, clear_object then "+ - "fill_region with region=all. Filling skips spots already covered by an existing plant, "+ - "so it is safe to run twice.", + "Say where EITHER by region — a compass name, not coordinates: nw|ne|sw|se for the quarter "+ + "corners, north|south|east|west (or top|bottom|left|right) for halves, or all for the whole "+ + "thing; north is the top of the garden — OR by an explicit rectangle in the object's local "+ + "frame (x0Cm,y0Cm,x1Cm,y1Cm; 0,0 = center, -y = north), for a middle third, a strip along "+ + "one edge, or any area a compass name can't say. Example: to replant a whole bed, "+ + "clear_object then fill_region with region=all. Filling skips spots already covered by an "+ + "existing plant, so it is safe to run twice. Dated today unless plantedAt says otherwise.", a.fillRegion), + llm.DefineTool("move_planting", + "Move ONE plop to a new position — within its object, or into another plantable object of "+ + "the same garden with toObjectId — keeping its plant, size, count and planting date. This "+ + "is how to relocate plants; removing and re-placing them would lose when they were planted. "+ + "Needs the plop's id and version (describe_garden or list_plantings).", + a.movePlanting), + llm.DefineTool("remove_planting", + "Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+ + "all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+ + "bed does. Needs the plop's id and version from describe_garden. Use for \"pull the "+ + "basil out of the corner\".", + a.removePlanting), + llm.DefineTool("remove_plantings", + "Remove every plop of ONE plant from an object, leaving the other plants in it — \"take the "+ + "beets out of the south bed\". Soft-removes them (kept for planting history, undoable as "+ + "one change). Use this rather than many remove_planting calls.", + a.removePlantings), llm.DefineTool("clear_object", "Remove all plants from an object. They are soft-removed, so the planting history for past "+ "seasons is kept and the change can be undone. Use this before replanting a bed with "+ @@ -58,11 +97,18 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox { "yet. Check find_plant first — creating a duplicate of something that already exists is "+ "worse than reusing it. The plant belongs to the user, not to any garden.", a.createPlant), + llm.DefineTool("update_plant", + "Change a plant in the user's own catalog: its name, category, spacing, color, days to "+ + "maturity, vendor, source link or notes. Only the fields you pass change. Needs the "+ + "plant's current version from find_plant. Built-in plants can't be edited — create_plant "+ + "the user's own variety instead.", + a.updatePlant), llm.DefineTool("add_journal_entry", "Write a dated observation into the garden's grow journal — what happened, and when. "+ "Attach it to one bed with objectId when it is about that bed. This is for events "+ "(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+ - "thing is. observedAt defaults to today; set it to backdate.", + "thing is and not for narrating your own plantings. observedAt defaults to today; set "+ + "it to backdate.", a.addJournalEntry), llm.DefineTool("read_journal", "Read back the garden's grow journal — the observations add_journal_entry wrote. "+ @@ -70,6 +116,13 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox { "recently observed first. Use this to answer \"what did I note about the west bed?\" "+ "or \"what happened last spring?\".", a.readJournal), + llm.DefineTool("read_history", + "Read the garden's change history: every change anyone made — by hand in the editor, or "+ + "in an earlier conversation with you — newest first, with what it changed and whether it "+ + "was undone. Use it to answer \"what changed this week?\" or \"what did you do last time?\" "+ + "rather than reciting from memory. You cannot undo from here; the person has an Undo "+ + "button on each change.", + a.readHistory), llm.DefineTool("update_object", "Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+ "rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+ @@ -82,12 +135,6 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox { "counterpart to create_object — use it for \"remove the old grow bag\". Permanent (not "+ "the same as clearing a bed's plants); prefer clear_object when the bed itself stays.", a.deleteObject), - llm.DefineTool("remove_planting", - "Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+ - "all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+ - "bed does. Needs the plop's id and version from describe_garden. Use for \"pull the "+ - "basil out of the corner\".", - a.removePlanting), llm.DefineTool("list_seed_lots", "List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+ "left — optionally for one plant via plantId. This is the detail behind the \"seed "+ @@ -96,15 +143,37 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox { llm.DefineTool("record_seed_lot", "Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+ "Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+ - "\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\".", + "\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\". To "+ + "count seed as used, plant with a seedLotId on place_planting or fill_region.", a.recordSeedLot), + llm.DefineTool("copy_garden", + "Duplicate a garden the user owns — beds, objects and plantings — as a new garden with the "+ + "given name. This is how a season plan is made: a copy named \"\" "+ + "(with an em dash) is that garden's plan for the year, and the editor offers it as such. "+ + "Use it for \"set up next year's plan\"; never use another real garden as a scratch space.", + a.copyGarden), ) } -// adapter carries the service and the acting user for the tool handlers. +// adapter carries the service, the acting user and their local day for the +// tool handlers. type adapter struct { svc *service.Service actor int64 + today string +} + +// day is the date a tool stamps: the one the model passed, else the gardener's +// local today, else nil for the service's UTC default. +func (a *adapter) day(explicit string) *string { + if d := strings.TrimSpace(explicit); d != "" { + return &d + } + if a.today != "" { + d := a.today + return &d + } + return nil } func (a *adapter) listGardens(ctx context.Context, _ struct{}) (any, error) { @@ -117,6 +186,13 @@ func (a *adapter) describeGarden(ctx context.Context, args struct { return a.svc.DescribeGarden(ctx, a.actor, args.GardenID) } +func (a *adapter) listPlantings(ctx context.Context, args struct { + ObjectID int64 `json:"objectId" description:"object whose plops to list"` + PlantID *int64 `json:"plantId" description:"optional: only plops of this plant"` +}) (any, error) { + return a.svc.ListObjectPlantings(ctx, a.actor, args.ObjectID, args.PlantID) +} + func (a *adapter) createObject(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden to add the object to"` Kind string `json:"kind" description:"bed | grow_bag | container | in_ground | tree | path | structure"` @@ -144,28 +220,69 @@ func (a *adapter) moveObject(ctx context.Context, args struct { } func (a *adapter) placePlanting(ctx context.Context, args struct { - ObjectID int64 `json:"objectId" description:"plantable object to plant in"` - PlantID int64 `json:"plantId" description:"plant to place"` - XCM float64 `json:"xCm" description:"center x in the object's local frame (cm; 0,0 = center, -y = north)"` - YCM float64 `json:"yCm" description:"center y in the object's local frame (cm)"` - RadiusCM float64 `json:"radiusCm" description:"plop radius in cm"` - Count *int `json:"count" description:"optional explicit plant count; omit to derive from area ÷ spacing²"` + ObjectID int64 `json:"objectId" description:"plantable object to plant in"` + PlantID int64 `json:"plantId" description:"plant to place"` + XCM float64 `json:"xCm" description:"center x in the object's local frame (cm; 0,0 = center, -y = north)"` + YCM float64 `json:"yCm" description:"center y in the object's local frame (cm)"` + RadiusCM float64 `json:"radiusCm" description:"optional plop radius in cm; omit (0) for one plant at half the plant's spacing"` + Count *int `json:"count" description:"optional explicit plant count; omit to derive from area ÷ spacing²"` + PlantedAt string `json:"plantedAt" description:"optional planting date, YYYY-MM-DD; defaults to today"` + SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) this planting uses, so the lot counts it as used"` }) (any, error) { return a.svc.CreatePlanting(ctx, a.actor, args.ObjectID, service.PlantingInput{ PlantID: args.PlantID, XCM: args.XCM, YCM: args.YCM, RadiusCM: args.RadiusCM, Count: args.Count, + PlantedAt: a.day(args.PlantedAt), SeedLotID: args.SeedLotID, }) } func (a *adapter) fillRegion(ctx context.Context, args struct { ObjectID int64 `json:"objectId" description:"plantable object to fill"` - Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all"` + Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all; leave empty when giving a rectangle"` + X0CM *float64 `json:"x0Cm" description:"rectangle instead of region: west edge, local cm (0 = center)"` + Y0CM *float64 `json:"y0Cm" description:"rectangle: north edge, local cm (negative is north of center)"` + X1CM *float64 `json:"x1Cm" description:"rectangle: east edge, local cm"` + Y1CM *float64 `json:"y1Cm" description:"rectangle: south edge, local cm"` PlantID int64 `json:"plantId" description:"plant to fill with"` SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"` Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"` + PlantedAt string `json:"plantedAt" description:"optional planting date for every plop, YYYY-MM-DD; defaults to today"` + SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) this fill uses, so the lot counts it as used"` }) (any, error) { - // nil: the agent runs server-side with no local day, so the fill dates - // plops UTC-today like its create_planting does. - return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode), nil) + spec := service.FillSpec{ + RegionName: args.Region, PlantID: args.PlantID, SpacingOverride: args.SpacingOverride, + Layout: service.FillLayout(args.Mode), PlantedAt: a.day(args.PlantedAt), SeedLotID: args.SeedLotID, + } + rect := []*float64{args.X0CM, args.Y0CM, args.X1CM, args.Y1CM} + given := 0 + for _, v := range rect { + if v != nil { + given++ + } + } + switch { + case given == 4 && strings.TrimSpace(args.Region) == "": + spec.Region = service.Region{MinX: *args.X0CM, MinY: *args.Y0CM, MaxX: *args.X1CM, MaxY: *args.Y1CM} + case given == 0 && strings.TrimSpace(args.Region) != "": + // the named region + case given == 4: + return nil, fmt.Errorf("%w: give either a region name or a rectangle, not both", domain.ErrInvalidInput) + case given > 0: + return nil, fmt.Errorf("%w: a rectangle needs all four of x0Cm, y0Cm, x1Cm, y1Cm", domain.ErrInvalidInput) + default: + return nil, fmt.Errorf("%w: say where to fill — a region name, or a rectangle", domain.ErrInvalidInput) + } + return a.svc.Fill(ctx, a.actor, args.ObjectID, spec) +} + +func (a *adapter) movePlanting(ctx context.Context, args struct { + PlantingID int64 `json:"plantingId" description:"plop to move (its id from describe_garden or list_plantings)"` + Version int64 `json:"version" description:"the plop's current version"` + XCM float64 `json:"xCm" description:"new center x in the destination object's local frame (cm; 0,0 = center, -y = north)"` + YCM float64 `json:"yCm" description:"new center y in the destination object's local frame (cm)"` + ToObjectID *int64 `json:"toObjectId" description:"optional: another plantable object in the same garden to move it into; omit to move within its current object"` +}) (any, error) { + return a.svc.MovePlanting(ctx, a.actor, args.PlantingID, + service.MoveInput{ToObjectID: args.ToObjectID, XCM: args.XCM, YCM: args.YCM}, args.Version) } func (a *adapter) findPlant(ctx context.Context, args struct { @@ -182,7 +299,7 @@ func (a *adapter) createPlant(ctx context.Context, args struct { Icon string `json:"icon" description:"a single emoji to draw it with, e.g. 🧄"` DaysToMaturity *int `json:"daysToMaturity" description:"optional days from planting to harvest"` SourceURL string `json:"sourceUrl" description:"optional http(s) link to where the seed came from"` - Vendor string `json:"vendor" description:"optional vendor name, e.g. \"Johnny\u0027s Selected Seeds\""` + Vendor string `json:"vendor" description:"optional vendor name, e.g. \"Johnny's Selected Seeds\""` }) (any, error) { return a.svc.CreatePlant(ctx, a.actor, service.PlantInput{ Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM, @@ -191,29 +308,58 @@ func (a *adapter) createPlant(ctx context.Context, args struct { }) } +func (a *adapter) updatePlant(ctx context.Context, args struct { + PlantID int64 `json:"plantId" description:"plant to change (the user's own, from find_plant)"` + Version int64 `json:"version" description:"the plant's current version (from find_plant)"` + Name *string `json:"name" description:"optional new name"` + Category *string `json:"category" description:"optional: vegetable | herb | flower | fruit | tree_shrub | cover"` + SpacingCM *float64 `json:"spacingCm" description:"optional new mature in-row spacing in cm"` + Color *string `json:"color" description:"optional new hex color"` + DaysToMaturity *int `json:"daysToMaturity" description:"optional days from planting to harvest"` + SourceURL *string `json:"sourceUrl" description:"optional http(s) link to where the seed came from"` + Vendor *string `json:"vendor" description:"optional vendor name"` + Notes *string `json:"notes" description:"optional free-text notes"` +}) (any, error) { + return a.svc.UpdatePlant(ctx, a.actor, args.PlantID, service.PlantPatch{ + Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM, Color: args.Color, + SetDays: args.DaysToMaturity != nil, DaysToMaturity: args.DaysToMaturity, + SourceURL: args.SourceURL, Vendor: args.Vendor, Notes: args.Notes, + }, args.Version) +} + func (a *adapter) addJournalEntry(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden the observation is about"` ObjectID *int64 `json:"objectId" description:"optional bed the observation is about; omit for a garden-level note"` Body string `json:"body" description:"what happened, in plain words"` ObservedAt string `json:"observedAt" description:"optional date it happened, YYYY-MM-DD; defaults to today"` }) (any, error) { - in := service.JournalInput{ObjectID: args.ObjectID, Body: args.Body} - if args.ObservedAt != "" { - in.ObservedAt = &args.ObservedAt - } - return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, in) + return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, service.JournalInput{ + ObjectID: args.ObjectID, Body: args.Body, ObservedAt: a.day(args.ObservedAt), + }) } func (a *adapter) clearObject(ctx context.Context, args struct { ObjectID int64 `json:"objectId" description:"object to remove all plants from"` }) (any, error) { - n, err := a.svc.ClearObject(ctx, a.actor, args.ObjectID) + n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{RemovedAt: a.day("")}) if err != nil { return nil, err } return map[string]int{"cleared": n}, nil } +func (a *adapter) removePlantings(ctx context.Context, args struct { + ObjectID int64 `json:"objectId" description:"object to remove the plant from"` + PlantID int64 `json:"plantId" description:"the plant to remove every plop of (from describe_garden)"` +}) (any, error) { + n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, + service.ClearOptions{PlantID: &args.PlantID, RemovedAt: a.day("")}) + if err != nil { + return nil, err + } + return map[string]int{"removed": n}, nil +} + func (a *adapter) readJournal(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden whose journal to read"` ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"` @@ -236,6 +382,61 @@ func (a *adapter) readJournal(ctx context.Context, args struct { return map[string]any{"entries": entries, "hasMore": hasMore}, nil } +// historyEntry is one change set as read_history reports it: the row a person +// would read in the History panel, not the revision snapshots behind it. +type historyEntry struct { + ID int64 `json:"id"` + When string `json:"when"` + Source string `json:"source"` + Who string `json:"who,omitempty"` + Summary string `json:"summary"` + Changes string `json:"changes"` + Undone bool `json:"undone,omitempty"` + // Undo is set when this entry is itself an undo of an earlier one. + Undo *int64 `json:"undoOf,omitempty"` +} + +func (a *adapter) readHistory(ctx context.Context, args struct { + GardenID int64 `json:"gardenId" description:"garden whose history to read"` + Limit int `json:"limit" description:"how many of the newest entries to return (default 20, max 100)"` + Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"` +}) (any, error) { + limit := args.Limit + if limit <= 0 { + limit = 20 + } + sets, hasMore, err := a.svc.GardenHistory(ctx, a.actor, args.GardenID, limit, args.Offset) + if err != nil { + return nil, err + } + entries := make([]historyEntry, 0, len(sets)) + for _, cs := range sets { + entries = append(entries, historyEntry{ + ID: cs.ID, When: cs.CreatedAt, Source: cs.Source, Who: cs.ActorName, + Summary: cs.Summary, Changes: describeCounts(cs.Counts), + Undone: cs.RevertedByID != nil, Undo: cs.RevertsID, + }) + } + return map[string]any{"entries": entries, "hasMore": hasMore}, nil +} + +// describeCounts turns a change set's tallies into words: "12 plantings created, +// 1 object updated". +func describeCounts(counts []domain.ChangeCount) string { + parts := make([]string, 0, len(counts)) + for _, c := range counts { + noun := c.EntityType + if c.N != 1 { + noun += "s" + } + parts = append(parts, fmt.Sprintf("%d %s %sd", c.N, noun, c.Op)) + } + if len(parts) == 0 { + return "nothing" + } + return strings.Join(parts, ", ") +} + func (a *adapter) updateObject(ctx context.Context, args struct { ObjectID int64 `json:"objectId" description:"object to change"` Version int64 `json:"version" description:"the object's current version (from describe_garden)"` @@ -264,9 +465,9 @@ func (a *adapter) removePlanting(ctx context.Context, args struct { PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"` Version int64 `json:"version" description:"the plop's current version (from describe_garden)"` }) (any, error) { - // Soft-remove via the service, so removed_at is stamped from the same - // (injectable) clock clear_object uses rather than the adapter's wall clock. - return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version) + // Soft-remove via the service, dated the gardener's local day like every + // other tool here (the service clock's UTC day when that isn't known). + return a.svc.RemovePlantingOn(ctx, a.actor, args.PlantingID, args.Version, a.day("")) } func (a *adapter) listSeedLots(ctx context.Context, args struct { @@ -290,3 +491,10 @@ func (a *adapter) recordSeedLot(ctx context.Context, args struct { PackedForYear: args.PackedForYear, Notes: args.Notes, }) } + +func (a *adapter) copyGarden(ctx context.Context, args struct { + GardenID int64 `json:"gardenId" description:"garden to duplicate (the user must own it)"` + Name string `json:"name" description:"name for the copy; \"\" makes it that garden's plan for the year"` +}) (any, error) { + return a.svc.CopyGarden(ctx, a.actor, args.GardenID, args.Name) +} diff --git a/internal/agent/tools_test.go b/internal/agent/tools_test.go index 2324285..c1a0862 100644 --- a/internal/agent/tools_test.go +++ b/internal/agent/tools_test.go @@ -24,7 +24,7 @@ import ( func TestToolboxScenario(t *testing.T) { ctx := context.Background() svc, ownerID := newAgentTestService(t) - box := NewToolbox(svc, ownerID) + box := NewToolbox(svc, ownerID, "") call := func(name string, args any) llm.ToolResult { t.Helper() @@ -78,12 +78,17 @@ func TestToolboxScenario(t *testing.T) { if len(desc.Objects) != 1 { t.Fatalf("objects = %d, want 1", len(desc.Objects)) } + // Plantings come grouped by plant: a group's Where names the region when the + // whole group sits in one, and a small group also lists its plops. seen := map[string]map[string]bool{} - for _, p := range desc.Objects[0].Plantings { - if seen[p.Plant] == nil { - seen[p.Plant] = map[string]bool{} + for _, g := range desc.Objects[0].Plantings { + if seen[g.Plant] == nil { + seen[g.Plant] = map[string]bool{} + } + seen[g.Plant][g.Where] = true + for _, p := range g.Each { + seen[g.Plant][p.Location] = true } - seen[p.Plant][p.Location] = true } if !seen["Garlic"]["NE corner"] { t.Errorf("garlic at %v, want NE corner", seen["Garlic"]) @@ -108,7 +113,7 @@ func TestToolboxScenario(t *testing.T) { if _, err := svc.AddShare(ctx, ownerID, g.ID, "v@example.com", domain.RoleViewer); err != nil { t.Fatalf("share: %v", err) } - viewerBox := NewToolbox(svc, viewerUser.ID) + viewerBox := NewToolbox(svc, viewerUser.ID, "") vr := viewerBox.Execute(ctx, llm.ToolCall{ID: "2", Name: "fill_region", Arguments: mustJSON(t, map[string]any{ "objectId": bed.ID, "region": "all", "plantId": garlic.ID, })}) @@ -148,7 +153,7 @@ func mustPlant(t *testing.T, svc *service.Service, owner int64, name string, spa func TestGarlicBedToCucumbers(t *testing.T) { ctx := context.Background() svc, owner := newAgentTestService(t) - box := NewToolbox(svc, owner) + box := NewToolbox(svc, owner, "") call := func(name string, args any) llm.ToolResult { t.Helper() @@ -233,7 +238,7 @@ func TestGarlicBedToCucumbers(t *testing.T) { func TestFindPlantReturnsCandidatesNotAGuess(t *testing.T) { ctx := context.Background() svc, owner := newAgentTestService(t) - box := NewToolbox(svc, owner) + box := NewToolbox(svc, owner, "") mustPlant(t, svc, owner, "German Red Garlic", 15, "🧄") @@ -272,7 +277,7 @@ func TestCreatePlantIsUserScoped(t *testing.T) { if err != nil { t.Fatalf("register: %v", err) } - box := NewToolbox(svc, other.ID) + box := NewToolbox(svc, other.ID, "") raw, _ := json.Marshal(map[string]any{ "name": "Painted Mountain Corn", "category": "vegetable", @@ -310,7 +315,7 @@ func TestCreatePlantIsUserScoped(t *testing.T) { func TestJournalToolWritesADatedObservation(t *testing.T) { ctx := context.Background() svc, owner := newAgentTestService(t) - box := NewToolbox(svc, owner) + box := NewToolbox(svc, owner, "") g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000}) if err != nil { @@ -354,7 +359,7 @@ func TestJournalToolWritesADatedObservation(t *testing.T) { func TestCorrectiveTools(t *testing.T) { ctx := context.Background() svc, owner := newAgentTestService(t) - box := NewToolbox(svc, owner) + box := NewToolbox(svc, owner, "") var gid int64 // set once the garden exists; the describe closure reads it. call := func(name string, args any) llm.ToolResult { @@ -405,10 +410,10 @@ func TestCorrectiveTools(t *testing.T) { t.Fatalf("place_planting: %s", r.Content) } d = describe() - if len(d.Objects[0].Plantings) != 1 { - t.Fatalf("want 1 plop before removal, got %d", len(d.Objects[0].Plantings)) + if len(d.Objects[0].Plantings) != 1 || len(d.Objects[0].Plantings[0].Each) != 1 { + t.Fatalf("want 1 plop before removal, got %+v", d.Objects[0].Plantings) } - plop := d.Objects[0].Plantings[0] + plop := d.Objects[0].Plantings[0].Each[0] if r := call("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}); r.IsError { t.Fatalf("remove_planting: %s", r.Content) } @@ -487,3 +492,237 @@ func newAgentTestService(t *testing.T) (*service.Service, int64) { } return svc, owner.ID } + +// TestToolsFromTheLiveSweep covers what a day of driving the live assistant +// asked for: grouped describes, whole-group removal, moves that keep the +// planting date, fills by rectangle, seed attribution, catalog edits, history +// reads, plan copies — and every date stamped the gardener's local day rather +// than the server's (UTC) or the model's (a year from its training data). +func TestToolsFromTheLiveSweep(t *testing.T) { + ctx := context.Background() + svc, owner := newAgentTestService(t) + const today = "2026-08-22" + box := NewToolbox(svc, owner, today) + call := func(name string, args any) llm.ToolResult { + t.Helper() + return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)}) + } + ok := func(name string, args any) string { + t.Helper() + r := call(name, args) + if r.IsError { + t.Fatalf("%s: %s", name, r.Content) + } + return r.Content + } + decode := func(raw string, into any) { + t.Helper() + if err := json.Unmarshal([]byte(raw), into); err != nil { + t.Fatalf("decode %v: %s", err, raw) + } + } + + g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000, UnitPref: domain.UnitImperial}) + if err != nil { + t.Fatalf("garden: %v", err) + } + garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄") + beet := mustPlant(t, svc, owner, "Beet", 10, "🌱") + tomato := mustPlant(t, svc, owner, "Cherokee Purple", 60, "🍅") + bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "South bed", XCM: 1000, YCM: 1000, WidthCM: 240, HeightCM: 120}) + if err != nil { + t.Fatalf("bed: %v", err) + } + other, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "North bed", XCM: 1000, YCM: 300, WidthCM: 240, HeightCM: 120}) + if err != nil { + t.Fatalf("other bed: %v", err) + } + lot, err := svc.CreateSeedLot(ctx, owner, service.SeedLotInput{PlantID: beet.ID, Quantity: 500, Unit: "seeds"}) + if err != nil { + t.Fatalf("lot: %v", err) + } + + // fill_region by rectangle (the middle third of the bed's width), in grid mode, + // charged to the lot, dated today by default. + ok("fill_region", map[string]any{ + "objectId": bed.ID, "plantId": beet.ID, "mode": "grid", "seedLotId": lot.ID, + "x0Cm": -40.0, "y0Cm": -60.0, "x1Cm": 40.0, "y1Cm": 60.0, + }) + // Neither a region nor a full rectangle is a mistake the model can read. + if r := call("fill_region", map[string]any{"objectId": bed.ID, "plantId": beet.ID, "x0Cm": -40.0}); !r.IsError || !strings.Contains(r.Content, "x0Cm, y0Cm, x1Cm, y1Cm") { + t.Errorf("half a rectangle: %+v, want a readable refusal", r) + } + if r := call("fill_region", map[string]any{"objectId": bed.ID, "plantId": beet.ID}); !r.IsError { + t.Error("fill_region with nowhere to fill succeeded") + } + // place_planting without a radius → one plant at half the spacing; two garlic + // cloves along the north edge, dated today. + ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": garlic.ID, "xCm": -100, "yCm": -50}) + ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": garlic.ID, "xCm": 100, "yCm": -50}) + // And a tomato planted back in May, with an explicit date. + ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": tomato.ID, "xCm": 0, "yCm": 0, "plantedAt": "2026-05-20"}) + + // describe_garden: one group per plant, dated; only the small ones listed. + var d service.DescribeResult + groups := func() map[string]service.DescribeGroup { + t.Helper() + decode(ok("describe_garden", map[string]any{"gardenId": g.ID}), &d) + out := map[string]service.DescribeGroup{} + for _, o := range d.Objects { + if o.ID == bed.ID { + for _, gr := range o.Plantings { + out[gr.Plant] = gr + } + } + } + return out + } + gs := groups() + beets := gs["Beet"] + if beets.Plops <= 8 || beets.Each != nil { + t.Errorf("beets: %d plops, each=%v; want a large group with no per-plop listing", beets.Plops, beets.Each) + } + if beets.PlantedAt != today || beets.Plants != beets.Plops { + t.Errorf("beets plantedAt %q plants %d; want today and one plant per grid plop", beets.PlantedAt, beets.Plants) + } + if !strings.Contains(beets.Where, "cm from the centre") { + t.Errorf("beets where = %q, want the bounding box of a middle-third fill", beets.Where) + } + cloves := gs["Garlic"] + if cloves.Plops != 2 || len(cloves.Each) != 2 || cloves.Where != "north half" || cloves.PlantedAt != today { + t.Errorf("garlic group = %+v, want 2 listed plops in the north half, dated today", cloves) + } + if r := cloves.Each[0].RadiusCM; r != 7.5 { + t.Errorf("a clove placed without a radius got %v, want spacing/2 = 7.5", r) + } + tom := gs["Cherokee Purple"] + if tom.Plops != 1 || tom.Where != "center" || tom.PlantedAt != "2026-05-20" { + t.Errorf("tomato group = %+v, want one plop at the center dated 2026-05-20", tom) + } + + // The lot counts the beets as used. + var lots []struct { + Used float64 `json:"used"` + Remaining float64 `json:"remaining"` + } + decode(ok("list_seed_lots", map[string]any{"plantId": beet.ID}), &lots) + if len(lots) != 1 || lots[0].Used != float64(beets.Plants) || lots[0].Remaining != 500-float64(beets.Plants) { + t.Errorf("lots = %+v, want %d used of 500", lots, beets.Plants) + } + + // list_plantings spells the big group out, narrowed to one plant. + var listed []service.DescribePlanting + decode(ok("list_plantings", map[string]any{"objectId": bed.ID, "plantId": beet.ID}), &listed) + if len(listed) != beets.Plops { + t.Errorf("list_plantings: %d beets, want %d", len(listed), beets.Plops) + } + + // move_planting: the tomato to the north bed, date kept; a within-bed move too. + var moved domain.Planting + decode(ok("move_planting", map[string]any{ + "plantingId": tom.Each[0].ID, "version": tom.Each[0].Version, "toObjectId": other.ID, "xCm": 10.0, "yCm": -20.0, + }), &moved) + if moved.ObjectID != other.ID || moved.PlantedAt == nil || *moved.PlantedAt != "2026-05-20" { + t.Errorf("moved tomato = %+v, want it in the north bed with its May date", moved) + } + decode(ok("move_planting", map[string]any{ + "plantingId": cloves.Each[0].ID, "version": cloves.Each[0].Version, "xCm": -110.0, "yCm": -55.0, + }), &moved) + if moved.ObjectID != bed.ID || moved.XCM != -110 { + t.Errorf("within-bed move = %+v, want the same bed at x=-110", moved) + } + + // remove_plantings: the beets out, the garlic stays — dated today. + var removed struct { + Removed int `json:"removed"` + } + decode(ok("remove_plantings", map[string]any{"objectId": bed.ID, "plantId": beet.ID}), &removed) + if removed.Removed != beets.Plops { + t.Errorf("remove_plantings removed %d, want the %d beets", removed.Removed, beets.Plops) + } + gs = groups() + if _, still := gs["Beet"]; still || gs["Garlic"].Plops != 2 { + t.Errorf("after remove_plantings the bed has %+v, want the garlic only", gs) + } + var pulled domain.Planting + decode(ok("remove_planting", map[string]any{"plantingId": gs["Garlic"].Each[0].ID, "version": gs["Garlic"].Each[0].Version}), &pulled) + if pulled.RemovedAt == nil || *pulled.RemovedAt != today { + t.Errorf("remove_planting dated the removal %v, want today %s", pulled.RemovedAt, today) + } + + // read_history sees all of that, newest first, and marks what was undone. + var hist struct { + Entries []historyEntry `json:"entries"` + HasMore bool `json:"hasMore"` + } + decode(ok("read_history", map[string]any{"gardenId": g.ID, "limit": 3}), &hist) + if len(hist.Entries) != 3 || !hist.HasMore { + t.Fatalf("read_history = %d entries, hasMore=%v; want 3 and more", len(hist.Entries), hist.HasMore) + } + if e := hist.Entries[1]; !strings.HasPrefix(e.Summary, "Removed Beet from South bed") || !strings.Contains(e.Changes, "planting") || e.Undone { + t.Errorf("entry = %+v, want the beet removal, not undone", e) + } + if _, conflicts, err := svc.RevertChangeSet(ctx, owner, hist.Entries[1].ID, domain.SourceUI); err != nil || len(conflicts) != 0 { + t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts) + } + decode(ok("read_history", map[string]any{"gardenId": g.ID, "limit": 3}), &hist) + if hist.Entries[0].Undo == nil || !hist.Entries[2].Undone { + t.Errorf("after an undo: newest = %+v, undone = %+v; want the revert to point at the removal, and the removal marked undone", hist.Entries[0], hist.Entries[2]) + } + + // update_plant on the user's own plant; a built-in is refused. + var matches []struct { + ID int64 `json:"id"` + Version int64 `json:"version"` + } + decode(ok("find_plant", map[string]any{"query": "cherokee"}), &matches) + var updated domain.Plant + decode(ok("update_plant", map[string]any{"plantId": matches[0].ID, "version": matches[0].Version, "daysToMaturity": 75}), &updated) + if updated.DaysToMaturity == nil || *updated.DaysToMaturity != 75 || updated.Name != "Cherokee Purple" { + t.Errorf("update_plant = %+v, want days 75 and the name untouched", updated) + } + decode(ok("find_plant", map[string]any{"query": "basil"}), &matches) + if r := call("update_plant", map[string]any{"plantId": matches[0].ID, "version": matches[0].Version, "daysToMaturity": 60}); !r.IsError { + t.Error("update_plant changed a built-in") + } + + // add_journal_entry is dated today unless told otherwise. + ok("add_journal_entry", map[string]any{"gardenId": g.ID, "body": "aphids on the beets"}) + entries, _, err := svc.ListJournal(ctx, owner, g.ID, service.JournalQuery{}) + if err != nil { + t.Fatalf("ListJournal: %v", err) + } + if len(entries) != 1 || entries[0].ObservedAt != today { + t.Errorf("journal = %+v, want one entry observed %s", entries, today) + } + + // copy_garden makes next year's plan: a whole copy under the plan name. + var plan domain.Garden + decode(ok("copy_garden", map[string]any{"gardenId": g.ID, "name": "Plot — 2027"}), &plan) + if plan.Name != "Plot — 2027" || plan.ID == g.ID { + t.Errorf("copy_garden = %+v, want a new garden named for the plan", plan) + } + decode(ok("describe_garden", map[string]any{"gardenId": plan.ID}), &d) + if len(d.Objects) != 2 { + t.Errorf("the plan copy has %d objects, want the source's 2", len(d.Objects)) + } +} + +// TestToolsDefaultToTheServiceDayWithoutOne — a toolbox built with no local day +// (a bare API caller) still dates everything: the service's UTC today. +func TestToolsDefaultToTheServiceDayWithoutOne(t *testing.T) { + a := &adapter{today: ""} + if d := a.day(""); d != nil { + t.Errorf("no day at all → %q, want nil (the service default)", *d) + } + if d := a.day(" 2026-01-02 "); d == nil || *d != "2026-01-02" { + t.Errorf("an explicit day → %v, want it trimmed", d) + } + a.today = "2026-08-22" + if d := a.day(""); d == nil || *d != "2026-08-22" { + t.Errorf("the gardener's day → %v, want 2026-08-22", d) + } + if d := a.day("2026-05-20"); d == nil || *d != "2026-05-20" { + t.Errorf("an explicit day beats the default: %v", d) + } +} diff --git a/internal/api/agent.go b/internal/api/agent.go index e1e8325..0bbcae6 100644 --- a/internal/api/agent.go +++ b/internal/api/agent.go @@ -35,6 +35,21 @@ const keepAliveInterval = 20 * time.Second type chatRequest struct { GardenID int64 `json:"gardenId" binding:"required"` Message string `json:"message" binding:"required"` + // Today is the sender's local date (YYYY-MM-DD): what the assistant tells the + // model the date is, and what the turn's plantings, removals and journal + // entries are dated. The UI always sends it, for the same reason it sends + // plantedAt on a fill — a gardener placing at 9 pm in Ohio planted today, not + // UTC's tomorrow. Optional for bare API callers, who get the server's UTC day. + Today string `json:"today"` +} + +// validToday accepts an empty date or one in YYYY-MM-DD form. +func validToday(s string) bool { + if s == "" { + return true + } + _, err := time.Parse("2006-01-02", s) + return err == nil } // chatEvent is one server-sent event. Exactly one field is set. @@ -60,17 +75,23 @@ func (h *handlers) agentChat(c *gin.Context) { // state, not a missing route: answer it plainly rather than 404ing a path // that exists. Loaded once here so a settings-driven swap mid-request can't // make it flip between the guard and the Run call. - runner := h.agent.get() - if runner == nil { - writeAPIError(c, http.StatusServiceUnavailable, "AGENT_DISABLED", "the garden assistant isn't enabled on this instance") - return - } - + // The body is checked before the runner: a malformed request is a 400 + // whether or not there is a model behind the route, so a client can't + // mistake its own bad date for the assistant being off. var req chatRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required") return } + if !validToday(req.Today) { + writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "today must be a YYYY-MM-DD date") + return + } + runner := h.agent.get() + if runner == nil { + writeAPIError(c, http.StatusServiceUnavailable, "AGENT_DISABLED", "the garden assistant isn't enabled on this instance") + return + } actor := mustActor(c) history, err := h.svc.AgentHistory(c.Request.Context(), actor.ID, req.GardenID) @@ -88,7 +109,7 @@ func (h *handlers) agentChat(c *gin.Context) { stopBeat := stream.keepAlive(keepAliveInterval) defer stopBeat() - turn, err := runner.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message, + turn, err := runner.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message, req.Today, replayHistory(history), func(s mdagent.Step) { send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}}) diff --git a/internal/api/agent_test.go b/internal/api/agent_test.go index 1d77ae3..4aebf29 100644 --- a/internal/api/agent_test.go +++ b/internal/api/agent_test.go @@ -47,3 +47,27 @@ func TestAgentDisabledWithoutAKey(t *testing.T) { t.Errorf("editor load: status %d, want 200 — an unconfigured agent must not break the app", w.Code) } } + +// TestChatRejectsAMalformedToday — the sender's local date is validated before +// anything else about the request, assistant or no assistant: a bad body is a +// 400 either way, so a client can't mistake its own bad date for the assistant +// being off. +func TestChatRejectsAMalformedToday(t *testing.T) { + r := authEngine(t, localCfg()) + cookie := registerAndCookie(t, r, "dates@example.com") + gid := createGardenAPI(t, r, cookie, "G") + + w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat", + map[string]any{"gardenId": gid, "message": "plant garlic", "today": "Aug 22"}, cookie) + if w.Code != http.StatusBadRequest { + t.Errorf("chat with today=%q: status %d, want 400", "Aug 22", w.Code) + } + // A well-formed date (or none) gets past validation to the runner check. + for _, today := range []string{"2026-08-22", ""} { + w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat", + map[string]any{"gardenId": gid, "message": "plant garlic", "today": today}, cookie) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("chat with today=%q: status %d, want 503 (no runner configured)", today, w.Code) + } + } +} diff --git a/internal/service/ops.go b/internal/service/ops.go index 743134f..dabb90f 100644 --- a/internal/service/ops.go +++ b/internal/service/ops.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "log/slog" "math" @@ -179,39 +180,79 @@ func validFillLayout(l FillLayout) (FillLayout, bool) { // when nil — the UI always sends its local day, so the default is for API and // agent callers. Returns the plops it created. func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) { + return s.Fill(ctx, actorID, objectID, FillSpec{ + Region: region, PlantID: plantID, SpacingOverride: spacingOverride, Layout: layout, PlantedAt: plantedAt, + }) +} + +// FillSpec is everything a fill needs besides the object it fills: where (a +// compass RegionName, or an explicit Region in the object's local frame when the +// name is empty), what, and how. +type FillSpec struct { + // RegionName is a compass name for NamedRegion ("ne", "south half", "all"). + // When it is empty, Region is used as given. + RegionName string + Region Region + PlantID int64 + // SpacingOverride replaces the plant's own spacing for this fill, in cm. + SpacingOverride *float64 + // Layout is clump (the default) or grid; see FillLayout. + Layout FillLayout + // PlantedAt dates every plop the fill makes (YYYY-MM-DD). nil means the + // service's UTC today; a caller that knows the person's local day sends it. + PlantedAt *string + // SeedLotID attributes every plop to one of the actor's seed lots, so the lot + // can report what it has left. Optional. + SeedLotID *int64 +} + +// Fill plants one plant across part of an object the actor can edit, per spec. +// FillRegion and FillNamedRegion are the two older spellings of it. +func (s *Service) Fill(ctx context.Context, actorID, objectID int64, spec FillSpec) ([]domain.Planting, error) { o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) if err != nil { return nil, err } - return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt) + region := spec.Region + if strings.TrimSpace(spec.RegionName) != "" { + if region, err = NamedRegion(o, spec.RegionName); err != nil { + return nil, err + } + } + return s.fillLoaded(ctx, actorID, o, region, spec) } -// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object -// already loaded and authorized (roleEditor). It validates the layout, rejects a +// fillLoaded is the body of Fill given an object already loaded and authorized +// (roleEditor) and its region resolved. It validates the layout, rejects a // non-finite region, clamps the region to the object's bounds, refuses fills over // maxFillPlops, and inserts the whole batch in one transaction rather than one // round-trip per plop. -func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) { +func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, spec FillSpec) ([]domain.Planting, error) { if !o.Plantable { return nil, domain.ErrInvalidInput } - if !validDatePtr(plantedAt) { + if !validDatePtr(spec.PlantedAt) { return nil, fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput) } - layout, ok := validFillLayout(layout) + layout, ok := validFillLayout(spec.Layout) if !ok { return nil, domain.ErrInvalidInput } - plant, err := s.visiblePlant(ctx, actorID, plantID) + plant, err := s.visiblePlant(ctx, actorID, spec.PlantID) if err != nil { return nil, err } + // Checked before anything is planted, as CreatePlanting does: a lot of the + // wrong variety, or someone else's, refuses the whole fill. + if err := s.checkSeedLotForPlanting(ctx, actorID, spec.SeedLotID, spec.PlantID); err != nil { + return nil, err + } spacing := plant.SpacingCM - if spacingOverride != nil { - if !isFinite(*spacingOverride) || *spacingOverride < minPlantSpacingCM || *spacingOverride > maxPlantSpacingCM { + if spec.SpacingOverride != nil { + if !isFinite(*spec.SpacingOverride) || *spec.SpacingOverride < minPlantSpacingCM || *spec.SpacingOverride > maxPlantSpacingCM { return nil, domain.ErrInvalidInput } - spacing = *spacingOverride + spacing = *spec.SpacingOverride } radius := plopRadiusFor(spacing, layout) if !isFinite(radius) || radius <= 0 { @@ -241,8 +282,8 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde return nil, err } plantedOn := s.now().UTC().Format(dateLayout) - if plantedAt != nil { - plantedOn = *plantedAt + if spec.PlantedAt != nil { + plantedOn = *spec.PlantedAt } batch := make([]*domain.Planting, 0, len(centers)) // Only the plops that were ALREADY here can cover a candidate: every plop this @@ -255,7 +296,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde if coveredByExisting(c.x, c.y, radius, existing) { continue } - batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn}) + batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: spec.PlantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn, SeedLotID: spec.SeedLotID}) } created, err := s.store.CreatePlantings(ctx, batch) if err != nil { @@ -385,15 +426,14 @@ func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool { // instead of a resolved Region — the ergonomic form for agent tools, which don't // hold the object's geometry. It resolves the name against the object, then fills. func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) { - o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) - if err != nil { - return nil, err + if strings.TrimSpace(regionName) == "" { + // Fill would read a blank name as "use the (zero) Region" and plant + // nothing; here a blank name is the caller's mistake, as it always was. + return nil, domain.ErrInvalidInput } - region, err := NamedRegion(o, regionName) - if err != nil { - return nil, err - } - return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt) + return s.Fill(ctx, actorID, objectID, FillSpec{ + RegionName: regionName, PlantID: plantID, SpacingOverride: spacingOverride, Layout: layout, PlantedAt: plantedAt, + }) } // ClearObject soft-removes every active plop in an object the actor can edit (one @@ -402,10 +442,29 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, // non-plantable after it was planted must still be clearable (you can always // remove existing plops, only not add new ones). func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) { + return s.ClearPlantings(ctx, actorID, objectID, ClearOptions{}) +} + +// ClearOptions narrows ClearPlantings. +type ClearOptions struct { + // PlantID limits the clear to one plant — "pull the beets out, leave the + // garlic" — nil clears every plant. + PlantID *int64 + // RemovedAt is the removal date (YYYY-MM-DD). nil means the service's UTC + // today; a caller that knows the person's local day sends it. + RemovedAt *string +} + +// ClearPlantings is ClearObject with options: all of an object's active plops, or +// only one plant's. The whole clear is one change set either way. +func (s *Service) ClearPlantings(ctx context.Context, actorID, objectID int64, opts ClearOptions) (int, error) { o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor) if err != nil { return 0, err } + if !validDatePtr(opts.RemovedAt) { + return 0, fmt.Errorf("%w: removedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput) + } // Snapshot the rows the bulk UPDATE is about to touch, since it reports only a // count — then clear exactly those ids. Clearing "every active plop" instead // would let a plop created between this read and the UPDATE be removed with no @@ -414,12 +473,32 @@ func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int if err != nil { return 0, err } + what := "" // names the plant in the summary when the clear is for one plant + if opts.PlantID != nil { + only := make([]domain.Planting, 0, len(before)) + for i := range before { + if before[i].PlantID == *opts.PlantID { + only = append(only, before[i]) + } + } + before = only + // The summary is read by a person, so name the plant, not its id. A plant + // that no longer exists just goes unnamed. + if plant, err := s.store.GetPlant(ctx, *opts.PlantID); err == nil { + what = plant.Name + } else if !errors.Is(err, domain.ErrNotFound) { + return 0, err + } + } ids := make([]int64, 0, len(before)) for i := range before { ids = append(ids, before[i].ID) } - today := s.now().UTC().Format(dateLayout) - n, err := s.store.ClearObjectPlantings(ctx, objectID, today, ids) + removedOn := s.now().UTC().Format(dateLayout) + if opts.RemovedAt != nil { + removedOn = *opts.RemovedAt + } + n, err := s.store.ClearObjectPlantings(ctx, objectID, removedOn, ids) if err != nil || n == 0 { return n, err } @@ -447,7 +526,14 @@ func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int } changes = append(changes, changeUpdate(domain.EntityPlanting, b.ID, &b, a)) } - s.record(ctx, g.ID, actorID, fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n), changes...) + summary := fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n) + if opts.PlantID != nil { + if what == "" { + what = "plantings" + } + summary = fmt.Sprintf("Removed %s from %s (%d plantings)", what, objectLabel(o), n) + } + s.record(ctx, g.ID, actorID, summary, changes...) return n, nil } @@ -461,39 +547,75 @@ type DescribeResult struct { Objects []DescribeObject `json:"objects"` } -// DescribeObject is one object plus its active plantings, for DescribeResult. -// Version is included so an agent can move/edit the object (the mutation guard). +// DescribeObject is one object plus its active plantings grouped by plant, for +// DescribeResult. Version is included so an agent can move/edit the object (the +// mutation guard). type DescribeObject struct { - ID int64 `json:"id"` - Kind string `json:"kind"` - Name string `json:"name"` - Shape string `json:"shape"` - WidthCM float64 `json:"widthCm"` - HeightCM float64 `json:"heightCm"` - XCM float64 `json:"xCm"` - YCM float64 `json:"yCm"` - RotationDeg float64 `json:"rotationDeg"` - Plantable bool `json:"plantable"` - Version int64 `json:"version"` - Plantings []DescribePlanting `json:"plantings"` + ID int64 `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + Shape string `json:"shape"` + WidthCM float64 `json:"widthCm"` + HeightCM float64 `json:"heightCm"` + XCM float64 `json:"xCm"` + YCM float64 `json:"yCm"` + RotationDeg float64 `json:"rotationDeg"` + Plantable bool `json:"plantable"` + Version int64 `json:"version"` + Plantings []DescribeGroup `json:"plantings"` } -// DescribePlanting is one plop with a rough compass location, for DescribeResult. -// ID + Version are included so an agent can address a single plop — remove it or -// move it — the same way DescribeObject.Version lets it edit an object. +// maxListedPlops is the largest group DescribeGroup.Each spells out plop by plop. +// Up to it, a group is a handful of placements someone may address one at a time +// ("pull the basil out of the corner"). Past it — a grid-filled bed is hundreds — +// the ids are noise that costs a model more than it informs, and the group is +// addressed as a whole (ClearPlantings) or listed on demand (ListObjectPlantings). +// The live instance's first describe of a grid-filled garden was ~450 plop +// entries, on every turn. +const maxListedPlops = 8 + +// DescribeGroup summarizes every active plop of one plant in an object — the +// unit a person talks about ("the cucumbers in the west bed") — with the count, +// a rough location, and when it went in. +type DescribeGroup struct { + PlantID int64 `json:"plantId"` + Plant string `json:"plant"` + // Plops is how many placements make up the group; Plants the effective plant + // count across them (explicit counts, else derived from area and spacing). + Plops int `json:"plops"` + Plants int `json:"plants"` + // Where is a rough location: a compass region when the group sits in one + // ("north half", "NE corner"), "throughout" when it spans the object, a short + // list of locations, or — for anything else — its bounding box in local cm. + Where string `json:"where"` + // PlantedAt is the planting date, or "first…last" when the plops differ. + PlantedAt string `json:"plantedAt,omitempty"` + // DaysToMaturity is the plant's, when the catalog knows it — with PlantedAt, + // enough to say when the harvest is due. + DaysToMaturity *int `json:"daysToMaturity,omitempty"` + // Each lists the plops individually (id, version, location) only when the + // group has at most maxListedPlops of them. + Each []DescribePlanting `json:"each,omitempty"` +} + +// DescribePlanting is one plop with a rough compass location. ID + Version let +// an agent address a single plop — remove it or move it — the same way +// DescribeObject.Version lets it edit an object. type DescribePlanting struct { - ID int64 `json:"id"` - Version int64 `json:"version"` - PlantID int64 `json:"plantId"` - Plant string `json:"plant"` - Count int `json:"count"` - Location string `json:"location"` - RadiusCM float64 `json:"radiusCm"` + ID int64 `json:"id"` + Version int64 `json:"version"` + PlantID int64 `json:"plantId"` + Plant string `json:"plant"` + Count int `json:"count"` + Location string `json:"location"` + RadiusCM float64 `json:"radiusCm"` + PlantedAt string `json:"plantedAt,omitempty"` } // DescribeGarden returns a structured summary — dimensions, objects, and each -// object's active plantings (plant, effective count, rough location) — for a -// garden the actor can view. Built on GardenFull so it inherits the ACL check. +// object's active plantings grouped by plant (count, rough location, planting +// date) — for a garden the actor can view. Built on GardenFull so it inherits +// the ACL check. func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) { full, err := s.GardenFull(ctx, actorID, gardenID, nil) if err != nil { @@ -517,33 +639,187 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) ( UnitPref: full.Garden.UnitPref, Objects: make([]DescribeObject, 0, len(full.Objects)), } - for _, o := range full.Objects { - do := DescribeObject{ + for i := range full.Objects { + o := &full.Objects[i] + res.Objects = append(res.Objects, DescribeObject{ ID: o.ID, Kind: o.Kind, Name: o.Name, Shape: o.Shape, WidthCM: o.WidthCM, HeightCM: o.HeightCM, XCM: o.XCM, YCM: o.YCM, RotationDeg: o.RotationDeg, Plantable: o.Plantable, Version: o.Version, - Plantings: []DescribePlanting{}, - } - for _, pl := range plopsByObject[o.ID] { - count := pl.DerivedCount - if pl.Count != nil { - count = *pl.Count - } - do.Plantings = append(do.Plantings, DescribePlanting{ - ID: pl.ID, - Version: pl.Version, - PlantID: pl.PlantID, - Plant: plantByID[pl.PlantID].Name, - Count: count, - Location: describeLocation(pl.XCM, pl.YCM), - RadiusCM: pl.RadiusCM, - }) - } - res.Objects = append(res.Objects, do) + Plantings: describeGroups(o, plopsByObject[o.ID], plantByID), + }) } return res, nil } +// ListObjectPlantings lists an object's active plops one by one — the ids that +// DescribeGarden summarizes away for a large group. plantID narrows it to one +// plant. Viewer role, like DescribeGarden. +func (s *Service) ListObjectPlantings(ctx context.Context, actorID, objectID int64, plantID *int64) ([]DescribePlanting, error) { + if _, _, err := s.objectForRole(ctx, actorID, objectID, roleViewer); err != nil { + return nil, err + } + plops, err := s.store.ListActivePlantingsForObject(ctx, objectID) + if err != nil { + return nil, err + } + // Plants looked up by id, not through the actor's catalog: a plop in a shared + // garden may be of the owner's private variety, and it still has a name. + plants := map[int64]domain.Plant{} + out := make([]DescribePlanting, 0, len(plops)) + for _, pl := range plops { + if plantID != nil && pl.PlantID != *plantID { + continue + } + plant, ok := plants[pl.PlantID] + if !ok { + p, err := s.store.GetPlant(ctx, pl.PlantID) + if err != nil { + return nil, err + } + plant = *p + plants[pl.PlantID] = plant + } + pl.DerivedCount = derivedCount(pl.RadiusCM, plant.SpacingCM) + out = append(out, describePlanting(pl, plant.Name)) + } + return out, nil +} + +// describeGroups groups an object's active plops by plant, in the order the +// plants first appear, so the same garden always describes the same way. +func describeGroups(o *domain.GardenObject, plops []domain.Planting, plantByID map[int64]domain.Plant) []DescribeGroup { + byPlant := map[int64][]domain.Planting{} + var order []int64 + for _, pl := range plops { + if _, seen := byPlant[pl.PlantID]; !seen { + order = append(order, pl.PlantID) + } + byPlant[pl.PlantID] = append(byPlant[pl.PlantID], pl) + } + groups := make([]DescribeGroup, 0, len(order)) + for _, pid := range order { + members := byPlant[pid] + plant := plantByID[pid] + g := DescribeGroup{ + PlantID: pid, Plant: plant.Name, Plops: len(members), + Where: summarizeWhere(o, members), PlantedAt: dateRange(members), + DaysToMaturity: plant.DaysToMaturity, + } + for _, pl := range members { + g.Plants += effectiveCount(pl) + } + if len(members) <= maxListedPlops { + g.Each = make([]DescribePlanting, 0, len(members)) + for _, pl := range members { + g.Each = append(g.Each, describePlanting(pl, plant.Name)) + } + } + groups = append(groups, g) + } + return groups +} + +func describePlanting(pl domain.Planting, plantName string) DescribePlanting { + d := DescribePlanting{ + ID: pl.ID, Version: pl.Version, PlantID: pl.PlantID, Plant: plantName, + Count: effectiveCount(pl), Location: describeLocation(pl.XCM, pl.YCM), RadiusCM: pl.RadiusCM, + } + if pl.PlantedAt != nil { + d.PlantedAt = *pl.PlantedAt + } + return d +} + +// effectiveCount is the plant count a plop stands for: its explicit count, else +// the one derived from its area and the plant's spacing. +func effectiveCount(pl domain.Planting) int { + if pl.Count != nil { + return *pl.Count + } + return pl.DerivedCount +} + +// dateRange is the planting date shared by a group's plops, "first…last" when +// they were planted on different days, or "" when none is dated. ISO dates +// order as strings, so min/max need no parsing. +func dateRange(plops []domain.Planting) string { + first, last := "", "" + for _, pl := range plops { + if pl.PlantedAt == nil || *pl.PlantedAt == "" { + continue + } + if first == "" || *pl.PlantedAt < first { + first = *pl.PlantedAt + } + if *pl.PlantedAt > last { + last = *pl.PlantedAt + } + } + if first == last { + return first + } + return first + "…" + last +} + +// summarizeWhere names where a group of plops sits in its object, in the words +// NamedRegion understands when that is exact ("north half", "NE corner"), and +// otherwise as honestly as it can: "throughout" for a group spanning most of the +// object, a short list of rough locations, or the bounding box of the plop +// centres in local cm — which is what a fill needs to put something back there. +func summarizeWhere(o *domain.GardenObject, plops []domain.Planting) string { + if len(plops) == 1 { + return describeLocation(plops[0].XCM, plops[0].YCM) + } + minX, maxX := plops[0].XCM, plops[0].XCM + minY, maxY := plops[0].YCM, plops[0].YCM + for _, pl := range plops[1:] { + minX, maxX = math.Min(minX, pl.XCM), math.Max(maxX, pl.XCM) + minY, maxY = math.Min(minY, pl.YCM), math.Max(maxY, pl.YCM) + } + const eps = 1e-6 + // A half is "everything on one side of the centre line, and not just ON it": + // a column of plops down the middle is neither the west half nor the east. + north := maxY <= eps && minY < -eps + south := minY >= -eps && maxY > eps + west := maxX <= eps && minX < -eps + east := minX >= -eps && maxX > eps + switch { + case north && west: + return "NW corner" + case north && east: + return "NE corner" + case south && west: + return "SW corner" + case south && east: + return "SE corner" + case north: + return "north half" + case south: + return "south half" + case west: + return "west half" + case east: + return "east half" + } + // Centres spanning at least 60% of both dimensions is a whole-object fill + // (the outer row sits half a spacing in from each edge). + if hw, hh := o.WidthCM/2, o.HeightCM/2; hw > 0 && hh > 0 && maxX-minX >= 1.2*hw && maxY-minY >= 1.2*hh { + return "throughout" + } + var locs []string + seen := map[string]bool{} + for _, pl := range plops { + if l := describeLocation(pl.XCM, pl.YCM); !seen[l] { + seen[l] = true + locs = append(locs, l) + } + } + if len(locs) <= 3 { + return strings.Join(locs, ", ") + } + return fmt.Sprintf("x %.0f…%.0f, y %.0f…%.0f cm from the centre", minX, maxX, minY, maxY) +} + // describeLocation reverse-maps a local point to a rough compass location — the // inverse of NamedRegion's quarters/halves ("NE corner", "south", "center"). func describeLocation(x, y float64) string { diff --git a/internal/service/ops_test.go b/internal/service/ops_test.go index 1fa58b4..78c1f34 100644 --- a/internal/service/ops_test.go +++ b/internal/service/ops_test.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "fmt" "math" "sort" "testing" @@ -471,12 +472,17 @@ func TestFillScenario(t *testing.T) { t.Fatalf("objects = %d, want 1", len(desc.Objects)) } // Tally plant → the set of rough locations it appears in. + // Plantings come grouped by plant: a group's Where names the region when the + // whole group sits in one, and a small group also lists its plops. locs := map[string]map[string]bool{} - for _, p := range desc.Objects[0].Plantings { - if locs[p.Plant] == nil { - locs[p.Plant] = map[string]bool{} + for _, g := range desc.Objects[0].Plantings { + if locs[g.Plant] == nil { + locs[g.Plant] = map[string]bool{} + } + locs[g.Plant][g.Where] = true + for _, p := range g.Each { + locs[g.Plant][p.Location] = true } - locs[p.Plant][p.Location] = true } if len(locs["Garlic"]) == 0 || !locs["Garlic"]["NE corner"] { t.Errorf("garlic locations = %v, want NE corner", locs["Garlic"]) @@ -538,3 +544,250 @@ func TestFillRegionPlantedAt(t *testing.T) { t.Errorf("bad date err = %v, want ErrInvalidInput", err) } } + +// TestDescribeGardenGroupsByPlant — describe_garden is what the assistant reads +// at the start of every turn, and the live one's first describe of a grid-filled +// garden was ~450 plop entries. A group per plant says what a person would say +// ("beans across the north half, sown in May"), spells out its plops only when +// there are few, and carries the dates the model had no way to know before. +func TestDescribeGardenGroupsByPlant(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Grouped", WidthCM: 2000, HeightCM: 2000}) + if err != nil { + t.Fatalf("garden: %v", err) + } + bed := seedFillBed(t, s, owner, g.ID, 400, 400) + beans := seedNamedPlant(t, s, owner, "Beans", 10) + basil := seedNamedPlant(t, s, owner, "Basil", 25) + may, june := "2026-05-01", "2026-06-01" + + // A grid fill of the north half: far more plops than get listed, all May. + if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "north", PlantID: beans.ID, Layout: FillGrid, PlantedAt: &may}); err != nil { + t.Fatalf("fill beans: %v", err) + } + // Three basil plops in the south half, on two dates, one with an explicit count. + three := 3 + for _, in := range []PlantingInput{ + {PlantID: basil.ID, XCM: -100, YCM: 100, RadiusCM: 20, PlantedAt: &may}, + {PlantID: basil.ID, XCM: 0, YCM: 150, RadiusCM: 20, PlantedAt: &june, Count: &three}, + {PlantID: basil.ID, XCM: 100, YCM: 100, RadiusCM: 20, PlantedAt: &june}, + } { + if _, err := s.CreatePlanting(ctx, owner, bed.ID, in); err != nil { + t.Fatalf("place basil: %v", err) + } + } + + desc, err := s.DescribeGarden(ctx, owner, g.ID) + if err != nil { + t.Fatalf("DescribeGarden: %v", err) + } + groups := map[string]DescribeGroup{} + for _, gr := range desc.Objects[0].Plantings { + groups[gr.Plant] = gr + } + if len(groups) != 2 { + t.Fatalf("groups = %d (%+v), want one per plant", len(groups), desc.Objects[0].Plantings) + } + + b := groups["Beans"] + if b.Plops <= maxListedPlops { + t.Fatalf("the beans fill made %d plops; the test needs more than %d to exercise the listing cap", b.Plops, maxListedPlops) + } + if b.Each != nil { + t.Errorf("a %d-plop group listed its plops individually", b.Plops) + } + if b.Where != "north half" { + t.Errorf("beans where = %q, want %q", b.Where, "north half") + } + if b.PlantedAt != may { + t.Errorf("beans plantedAt = %q, want %q", b.PlantedAt, may) + } + if b.Plants != b.Plops { + t.Errorf("grid beans: plants %d ≠ plops %d (one plant per grid plop)", b.Plants, b.Plops) + } + + ba := groups["Basil"] + if ba.Plops != 3 || len(ba.Each) != 3 { + t.Errorf("basil: plops %d, each %d; want 3 and 3 (a small group lists its plops)", ba.Plops, len(ba.Each)) + } + if ba.Where != "south half" { + t.Errorf("basil where = %q, want %q", ba.Where, "south half") + } + if ba.PlantedAt != may+"…"+june { + t.Errorf("basil plantedAt = %q, want the range %q", ba.PlantedAt, may+"…"+june) + } + // Two derived counts (π·20²/25² ≈ 2 each) plus the explicit 3. + if want := 2*derivedCount(20, 25) + 3; ba.Plants != want { + t.Errorf("basil plants = %d, want %d", ba.Plants, want) + } + for _, e := range ba.Each { + if e.PlantedAt == "" || e.Version == 0 || e.ID == 0 { + t.Errorf("listed plop %+v is missing id, version or date", e) + } + } + + // The big group's ids are a call away, narrowed to one plant. + listed, err := s.ListObjectPlantings(ctx, owner, bed.ID, &beans.ID) + if err != nil { + t.Fatalf("ListObjectPlantings: %v", err) + } + if len(listed) != b.Plops { + t.Errorf("listed %d beans, want %d", len(listed), b.Plops) + } + for _, p := range listed { + if p.PlantID != beans.ID || p.PlantedAt != may || p.Plant != "Beans" { + t.Errorf("listed plop %+v, want a May bean", p) + break + } + } + // A stranger gets not-found, like everything else behind the garden ACL. + stranger := seedUser(t, s, "s@example.com") + if _, err := s.ListObjectPlantings(ctx, stranger, bed.ID, nil); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("stranger ListObjectPlantings err = %v, want ErrNotFound", err) + } +} + +// TestSummarizeWhere pins the words a group's location comes out in: the +// compass names NamedRegion understands when the group fits one, "throughout" +// for a whole-bed fill, a short list for a few scattered plops, and a bounding +// box for anything else — never a column down the middle called a "half". +func TestSummarizeWhere(t *testing.T) { + o := &domain.GardenObject{WidthCM: 200, HeightCM: 100} + at := func(pts ...[2]float64) []domain.Planting { + out := make([]domain.Planting, 0, len(pts)) + for _, p := range pts { + out = append(out, domain.Planting{XCM: p[0], YCM: p[1]}) + } + return out + } + for _, tc := range []struct { + name string + in []domain.Planting + want string + }{ + {"single", at([2]float64{0, -10}), "north"}, + {"ne corner", at([2]float64{10, -10}, [2]float64{80, -40}), "NE corner"}, + {"south half", at([2]float64{-80, 10}, [2]float64{80, 40}), "south half"}, + {"column down the middle", at([2]float64{0, -40}, [2]float64{0, 0}, [2]float64{0, 40}), "north, center, south"}, + {"whole bed", at([2]float64{-90, -40}, [2]float64{90, -40}, [2]float64{-90, 40}, [2]float64{90, 40}, [2]float64{0, 0}), "throughout"}, + {"middle third", at([2]float64{-30, -40}, [2]float64{30, -40}, [2]float64{-30, 0}, [2]float64{30, 0}, [2]float64{-30, 40}, [2]float64{30, 40}), "x -30…30, y -40…40 cm from the centre"}, + } { + if got := summarizeWhere(o, tc.in); got != tc.want { + t.Errorf("%s: summarizeWhere = %q, want %q", tc.name, got, tc.want) + } + } +} + +// TestClearPlantingsOnePlantOnTheDayTold — "take the beets out, leave the +// garlic", dated the gardener's day: the whole-bed clear's narrower sibling, and +// what the assistant needed instead of 116 single removals. +func TestClearPlantingsOnePlantOnTheDayTold(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Mixed", WidthCM: 2000, HeightCM: 2000}) + if err != nil { + t.Fatalf("garden: %v", err) + } + bed := seedFillBed(t, s, owner, g.ID, 400, 200) + garlic := seedNamedPlant(t, s, owner, "Garlic", 15) + beet := seedNamedPlant(t, s, owner, "Beet", 10) + if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "west", PlantID: garlic.ID}); err != nil { + t.Fatalf("fill garlic: %v", err) + } + beets, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "east", PlantID: beet.ID}) + if err != nil { + t.Fatalf("fill beets: %v", err) + } + + day := "2026-08-22" + n, err := s.ClearPlantings(ctx, owner, bed.ID, ClearOptions{PlantID: &beet.ID, RemovedAt: &day}) + if err != nil { + t.Fatalf("ClearPlantings: %v", err) + } + if n != len(beets) { + t.Errorf("cleared %d, want the %d beets", n, len(beets)) + } + rows, err := s.store.ListPlantingsForObject(ctx, bed.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + for _, r := range rows { + switch { + case r.PlantID == beet.ID && (r.RemovedAt == nil || *r.RemovedAt != day): + t.Errorf("beet %d removedAt = %v, want %q", r.ID, r.RemovedAt, day) + case r.PlantID == garlic.ID && r.RemovedAt != nil: + t.Errorf("garlic %d was removed by a clear aimed at the beets", r.ID) + } + } + sets, _, err := s.GardenHistory(ctx, owner, g.ID, 0, 0) + if err != nil { + t.Fatalf("history: %v", err) + } + if want := fmt.Sprintf("Removed Beet from %s (%d plantings)", objectLabel(bed), n); sets[0].Summary != want { + t.Errorf("summary = %q, want %q", sets[0].Summary, want) + } + + // Nothing left of that plant clears nothing, cleanly; a bad date is refused + // before anything is touched. + if n, err := s.ClearPlantings(ctx, owner, bed.ID, ClearOptions{PlantID: &beet.ID}); err != nil || n != 0 { + t.Errorf("second clear = (%d, %v), want (0, nil)", n, err) + } + bad := "22/08/2026" + if _, err := s.ClearPlantings(ctx, owner, bed.ID, ClearOptions{RemovedAt: &bad}); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("bad date err = %v, want ErrInvalidInput", err) + } +} + +// TestFillByRectangleAttributesSeed — a fill can be aimed at any rectangle of the +// object's local frame (the middle third, a strip along one edge), not only a +// compass name, and can charge its plops to a seed lot so the lot's "remaining" +// means something. +func TestFillByRectangleAttributesSeed(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Rect", WidthCM: 2000, HeightCM: 2000}) + if err != nil { + t.Fatalf("garden: %v", err) + } + bed := seedFillBed(t, s, owner, g.ID, 240, 120) + beet := seedNamedPlant(t, s, owner, "Beet", 10) + lot, err := s.CreateSeedLot(ctx, owner, SeedLotInput{PlantID: beet.ID, Quantity: 500, Unit: "seeds"}) + if err != nil { + t.Fatalf("lot: %v", err) + } + + created, err := s.Fill(ctx, owner, bed.ID, FillSpec{ + Region: Region{MinX: -40, MinY: -60, MaxX: 40, MaxY: 60}, PlantID: beet.ID, Layout: FillGrid, SeedLotID: &lot.ID, + }) + if err != nil { + t.Fatalf("Fill: %v", err) + } + if len(created) == 0 { + t.Fatal("the rectangle fill planted nothing") + } + for _, p := range created { + if p.XCM < -40 || p.XCM > 40 || p.YCM < -60 || p.YCM > 60 { + t.Errorf("plop at (%v,%v) is outside the rectangle", p.XCM, p.YCM) + } + if p.SeedLotID == nil || *p.SeedLotID != lot.ID { + t.Errorf("plop %d seedLotId = %v, want the lot", p.ID, p.SeedLotID) + } + } + got, err := s.GetSeedLot(ctx, owner, lot.ID) + if err != nil { + t.Fatalf("GetSeedLot: %v", err) + } + if got.Used != float64(len(created)) || got.Remaining != 500-float64(len(created)) { + t.Errorf("lot used/remaining = %v/%v, want %d/%v", got.Used, got.Remaining, len(created), 500-float64(len(created))) + } + + // Someone else's lot, or a lot of another plant, refuses the whole fill. + garlic := seedNamedPlant(t, s, owner, "Garlic", 15) + if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "all", PlantID: garlic.ID, SeedLotID: &lot.ID}); err == nil { + t.Error("a fill charged to a lot of a different plant succeeded") + } +} diff --git a/internal/service/plantings.go b/internal/service/plantings.go index 879feff..4c7a01f 100644 --- a/internal/service/plantings.go +++ b/internal/service/plantings.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "fmt" "math" "strings" "time" @@ -89,12 +90,18 @@ func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, i return nil, err } + radius := in.RadiusCM + if radius == 0 { + // Unspecified means ONE plant: the editor's tap-to-place radius, half the + // spacing. A clump (1.5× spacing) is what a fill makes, not a placement. + radius = plant.SpacingCM / 2 + } p := &domain.Planting{ ObjectID: objectID, PlantID: in.PlantID, XCM: in.XCM, YCM: in.YCM, - RadiusCM: in.RadiusCM, + RadiusCM: radius, Count: in.Count, Label: trimStringPtr(in.Label), PlantedAt: in.PlantedAt, @@ -179,14 +186,89 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64, } // RemovePlanting soft-removes a single plop — the one-plop counterpart to -// ClearObject, used by the agent's remove_planting tool. It stamps removed_at -// from the service clock (s.now()), same as ClearObject and the fill path, so the -// removal date can't diverge by which caller set it; then delegates to +// ClearObject. It stamps removed_at from the service clock (s.now()), the same +// UTC day ClearObject and the fill path default to; RemovePlantingOn is the +// form for a caller that knows the gardener's local day. Both delegate to // UpdatePlanting for the editor-role check, version guard and history record. func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64) (*domain.Planting, error) { - today := s.now().UTC().Format(dateLayout) + return s.RemovePlantingOn(ctx, actorID, plantingID, version, nil) +} + +// RemovePlantingOn is RemovePlanting with the removal date supplied (YYYY-MM-DD) +// by a caller that knows the person's local day. nil keeps the service clock's +// UTC today. +func (s *Service) RemovePlantingOn(ctx context.Context, actorID, plantingID, version int64, removedAt *string) (*domain.Planting, error) { + if !validDatePtr(removedAt) { + return nil, fmt.Errorf("%w: removedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput) + } + on := s.now().UTC().Format(dateLayout) + if removedAt != nil { + on = *removedAt + } return s.UpdatePlanting(ctx, actorID, plantingID, - PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, version) + PlantingPatch{SetRemovedAt: true, RemovedAt: &on}, version) +} + +// MoveInput says where a plop goes: a position in the local frame of ToObjectID, +// or of the plop's current object when ToObjectID is nil. +type MoveInput struct { + ToObjectID *int64 + XCM, YCM float64 +} + +// MovePlanting relocates one plop — within its object, or into another plantable +// object of the same garden — keeping its plant, size, count and planting date. +// Removing and re-placing is not the same thing: "move the tomatoes to the other +// bed" is not "pull them up and plant new ones today", and the live assistant +// did exactly that for want of this. Version-guarded like UpdatePlanting; a +// within-object move IS an UpdatePlanting of the position. +func (s *Service) MovePlanting(ctx context.Context, actorID, plantingID int64, in MoveInput, version int64) (*domain.Planting, error) { + pl, err := s.store.GetPlanting(ctx, plantingID) + if err != nil { + return nil, err // ErrNotFound + } + if in.ToObjectID == nil || *in.ToObjectID == pl.ObjectID { + return s.UpdatePlanting(ctx, actorID, plantingID, PlantingPatch{XCM: &in.XCM, YCM: &in.YCM}, version) + } + from, g, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor) + if err != nil { + return nil, err + } + to, toGarden, err := s.objectForRole(ctx, actorID, *in.ToObjectID, roleEditor) + if err != nil { + return nil, err + } + if toGarden.ID != g.ID { + return nil, fmt.Errorf("%w: a planting can only move within its own garden", domain.ErrInvalidInput) + } + if !to.Plantable { + return nil, fmt.Errorf("%w: %s can't hold plants", domain.ErrInvalidInput, objectLabel(to)) + } + // By id, not through the actor's catalog: the plop may be of a variety the + // actor can't see (a shared editor, the owner's private plant), and moving it + // isn't choosing it. + plant, err := s.store.GetPlant(ctx, pl.PlantID) + if err != nil { + return nil, err + } + before := *pl + pl.ObjectID = to.ID + pl.XCM, pl.YCM = in.XCM, in.YCM + if err := finalizePlanting(pl, to, true); err != nil { + return nil, err + } + pl.Version = version + updated, err := s.store.UpdatePlanting(ctx, pl) + if err != nil { + if errors.Is(err, domain.ErrVersionConflict) && updated != nil { + s.enrichDerived(ctx, updated) + } + return updated, err + } + s.record(ctx, g.ID, actorID, "Moved "+plant.Name+" from "+objectLabel(from)+" to "+objectLabel(to), + changeUpdate(domain.EntityPlanting, updated.ID, &before, updated)) + updated.DerivedCount = derivedCount(updated.RadiusCM, plant.SpacingCM) + return updated, nil } // plantingEditSummary describes a plop edit for the history list. Soft-removal diff --git a/internal/service/plantings_test.go b/internal/service/plantings_test.go index 8a4f0f9..0e3fdbd 100644 --- a/internal/service/plantings_test.go +++ b/internal/service/plantings_test.go @@ -207,11 +207,22 @@ func TestPlantingBoundsCheck(t *testing.T) { }); err != nil { t.Errorf("edge-of-bounds center should be allowed: %v", err) } - // Non-positive radius rejected. + // A negative radius is rejected; an unspecified (zero) one means ONE plant — + // half the plant's spacing, the editor's tap-to-place size — so a caller that + // just says "put a tomato here" gets a tomato-sized plop, not an error. if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ - PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 0, + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: -1, }); !errors.Is(err, domain.ErrInvalidInput) { - t.Errorf("zero radius err = %v, want ErrInvalidInput", err) + t.Errorf("negative radius err = %v, want ErrInvalidInput", err) + } + one, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 0, + }) + if err != nil { + t.Fatalf("zero radius: %v, want the one-plant default", err) + } + if one.RadiusCM != plant.SpacingCM/2 || one.DerivedCount != 1 { + t.Errorf("zero radius → radius %v (count %d), want spacing/2 = %v (count 1)", one.RadiusCM, one.DerivedCount, plant.SpacingCM/2) } } @@ -376,3 +387,96 @@ func TestDeletePlanting(t *testing.T) { t.Errorf("planting still present after delete: %d", len(full.Plantings)) } } + +// TestMovePlantingAcrossBedsKeepsTheDate — "move the tomatoes to the other bed" +// is not "pull them up and plant new ones today". The assistant had only the +// latter for want of this, and the plants lost their planting date on the way. +func TestMovePlantingAcrossBedsKeepsTheDate(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + from, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{Kind: domain.KindBed, Name: "A", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200}) + if err != nil { + t.Fatalf("bed A: %v", err) + } + to, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{Kind: domain.KindBed, Name: "B", XCM: 900, YCM: 500, WidthCM: 200, HeightCM: 200}) + if err != nil { + t.Fatalf("bed B: %v", err) + } + path, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{Kind: domain.KindPath, Name: "Path", XCM: 700, YCM: 900, WidthCM: 400, HeightCM: 100}) + if err != nil { + t.Fatalf("path: %v", err) + } + if path.Plantable { + no := false + if path, err = s.UpdateObject(ctx, owner, path.ID, ObjectPatch{Plantable: &no}, path.Version); err != nil { + t.Fatalf("make the path unplantable: %v", err) + } + } + plant := seedOwnPlant(t, s, owner, 30) + may := "2026-05-20" + pl, err := s.CreatePlanting(ctx, owner, from.ID, PlantingInput{PlantID: plant.ID, XCM: 10, YCM: 10, RadiusCM: 15, PlantedAt: &may}) + if err != nil { + t.Fatalf("plant: %v", err) + } + + moved, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &to.ID, XCM: -50, YCM: 20}, pl.Version) + if err != nil { + t.Fatalf("MovePlanting: %v", err) + } + if moved.ObjectID != to.ID || moved.XCM != -50 || moved.YCM != 20 { + t.Errorf("moved to object %d at (%v,%v), want B (%d) at (-50,20)", moved.ObjectID, moved.XCM, moved.YCM, to.ID) + } + if moved.PlantedAt == nil || *moved.PlantedAt != may { + t.Errorf("plantedAt after the move = %v, want %q kept", moved.PlantedAt, may) + } + if moved.Version != pl.Version+1 || moved.DerivedCount == 0 { + t.Errorf("moved row version %d (count %d), want %d and a derived count", moved.Version, moved.DerivedCount, pl.Version+1) + } + + // It reads as a move in history, and undo puts it back in A. + sets, _, err := s.GardenHistory(ctx, owner, g.ID, 0, 0) + if err != nil { + t.Fatalf("history: %v", err) + } + if want := "Moved " + plant.Name + " from A to B"; sets[0].Summary != want { + t.Errorf("summary = %q, want %q", sets[0].Summary, want) + } + if _, conflicts, err := s.RevertChangeSet(ctx, owner, sets[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 { + t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts) + } + back, err := s.store.GetPlanting(ctx, pl.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if back.ObjectID != from.ID || back.XCM != 10 { + t.Errorf("after undo the plop is in object %d at x=%v, want A (%d) at 10", back.ObjectID, back.XCM, from.ID) + } + + // Refused: a position outside the target, a target that can't hold plants, a + // bed in another garden — and a stale version conflicts like any edit. + cur := back + if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &to.ID, XCM: 500, YCM: 0}, cur.Version); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("out-of-bounds move err = %v, want ErrInvalidInput", err) + } + if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &path.ID, XCM: 0, YCM: 0}, cur.Version); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("move into a path err = %v, want ErrInvalidInput", err) + } + other := seedGarden(t, s, owner) + far, err := s.CreateObject(ctx, owner, other.ID, ObjectInput{Kind: domain.KindBed, Name: "Far", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200}) + if err != nil { + t.Fatalf("far bed: %v", err) + } + if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &far.ID, XCM: 0, YCM: 0}, cur.Version); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("move into another garden err = %v, want ErrInvalidInput", err) + } + if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{XCM: 5, YCM: 5}, cur.Version-1); !errors.Is(err, domain.ErrVersionConflict) { + t.Errorf("stale version err = %v, want ErrVersionConflict", err) + } + // A within-bed move is just a position change. + within, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{XCM: 5, YCM: 5}, cur.Version) + if err != nil || within.ObjectID != from.ID || within.XCM != 5 { + t.Errorf("within-bed move = %+v, %v; want the same bed at x=5", within, err) + } +} diff --git a/internal/service/revisions.go b/internal/service/revisions.go index de03ad7..5a350df 100644 --- a/internal/service/revisions.go +++ b/internal/service/revisions.go @@ -200,7 +200,21 @@ func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary s return } if sc := scopeFrom(ctx); sc != nil { - sc.append(revs) + if sc.gardenID == gardenID { + sc.append(revs) + return + } + // The scope is for ANOTHER garden — an agent turn on garden A that the + // model pointed at an object in garden B. Joining the scope would file B's + // revisions under A's history, where B's undo can't see them and A's undo + // would revert rows in a garden the person isn't looking at. Record them + // where they belong, as their own change set, keeping the source and run + // id so the entry still reads as the agent's work. + own := &changeScope{gardenID: gardenID, actorID: actorID, source: sc.source, summary: summary, agentRunID: sc.agentRunID} + own.append(revs) + if _, err := s.commitScope(ctx, own, nil); err != nil { + slog.Error("service: record change set outside the open scope", "error", err, "garden", gardenID, "summary", summary) + } return } // Auto-scope: one operation, its own change set. Written through the same diff --git a/internal/service/revisions_test.go b/internal/service/revisions_test.go index 751ae2f..c5a2b08 100644 --- a/internal/service/revisions_test.go +++ b/internal/service/revisions_test.go @@ -893,3 +893,50 @@ func TestAutoScopedMutationRecordsEvenIfTheCallerWentAway(t *testing.T) { t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM) } } + +// TestRecordOutsideTheOpenScopeFilesUnderItsOwnGarden — a scope is for ONE +// garden, but nothing stops a mutation inside it from touching another garden +// the actor can edit (the agent, pointed at "my other garden"). Those revisions +// belong to the garden they changed, as their own change set carrying the +// scope's source and run id — not to the open scope, whose undo would then +// quietly revert rows in a garden nobody is looking at. +func TestRecordOutsideTheOpenScopeFilesUnderItsOwnGarden(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + a := seedGarden(t, s, owner) + b := seedGarden(t, s, owner) + bedB, err := s.CreateObject(ctx, owner, b.ID, ObjectInput{Kind: domain.KindBed, Name: "Bed", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200}) + if err != nil { + t.Fatalf("bed: %v", err) + } + beforeB, _, _ := s.GardenHistory(ctx, owner, b.ID, 0, 0) + + run := "run-1" + cs, err := s.WithChangeSet(ctx, owner, a.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "a turn on A", AgentRunID: &run}, + func(ctx context.Context) error { + name := "Renamed from A" + _, err := s.UpdateObject(ctx, owner, bedB.ID, ObjectPatch{Name: &name}, bedB.Version) + return err + }) + if err != nil { + t.Fatalf("WithChangeSet: %v", err) + } + if cs != nil { + t.Errorf("the scope on A wrote change set %d, but nothing in A changed", cs.ID) + } + afterB, _, _ := s.GardenHistory(ctx, owner, b.ID, 0, 0) + if len(afterB) != len(beforeB)+1 { + t.Fatalf("B's history grew by %d, want 1", len(afterB)-len(beforeB)) + } + got := afterB[0] + if got.Source != domain.SourceAgent || got.AgentRunID == nil || *got.AgentRunID != run { + t.Errorf("B's entry = source %q run %v, want the scope's (agent, %q)", got.Source, got.AgentRunID, run) + } + if _, conflicts, err := s.RevertChangeSet(ctx, owner, got.ID, domain.SourceUI); err != nil || len(conflicts) != 0 { + t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts) + } + if d, err := s.DescribeGarden(ctx, owner, b.ID); err != nil || len(d.Objects) != 1 || d.Objects[0].Name != "Bed" { + t.Errorf("after undo B is %+v (%v), want the bed's name back", d, err) + } +} diff --git a/internal/store/plantings.go b/internal/store/plantings.go index 838e8f4..8ee2441 100644 --- a/internal/store/plantings.go +++ b/internal/store/plantings.go @@ -268,13 +268,13 @@ func (d *DB) CreatePlantings(ctx context.Context, plantings []*domain.Planting) func (d *DB) UpdatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) { updated, err := scanPlanting(d.sql.QueryRowContext(ctx, `UPDATE plantings - SET plant_id = ?, x_cm = ?, y_cm = ?, radius_cm = ?, count = ?, label = ?, + SET object_id = ?, plant_id = ?, x_cm = ?, y_cm = ?, radius_cm = ?, count = ?, label = ?, planted_at = ?, removed_at = ?, seed_lot_id = ?, version = version + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND version = ? RETURNING `+plantingColumns, - p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.RemovedAt, p.SeedLotID, + p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.RemovedAt, p.SeedLotID, p.ID, p.Version, )) if errors.Is(err, sql.ErrNoRows) { diff --git a/web/src/editor/AssistantTab.tsx b/web/src/editor/AssistantTab.tsx index 16c9a7b..d42faf5 100644 --- a/web/src/editor/AssistantTab.tsx +++ b/web/src/editor/AssistantTab.tsx @@ -88,6 +88,10 @@ export function AssistantTab({ gardenId, canEdit, undo, large = false }: { garde return (
+ {/* The thread scrolls on its own so the composer stays put: with the whole + tab scrolling, a long conversation pushed the input off the bottom and + every new message scrolled it further away. */} +
{!canEdit && You can only view this garden, so the assistant can't change anything in it.} {history.isPending &&

Loading the conversation…

} {history.isError && {errorMessage(history.error, "Couldn't load the conversation.")}} @@ -103,8 +107,10 @@ export function AssistantTab({ gardenId, canEdit, undo, large = false }: { garde {m.body}
) : ( -
-
+
+ {/* min-w-0 / max-w-full: a wide markdown table scrolls inside its own + wrapper instead of widening the bubble past the panel. */} +
{m.body}}> {m.body}}> {m.body} @@ -136,7 +142,8 @@ export function AssistantTab({ gardenId, canEdit, undo, large = false }: { garde {warning && {warning}} {error && {error}}
-
+
+
{messages.length > 0 && !pending && (