Agent: undo for real, past seasons, and tools that correct the record
Six tools the live assistant kept needing and a prompt that knows about them: - undo_change wraps RevertChangeSet(source=agent). A revert is its own change set, so Run reports the last one as the turn's handle when the turn changed nothing else — an undo-only reply keeps its "Undo this", which is now a redo. - describe_garden takes a year: the season view (GardenFull(year)), pulled plops included, with removed/removedAt per group and per plop; list_years says which years have records. Rotation questions finally have data. - update_planting corrects a plop's date, count, label, radius or seed lot in place; remove_planting, remove_plantings and clear_object take a removedAt so a harvest can be backdated. - update_journal_entry / delete_journal_entry correct a note instead of stacking a contradicting one. - update_garden renames/resizes/re-units a garden and rewrites its notes — and the notes now go into the system prompt as the gardener's standing facts, so "remember we're in zone 6a" persists across conversations. describe_garden also reports the garden's notes, version and grid, which the new tools need. Prompt, CLAUDE.md and DESIGN.md updated to match; UI step labels for the new tools. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -173,10 +173,22 @@ Conventions that follow from it:
|
||||
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`.
|
||||
With a `year` it is the season view (`GardenFull(year)`: every plop whose
|
||||
time in the ground overlapped the year, pulled ones included, with `removed`
|
||||
/ `removedAt` per group) — that is how "what was here last year?" is answered.
|
||||
- **The assistant undoes through `undo_change`, never by claiming.** Asked to
|
||||
"undo the beets", the live model once replied "Done!" and changed nothing.
|
||||
`undo_change` wraps `RevertChangeSet(source=agent)`; the prompt still forbids
|
||||
claiming a change no tool made. A revert is its own change set (it points at
|
||||
what it undid), so it never joins the turn's scope — `Run` reports the last
|
||||
revert as the turn's `ChangeSetID` when the turn made no other change, so the
|
||||
reply's "Undo this" is a redo. Keep that fallback: without it an undo-only
|
||||
turn is the one change in the conversation with no undo button.
|
||||
- **Garden notes are the assistant's memory.** `systemPrompt` quotes
|
||||
`Garden.Notes` (owner-written, `%q`) as standing context, and `update_garden`
|
||||
is how the model adds "we're in zone 6a" to them. Notes are replaced whole,
|
||||
so the tool description tells the model to merge; don't add a second store
|
||||
for "things the assistant remembers".
|
||||
|
||||
- **Request deadlines are extended through `responseController(c)`, never
|
||||
`http.NewResponseController(c.Writer)`.** A controller built in a handler
|
||||
|
||||
@@ -149,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. 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.
|
||||
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.
|
||||
|
||||
## Deliberate v1 limits
|
||||
|
||||
|
||||
@@ -120,13 +120,15 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, toda
|
||||
result *agent.Result
|
||||
runErr error
|
||||
truncErr bool
|
||||
tools *adapter
|
||||
)
|
||||
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
|
||||
Source: domain.SourceAgent,
|
||||
Summary: turnSummary(message),
|
||||
AgentRunID: &runID,
|
||||
}, func(ctx context.Context) error {
|
||||
box := NewToolbox(r.svc, actorID, today)
|
||||
var box *llm.Toolbox
|
||||
box, tools = newToolbox(r.svc, actorID, today)
|
||||
a := agent.New(r.model, systemPrompt(garden, today),
|
||||
agent.WithMaxSteps(maxSteps),
|
||||
agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats),
|
||||
@@ -156,6 +158,12 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, toda
|
||||
turn := &Turn{Truncated: truncErr}
|
||||
if changeSet != nil {
|
||||
turn.ChangeSetID = &changeSet.ID
|
||||
} else if tools != nil {
|
||||
// An undo is its own change set, outside the turn's scope (it has to
|
||||
// point back at what it reverted). A turn that did nothing BUT undo
|
||||
// would otherwise come back with no handle, and the reply would lose
|
||||
// the "Undo this" that every other change gets — here it is a redo.
|
||||
turn.ChangeSetID = tools.lastRevert()
|
||||
}
|
||||
if result != nil {
|
||||
turn.Reply = result.Output
|
||||
@@ -216,7 +224,8 @@ func turnSummary(message string) string {
|
||||
}
|
||||
|
||||
// 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 gardener's standing notes, 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
|
||||
@@ -226,6 +235,11 @@ func turnSummary(message string) string {
|
||||
// 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.
|
||||
//
|
||||
// The garden's notes are the assistant's memory. They are the owner's own text
|
||||
// (only the owner can edit them), so they are given as background the gardener
|
||||
// wrote — zone, frost dates, soil, how they like things done — and update_garden
|
||||
// is how the assistant adds to them when told something worth keeping.
|
||||
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)
|
||||
@@ -234,10 +248,19 @@ func systemPrompt(g *domain.Garden, today string) string {
|
||||
"(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)
|
||||
}
|
||||
notes := "The gardener has written no notes about this garden yet."
|
||||
if n := strings.TrimSpace(g.Notes); n != "" {
|
||||
// %q: the notes are the gardener's own words, but they are data, not
|
||||
// prompt — quoting keeps a line in them from reading as an instruction
|
||||
// to someone the garden is shared with.
|
||||
notes = "The gardener's notes about this garden — their standing facts about the place, to use as " +
|
||||
"context (zone, frost dates, soil, sun, how they like things done): " + fmt.Sprintf("%q", n)
|
||||
}
|
||||
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), %s. Today is %s — the gardener's local date.
|
||||
%s
|
||||
%s
|
||||
|
||||
Conventions you cannot guess and must not assume:
|
||||
- Every measurement a tool takes or returns is in CENTIMETERS.
|
||||
@@ -263,15 +286,29 @@ How to work:
|
||||
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.
|
||||
- Past seasons: describe_garden with a year shows what was in each bed that year, pulled plants
|
||||
included; list_years says which years have records. Check it before advising on rotation or
|
||||
answering "what was here last year?" — do not guess from what is growing now.
|
||||
- To undo something — yours or anyone's — find the change in read_history and call undo_change
|
||||
with its id. It reverts as a new change that can itself be undone. "Undo the beets" means the
|
||||
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.
|
||||
- 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
|
||||
update_garden instead of removing and re-adding.
|
||||
- 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
|
||||
(keeping what is already there), and say you did. You will see those notes in every later
|
||||
conversation.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
say so plainly — never describe a change you did not make, and never say something is undone
|
||||
unless undo_change did it.
|
||||
- Every reply of yours that changed the garden has an "Undo this" button under it, and the
|
||||
History panel can revert any change; mention that when it helps.
|
||||
- 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.
|
||||
@@ -283,5 +320,5 @@ How to behave:
|
||||
// %q throughout for the garden's name: any editor can rename a garden, and
|
||||
// a name is data, not prompt — quoting keeps a newline or a stray quote
|
||||
// in it from reading as a new instruction.
|
||||
g.Name, g.ID, size, today, units, fmt.Sprintf("%q", g.Name+" — <year>"))
|
||||
g.Name, g.ID, size, today, units, notes, fmt.Sprintf("%q", g.Name+" — <year>"))
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ func TestPartialWorkSurvivesATimeout(t *testing.T) {
|
||||
if _, conflicts, rerr := svc.RevertChangeSet(ctx, owner, after[0].ID, domain.SourceUI); rerr != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", rerr, conflicts)
|
||||
}
|
||||
o, _ := svc.DescribeGarden(ctx, owner, g.ID)
|
||||
o, _ := svc.DescribeGarden(ctx, owner, g.ID, nil)
|
||||
if len(o.Objects) > 0 && o.Objects[0].XCM != bed.XCM {
|
||||
t.Errorf("undo left the bed at %v, want %v", o.Objects[0].XCM, bed.XCM)
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func TestSystemPromptKnowsTheDayAndTheGardenersUnits(t *testing.T) {
|
||||
"feet and inches",
|
||||
"24.0 x 24.0 ft",
|
||||
"never describe a change you did not make",
|
||||
"You cannot undo",
|
||||
"undo_change",
|
||||
"Undo this",
|
||||
"rather than clearing beds on a guess",
|
||||
"not to narrate your own planting",
|
||||
@@ -495,8 +495,96 @@ func TestTurnOnAnotherGardenFilesHistoryThere(t *testing.T) {
|
||||
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)
|
||||
d, _ := svc.DescribeGarden(ctx, owner, b.ID, nil)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnThatOnlyUndoesIsItselfUndoable — a revert is its own change set,
|
||||
// outside the turn's scope, so a turn that did nothing but undo would come back
|
||||
// with no change of its own; the reply would then be the one change in the
|
||||
// conversation without an "Undo this". It gets the revert instead — a redo.
|
||||
func TestTurnThatOnlyUndoesIsItselfUndoable(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)
|
||||
}
|
||||
beets := mustPlant(t, svc, owner, "Beets", 10, "")
|
||||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
// The beets went in by hand in the editor: the change the person wants undone.
|
||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", beets.ID, nil, service.FillClump, nil); err != nil {
|
||||
t.Fatalf("plant beets: %v", err)
|
||||
}
|
||||
history, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||
if err != nil || len(history) == 0 {
|
||||
t.Fatalf("history: %v (%d entries)", err, len(history))
|
||||
}
|
||||
planted := history[0]
|
||||
|
||||
r := scriptedRunner(t, svc,
|
||||
toolCall("read_history", map[string]any{"gardenId": g.ID}),
|
||||
toolCall("undo_change", map[string]any{"changeSetId": planted.ID}),
|
||||
fake.Reply("Undone — the beets are out of the bed again."),
|
||||
)
|
||||
turn, err := r.Run(ctx, owner, g.ID, "undo the beets", "", nil, nil)
|
||||
if 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.Fatalf("%d beets still in the bed after the undo", len(full.Plantings))
|
||||
}
|
||||
after, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||
if len(after) != len(history)+1 {
|
||||
t.Fatalf("history grew by %d, want exactly the revert", len(after)-len(history))
|
||||
}
|
||||
revert := after[0]
|
||||
if revert.Source != domain.SourceAgent || revert.RevertsID == nil || *revert.RevertsID != planted.ID {
|
||||
t.Errorf("newest entry = %+v; want the agent's revert of %d", revert, planted.ID)
|
||||
}
|
||||
if turn.ChangeSetID == nil || *turn.ChangeSetID != revert.ID {
|
||||
t.Fatalf("turn.ChangeSetID = %v, want the revert %d so the reply can offer a redo", turn.ChangeSetID, revert.ID)
|
||||
}
|
||||
// And "Undo this" on that reply is a redo.
|
||||
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, *turn.ChangeSetID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("redo: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
full, _ = svc.GardenFull(ctx, owner, g.ID, nil)
|
||||
if len(full.Plantings) == 0 {
|
||||
t.Error("redoing the turn did not put the beets back")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemPromptCarriesTheGardenersNotes — the notes are the assistant's
|
||||
// memory: what the gardener told it about the place comes back on every turn,
|
||||
// quoted as their words rather than pasted as instructions.
|
||||
func TestSystemPromptCarriesTheGardenersNotes(t *testing.T) {
|
||||
with := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitMetric,
|
||||
Notes: "Zone 6a.\nLast frost \"usually\" May 10."}, "2026-08-23")
|
||||
for _, want := range []string{
|
||||
`"Zone 6a.\nLast frost \"usually\" May 10."`,
|
||||
"update_garden",
|
||||
"undo_change",
|
||||
"describe_garden with a year",
|
||||
} {
|
||||
if !strings.Contains(with, want) {
|
||||
t.Errorf("prompt is missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(with, "You cannot undo") {
|
||||
t.Error("the prompt still says the assistant cannot undo")
|
||||
}
|
||||
without := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitMetric}, "2026-08-23")
|
||||
if !strings.Contains(without, "no notes") {
|
||||
t.Error("a garden without notes doesn't say so")
|
||||
}
|
||||
}
|
||||
|
||||
+309
-21
@@ -3,7 +3,10 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
|
||||
@@ -24,19 +27,35 @@ import (
|
||||
// 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 {
|
||||
box, _ := newToolbox(svc, actorID, today)
|
||||
return box
|
||||
}
|
||||
|
||||
// newToolbox is NewToolbox plus the adapter behind it, which Run keeps hold of:
|
||||
// the adapter remembers what undo_change reverted, and a turn that only undid
|
||||
// something has no other handle to offer as its change.
|
||||
func newToolbox(svc *service.Service, actorID int64, today string) (*llm.Toolbox, *adapter) {
|
||||
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 grouped by plant — how many, roughly where, when they went in, "+
|
||||
"Summarize a garden: its dimensions, notes, version, objects (with sizes/positions/version), "+
|
||||
"and each object's 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, and xCm/yCm in the object's local frame so a "+
|
||||
"move can keep their layout); a large one (a grid-filled bed) does not — use "+
|
||||
"list_plantings for those, or act on the whole group with remove_plantings.",
|
||||
"version for move_planting/remove_planting/update_planting, and xCm/yCm in the object's "+
|
||||
"local frame so a move can keep their layout); a large one (a grid-filled bed) does not — "+
|
||||
"use list_plantings for those, or act on the whole group with remove_plantings. Without a "+
|
||||
"year it describes what is growing now; with one it is that season's view — every plop "+
|
||||
"whose time in the ground overlapped the year, pulled ones included, each group saying how "+
|
||||
"many were removed and when. That is how to answer \"what was in this bed last year?\" and "+
|
||||
"to check rotation before replanting. list_years says which years have data.",
|
||||
a.describeGarden),
|
||||
llm.DefineTool("list_years",
|
||||
"List the years this garden has planting records for, newest first — the years "+
|
||||
"describe_garden can show as a season view.",
|
||||
a.listYears),
|
||||
llm.DefineTool("list_plantings",
|
||||
"List one object's active plops one by one, each with its id, version, position (xCm/yCm "+
|
||||
"in the object's local frame), location, count and planting date — the detail "+
|
||||
@@ -71,21 +90,30 @@ func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox
|
||||
"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("update_planting",
|
||||
"Correct ONE plop's record without moving it: the date it was planted (plantedAt), its "+
|
||||
"plant count (count, or clearCount to go back to deriving it from area and spacing), "+
|
||||
"its label, its radius, or the seed lot it came from. Use for \"those tomatoes actually "+
|
||||
"went in on May 20\" or \"that clump is five plants\". Needs the plop's id and version "+
|
||||
"(describe_garden or list_plantings). Only the fields you pass change.",
|
||||
a.updatePlanting),
|
||||
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 (describe_garden or list_plantings). Use "+
|
||||
"for \"pull the basil out of the corner\".",
|
||||
"for \"pull the basil out of the corner\". Dated today unless removedAt says when it "+
|
||||
"actually came out (\"I harvested the garlic on Aug 1\").",
|
||||
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.",
|
||||
"one change). Use this rather than many remove_planting calls. Dated today unless "+
|
||||
"removedAt says when they actually came out.",
|
||||
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 "+
|
||||
"something else.",
|
||||
"something else. Dated today unless removedAt says when the bed was actually cleared.",
|
||||
a.clearObject),
|
||||
llm.DefineTool("find_plant",
|
||||
"Look up plants in the user's catalog by name or category, to get the plantId that "+
|
||||
@@ -115,16 +143,34 @@ func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox
|
||||
llm.DefineTool("read_journal",
|
||||
"Read back the garden's grow journal — the observations add_journal_entry wrote. "+
|
||||
"Narrow it with objectId (one bed), or a from/to date range (YYYY-MM-DD). Most "+
|
||||
"recently observed first. Use this to answer \"what did I note about the west bed?\" "+
|
||||
"or \"what happened last spring?\".",
|
||||
"recently observed first. Each entry carries the id and version that "+
|
||||
"update_journal_entry and delete_journal_entry need. Use this to answer \"what did I "+
|
||||
"note about the west bed?\" or \"what happened last spring?\".",
|
||||
a.readJournal),
|
||||
llm.DefineTool("update_journal_entry",
|
||||
"Correct a journal entry the user wrote — its text, or the date it describes — instead "+
|
||||
"of adding a second entry that contradicts the first: \"that note was about the "+
|
||||
"cucumbers, not the cantaloupe\". Needs the entry's id and version from read_journal. "+
|
||||
"Only the user's own entries can be edited.",
|
||||
a.updateJournalEntry),
|
||||
llm.DefineTool("delete_journal_entry",
|
||||
"Delete a journal entry, by its id from read_journal. This is permanent — the journal is "+
|
||||
"not in the undo history — so delete only the entry the user pointed at.",
|
||||
a.deleteJournalEntry),
|
||||
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.",
|
||||
"in an earlier conversation with you — newest first, with its id, 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, and to find the id undo_change needs.",
|
||||
a.readHistory),
|
||||
llm.DefineTool("undo_change",
|
||||
"Undo one change from the history by its id (from read_history): it reverts everything "+
|
||||
"that change did, as a new change that can itself be undone. This is how to do \"undo "+
|
||||
"the beets\" or \"put it back the way it was\" — find the change in read_history, then "+
|
||||
"undo it; never claim to have undone something without calling this. A change already "+
|
||||
"marked undone needs no second undo. Anything edited since that change is left alone "+
|
||||
"and reported under conflicts; tell the user about those.",
|
||||
a.undoChange),
|
||||
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 "+
|
||||
@@ -154,7 +200,17 @@ func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox
|
||||
"(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),
|
||||
)
|
||||
llm.DefineTool("update_garden",
|
||||
"Change a garden the user owns: rename it, resize it (widthCm/heightCm), switch its units "+
|
||||
"(metric|imperial), set its grid (gridSizeCm, snapToGrid), or rewrite its notes. Only the "+
|
||||
"fields you pass change. Needs the garden's current version from describe_garden. The "+
|
||||
"notes are the gardener's standing facts about the place — zone, frost dates, soil, sun, "+
|
||||
"how they like things done — and you are given them at the start of every conversation, "+
|
||||
"so when the user tells you something worth remembering (\"we're in zone 6a\", \"last "+
|
||||
"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.",
|
||||
a.updateGarden),
|
||||
), a
|
||||
}
|
||||
|
||||
// adapter carries the service, the acting user and their local day for the
|
||||
@@ -163,6 +219,25 @@ type adapter struct {
|
||||
svc *service.Service
|
||||
actor int64
|
||||
today string
|
||||
|
||||
mu sync.Mutex
|
||||
// reverts is every change set undo_change produced this turn. A revert is
|
||||
// its own change set (it points back at the one it undid, and the target is
|
||||
// marked undone), so it never joins the turn's scope — which leaves a turn
|
||||
// that only undid something with no change of its own. Run reports the last
|
||||
// revert as that turn's handle, so "Undo this" under the reply can redo it.
|
||||
reverts []int64
|
||||
}
|
||||
|
||||
// lastRevert is the newest change set undo_change produced this turn, if any.
|
||||
func (a *adapter) lastRevert() *int64 {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if len(a.reverts) == 0 {
|
||||
return nil
|
||||
}
|
||||
id := a.reverts[len(a.reverts)-1]
|
||||
return &id
|
||||
}
|
||||
|
||||
// day is the date a tool stamps: the one the model passed, else the gardener's
|
||||
@@ -184,8 +259,45 @@ func (a *adapter) listGardens(ctx context.Context, _ struct{}) (any, error) {
|
||||
|
||||
func (a *adapter) describeGarden(ctx context.Context, args struct {
|
||||
GardenID int64 `json:"gardenId" description:"id of the garden to describe"`
|
||||
Year *int `json:"year" description:"optional: describe that year's season instead of what is growing now — every plop in the ground at any point in the year, pulled ones included"`
|
||||
}) (any, error) {
|
||||
return a.svc.DescribeGarden(ctx, a.actor, args.GardenID)
|
||||
return a.svc.DescribeGarden(ctx, a.actor, args.GardenID, args.Year)
|
||||
}
|
||||
|
||||
func (a *adapter) listYears(ctx context.Context, args struct {
|
||||
GardenID int64 `json:"gardenId" description:"garden whose planting years to list"`
|
||||
}) (any, error) {
|
||||
years, err := a.svc.GardenYears(ctx, a.actor, args.GardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The service pads the list with ITS current year (UTC); the gardener's may
|
||||
// differ around New Year. Theirs is the one "this year" means to them.
|
||||
if y, ok := yearOf(a.today); ok {
|
||||
present := false
|
||||
for _, have := range years {
|
||||
if have == y {
|
||||
present = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !present {
|
||||
years = append([]int{y}, years...)
|
||||
}
|
||||
}
|
||||
return map[string]any{"years": years}, nil
|
||||
}
|
||||
|
||||
// yearOf is the year of a YYYY-MM-DD date, or false for anything else.
|
||||
func yearOf(date string) (int, bool) {
|
||||
if len(date) < 4 {
|
||||
return 0, false
|
||||
}
|
||||
y, err := strconv.Atoi(date[:4])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return y, true
|
||||
}
|
||||
|
||||
func (a *adapter) listPlantings(ctx context.Context, args struct {
|
||||
@@ -345,8 +457,9 @@ func (a *adapter) addJournalEntry(ctx context.Context, args struct {
|
||||
|
||||
func (a *adapter) clearObject(ctx context.Context, args struct {
|
||||
ObjectID int64 `json:"objectId" description:"object to remove all plants from"`
|
||||
RemovedAt string `json:"removedAt" description:"optional date the plants came out, YYYY-MM-DD; defaults to today"`
|
||||
}) (any, error) {
|
||||
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{RemovedAt: a.day("")})
|
||||
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{RemovedAt: a.day(args.RemovedAt)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -356,19 +469,73 @@ func (a *adapter) clearObject(ctx context.Context, args struct {
|
||||
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)"`
|
||||
RemovedAt string `json:"removedAt" description:"optional date they came out, YYYY-MM-DD; defaults to today"`
|
||||
}) (any, error) {
|
||||
if args.PlantID == 0 {
|
||||
// Left out, it would "remove" plant 0 — nothing — and report success.
|
||||
return nil, fmt.Errorf("%w: plantId is required — say which plant to remove, or use clear_object for all of them", domain.ErrInvalidInput)
|
||||
}
|
||||
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID,
|
||||
service.ClearOptions{PlantID: &args.PlantID, RemovedAt: a.day("")})
|
||||
service.ClearOptions{PlantID: &args.PlantID, RemovedAt: a.day(args.RemovedAt)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]int{"removed": n}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) updatePlanting(ctx context.Context, args struct {
|
||||
PlantingID int64 `json:"plantingId" description:"plop to correct (its id from describe_garden or list_plantings)"`
|
||||
Version int64 `json:"version" description:"the plop's current version"`
|
||||
PlantedAt *string `json:"plantedAt" description:"optional corrected planting date, YYYY-MM-DD"`
|
||||
Count *int `json:"count" description:"optional explicit plant count for the plop"`
|
||||
ClearCount bool `json:"clearCount" description:"optional: drop an explicit count and derive it from area and spacing again"`
|
||||
Label *string `json:"label" description:"optional label for the plop; empty clears it"`
|
||||
RadiusCM *float64 `json:"radiusCm" description:"optional new radius in cm"`
|
||||
SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) to attribute it to"`
|
||||
ClearLot bool `json:"clearSeedLot" description:"optional: detach it from its seed lot"`
|
||||
}) (any, error) {
|
||||
patch := service.PlantingPatch{RadiusCM: args.RadiusCM}
|
||||
if args.PlantedAt != nil {
|
||||
if _, err := parseDay(*args.PlantedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patch.SetPlantedAt, patch.PlantedAt = true, args.PlantedAt
|
||||
}
|
||||
switch {
|
||||
case args.ClearCount && args.Count != nil:
|
||||
return nil, fmt.Errorf("%w: give a count or clearCount, not both", domain.ErrInvalidInput)
|
||||
case args.ClearCount:
|
||||
patch.SetCount = true
|
||||
case args.Count != nil:
|
||||
patch.SetCount, patch.Count = true, args.Count
|
||||
}
|
||||
if args.Label != nil {
|
||||
patch.SetLabel = true
|
||||
if strings.TrimSpace(*args.Label) != "" {
|
||||
patch.Label = args.Label
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case args.ClearLot && args.SeedLotID != nil:
|
||||
return nil, fmt.Errorf("%w: give a seedLotId or clearSeedLot, not both", domain.ErrInvalidInput)
|
||||
case args.ClearLot:
|
||||
patch.SetSeedLotID = true
|
||||
case args.SeedLotID != nil:
|
||||
patch.SetSeedLotID, patch.SeedLotID = true, args.SeedLotID
|
||||
}
|
||||
return a.svc.UpdatePlanting(ctx, a.actor, args.PlantingID, patch, args.Version)
|
||||
}
|
||||
|
||||
// parseDay checks a date the model typed, so a malformed one fails with a
|
||||
// message about the date rather than as a bare "invalid input" from the store.
|
||||
func parseDay(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if _, err := time.Parse(dateLayout, s); err != nil {
|
||||
return "", fmt.Errorf("%w: %q is not a YYYY-MM-DD date", domain.ErrInvalidInput, s)
|
||||
}
|
||||
return s, 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"`
|
||||
@@ -391,6 +558,75 @@ func (a *adapter) readJournal(ctx context.Context, args struct {
|
||||
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) updateJournalEntry(ctx context.Context, args struct {
|
||||
EntryID int64 `json:"entryId" description:"journal entry to correct (its id from read_journal)"`
|
||||
Version int64 `json:"version" description:"the entry's current version (from read_journal)"`
|
||||
Body *string `json:"body" description:"optional corrected text"`
|
||||
ObservedAt *string `json:"observedAt" description:"optional corrected date it happened, YYYY-MM-DD"`
|
||||
}) (any, error) {
|
||||
if args.Body == nil && args.ObservedAt == nil {
|
||||
return nil, fmt.Errorf("%w: say what to change — the body, the date, or both", domain.ErrInvalidInput)
|
||||
}
|
||||
if args.ObservedAt != nil {
|
||||
if _, err := parseDay(*args.ObservedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return a.svc.UpdateJournalEntry(ctx, a.actor, args.EntryID,
|
||||
service.JournalPatch{Body: args.Body, ObservedAt: args.ObservedAt}, args.Version)
|
||||
}
|
||||
|
||||
func (a *adapter) deleteJournalEntry(ctx context.Context, args struct {
|
||||
EntryID int64 `json:"entryId" description:"journal entry to delete (its id from read_journal)"`
|
||||
}) (any, error) {
|
||||
if err := a.svc.DeleteJournalEntry(ctx, a.actor, args.EntryID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"deleted": args.EntryID}, nil
|
||||
}
|
||||
|
||||
// undoResult is what undo_change reports: the change it reverted, the new
|
||||
// change set that did so (undoable in turn), and what it had to leave alone.
|
||||
type undoResult struct {
|
||||
UndoneID int64 `json:"undoneId"`
|
||||
// ChangeSet is the revert itself — the history entry that can be undone
|
||||
// to redo — and Summary its row in the history ("Undid: …").
|
||||
ChangeSet *int64 `json:"changeSetId,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Changes string `json:"changes"`
|
||||
Conflicts []domain.RevertConflict `json:"conflicts"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
func (a *adapter) undoChange(ctx context.Context, args struct {
|
||||
ChangeSetID int64 `json:"changeSetId" description:"the change to undo — its id from read_history"`
|
||||
}) (any, error) {
|
||||
if args.ChangeSetID == 0 {
|
||||
return nil, fmt.Errorf("%w: changeSetId is required — find the change in read_history first", domain.ErrInvalidInput)
|
||||
}
|
||||
cs, conflicts, err := a.svc.RevertChangeSet(ctx, a.actor, args.ChangeSetID, domain.SourceAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := undoResult{UndoneID: args.ChangeSetID, Conflicts: conflicts}
|
||||
if cs == nil {
|
||||
// Nothing applied: every revision was a conflict, or the set was empty.
|
||||
res.Changes = "nothing"
|
||||
res.Note = "Nothing was reverted — everything that change touched has been edited since, or there was nothing left to undo."
|
||||
return res, nil
|
||||
}
|
||||
res.ChangeSet = &cs.ID
|
||||
res.Summary = cs.Summary
|
||||
res.Changes = describeCounts(cs.Counts)
|
||||
if len(conflicts) > 0 {
|
||||
res.Note = "Part of the change was left alone because it had been edited since; see conflicts."
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.reverts = append(a.reverts, cs.ID)
|
||||
a.mu.Unlock()
|
||||
return res, 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 {
|
||||
@@ -473,10 +709,12 @@ func (a *adapter) deleteObject(ctx context.Context, args struct {
|
||||
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)"`
|
||||
RemovedAt string `json:"removedAt" description:"optional date it came out, YYYY-MM-DD; defaults to today"`
|
||||
}) (any, error) {
|
||||
// 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.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, a.day(""))
|
||||
// Soft-remove via the service, dated the day the gardener said, else their
|
||||
// local day like every other tool here (the service clock's UTC day when
|
||||
// that isn't known).
|
||||
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, a.day(args.RemovedAt))
|
||||
}
|
||||
|
||||
func (a *adapter) listSeedLots(ctx context.Context, args struct {
|
||||
@@ -507,3 +745,53 @@ func (a *adapter) copyGarden(ctx context.Context, args struct {
|
||||
}) (any, error) {
|
||||
return a.svc.CopyGarden(ctx, a.actor, args.GardenID, args.Name)
|
||||
}
|
||||
|
||||
func (a *adapter) updateGarden(ctx context.Context, args struct {
|
||||
GardenID int64 `json:"gardenId" description:"garden to change (the user must own it)"`
|
||||
Version int64 `json:"version" description:"the garden's current version (from describe_garden)"`
|
||||
Name *string `json:"name" description:"optional new name"`
|
||||
WidthCM *float64 `json:"widthCm" description:"optional new width in cm"`
|
||||
HeightCM *float64 `json:"heightCm" description:"optional new height in cm"`
|
||||
UnitPref *string `json:"units" description:"optional: metric | imperial — how the gardener wants lengths shown"`
|
||||
Notes *string `json:"notes" description:"optional replacement for the WHOLE notes text (merge the current notes in yourself); empty clears them"`
|
||||
GridSizeCM *float64 `json:"gridSizeCm" description:"optional grid spacing for the editor, in cm"`
|
||||
SnapToGrid *bool `json:"snapToGrid" description:"optional: whether objects snap to that grid"`
|
||||
}) (any, error) {
|
||||
if args.Name == nil && args.WidthCM == nil && args.HeightCM == nil && args.UnitPref == nil &&
|
||||
args.Notes == nil && args.GridSizeCM == nil && args.SnapToGrid == nil {
|
||||
return nil, fmt.Errorf("%w: say what to change about the garden", domain.ErrInvalidInput)
|
||||
}
|
||||
// UpdateGarden takes the whole row, so start from the current one and
|
||||
// overlay what the model passed — the same merge the editor's settings
|
||||
// dialog does, and the only way a one-field change leaves the rest alone.
|
||||
g, err := a.svc.GetGarden(ctx, a.actor, args.GardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in := service.GardenInput{
|
||||
Name: g.Name, WidthCM: g.WidthCM, HeightCM: g.HeightCM, UnitPref: g.UnitPref,
|
||||
Notes: g.Notes, GridSizeCM: g.GridSizeCM, SnapToGrid: g.SnapToGrid,
|
||||
}
|
||||
if args.Name != nil {
|
||||
in.Name = *args.Name
|
||||
}
|
||||
if args.WidthCM != nil {
|
||||
in.WidthCM = *args.WidthCM
|
||||
}
|
||||
if args.HeightCM != nil {
|
||||
in.HeightCM = *args.HeightCM
|
||||
}
|
||||
if args.UnitPref != nil {
|
||||
in.UnitPref = strings.ToLower(strings.TrimSpace(*args.UnitPref))
|
||||
}
|
||||
if args.Notes != nil {
|
||||
in.Notes = *args.Notes
|
||||
}
|
||||
if args.GridSizeCM != nil {
|
||||
in.GridSizeCM = *args.GridSizeCM
|
||||
}
|
||||
if args.SnapToGrid != nil {
|
||||
in.SnapToGrid = *args.SnapToGrid
|
||||
}
|
||||
return a.svc.UpdateGarden(ctx, a.actor, args.GardenID, in, args.Version)
|
||||
}
|
||||
|
||||
@@ -735,3 +735,173 @@ func TestToolsDefaultToTheServiceDayWithoutOne(t *testing.T) {
|
||||
t.Errorf("an explicit day beats the default: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordKeepingTools covers the tools that correct the record rather than
|
||||
// change the garden — and the one that undoes a change for real. Each answers a
|
||||
// thing the live assistant could not do: fix a planting date, backdate a
|
||||
// harvest, correct a journal note, remember the gardener's zone, see last
|
||||
// season, and undo without pretending.
|
||||
func TestRecordKeepingTools(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
box := NewToolbox(svc, owner, "2026-08-23")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Home", WidthCM: 1200, HeightCM: 800, Notes: "Zone 6a."})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
beet := mustPlant(t, svc, owner, "Beet", 10, "")
|
||||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "South bed", XCM: 600, YCM: 400, WidthCM: 400, HeightCM: 200})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
|
||||
// --- update_garden: one field changes, the rest survive, notes merge by hand.
|
||||
var desc service.DescribeResult
|
||||
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
|
||||
if desc.Notes != "Zone 6a." || desc.Version != g.Version {
|
||||
t.Fatalf("describe carries notes %q v%d, want %q v%d", desc.Notes, desc.Version, "Zone 6a.", g.Version)
|
||||
}
|
||||
if r := call("update_garden", map[string]any{"gardenId": g.ID, "version": desc.Version}); !r.IsError || !strings.Contains(r.Content, "what to change") {
|
||||
t.Errorf("update_garden with nothing to change = %q, want a refusal that says so", r.Content)
|
||||
}
|
||||
var updated domain.Garden
|
||||
mustCall("update_garden", map[string]any{
|
||||
"gardenId": g.ID, "version": desc.Version, "notes": desc.Notes + "\nLast frost is usually around May 10.",
|
||||
}, &updated)
|
||||
if updated.Name != "Home" || updated.WidthCM != 1200 || updated.HeightCM != 800 || updated.UnitPref != domain.UnitMetric {
|
||||
t.Errorf("a notes-only update changed other fields: %+v", updated)
|
||||
}
|
||||
if !strings.HasPrefix(updated.Notes, "Zone 6a.") || !strings.Contains(updated.Notes, "May 10") {
|
||||
t.Errorf("notes = %q, want the old note kept and the new line added", updated.Notes)
|
||||
}
|
||||
if r := call("update_garden", map[string]any{"gardenId": g.ID, "version": desc.Version, "name": "Stale"}); !r.IsError {
|
||||
t.Error("update_garden with a stale version succeeded")
|
||||
}
|
||||
mustCall("update_garden", map[string]any{"gardenId": g.ID, "version": updated.Version, "units": "Imperial"}, &updated)
|
||||
if updated.UnitPref != domain.UnitImperial {
|
||||
t.Errorf("units = %q after asking for imperial", updated.UnitPref)
|
||||
}
|
||||
|
||||
// --- update_planting: correct a plop's record without touching its position.
|
||||
var plop domain.Planting
|
||||
mustCall("place_planting", map[string]any{"objectId": bed.ID, "plantId": beet.ID, "xCm": 50, "yCm": -30, "radiusCm": 20, "plantedAt": "2026-05-01"}, &plop)
|
||||
mustCall("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "count": 5, "label": "from the market"}, &plop)
|
||||
if plop.Count == nil || *plop.Count != 5 || plop.Label == nil || *plop.Label != "from the market" || plop.XCM != 50 {
|
||||
t.Errorf("after count+label: %+v", plop)
|
||||
}
|
||||
// Decoded into a fresh value: a field the response omits must read as
|
||||
// cleared, not as whatever the previous decode left in the pointer.
|
||||
cleared, version := domain.Planting{}, plop.Version
|
||||
mustCall("update_planting", map[string]any{"plantingId": plop.ID, "version": version, "clearCount": true, "plantedAt": "2026-05-20", "label": ""}, &cleared)
|
||||
plop = cleared
|
||||
if plop.Count != nil || plop.Label != nil || plop.PlantedAt == nil || *plop.PlantedAt != "2026-05-20" {
|
||||
t.Errorf("after clearCount/plantedAt/empty label: %+v", plop)
|
||||
}
|
||||
if r := call("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "plantedAt": "May 20"}); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") {
|
||||
t.Errorf("a prose date = %q, want a refusal naming the format", r.Content)
|
||||
}
|
||||
if r := call("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "count": 3, "clearCount": true}); !r.IsError {
|
||||
t.Error("count and clearCount together were accepted")
|
||||
}
|
||||
|
||||
// --- remove_planting on the day the gardener said, not today.
|
||||
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)
|
||||
}
|
||||
|
||||
// --- the season view sees it, the live view does not.
|
||||
var years struct{ Years []int }
|
||||
mustCall("list_years", map[string]any{"gardenId": g.ID}, &years)
|
||||
if len(years.Years) == 0 || years.Years[0] != 2026 {
|
||||
t.Errorf("years = %v, want 2026 first", 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)
|
||||
}
|
||||
mustCall("describe_garden", map[string]any{"gardenId": g.ID, "year": 2026}, &desc)
|
||||
if desc.Year == nil || *desc.Year != 2026 || len(desc.Objects[0].Plantings) != 1 {
|
||||
t.Fatalf("2026 describe = year %v, %d groups; want the beet group", desc.Year, len(desc.Objects[0].Plantings))
|
||||
}
|
||||
if gr := desc.Objects[0].Plantings[0]; gr.Removed != 1 || gr.RemovedAt != "2026-08-01" || gr.PlantedAt != "2026-05-20" {
|
||||
t.Errorf("2026 beet group = %+v; want 1 removed 2026-08-01, planted 2026-05-20", gr)
|
||||
}
|
||||
|
||||
// --- undo_change: the removal is the newest history entry; undoing it puts
|
||||
// the beet back, as a change that is itself in the history and undoable.
|
||||
var hist struct {
|
||||
Entries []historyEntry `json:"entries"`
|
||||
}
|
||||
mustCall("read_history", map[string]any{"gardenId": g.ID, "limit": 5}, &hist)
|
||||
if len(hist.Entries) == 0 || !strings.HasPrefix(hist.Entries[0].Summary, "Removed Beet") {
|
||||
t.Fatalf("history[0] = %+v, want the beet's removal", hist.Entries)
|
||||
}
|
||||
removal := hist.Entries[0].ID
|
||||
if r := call("undo_change", map[string]any{}); !r.IsError || !strings.Contains(r.Content, "read_history") {
|
||||
t.Errorf("undo_change without an id = %q, want a refusal pointing at read_history", r.Content)
|
||||
}
|
||||
var undone undoResult
|
||||
mustCall("undo_change", map[string]any{"changeSetId": removal}, &undone)
|
||||
if undone.ChangeSet == nil || undone.UndoneID != removal || len(undone.Conflicts) != 0 || !strings.Contains(undone.Changes, "1 planting updated") {
|
||||
t.Errorf("undo result = %+v; want a new change set, no conflicts, one planting updated", undone)
|
||||
}
|
||||
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
|
||||
if len(desc.Objects[0].Plantings) != 1 || desc.Objects[0].Plantings[0].Each[0].ID != plop.ID {
|
||||
t.Errorf("after the undo the beet is not back: %+v", desc.Objects[0].Plantings)
|
||||
}
|
||||
mustCall("read_history", map[string]any{"gardenId": g.ID, "limit": 5}, &hist)
|
||||
if e := hist.Entries[0]; e.ID != *undone.ChangeSet || e.UndoOf == nil || *e.UndoOf != removal || e.Source != domain.SourceAgent {
|
||||
t.Errorf("history[0] after undo = %+v; want the agent's revert of %d", e, removal)
|
||||
}
|
||||
if !hist.Entries[1].Undone {
|
||||
t.Error("the removal is not marked undone")
|
||||
}
|
||||
if got := (&adapter{}).lastRevert(); got != nil {
|
||||
t.Errorf("a fresh adapter remembers a revert: %v", *got)
|
||||
}
|
||||
|
||||
// --- the journal: correct an entry in place, then delete it.
|
||||
var entry domain.JournalEntry
|
||||
mustCall("add_journal_entry", map[string]any{"gardenId": g.ID, "body": "Aphids on the cantaloupe.", "observedAt": "2026-08-20"}, &entry)
|
||||
if r := call("update_journal_entry", map[string]any{"entryId": entry.ID, "version": entry.Version}); !r.IsError {
|
||||
t.Error("update_journal_entry with nothing to change succeeded")
|
||||
}
|
||||
if r := call("update_journal_entry", map[string]any{"entryId": entry.ID, "version": entry.Version, "observedAt": "yesterday"}); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") {
|
||||
t.Errorf("a prose date = %q, want a refusal naming the format", r.Content)
|
||||
}
|
||||
mustCall("update_journal_entry", map[string]any{"entryId": entry.ID, "version": entry.Version, "body": "Aphids on the cucumbers.", "observedAt": "2026-08-19"}, &entry)
|
||||
if entry.Body != "Aphids on the cucumbers." || entry.ObservedAt != "2026-08-19" {
|
||||
t.Errorf("corrected entry = %+v", entry)
|
||||
}
|
||||
var journal struct {
|
||||
Entries []domain.JournalEntry `json:"entries"`
|
||||
}
|
||||
mustCall("read_journal", map[string]any{"gardenId": g.ID}, &journal)
|
||||
if len(journal.Entries) != 1 || journal.Entries[0].Body != "Aphids on the cucumbers." {
|
||||
t.Errorf("journal after the correction = %+v, want the one corrected entry", journal.Entries)
|
||||
}
|
||||
mustCall("delete_journal_entry", map[string]any{"entryId": entry.ID}, nil)
|
||||
mustCall("read_journal", map[string]any{"gardenId": g.ID}, &journal)
|
||||
if len(journal.Entries) != 0 {
|
||||
t.Errorf("journal after the delete = %+v, want empty", journal.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
+48
-12
@@ -550,12 +550,21 @@ func (s *Service) ClearPlantings(ctx context.Context, actorID, objectID int64, o
|
||||
}
|
||||
|
||||
// DescribeResult is a structured summary of a garden for prompting an agent.
|
||||
// Version and Notes are here for update_garden: the version is its guard, and
|
||||
// the notes are the whole text a new note has to be merged into.
|
||||
type DescribeResult struct {
|
||||
GardenID int64 `json:"gardenId"`
|
||||
Name string `json:"name"`
|
||||
WidthCM float64 `json:"widthCm"`
|
||||
HeightCM float64 `json:"heightCm"`
|
||||
UnitPref string `json:"unitPref"`
|
||||
GridSizeCM float64 `json:"gridSizeCm"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
// Year is set on a season view: the plantings are then every plop whose time
|
||||
// in the ground overlapped that year, pulled ones included, rather than what
|
||||
// is growing now.
|
||||
Year *int `json:"year,omitempty"`
|
||||
Objects []DescribeObject `json:"objects"`
|
||||
}
|
||||
|
||||
@@ -605,6 +614,11 @@ type DescribeGroup struct {
|
||||
// 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"`
|
||||
// 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
|
||||
// plops, so both are absent from a describe of what is growing now.
|
||||
Removed int `json:"removed,omitempty"`
|
||||
RemovedAt string `json:"removedAt,omitempty"`
|
||||
// Each lists the plops individually (id, version, position, location) only
|
||||
// when the group has at most maxListedPlops of them.
|
||||
Each []DescribePlanting `json:"each,omitempty"`
|
||||
@@ -626,14 +640,18 @@ type DescribePlanting struct {
|
||||
Location string `json:"location"`
|
||||
RadiusCM float64 `json:"radiusCm"`
|
||||
PlantedAt string `json:"plantedAt,omitempty"`
|
||||
// RemovedAt is set on a pulled plop, which only a season view lists.
|
||||
RemovedAt string `json:"removedAt,omitempty"`
|
||||
}
|
||||
|
||||
// DescribeGarden returns a structured summary — dimensions, objects, and each
|
||||
// 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)
|
||||
// object's plantings grouped by plant (count, rough location, planting date) —
|
||||
// for a garden the actor can view. year nil describes what is growing now; a
|
||||
// year is the season view, every plop whose time in the ground overlapped it,
|
||||
// pulled ones included — what "what was in this bed last year?" needs. Built
|
||||
// on GardenFull so it inherits the ACL check and the year's bounds.
|
||||
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64, year *int) (*DescribeResult, error) {
|
||||
full, err := s.GardenFull(ctx, actorID, gardenID, year)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -653,6 +671,10 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (
|
||||
WidthCM: full.Garden.WidthCM,
|
||||
HeightCM: full.Garden.HeightCM,
|
||||
UnitPref: full.Garden.UnitPref,
|
||||
GridSizeCM: full.Garden.GridSizeCM,
|
||||
Notes: full.Garden.Notes,
|
||||
Version: full.Garden.Version,
|
||||
Year: year,
|
||||
Objects: make([]DescribeObject, 0, len(full.Objects)),
|
||||
}
|
||||
for i := range full.Objects {
|
||||
@@ -722,9 +744,13 @@ func describeGroups(o *domain.GardenObject, plops []domain.Planting, plantByID m
|
||||
PlantID: pid, Plant: plant.Name, Plops: len(members),
|
||||
Where: summarizeWhere(o, members), PlantedAt: dateRange(members),
|
||||
DaysToMaturity: plant.DaysToMaturity,
|
||||
RemovedAt: dateRangeOf(members, func(pl domain.Planting) *string { return pl.RemovedAt }),
|
||||
}
|
||||
for _, pl := range members {
|
||||
g.Plants += effectiveCount(pl)
|
||||
if pl.RemovedAt != nil {
|
||||
g.Removed++
|
||||
}
|
||||
}
|
||||
if len(members) <= maxListedPlops {
|
||||
g.Each = make([]DescribePlanting, 0, len(members))
|
||||
@@ -746,6 +772,9 @@ func describePlanting(pl domain.Planting, plantName string) DescribePlanting {
|
||||
if pl.PlantedAt != nil {
|
||||
d.PlantedAt = *pl.PlantedAt
|
||||
}
|
||||
if pl.RemovedAt != nil {
|
||||
d.RemovedAt = *pl.RemovedAt
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
@@ -759,19 +788,26 @@ func effectiveCount(pl domain.Planting) int {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// they were planted on different days, or "" when none is dated.
|
||||
func dateRange(plops []domain.Planting) string {
|
||||
return dateRangeOf(plops, func(pl domain.Planting) *string { return pl.PlantedAt })
|
||||
}
|
||||
|
||||
// dateRangeOf summarizes one date field across a group's plops: the one date
|
||||
// they share, "first…last" when they differ, or "" when none is set. ISO dates
|
||||
// order as strings, so min/max need no parsing.
|
||||
func dateRangeOf(plops []domain.Planting, pick func(domain.Planting) *string) string {
|
||||
first, last := "", ""
|
||||
for _, pl := range plops {
|
||||
if pl.PlantedAt == nil || *pl.PlantedAt == "" {
|
||||
d := pick(pl)
|
||||
if d == nil || *d == "" {
|
||||
continue
|
||||
}
|
||||
if first == "" || *pl.PlantedAt < first {
|
||||
first = *pl.PlantedAt
|
||||
if first == "" || *d < first {
|
||||
first = *d
|
||||
}
|
||||
if *pl.PlantedAt > last {
|
||||
last = *pl.PlantedAt
|
||||
if *d > last {
|
||||
last = *d
|
||||
}
|
||||
}
|
||||
if first == last {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
@@ -439,7 +440,7 @@ func TestOpsForbiddenForViewer(t *testing.T) {
|
||||
t.Errorf("viewer clear = %v, want ErrForbidden", err)
|
||||
}
|
||||
// But a viewer can DescribeGarden (read).
|
||||
if _, err := s.DescribeGarden(ctx, viewer, g.ID); err != nil {
|
||||
if _, err := s.DescribeGarden(ctx, viewer, g.ID, nil); err != nil {
|
||||
t.Errorf("viewer describe = %v, want ok", err)
|
||||
}
|
||||
}
|
||||
@@ -470,7 +471,7 @@ func TestFillScenario(t *testing.T) {
|
||||
fill("nw", basil.ID)
|
||||
fill("south", beans.ID)
|
||||
|
||||
desc, err := s.DescribeGarden(ctx, owner, g.ID)
|
||||
desc, err := s.DescribeGarden(ctx, owner, g.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeGarden: %v", err)
|
||||
}
|
||||
@@ -585,7 +586,7 @@ func TestDescribeGardenGroupsByPlant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
desc, err := s.DescribeGarden(ctx, owner, g.ID)
|
||||
desc, err := s.DescribeGarden(ctx, owner, g.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeGarden: %v", err)
|
||||
}
|
||||
@@ -825,3 +826,97 @@ func TestFillByRectangleAttributesSeed(t *testing.T) {
|
||||
t.Errorf("overhanging rectangle: %d plops, %v; want some", len(created), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDescribeGardenByYear — "what was in this bed last year?" is the question
|
||||
// rotation advice hangs on, and a describe of what is growing now cannot answer
|
||||
// it. With a year, describe is the season view: every plop whose time in the
|
||||
// ground overlapped the year, pulled ones included, each group saying how many
|
||||
// came out and when.
|
||||
func TestDescribeGardenByYear(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Seasons", WidthCM: 2000, HeightCM: 2000, Notes: "Zone 6a"})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
bed := seedFillBed(t, s, owner, g.ID, 400, 400)
|
||||
garlic := seedNamedPlant(t, s, owner, "Garlic", 15)
|
||||
beans := seedNamedPlant(t, s, owner, "Beans", 10)
|
||||
basil := seedNamedPlant(t, s, owner, "Basil", 25)
|
||||
|
||||
plantAndPull := func(plantID int64, x float64, planted, pulled string) {
|
||||
t.Helper()
|
||||
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plantID, XCM: x, YCM: -100, RadiusCM: 20, PlantedAt: &planted})
|
||||
if err != nil {
|
||||
t.Fatalf("plant %d: %v", plantID, err)
|
||||
}
|
||||
if pulled != "" {
|
||||
if _, err := s.RemovePlanting(ctx, owner, pl.ID, pl.Version, &pulled); err != nil {
|
||||
t.Fatalf("pull %d: %v", pl.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
plantAndPull(garlic.ID, -100, "2025-10-15", "2026-07-01") // overwintered: in both years
|
||||
plantAndPull(beans.ID, 0, "2025-05-01", "2025-09-01") // 2025 only
|
||||
plantAndPull(basil.ID, 100, "2026-06-01", "") // growing now
|
||||
|
||||
groupsOf := func(year *int) map[string]DescribeGroup {
|
||||
t.Helper()
|
||||
desc, err := s.DescribeGarden(ctx, owner, g.ID, year)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeGarden(%v): %v", year, err)
|
||||
}
|
||||
if (year == nil) != (desc.Year == nil) || (year != nil && *desc.Year != *year) {
|
||||
t.Errorf("describe(%v) reports year %v", year, desc.Year)
|
||||
}
|
||||
if desc.Notes != "Zone 6a" || desc.Version != g.Version {
|
||||
t.Errorf("describe carries notes %q version %d; want the garden's (%q, %d)", desc.Notes, desc.Version, "Zone 6a", g.Version)
|
||||
}
|
||||
out := map[string]DescribeGroup{}
|
||||
for _, gr := range desc.Objects[0].Plantings {
|
||||
out[gr.Plant] = gr
|
||||
}
|
||||
return out
|
||||
}
|
||||
names := func(m map[string]DescribeGroup) []string {
|
||||
var out []string
|
||||
for n := range m {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
now := groupsOf(nil)
|
||||
if got := names(now); !reflect.DeepEqual(got, []string{"Basil"}) {
|
||||
t.Errorf("now = %v, want only the basil still growing", got)
|
||||
}
|
||||
if b := now["Basil"]; b.Removed != 0 || b.RemovedAt != "" || b.Each[0].RemovedAt != "" {
|
||||
t.Errorf("a live plop reports a removal: %+v", b)
|
||||
}
|
||||
|
||||
y2025 := 2025
|
||||
last := groupsOf(&y2025)
|
||||
if got := names(last); !reflect.DeepEqual(got, []string{"Beans", "Garlic"}) {
|
||||
t.Errorf("2025 = %v, want the beans and the overwintered garlic", got)
|
||||
}
|
||||
if b := last["Beans"]; b.Removed != 1 || b.RemovedAt != "2025-09-01" || b.PlantedAt != "2025-05-01" {
|
||||
t.Errorf("2025 beans = %+v; want 1 removed on 2025-09-01, planted 2025-05-01", b)
|
||||
}
|
||||
if gl := last["Garlic"]; gl.Removed != 1 || gl.RemovedAt != "2026-07-01" || len(gl.Each) != 1 || gl.Each[0].RemovedAt != "2026-07-01" {
|
||||
t.Errorf("2025 garlic = %+v; want its 2026 removal on the group and the plop", gl)
|
||||
}
|
||||
|
||||
y2026 := 2026
|
||||
this := groupsOf(&y2026)
|
||||
if got := names(this); !reflect.DeepEqual(got, []string{"Basil", "Garlic"}) {
|
||||
t.Errorf("2026 = %v, want the basil and the garlic pulled in July", got)
|
||||
}
|
||||
|
||||
// A typo'd year is refused, not an empty garden.
|
||||
bad := 20026
|
||||
if _, err := s.DescribeGarden(ctx, owner, g.ID, &bad); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("describe(20026) err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -936,7 +936,7 @@ func TestRecordOutsideTheOpenScopeFilesUnderItsOwnGarden(t *testing.T) {
|
||||
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" {
|
||||
if d, err := s.DescribeGarden(ctx, owner, b.ID, nil); 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface AgentStep {
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
list_gardens: 'Looking at your gardens',
|
||||
describe_garden: 'Reading the garden',
|
||||
list_years: 'Checking past seasons',
|
||||
list_plantings: 'Reading a bed',
|
||||
create_object: 'Adding a bed',
|
||||
move_object: 'Moving a bed',
|
||||
@@ -106,6 +107,7 @@ const TOOL_LABELS: Record<string, string> = {
|
||||
place_planting: 'Planting',
|
||||
fill_region: 'Filling a bed',
|
||||
move_planting: 'Moving a plant',
|
||||
update_planting: 'Correcting a planting',
|
||||
remove_planting: 'Pulling a plant',
|
||||
remove_plantings: 'Pulling plants',
|
||||
clear_object: 'Clearing a bed',
|
||||
@@ -114,10 +116,14 @@ const TOOL_LABELS: Record<string, string> = {
|
||||
update_plant: 'Updating your catalog',
|
||||
add_journal_entry: 'Writing a journal note',
|
||||
read_journal: 'Reading the journal',
|
||||
update_journal_entry: 'Correcting a journal note',
|
||||
delete_journal_entry: 'Deleting a journal note',
|
||||
read_history: 'Reading the history',
|
||||
undo_change: 'Undoing a change',
|
||||
list_seed_lots: 'Checking your seed',
|
||||
record_seed_lot: 'Recording seed',
|
||||
copy_garden: 'Copying the garden',
|
||||
update_garden: 'Changing the garden',
|
||||
}
|
||||
|
||||
export function describeStep(step: AgentStep): string {
|
||||
|
||||
Reference in New Issue
Block a user