Agent: catalog and garden tools, and a ready date on every describe group #130
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, 0–100"`
|
||||||
|
Notes *string `json:"notes" description:"optional replacement notes"`
|
||||||
|
}) (any, error) {
|
||||||
|
if args.Quantity == nil && args.Unit == nil && args.Vendor == nil && args.SourceURL == nil &&
|
||||||
|
|
|||||||
|
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)
|
||||||
|
gitea-actions
commented
🟡 Wrapping the same sentinel error with %w is an unusual pattern maintainability · flagged by 1 model
🪰 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
@@ -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 {
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gitea-actions
commented
🟡 mustCall closure copied verbatim from TestRecordKeepingTools — candidate for a file-level helper maintainability · flagged by 1 model
🪰 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user
🟡 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 inupdateSeedLothand-enumerates every nullable field (args.Quantity == nil && args.Unit == nil && …). This faithfully mirrors the established pattern inupdateGarden(tools.go:823-825), so it is consistent with the surrounding code, but it is a maintenance hazard: the next field added to theupdateSeedLotargs struct /SeedLotPatchmust also be appended to this boolean chain in lockstep, or a valid single-(new-)field…🪰 Gadfly · advisory