Agent: catalog and garden tools, and a ready date on every describe group #130

Merged
steve merged 2 commits from feat/agent-catalog-tools into main 2026-08-23 06:12:26 +00:00
7 changed files with 313 additions and 19 deletions
+1 -1
View File
@@ -149,7 +149,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
7. **Sharing** — invite by email, roles, viewer read-only mode. 7. **Sharing** — invite by email, roles, viewer read-only mode.
8. **Polish** — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging. 8. **Polish** — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging.
9. **Agent seam**`ops.go` bulk ops + `internal/agent` DefineTool wrappers. 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. 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 "<garden> — <year>" 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. The record-keeping round (2026-08-23): `undo_change` exposes `RevertChangeSet` with `source=agent` — the revert is its own change set, so an undo-only turn reports it as the turn's handle and "Undo this" becomes a redo; `describe_garden` takes a `year` (the season view, pulled plops included, `removed`/`removedAt` per group) with `list_years` beside it, for rotation questions; `update_planting`, `update_journal_entry`/`delete_journal_entry` and `update_garden` correct records in place, and `remove_planting`/`remove_plantings`/`clear_object` take a `removedAt` so a harvest can be backdated; the garden's **notes go into the system prompt** as the gardener's standing facts, and `update_garden` is how the assistant remembers what it is told. 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 "<garden> — <year>" 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. The record-keeping round (2026-08-23): `undo_change` exposes `RevertChangeSet` with `source=agent` — the revert is its own change set, so an undo-only turn reports it as the turn's handle and "Undo this" becomes a redo; `describe_garden` takes a `year` (the season view, pulled plops included, `removed`/`removedAt` per group) with `list_years` beside it, for rotation questions; `update_planting`, `update_journal_entry`/`delete_journal_entry` and `update_garden` correct records in place, and `remove_planting`/`remove_plantings`/`clear_object` take a `removedAt` so a harvest can be backdated; the garden's **notes go into the system prompt** as the gardener's standing facts, and `update_garden` is how the assistant remembers what it is told. Round two added the catalog side — `update_seed_lot`/`delete_seed_lot`, `delete_plant` (refused while anything references the plant), `create_garden` — and `readyAround` on each describe group (planting date + days to maturity, pulled plops excluded), so "what can I pick this week?" is a lookup rather than arithmetic the model gets wrong.
## Deliberate v1 limits ## Deliberate v1 limits
+7 -2
View File
@@ -296,8 +296,13 @@ How to work:
change that planted the beets, not pulling them out today; do not re-create what you can change that planted the beets, not pulling them out today; do not re-create what you can
revert. A change already marked undone stays undone. revert. A change already marked undone stays undone.
- To correct a record rather than change the garden — a planting date, a plant count, a journal - To correct a record rather than change the garden — a planting date, a plant count, a journal
entry's text or date, the garden's notes — use update_planting, update_journal_entry and entry's text or date, the garden's notes, a seed lot — use update_planting, update_journal_entry,
update_garden instead of removing and re-adding. update_garden and update_seed_lot instead of removing and re-adding.
- "What can I pick soon?": each group in describe_garden carries readyAround, its planting date
plus the plant's days to maturity. Compare that with today rather than doing the sums yourself;
a group without it is a plant the catalog has no days for.
- A new garden (another place, not a plan) is create_garden; it opens from the gardens list, and
this conversation stays with the garden it started in.
- When the gardener tells you something worth keeping about the place — their zone, usual - When the gardener tells you something worth keeping about the place — their zone, usual
frost dates, soil, a standing preference — add it to the garden's notes with update_garden frost dates, soil, a standing preference — add it to the garden's notes with update_garden
(keeping what is already there), and say you did. You will see those notes in every later (keeping what is already there), and say you did. You will see those notes in every later
+97
View File
@@ -2,6 +2,7 @@ package agent
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"sort" "sort"
"strconv" "strconv"
@@ -211,6 +212,29 @@ func newToolbox(svc *service.Service, actorID int64, today string) (*llm.Toolbox
"frost is usually around May 10\"), add it here. notes replaces the WHOLE text: take the "+ "frost is usually around May 10\"), add it here. notes replaces the WHOLE text: take the "+
"current notes from describe_garden, add the new line, and pass all of it.", "current notes from describe_garden, add the new line, and pass all of it.",
a.updateGarden), a.updateGarden),
llm.DefineTool("create_garden",
"Start a NEW garden for the user — a different place (the front yard, the allotment), "+
"sized in cm and owned by them. Not for a season plan: a plan is a copy_garden of the "+
"real garden. The new garden opens from the gardens list; this conversation stays with "+
"the one it started in.",
a.createGarden),
llm.DefineTool("update_seed_lot",
"Correct a seed lot the user recorded — quantity, unit, vendor, link, purchase date, "+
"packed-for year, germination rate or notes: \"it was three packets, not two\". Needs "+
"the lot's id and version from list_seed_lots. Only the fields you pass change; the "+
"plant a lot is for cannot change (record a new lot instead).",
a.updateSeedLot),
llm.DefineTool("delete_seed_lot",
"Delete a seed lot the user recorded, by its id from list_seed_lots. Plantings attributed "+
"to it stay in the garden, just no longer linked to a purchase. Permanent — seed lots "+
"are not in the undo history — so delete only the lot the user pointed at.",
a.deleteSeedLot),
llm.DefineTool("delete_plant",
"Delete a plant from the user's own catalog — a duplicate or a mistake. Refused while "+
"anything is planted with it (in any garden, past seasons included) or a seed lot "+
"records it; say so rather than removing those to make it deletable. Built-in plants "+
"can't be deleted. Permanent — the catalog is not in the undo history.",
a.deletePlant),
), a ), a
} }
@@ -834,3 +858,76 @@ func (a *adapter) updateGarden(ctx context.Context, args struct {
} }
return a.svc.UpdateGarden(ctx, a.actor, args.GardenID, in, args.Version) return a.svc.UpdateGarden(ctx, a.actor, args.GardenID, in, args.Version)
} }
func (a *adapter) createGarden(ctx context.Context, args struct {
Name string `json:"name" description:"the garden's name"`
WidthCM float64 `json:"widthCm" description:"optional width in cm (default 1000)"`
HeightCM float64 `json:"heightCm" description:"optional height in cm (default 1000)"`
UnitPref string `json:"units" description:"optional: metric (default) | imperial — how the gardener wants lengths shown"`
Notes string `json:"notes" description:"optional standing notes about the place — zone, frost dates, soil"`
GridSizeCM float64 `json:"gridSizeCm" description:"optional editor grid spacing in cm (default 100)"`
SnapToGrid bool `json:"snapToGrid" description:"optional: snap objects to that grid"`
}) (any, error) {
return a.svc.CreateGarden(ctx, a.actor, service.GardenInput{
Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM,
UnitPref: strings.ToLower(strings.TrimSpace(args.UnitPref)), Notes: args.Notes,
GridSizeCM: args.GridSizeCM, SnapToGrid: args.SnapToGrid,
})
}
func (a *adapter) updateSeedLot(ctx context.Context, args struct {
LotID int64 `json:"lotId" description:"seed lot to correct (its id from list_seed_lots)"`
Version int64 `json:"version" description:"the lot's current version (from list_seed_lots)"`
Quantity *float64 `json:"quantity" description:"optional corrected quantity, in the lot's unit"`
Unit *string `json:"unit" description:"optional unit: seeds | grams | ounces | packets | bulbs | plants"`
Vendor *string `json:"vendor" description:"optional vendor name"`
SourceURL *string `json:"sourceUrl" description:"optional http(s) link to where it was bought; empty clears it"`
PurchasedAt *string `json:"purchasedAt" description:"optional purchase date, YYYY-MM-DD"`
PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"`
GerminationPct *float64 `json:"germinationPct" description:"optional germination rate, 0100"`
Notes *string `json:"notes" description:"optional replacement notes"`
}) (any, error) {
if args.Quantity == nil && args.Unit == nil && args.Vendor == nil && args.SourceURL == nil &&
Review

🟡 updateSeedLot 'nothing to change' guard hand-enumerates every field; must be kept in sync with the args struct when new fields are added

maintainability · flagged by 1 model

  • internal/agent/tools.go:890-892 — The "nothing to change" guard in updateSeedLot hand-enumerates every nullable field (args.Quantity == nil && args.Unit == nil && …). This faithfully mirrors the established pattern in updateGarden (tools.go:823-825), so it is consistent with the surrounding code, but it is a maintenance hazard: the next field added to the updateSeedLot args struct / SeedLotPatch must also be appended to this boolean chain in lockstep, or a valid single-(new-)field…

🪰 Gadfly · advisory

🟡 **updateSeedLot 'nothing to change' guard hand-enumerates every field; must be kept in sync with the args struct when new fields are added** _maintainability · flagged by 1 model_ - `internal/agent/tools.go:890-892` — The "nothing to change" guard in `updateSeedLot` hand-enumerates every nullable field (`args.Quantity == nil && args.Unit == nil && …`). This faithfully mirrors the established pattern in `updateGarden` (tools.go:823-825), so it is consistent with the surrounding code, but it is a maintenance hazard: the next field added to the `updateSeedLot` args struct / `SeedLotPatch` must also be appended to this boolean chain in lockstep, or a valid single-(new-)field… <sub>🪰 Gadfly · advisory</sub>
args.PurchasedAt == nil && args.PackedForYear == nil && args.GerminationPct == nil && args.Notes == nil {
return nil, fmt.Errorf("%w: say what to change about the lot", domain.ErrInvalidInput)
}
purchased := args.PurchasedAt
if purchased != nil {
on, err := parseDay(*purchased)
if err != nil {
return nil, err
}
purchased = &on
}
patch := service.SeedLotPatch{
Vendor: args.Vendor, SourceURL: args.SourceURL, Quantity: args.Quantity, Unit: args.Unit, Notes: args.Notes,
SetPurchasedAt: purchased != nil, PurchasedAt: purchased,
SetPackedForYear: args.PackedForYear != nil, PackedForYear: args.PackedForYear,
SetGerminationPct: args.GerminationPct != nil, GerminationPct: args.GerminationPct,
}
return a.svc.UpdateSeedLot(ctx, a.actor, args.LotID, patch, args.Version)
}
func (a *adapter) deleteSeedLot(ctx context.Context, args struct {
LotID int64 `json:"lotId" description:"seed lot to delete (its id from list_seed_lots)"`
}) (any, error) {
if err := a.svc.DeleteSeedLot(ctx, a.actor, args.LotID); err != nil {
return nil, err
}
return map[string]any{"deleted": args.LotID}, nil
}
func (a *adapter) deletePlant(ctx context.Context, args struct {
PlantID int64 `json:"plantId" description:"plant to delete from the user's catalog (from find_plant)"`
}) (any, error) {
err := a.svc.DeletePlant(ctx, a.actor, args.PlantID)
if errors.Is(err, domain.ErrPlantInUse) {
// The sentinel's text is for a log line; the model needs to know what
// to tell the person, and what not to do about it.
return nil, fmt.Errorf("%w: the plant is still used — by plantings (past seasons count) or a seed lot — so it stays; tell the user rather than removing those", domain.ErrPlantInUse)
Review

🟡 Wrapping the same sentinel error with %w is an unusual pattern

maintainability · flagged by 1 model

  • internal/agent/tools.go:927 — The deletePlant adapter wraps domain.ErrPlantInUse with %w and the same sentinel, layering the error. The comment says the sentinel text is “for a log line,” but wrapping the same sentinel rather than returning a plain string or a distinct error type is an unusual pattern that could confuse future maintainers about error identity. A plain fmt.Errorf("...") (without %w) or a dedicated agent-facing error would be cleaner, since the agent layer is intenti…

🪰 Gadfly · advisory

🟡 **Wrapping the same sentinel error with %w is an unusual pattern** _maintainability · flagged by 1 model_ - `internal/agent/tools.go:927` — The `deletePlant` adapter wraps `domain.ErrPlantInUse` with `%w` and the same sentinel, layering the error. The comment says the sentinel text is “for a log line,” but wrapping the same sentinel rather than returning a plain string or a distinct error type is an unusual pattern that could confuse future maintainers about error identity. A plain `fmt.Errorf("...")` (without `%w`) or a dedicated agent-facing error would be cleaner, since the agent layer is intenti… <sub>🪰 Gadfly · advisory</sub>
}
if err != nil {
return nil, err
}
return map[string]any{"deleted": args.PlantID}, nil
}
+111 -16
View File
@@ -123,6 +123,34 @@ func TestToolboxScenario(t *testing.T) {
} }
} }
// toolCaller returns the two ways a test drives a toolbox: call executes a
// tool with JSON-encoded args and hands back the raw result (for asserting on
// refusals), and mustCall fails the test on a tool error and decodes the
// result into `into` when one is given.
func toolCaller(t *testing.T, ctx context.Context, box *llm.Toolbox) (
call func(name string, args any) llm.ToolResult,
mustCall func(name string, args any, into any),
) {
t.Helper()
call = func(name string, args any) llm.ToolResult {
t.Helper()
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
}
mustCall = func(name string, args any, into any) {
t.Helper()
res := call(name, args)
if res.IsError {
t.Fatalf("%s: %s", name, res.Content)
}
if into != nil {
if err := json.Unmarshal([]byte(res.Content), into); err != nil {
t.Fatalf("decode %s: %v (%s)", name, err, res.Content)
}
}
}
return call, mustCall
}
func mustJSON(t *testing.T, v any) json.RawMessage { func mustJSON(t *testing.T, v any) json.RawMessage {
t.Helper() t.Helper()
b, err := json.Marshal(v) b, err := json.Marshal(v)
@@ -750,22 +778,7 @@ func TestRecordKeepingTools(t *testing.T) {
svc, owner := newAgentTestService(t) svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner, "2026-08-23") box := NewToolbox(svc, owner, "2026-08-23")
call := func(name string, args any) llm.ToolResult { call, mustCall := toolCaller(t, ctx, box)
t.Helper()
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
}
mustCall := func(name string, args any, into any) {
t.Helper()
res := call(name, args)
if res.IsError {
t.Fatalf("%s: %s", name, res.Content)
}
if into != nil {
if err := json.Unmarshal([]byte(res.Content), into); err != nil {
t.Fatalf("decode %s: %v (%s)", name, err, res.Content)
}
}
}
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Home", WidthCM: 1200, HeightCM: 800, Notes: "Zone 6a."}) g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Home", WidthCM: 1200, HeightCM: 800, Notes: "Zone 6a."})
if err != nil { if err != nil {
2
@@ -931,3 +944,85 @@ func TestRecordKeepingTools(t *testing.T) {
t.Errorf("journal after the delete = %+v, want empty", journal.Entries) t.Errorf("journal after the delete = %+v, want empty", journal.Entries)
} }
} }
Review

