Address #129 review: one date path, ordered years, trimmed dates
Build image / build-and-push (push) Successful in 8s

- Every dated tool argument now goes through day() → parseDay, so a prose
  date on remove_planting / remove_plantings / clear_object (and place,
  fill, journal) is refused with the same message as update_planting's.
- parseDay's trimmed value is what gets stored, not the raw argument.
- list_years re-sorts after adding the gardener's year instead of
  prepending it: newest first holds when their year is the oldest.
- ClearSeedLot matches its JSON tag; the label-clearing branch says why nil.
- The prompt says the notes are facts to plan with, not instructions.

Left as is: update_garden's read-then-overlay merge. UpdateGarden is
whole-row by design (the REST PATCH sends every field too), and a service
GardenPatch would duplicate gardenFromInput's validation for one caller.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-08-23 02:03:44 -04:00
co-authored by Claude Fable 5
parent deec7bb917
commit 6aa08ddbe7
3 changed files with 107 additions and 40 deletions
+33 -7
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
"sort"
"strings"
"testing"
@@ -721,17 +722,20 @@ func TestToolsFromTheLiveSweep(t *testing.T) {
// (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, err := a.day(""); d != nil || err != nil {
t.Errorf("no day at all → %v, %v; want nil (the service default)", d, err)
}
if d := a.day(" 2026-01-02 "); d == nil || *d != "2026-01-02" {
t.Errorf("an explicit day → %v, want it trimmed", d)
if d, err := a.day(" 2026-01-02 "); err != nil || d == nil || *d != "2026-01-02" {
t.Errorf("an explicit day → %v, %v; want it trimmed", d, err)
}
if d, err := a.day("Tuesday"); err == nil || !strings.Contains(err.Error(), "YYYY-MM-DD") {
t.Errorf("a prose day → %v, %v; want a refusal naming the format", d, err)
}
a.today = "2026-08-22"
if d := a.day(""); d == nil || *d != "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" {
if d, _ := a.day("2026-05-20"); d == nil || *d != "2026-05-20" {
t.Errorf("an explicit day beats the default: %v", d)
}
}
@@ -822,7 +826,20 @@ func TestRecordKeepingTools(t *testing.T) {
t.Error("count and clearCount together were accepted")
}
// --- remove_planting on the day the gardener said, not today.
// A padded date is stored clean, not refused downstream with a bare error.
mustCall("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "plantedAt": " 2026-05-20 "}, &plop)
if plop.PlantedAt == nil || *plop.PlantedAt != "2026-05-20" {
t.Errorf("padded plantedAt stored as %v", plop.PlantedAt)
}
// --- remove_planting on the day the gardener said, not today; a prose day
// is refused the same way every dated tool refuses one.
for _, tool := range []string{"remove_planting", "remove_plantings", "clear_object"} {
args := map[string]any{"plantingId": plop.ID, "version": plop.Version, "objectId": bed.ID, "plantId": beet.ID, "removedAt": "Aug 1"}
if r := call(tool, args); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") {
t.Errorf("%s with a prose removedAt = %q, want a refusal naming the format", tool, r.Content)
}
}
mustCall("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "removedAt": "2026-08-01"}, &plop)
if plop.RemovedAt == nil || *plop.RemovedAt != "2026-08-01" {
t.Errorf("removedAt = %v, want the harvest date 2026-08-01", plop.RemovedAt)
@@ -834,6 +851,15 @@ func TestRecordKeepingTools(t *testing.T) {
if len(years.Years) == 0 || years.Years[0] != 2026 {
t.Errorf("years = %v, want 2026 first", years.Years)
}
// A gardener whose local year is behind the data's gets it listed, in
// order — newest first holds even when theirs is the oldest.
raw := NewToolbox(svc, owner, "2024-12-31").Execute(ctx, llm.ToolCall{ID: "3", Name: "list_years", Arguments: mustJSON(t, map[string]any{"gardenId": g.ID})})
if err := json.Unmarshal([]byte(raw.Content), &years); err != nil || raw.IsError {
t.Fatalf("list_years for 2024: %v %s", err, raw.Content)
}
if !sort.SliceIsSorted(years.Years, func(i, j int) bool { return years.Years[i] > years.Years[j] }) || years.Years[len(years.Years)-1] != 2024 {
t.Errorf("years for a 2024 gardener = %v, want newest first with 2024 last", years.Years)
}
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
if len(desc.Objects[0].Plantings) != 0 {
t.Errorf("the live describe still lists the pulled beet: %+v", desc.Objects[0].Plantings)