🟡 mustCall closure copied verbatim from TestRecordKeepingTools — candidate for a file-level helper

maintainability · flagged by 1 model

  • mustCall closure duplicated verbatim between TestRecordKeepingTools and TestCatalogAndGardenTools (internal/agent/tools_test.go:947). The body is identical to the one introduced at line 757 — same signature, same t.Fatalf messages, same json.Unmarshal call. The call closure (capturing ctx and box) appears multiple times across the file and was never extracted, so this follows the existing style, but mustCall is a richer wrapper that a third test would copy again. Extrac…

🪰 Gadfly · advisory

🟡 **mustCall closure copied verbatim from TestRecordKeepingTools — candidate for a file-level helper** _maintainability · flagged by 1 model_ - **`mustCall` closure duplicated verbatim between `TestRecordKeepingTools` and `TestCatalogAndGardenTools`** (`internal/agent/tools_test.go:947`). The body is identical to the one introduced at line 757 — same signature, same `t.Fatalf` messages, same `json.Unmarshal` call. The `call` closure (capturing `ctx` and `box`) appears multiple times across the file and was never extracted, so this follows the existing style, but `mustCall` is a richer wrapper that a third test would copy again. Extrac… <sub>🪰 Gadfly · advisory</sub>
// TestCatalogAndGardenTools — the catalog side of the record: correct or delete
// a seed lot, delete a duplicate plant (refused while anything references it,
// in words the model can pass on), and start a new garden with sane defaults.
func TestCatalogAndGardenTools(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner, "2026-08-23")
call, mustCall := toolCaller(t, ctx, box)
// --- create_garden: defaults, then an imperial one with notes.
var g domain.Garden
mustCall("create_garden", map[string]any{"name": "Front yard"}, &g)
if g.ID == 0 || g.WidthCM != 1000 || g.HeightCM != 1000 || g.UnitPref != domain.UnitMetric || g.MyRole != domain.RoleOwner {
t.Errorf("default garden = %+v", g)
}
var imperial domain.Garden
mustCall("create_garden", map[string]any{"name": "Allotment", "widthCm": 609.6, "heightCm": 304.8, "units": "Imperial", "notes": "Zone 6a"}, &imperial)
if imperial.UnitPref != domain.UnitImperial || imperial.Notes != "Zone 6a" || imperial.WidthCM != 609.6 {
t.Errorf("imperial garden = %+v", imperial)
}
if r := call("create_garden", map[string]any{"name": " "}); !r.IsError {
t.Error("a garden with a blank name was created")
}
// --- seed lots: record, correct, delete.
cp := mustPlant(t, svc, owner, "Cherokee Purple", 60, "🍅")
var lot domain.SeedLot
mustCall("record_seed_lot", map[string]any{"plantId": cp.ID, "quantity": 2, "unit": "packets", "vendor": "Baker Creek"}, &lot)
if r := call("update_seed_lot", map[string]any{"lotId": lot.ID, "version": lot.Version}); !r.IsError || !strings.Contains(r.Content, "what to change") {
t.Errorf("update_seed_lot with nothing to change = %q", r.Content)
}
if r := call("update_seed_lot", map[string]any{"lotId": lot.ID, "version": lot.Version, "purchasedAt": "last spring"}); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") {
t.Errorf("a prose purchase date = %q, want a refusal naming the format", r.Content)
}
mustCall("update_seed_lot", map[string]any{"lotId": lot.ID, "version": lot.Version, "quantity": 3, "packedForYear": 2026, "purchasedAt": "2026-02-01"}, &lot)
if lot.Quantity != 3 || lot.Remaining != 3 || lot.Vendor != "Baker Creek" || lot.PackedForYear == nil || *lot.PackedForYear != 2026 || lot.PurchasedAt == nil || *lot.PurchasedAt != "2026-02-01" {
t.Errorf("corrected lot = %+v; want quantity 3 (all remaining), vendor kept, year and date set", lot)
}
// --- delete_plant: refused while the lot references it, in plain words.
if r := call("delete_plant", map[string]any{"plantId": cp.ID}); !r.IsError || !strings.Contains(r.Content, "seed lot") {
t.Errorf("delete_plant with a lot = %q, want a refusal that names the lot", r.Content)
}
mustCall("delete_seed_lot", map[string]any{"lotId": lot.ID}, nil)
var lots []domain.SeedLot
mustCall("list_seed_lots", map[string]any{"plantId": cp.ID}, &lots)
if len(lots) != 0 {
t.Errorf("lots after delete = %+v, want none", lots)
}
// ...and while a planting (even a pulled one) references it.
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "Bed", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200})
if err != nil {
t.Fatalf("bed: %v", err)
}
var plop domain.Planting
mustCall("place_planting", map[string]any{"objectId": bed.ID, "plantId": cp.ID, "xCm": 0, "yCm": 0}, &plop)
mustCall("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}, nil)
if r := call("delete_plant", map[string]any{"plantId": cp.ID}); !r.IsError || !strings.Contains(r.Content, "past seasons") {
t.Errorf("delete_plant with a pulled planting = %q, want a refusal that says past seasons count", r.Content)
}
if err := svc.DeletePlanting(ctx, owner, plop.ID); err != nil {
t.Fatalf("hard delete: %v", err)
}
mustCall("delete_plant", map[string]any{"plantId": cp.ID}, nil)
var matches []struct{ ID int64 }
mustCall("find_plant", map[string]any{"query": "Cherokee Purple"}, &matches)
for _, m := range matches {
if m.ID == cp.ID {
t.Error("the deleted plant is still in the catalog")
}
}
// Built-ins are not the user's to delete.
mustCall("find_plant", map[string]any{"query": "tomato"}, &matches)
if len(matches) == 0 {
t.Fatal("no built-in tomato to test with")
}
if r := call("delete_plant", map[string]any{"plantId": matches[0].ID}); !r.IsError {
t.Error("a built-in plant was deleted")
}
}
+29
View File
@@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"math" "math"
"strings" "strings"
"time"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
) )
@@ -614,6 +615,11 @@ type DescribeGroup struct {
// DaysToMaturity is the plant's, when the catalog knows it — with PlantedAt, // DaysToMaturity is the plant's, when the catalog knows it — with PlantedAt,
// enough to say when the harvest is due. // enough to say when the harvest is due.
DaysToMaturity *int `json:"daysToMaturity,omitempty"` DaysToMaturity *int `json:"daysToMaturity,omitempty"`
// ReadyAround is that arithmetic done: planting date plus days to maturity
// for the plops still in the ground, as one date or "first…last". Absent
// when the catalog has no days for the plant or nothing is dated. The
// model was asked "what can I pick this week?" and got the sums wrong.
ReadyAround string `json:"readyAround,omitempty"`
// Removed counts the plops in the group that have been pulled, and RemovedAt // Removed counts the plops in the group that have been pulled, and RemovedAt
// is when ("first…last" when they differ). Only a season view lists pulled // is when ("first…last" when they differ). Only a season view lists pulled
// plops, so both are absent from a describe of what is growing now. // plops, so both are absent from a describe of what is growing now.
@@ -746,6 +752,15 @@ func describeGroups(o *domain.GardenObject, plops []domain.Planting, plantByID m
DaysToMaturity: plant.DaysToMaturity, DaysToMaturity: plant.DaysToMaturity,
RemovedAt: dateRangeOf(members, func(pl domain.Planting) *string { return pl.RemovedAt }), RemovedAt: dateRangeOf(members, func(pl domain.Planting) *string { return pl.RemovedAt }),
} }
if plant.DaysToMaturity != nil {
days := *plant.DaysToMaturity
g.ReadyAround = dateRangeOf(members, func(pl domain.Planting) *string {
if pl.RemovedAt != nil {
return nil // pulled already; its harvest is not ahead of us
}
return readyDate(pl.PlantedAt, days)
})
}
for _, pl := range members { for _, pl := range members {
g.Plants += effectiveCount(pl) g.Plants += effectiveCount(pl)
if pl.RemovedAt != nil { if pl.RemovedAt != nil {
@@ -778,6 +793,20 @@ func describePlanting(pl domain.Planting, plantName string) DescribePlanting {
return d return d
} }
// readyDate is plantedAt plus days to maturity, or nil when the plop is undated
// (or its date is not one the store should have accepted).
func readyDate(plantedAt *string, days int) *string {
if plantedAt == nil || *plantedAt == "" {
return nil
}
t, err := time.Parse(dateLayout, *plantedAt)
if err != nil {
return nil
}
d := t.AddDate(0, 0, days).Format(dateLayout)
return &d
}
// effectiveCount is the plant count a plop stands for: its explicit count, else // effectiveCount is the plant count a plop stands for: its explicit count, else
// the one derived from its area and the plant's spacing. // the one derived from its area and the plant's spacing.
func effectiveCount(pl domain.Planting) int { func effectiveCount(pl domain.Planting) int {
+64
View File
@@ -920,3 +920,67 @@ func TestDescribeGardenByYear(t *testing.T) {
t.Errorf("describe(20026) err = %v, want ErrInvalidInput", err) t.Errorf("describe(20026) err = %v, want ErrInvalidInput", err)
} }
} }
// TestDescribeGroupSaysWhenReady — "what can I pick this week?" is a lookup
// when the group carries the date, and a sum the model gets wrong when it
// doesn't. Planting date plus days to maturity, for the plops still in the
// ground; nothing for a plant the catalog has no days for.
func TestDescribeGroupSaysWhenReady(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Harvest", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed := seedFillBed(t, s, owner, g.ID, 400, 400)
sixty := 60
radish, err := s.CreatePlant(ctx, owner, PlantInput{Name: "Radish", Category: domain.CategoryVegetable, SpacingCM: 5, Color: "#c33", Icon: "🌱", DaysToMaturity: &sixty})
if err != nil {
t.Fatalf("radish: %v", err)
}
mint := seedNamedPlant(t, s, owner, "Mint", 30) // no days to maturity
plant := func(plantID int64, x float64, on string) *domain.Planting {
t.Helper()
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plantID, XCM: x, YCM: 0, RadiusCM: 10, PlantedAt: &on})
if err != nil {
t.Fatalf("plant: %v", err)
}
return pl
}
plant(radish.ID, -100, "2026-05-01")
plant(radish.ID, 0, "2026-05-11")
pulled := plant(radish.ID, 100, "2026-03-01")
on := "2026-04-20"
if _, err := s.RemovePlanting(ctx, owner, pulled.ID, pulled.Version, &on); err != nil {
t.Fatalf("pull: %v", err)
}
plant(mint.ID, 150, "2026-05-01")
groups := func(year *int) map[string]DescribeGroup {
t.Helper()
desc, err := s.DescribeGarden(ctx, owner, g.ID, year)
if err != nil {
t.Fatalf("describe: %v", err)
}
out := map[string]DescribeGroup{}
for _, gr := range desc.Objects[0].Plantings {
out[gr.Plant] = gr
}
return out
}
now := groups(nil)
if got := now["Radish"].ReadyAround; got != "2026-06-30…2026-07-10" {
t.Errorf("radish readyAround = %q, want %q", got, "2026-06-30…2026-07-10")
}
if got := now["Mint"].ReadyAround; got != "" {
t.Errorf("mint has no days to maturity but readyAround = %q", got)
}
// The season view lists the pulled radish too, but its harvest is behind
// us: the range is still the two still growing.
y := 2026
if got := groups(&y)["Radish"]; got.Removed != 1 || got.ReadyAround != "2026-06-30…2026-07-10" {
t.Errorf("2026 radish = removed %d, readyAround %q; want 1 and the live plops' range", got.Removed, got.ReadyAround)
}
}
+4
View File
@@ -122,8 +122,12 @@ const TOOL_LABELS: Record<string, string> = {
undo_change: 'Undoing a change', undo_change: 'Undoing a change',
list_seed_lots: 'Checking your seed', list_seed_lots: 'Checking your seed',
record_seed_lot: 'Recording seed', record_seed_lot: 'Recording seed',
update_seed_lot: 'Correcting a seed lot',
delete_seed_lot: 'Deleting a seed lot',
delete_plant: 'Removing a plant from your catalog',
copy_garden: 'Copying the garden', copy_garden: 'Copying the garden',
update_garden: 'Changing the garden', update_garden: 'Changing the garden',
create_garden: 'Starting a garden',
} }
export function describeStep(step: AgentStep): string { export function describeStep(step: AgentStep): string {