Agent: what a day of live use asked for
Twenty-one prompts against the live assistant found one fabricated success,
a model that believed it was 2025, and a describe_garden that was ~450 plop
entries per turn. This is the set of fixes, each traceable to a finding:
- The gardener's LOCAL day travels with the turn (`today` on POST /agent/chat,
sent by the UI like plantedAt) into the system prompt and every dated tool
default. Left to guess, the model dated journal entries a year back; left to
the server, a 9 pm fill landed on UTC's tomorrow.
- describe_garden groups plops by plant — count, where, planted date, days to
maturity — and lists ids only for groups of ≤ 8; list_plantings spells a big
group out on demand and remove_plantings acts on one plant in a bed ("take
the beets out, leave the garlic"), which used to mean 116 single removals.
- New tools: move_planting (keeps the planting date; across beds via the new
MovePlanting, which is why the store's UPDATE now writes object_id),
update_plant, read_history, copy_garden (the "<garden> — <year>" plan
convention). fill_region takes an explicit local rectangle and a seedLotId;
place_planting's radius defaults to one plant (spacing/2) instead of a guess.
- The system prompt states the date and the gardener's units, forbids claiming
a change no tool made, says it cannot undo and points at the Undo button,
asks before clearing beds on an ambiguous sentence, and stops narrating its
own plantings into the journal.
- A mutation aimed at ANOTHER garden inside a turn is recorded under that
garden as its own change set, not filed into the open scope.
- UI: the thread scrolls inside the Assistant panel so the composer stays
put; every tool has a step label; wide tables stay inside the bubble.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
+11
-4
@@ -29,8 +29,15 @@
|
||||
//
|
||||
// # Unconfigured instances
|
||||
//
|
||||
// With no API key the assistant is simply not offered: the chat route isn't
|
||||
// registered and the capability isn't advertised — the same shape as OIDC
|
||||
// 404ing when unconfigured. An instance without a key starts and serves the app
|
||||
// exactly as it did before.
|
||||
// With no API key the assistant is simply not offered: the chat route answers
|
||||
// 503 and /capabilities says agent:false, so the UI never shows the tab. (The
|
||||
// route is always registered — a Settings change can turn the assistant on
|
||||
// without a restart, which a missing route couldn't do.) An instance without a
|
||||
// key starts and serves the app exactly as it did before.
|
||||
//
|
||||
// # The gardener's day
|
||||
//
|
||||
// A turn carries the person's local date (from the client) into the prompt and
|
||||
// every dated tool default. The model is never the source of a date: left to
|
||||
// guess, it wrote the year it remembered from training.
|
||||
package agent
|
||||
|
||||
+71
-19
@@ -32,6 +32,9 @@ const (
|
||||
maxSameCallRepeats = 3
|
||||
)
|
||||
|
||||
// dateLayout is the YYYY-MM-DD form every date crosses the tool boundary in.
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
// Runner drives a model over pansy's toolbox. One per process; Run is safe to
|
||||
// call concurrently.
|
||||
type Runner struct {
|
||||
@@ -73,17 +76,31 @@ type Turn struct {
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// Run executes one turn against a garden, as actorID.
|
||||
// Run executes one turn against a garden, as actorID, on the day it is where
|
||||
// they are.
|
||||
//
|
||||
// today is the gardener's local date (YYYY-MM-DD) as the client reports it; it
|
||||
// goes into the prompt, so the model knows what day it is, and to every tool, so
|
||||
// what the turn plants, removes or journals is dated the day the person did it.
|
||||
// Empty means "the service's UTC today" — the best a caller with no local clock
|
||||
// (a bare API client) can do. The model itself must never be the source of the
|
||||
// date: left to guess, the live one stamped a year it remembered from training.
|
||||
//
|
||||
// The whole turn runs inside ONE change set, so everything the model did undoes
|
||||
// together. That is what makes acting without a confirmation prompt defensible.
|
||||
// The scope is opened even for a turn that turns out to be a question — a change
|
||||
// set with no revisions is never written, so asking costs nothing.
|
||||
func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message string, history []llm.Message, onStep func(agent.Step)) (*Turn, error) {
|
||||
func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, today string, history []llm.Message, onStep func(agent.Step)) (*Turn, error) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
today = strings.TrimSpace(today)
|
||||
if today == "" {
|
||||
today = time.Now().UTC().Format(dateLayout)
|
||||
} else if _, err := time.Parse(dateLayout, today); err != nil {
|
||||
return nil, fmt.Errorf("%w: today must be a YYYY-MM-DD date", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, runTimeout)
|
||||
defer cancel()
|
||||
@@ -109,8 +126,8 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message strin
|
||||
Summary: turnSummary(message),
|
||||
AgentRunID: &runID,
|
||||
}, func(ctx context.Context) error {
|
||||
box := NewToolbox(r.svc, actorID)
|
||||
a := agent.New(r.model, systemPrompt(garden),
|
||||
box := NewToolbox(r.svc, actorID, today)
|
||||
a := agent.New(r.model, systemPrompt(garden, today),
|
||||
agent.WithMaxSteps(maxSteps),
|
||||
agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats),
|
||||
)
|
||||
@@ -198,35 +215,70 @@ func turnSummary(message string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// systemPrompt gives the model the conventions it cannot infer.
|
||||
// systemPrompt gives the model the conventions it cannot infer, the day it is,
|
||||
// and the rules of conduct the live instance showed it needs.
|
||||
//
|
||||
// The compass convention in particular is not guessable: -y is north because
|
||||
// screen y grows downward, and a model that assumes otherwise plants the south
|
||||
// half when asked for the north one.
|
||||
func systemPrompt(g *domain.Garden) string {
|
||||
units := "metric — all measurements are centimeters"
|
||||
// half when asked for the north one. The date is not guessable either — a model
|
||||
// asked to backdate nothing still wrote the year it remembered from training —
|
||||
// and the conduct rules each answer a thing the assistant actually did in live
|
||||
// testing: reported a change it never made, narrated every planting into the
|
||||
// journal, swapped four beds on an ambiguous sentence, and answered an imperial
|
||||
// gardener in centimeters.
|
||||
func systemPrompt(g *domain.Garden, today string) string {
|
||||
units := "The gardener works in meters and centimeters; answer in those."
|
||||
size := fmt.Sprintf("%.0f x %.0f cm", g.WidthCM, g.HeightCM)
|
||||
if g.UnitPref == domain.UnitImperial {
|
||||
units = "imperial for display, but every measurement you send or receive is in CENTIMETERS"
|
||||
units = "The gardener thinks in feet and inches. Convert what they say before calling a tool " +
|
||||
"(1 ft = 30.48 cm, 1 in = 2.54 cm) and answer in feet and inches, never in centimeters."
|
||||
size = fmt.Sprintf("%.1f x %.1f ft (%.0f x %.0f cm)", g.WidthCM/30.48, g.HeightCM/30.48, g.WidthCM, g.HeightCM)
|
||||
}
|
||||
return fmt.Sprintf(`You are pansy's garden assistant. You help plan and edit a real garden by calling tools.
|
||||
|
||||
The garden you are working on is %q (id %d), %.0f x %.0f cm. The user's units are %s.
|
||||
The garden you are working on is %q (id %d), %s. Today is %s — the gardener's local date.
|
||||
%s
|
||||
|
||||
Conventions you cannot guess and must not assume:
|
||||
- Every measurement a tool takes or returns is in CENTIMETERS.
|
||||
- Positions in a garden are centimeters from its top-left corner: x grows east, y grows SOUTH.
|
||||
- Inside an object (a bed), positions are relative to that object's CENTER, and -y is NORTH.
|
||||
So the north half of a bed is negative y. Getting this backwards plants the wrong end.
|
||||
- Objects and plantings are version-guarded. Use the version from describe_garden when editing.
|
||||
- Dates are YYYY-MM-DD. Tools date what they plant, remove or journal as today unless you pass
|
||||
a date; pass one only when the gardener says it happened on another day.
|
||||
|
||||
How to work:
|
||||
- Start from describe_garden to see what is actually there. Do not guess ids.
|
||||
- Use find_plant to turn a plant name into an id. If it returns several candidates,
|
||||
pick the one that matches what the user said, or ask them which they meant.
|
||||
- To replant a bed with something else: clear_object, then fill_region with region "all".
|
||||
- When a tool refuses (for example, the user only has view access to this garden),
|
||||
explain what happened in plain words. Do not retry it.
|
||||
- Start from describe_garden to see what is actually there. Do not guess ids. It groups each
|
||||
bed's plantings by plant, with a count, a rough location and the planting date; a group lists
|
||||
its plops one by one only when it is small. For the ids of a large group use list_plantings,
|
||||
or act on the whole group at once with remove_plantings.
|
||||
- Use find_plant to turn a plant name into an id. If it returns several candidates, pick the one
|
||||
that matches what the user said, or ask them which they meant.
|
||||
- To replant a bed with something else: clear_object, then fill_region with region "all". To take
|
||||
one plant out of a mixed bed: remove_plantings. To relocate plants: move_planting, which keeps
|
||||
their planting date — do not remove and replant them.
|
||||
- fill_region in grid mode lays out individual plants at true spacing, which is what "so I can
|
||||
plant from it" means; clump mode is a quick sketch. For an area no compass name describes (a
|
||||
middle third, a strip along one edge) give fill_region a rectangle instead of placing plops by hand.
|
||||
- A garden named "%s — <year>" is this garden's plan for that year; copy_garden with that name
|
||||
makes one. Never use a different real garden as a scratch space.
|
||||
- When a tool refuses (for example, the user only has view access to this garden), explain what
|
||||
happened in plain words. Do not retry it.
|
||||
|
||||
When you are done, say briefly what you changed — the user is watching the canvas
|
||||
and wants to know what to look at. If you changed nothing, say that too.`,
|
||||
g.Name, g.ID, g.WidthCM, g.HeightCM, units)
|
||||
How to behave:
|
||||
- Only claim what a tool actually did. If a tool failed, or there is no tool for what was asked,
|
||||
say so plainly — never describe a change you did not make.
|
||||
- You cannot undo. Every reply of yours that changed the garden has an "Undo this" button under
|
||||
it, and the History panel can revert any change; point the gardener there, or offer to reverse
|
||||
the change by hand with tools.
|
||||
- When a request could mean materially different things — "swap the cucumbers and the melons"
|
||||
with two beds of each — say what you would do and ask, rather than clearing beds on a guess.
|
||||
When it is clear, just do it.
|
||||
- The plan already records what was planted where and when. Write a journal entry only when the
|
||||
gardener asks for one or tells you something that happened — weather, pests, a harvest, an
|
||||
observation — not to narrate your own planting.
|
||||
- The gardener is watching the canvas. When you are done, say briefly what you changed and where
|
||||
to look; if you changed nothing, say that too.`,
|
||||
g.Name, g.ID, size, today, units, g.Name)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestTurnIsOneChangeSet(t *testing.T) {
|
||||
fake.Reply("Cleared the garlic and replanted the bed with cucumbers."),
|
||||
)
|
||||
|
||||
turn, err := r.Run(ctx, owner, g.ID, "change the garlic bed to cucumbers this year", nil, nil)
|
||||
turn, err := r.Run(ctx, owner, g.ID, "change the garlic bed to cucumbers this year", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
@@ -152,14 +152,14 @@ func TestViewerGetsAnExplainableRefusal(t *testing.T) {
|
||||
// A viewer can't open a change set at all, so the turn is refused up front —
|
||||
// before any model call — and the API turns that into a plain explanation.
|
||||
r := scriptedRunner(t, svc, fake.Reply("unused"))
|
||||
_, err = r.Run(ctx, viewer.ID, g.ID, "plant garlic in that bed", nil, nil)
|
||||
_, err = r.Run(ctx, viewer.ID, g.ID, "plant garlic in that bed", "", nil, nil)
|
||||
if !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Fatalf("viewer turn err = %v, want ErrForbidden", err)
|
||||
}
|
||||
|
||||
// And at the tool layer, a refusal comes back as a readable tool result
|
||||
// rather than killing the run.
|
||||
box := NewToolbox(svc, viewer.ID)
|
||||
box := NewToolbox(svc, viewer.ID, "")
|
||||
raw, _ := json.Marshal(map[string]any{"objectId": bed.ID})
|
||||
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "clear_object", Arguments: raw})
|
||||
if !res.IsError {
|
||||
@@ -187,7 +187,7 @@ func TestRunStopsAtTheStepCap(t *testing.T) {
|
||||
}
|
||||
r := scriptedRunner(t, svc, steps...)
|
||||
|
||||
turn, err := r.Run(ctx, owner, g.ID, "look at the garden", nil, nil)
|
||||
turn, err := r.Run(ctx, owner, g.ID, "look at the garden", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a capped run should end cleanly, got %v", err)
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func TestReadOnlyTurnWritesNoChangeSet(t *testing.T) {
|
||||
toolCall("describe_garden", map[string]any{"gardenId": g.ID}),
|
||||
fake.Reply("It's empty — nothing planted yet."),
|
||||
)
|
||||
turn, err := r.Run(ctx, owner, g.ID, "what's in the garden?", nil, nil)
|
||||
turn, err := r.Run(ctx, owner, g.ID, "what's in the garden?", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
@@ -268,8 +268,8 @@ func TestTurnSummaryFitsAHistoryRow(t *testing.T) {
|
||||
// TestSystemPromptStatesTheCompassConvention — -y being north is not guessable,
|
||||
// and a model that assumes otherwise plants the wrong end of the bed.
|
||||
func TestSystemPromptStatesTheCompassConvention(t *testing.T) {
|
||||
p := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitImperial})
|
||||
for _, want := range []string{"NORTH", "-y", "centimeters", "Plot", "version"} {
|
||||
p := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitImperial}, "2026-08-22")
|
||||
for _, want := range []string{"NORTH", "-y", "CENTIMETERS", "Plot", "version"} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Errorf("system prompt is missing %q:\n%s", want, p)
|
||||
}
|
||||
@@ -315,7 +315,7 @@ func TestPartialWorkSurvivesATimeout(t *testing.T) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
_, err = r.Run(cancelled, owner, g.ID, "move the bed", nil, nil)
|
||||
_, err = r.Run(cancelled, owner, g.ID, "move the bed", "", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected the turn to fail")
|
||||
}
|
||||
@@ -356,3 +356,147 @@ func TestTurnSummaryTrimsByRunes(t *testing.T) {
|
||||
t.Errorf("summary is %d runes, want it trimmed", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemPromptKnowsTheDayAndTheGardenersUnits — two things the live model
|
||||
// got wrong for want of being told: it dated journal entries with the year it
|
||||
// remembered from training, and answered a feet-and-inches gardener in
|
||||
// centimeters. The conduct rules are checked by their load-bearing phrases.
|
||||
func TestSystemPromptKnowsTheDayAndTheGardenersUnits(t *testing.T) {
|
||||
imperial := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 731.52, HeightCM: 731.52, UnitPref: domain.UnitImperial}, "2026-08-22")
|
||||
for _, want := range []string{
|
||||
"Today is 2026-08-22",
|
||||
"feet and inches",
|
||||
"24.0 x 24.0 ft",
|
||||
"never describe a change you did not make",
|
||||
"You cannot undo",
|
||||
"Undo this",
|
||||
"rather than clearing beds on a guess",
|
||||
"not to narrate your own planting",
|
||||
`"Plot — <year>"`,
|
||||
"remove_plantings",
|
||||
"move_planting",
|
||||
"list_plantings",
|
||||
} {
|
||||
if !strings.Contains(imperial, want) {
|
||||
t.Errorf("imperial prompt is missing %q", want)
|
||||
}
|
||||
}
|
||||
metric := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitMetric}, "2026-08-22")
|
||||
if strings.Contains(metric, "feet and inches") {
|
||||
t.Error("metric prompt tells the model to answer in feet and inches")
|
||||
}
|
||||
if !strings.Contains(metric, "500 x 400 cm") {
|
||||
t.Error("metric prompt doesn't state the garden's size in cm")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRejectsAMalformedToday — the date reaches every tool as a default, so a
|
||||
// bad one must stop the turn before the model runs, not fail its first fill.
|
||||
func TestRunRejectsAMalformedToday(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
r := scriptedRunner(t, svc, fake.Reply("unused"))
|
||||
if _, err := r.Run(ctx, owner, g.ID, "hello", "yesterday", nil, nil); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("Run with today=%q: err = %v, want ErrInvalidInput", "yesterday", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnDatesItsWorkTheGardenersDay — what a turn plants is dated the day the
|
||||
// gardener sent it, not the server's UTC day (which is tomorrow by nine in the
|
||||
// evening in Ohio) and not a day the model chose.
|
||||
func TestTurnDatesItsWorkTheGardenersDay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄")
|
||||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||||
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 200, HeightCM: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
r := scriptedRunner(t, svc,
|
||||
toolCall("fill_region", map[string]any{"objectId": bed.ID, "region": "all", "plantId": garlic.ID}),
|
||||
fake.Reply("Filled the bed with garlic."),
|
||||
)
|
||||
if _, err := r.Run(ctx, owner, g.ID, "fill the bed with garlic", "2026-08-22", nil, nil); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
full, err := svc.GardenFull(ctx, owner, g.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GardenFull: %v", err)
|
||||
}
|
||||
if len(full.Plantings) == 0 {
|
||||
t.Fatal("the turn planted nothing")
|
||||
}
|
||||
for _, p := range full.Plantings {
|
||||
if p.PlantedAt == nil || *p.PlantedAt != "2026-08-22" {
|
||||
t.Errorf("plop %d plantedAt = %v, want the gardener's day 2026-08-22", p.ID, p.PlantedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnOnAnotherGardenFilesHistoryThere — a turn is scoped to one garden, but
|
||||
// nothing stops the model from pointing a tool at an object in another garden
|
||||
// the person can edit ("do the same in my other garden"). Those revisions must
|
||||
// land in THAT garden's history, as the agent's work, where its undo can see
|
||||
// them — not in the open scope, where undoing this turn would quietly revert
|
||||
// rows in a garden the person isn't looking at.
|
||||
func TestTurnOnAnotherGardenFilesHistoryThere(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
a, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "A", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden A: %v", err)
|
||||
}
|
||||
b, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "B", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden B: %v", err)
|
||||
}
|
||||
bedB, err := svc.CreateObject(ctx, owner, b.ID, service.ObjectInput{
|
||||
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 200, HeightCM: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
beforeA, _, _ := svc.GardenHistory(ctx, owner, a.ID, 0, 0)
|
||||
beforeB, _, _ := svc.GardenHistory(ctx, owner, b.ID, 0, 0)
|
||||
|
||||
r := scriptedRunner(t, svc,
|
||||
toolCall("update_object", map[string]any{"objectId": bedB.ID, "version": bedB.Version, "name": "Renamed from A"}),
|
||||
fake.Reply("Renamed the bed in B."),
|
||||
)
|
||||
turn, err := r.Run(ctx, owner, a.ID, "rename the bed in my other garden", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if turn.ChangeSetID != nil {
|
||||
t.Errorf("the turn on A produced change set %d, but it changed nothing in A", *turn.ChangeSetID)
|
||||
}
|
||||
afterA, _, _ := svc.GardenHistory(ctx, owner, a.ID, 0, 0)
|
||||
if len(afterA) != len(beforeA) {
|
||||
t.Errorf("A's history grew by %d for a change made in B", len(afterA)-len(beforeA))
|
||||
}
|
||||
afterB, _, _ := svc.GardenHistory(ctx, owner, b.ID, 0, 0)
|
||||
if len(afterB) != len(beforeB)+1 {
|
||||
t.Fatalf("B's history grew by %d, want 1", len(afterB)-len(beforeB))
|
||||
}
|
||||
if cs := afterB[0]; cs.Source != domain.SourceAgent || cs.AgentRunID == nil {
|
||||
t.Errorf("B's entry = source %q, run %v; want the agent's, with its run id", cs.Source, cs.AgentRunID)
|
||||
}
|
||||
// And it undoes from B, where the person would look for it.
|
||||
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, afterB[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
d, _ := svc.DescribeGarden(ctx, owner, b.ID)
|
||||
if len(d.Objects) != 1 || d.Objects[0].Name != "Bed" {
|
||||
t.Errorf("after undo B's bed is %+v, want its original name back", d.Objects)
|
||||
}
|
||||
}
|
||||
|
||||
+250
-42
@@ -2,28 +2,45 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// NewToolbox builds a majordomo toolbox over pansy's service layer, bound to a
|
||||
// single acting user. Every tool call runs as actorID, so pansy's permission
|
||||
// checks (requireGardenRole / objectForRole) apply unchanged. Construct one per
|
||||
// authenticated agent session:
|
||||
// single acting user and to the day it is where they are. Every tool call runs
|
||||
// as actorID, so pansy's permission checks (requireGardenRole / objectForRole)
|
||||
// apply unchanged. Construct one per authenticated agent session:
|
||||
//
|
||||
// box := agent.NewToolbox(svc, session.UserID)
|
||||
// box := agent.NewToolbox(svc, session.UserID, "2026-08-22")
|
||||
// agent.Run(ctx, model, box, "fill the NE corner with garlic")
|
||||
func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
|
||||
a := &adapter{svc: svc, actor: actorID}
|
||||
//
|
||||
// today (YYYY-MM-DD) is the date every tool stamps on what it plants, removes or
|
||||
// journals unless the model passes one — the gardener's local day, from the
|
||||
// client, because the server's UTC day is tomorrow by nine in the evening in
|
||||
// Ohio. Empty falls back to the service's UTC today.
|
||||
func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox {
|
||||
a := &adapter{svc: svc, actor: actorID, today: strings.TrimSpace(today)}
|
||||
return llm.NewToolbox("pansy",
|
||||
llm.DefineTool("list_gardens",
|
||||
"List the gardens the user can see (owned and shared), with the user's role on each.",
|
||||
a.listGardens),
|
||||
llm.DefineTool("describe_garden",
|
||||
"Summarize a garden: its dimensions, objects (with sizes/positions/version), and each object's active plantings with a rough compass location.",
|
||||
"Summarize a garden: its dimensions, objects (with sizes/positions/version), and each "+
|
||||
"object's active plantings grouped by plant — how many, roughly where, when they went in, "+
|
||||
"and days to maturity when known. A small group lists its plops individually (id + "+
|
||||
"version, for move_planting/remove_planting); a large one (a grid-filled bed) does not — "+
|
||||
"use list_plantings for those ids, or act on the whole group with remove_plantings.",
|
||||
a.describeGarden),
|
||||
llm.DefineTool("list_plantings",
|
||||
"List one object's active plops one by one, each with its id, version, location, count and "+
|
||||
"planting date — the detail describe_garden leaves out for a large group. Narrow to one "+
|
||||
"plant with plantId. Use it only when you need to address individual plops.",
|
||||
a.listPlantings),
|
||||
llm.DefineTool("create_object",
|
||||
"Add an object (bed, grow_bag, container, in_ground, tree, path, structure) to a garden, positioned by its center in garden cm.",
|
||||
a.createObject),
|
||||
@@ -31,16 +48,38 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
|
||||
"Move an object to a new center position (garden cm). Needs the object's current version from describe_garden.",
|
||||
a.moveObject),
|
||||
llm.DefineTool("place_planting",
|
||||
"Place one plop of a plant inside a plantable object, positioned in the object's LOCAL frame (0,0 = object center, -y = north).",
|
||||
"Place one plop of a plant inside a plantable object, positioned in the object's LOCAL frame "+
|
||||
"(0,0 = object center, -y = north). Omit radiusCm for a single plant (it defaults to half "+
|
||||
"the plant's spacing); a larger radius is a clump, whose plant count is derived from its "+
|
||||
"area unless you pass count. Dated today unless plantedAt says otherwise.",
|
||||
a.placePlanting),
|
||||
llm.DefineTool("fill_region",
|
||||
"Fill part of a plantable object with one plant, hex-packed at the plant's spacing. "+
|
||||
"region is a compass name, not coordinates: nw|ne|sw|se for the quarter corners, "+
|
||||
"north|south|east|west (or top|bottom|left|right) for halves, or all for the whole thing. "+
|
||||
"North is the top of the garden. Example: to replant a whole bed, clear_object then "+
|
||||
"fill_region with region=all. Filling skips spots already covered by an existing plant, "+
|
||||
"so it is safe to run twice.",
|
||||
"Say where EITHER by region — a compass name, not coordinates: nw|ne|sw|se for the quarter "+
|
||||
"corners, north|south|east|west (or top|bottom|left|right) for halves, or all for the whole "+
|
||||
"thing; north is the top of the garden — OR by an explicit rectangle in the object's local "+
|
||||
"frame (x0Cm,y0Cm,x1Cm,y1Cm; 0,0 = center, -y = north), for a middle third, a strip along "+
|
||||
"one edge, or any area a compass name can't say. Example: to replant a whole bed, "+
|
||||
"clear_object then fill_region with region=all. Filling skips spots already covered by an "+
|
||||
"existing plant, so it is safe to run twice. Dated today unless plantedAt says otherwise.",
|
||||
a.fillRegion),
|
||||
llm.DefineTool("move_planting",
|
||||
"Move ONE plop to a new position — within its object, or into another plantable object of "+
|
||||
"the same garden with toObjectId — keeping its plant, size, count and planting date. This "+
|
||||
"is how to relocate plants; removing and re-placing them would lose when they were planted. "+
|
||||
"Needs the plop's id and version (describe_garden or list_plantings).",
|
||||
a.movePlanting),
|
||||
llm.DefineTool("remove_planting",
|
||||
"Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+
|
||||
"all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+
|
||||
"bed does. Needs the plop's id and version from describe_garden. Use for \"pull the "+
|
||||
"basil out of the corner\".",
|
||||
a.removePlanting),
|
||||
llm.DefineTool("remove_plantings",
|
||||
"Remove every plop of ONE plant from an object, leaving the other plants in it — \"take the "+
|
||||
"beets out of the south bed\". Soft-removes them (kept for planting history, undoable as "+
|
||||
"one change). Use this rather than many remove_planting calls.",
|
||||
a.removePlantings),
|
||||
llm.DefineTool("clear_object",
|
||||
"Remove all plants from an object. They are soft-removed, so the planting history for past "+
|
||||
"seasons is kept and the change can be undone. Use this before replanting a bed with "+
|
||||
@@ -58,11 +97,18 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
|
||||
"yet. Check find_plant first — creating a duplicate of something that already exists is "+
|
||||
"worse than reusing it. The plant belongs to the user, not to any garden.",
|
||||
a.createPlant),
|
||||
llm.DefineTool("update_plant",
|
||||
"Change a plant in the user's own catalog: its name, category, spacing, color, days to "+
|
||||
"maturity, vendor, source link or notes. Only the fields you pass change. Needs the "+
|
||||
"plant's current version from find_plant. Built-in plants can't be edited — create_plant "+
|
||||
"the user's own variety instead.",
|
||||
a.updatePlant),
|
||||
llm.DefineTool("add_journal_entry",
|
||||
"Write a dated observation into the garden's grow journal — what happened, and when. "+
|
||||
"Attach it to one bed with objectId when it is about that bed. This is for events "+
|
||||
"(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+
|
||||
"thing is. observedAt defaults to today; set it to backdate.",
|
||||
"thing is and not for narrating your own plantings. observedAt defaults to today; set "+
|
||||
"it to backdate.",
|
||||
a.addJournalEntry),
|
||||
llm.DefineTool("read_journal",
|
||||
"Read back the garden's grow journal — the observations add_journal_entry wrote. "+
|
||||
@@ -70,6 +116,13 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
|
||||
"recently observed first. Use this to answer \"what did I note about the west bed?\" "+
|
||||
"or \"what happened last spring?\".",
|
||||
a.readJournal),
|
||||
llm.DefineTool("read_history",
|
||||
"Read the garden's change history: every change anyone made — by hand in the editor, or "+
|
||||
"in an earlier conversation with you — newest first, with what it changed and whether it "+
|
||||
"was undone. Use it to answer \"what changed this week?\" or \"what did you do last time?\" "+
|
||||
"rather than reciting from memory. You cannot undo from here; the person has an Undo "+
|
||||
"button on each change.",
|
||||
a.readHistory),
|
||||
llm.DefineTool("update_object",
|
||||
"Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+
|
||||
"rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+
|
||||
@@ -82,12 +135,6 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
|
||||
"counterpart to create_object — use it for \"remove the old grow bag\". Permanent (not "+
|
||||
"the same as clearing a bed's plants); prefer clear_object when the bed itself stays.",
|
||||
a.deleteObject),
|
||||
llm.DefineTool("remove_planting",
|
||||
"Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+
|
||||
"all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+
|
||||
"bed does. Needs the plop's id and version from describe_garden. Use for \"pull the "+
|
||||
"basil out of the corner\".",
|
||||
a.removePlanting),
|
||||
llm.DefineTool("list_seed_lots",
|
||||
"List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+
|
||||
"left — optionally for one plant via plantId. This is the detail behind the \"seed "+
|
||||
@@ -96,15 +143,37 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
|
||||
llm.DefineTool("record_seed_lot",
|
||||
"Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+
|
||||
"Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+
|
||||
"\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\".",
|
||||
"\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\". To "+
|
||||
"count seed as used, plant with a seedLotId on place_planting or fill_region.",
|
||||
a.recordSeedLot),
|
||||
llm.DefineTool("copy_garden",
|
||||
"Duplicate a garden the user owns — beds, objects and plantings — as a new garden with the "+
|
||||
"given name. This is how a season plan is made: a copy named \"<garden name> — <year>\" "+
|
||||
"(with an em dash) is that garden's plan for the year, and the editor offers it as such. "+
|
||||
"Use it for \"set up next year's plan\"; never use another real garden as a scratch space.",
|
||||
a.copyGarden),
|
||||
)
|
||||
}
|
||||
|
||||
// adapter carries the service and the acting user for the tool handlers.
|
||||
// adapter carries the service, the acting user and their local day for the
|
||||
// tool handlers.
|
||||
type adapter struct {
|
||||
svc *service.Service
|
||||
actor int64
|
||||
today string
|
||||
}
|
||||
|
||||
// day is the date a tool stamps: the one the model passed, else the gardener's
|
||||
// local today, else nil for the service's UTC default.
|
||||
func (a *adapter) day(explicit string) *string {
|
||||
if d := strings.TrimSpace(explicit); d != "" {
|
||||
return &d
|
||||
}
|
||||
if a.today != "" {
|
||||
d := a.today
|
||||
return &d
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *adapter) listGardens(ctx context.Context, _ struct{}) (any, error) {
|
||||
@@ -117,6 +186,13 @@ func (a *adapter) describeGarden(ctx context.Context, args struct {
|
||||
return a.svc.DescribeGarden(ctx, a.actor, args.GardenID)
|
||||
}
|
||||
|
||||
func (a *adapter) listPlantings(ctx context.Context, args struct {
|
||||
ObjectID int64 `json:"objectId" description:"object whose plops to list"`
|
||||
PlantID *int64 `json:"plantId" description:"optional: only plops of this plant"`
|
||||
}) (any, error) {
|
||||
return a.svc.ListObjectPlantings(ctx, a.actor, args.ObjectID, args.PlantID)
|
||||
}
|
||||
|
||||
func (a *adapter) createObject(ctx context.Context, args struct {
|
||||
GardenID int64 `json:"gardenId" description:"garden to add the object to"`
|
||||
Kind string `json:"kind" description:"bed | grow_bag | container | in_ground | tree | path | structure"`
|
||||
@@ -144,28 +220,69 @@ func (a *adapter) moveObject(ctx context.Context, args struct {
|
||||
}
|
||||
|
||||
func (a *adapter) placePlanting(ctx context.Context, args struct {
|
||||
ObjectID int64 `json:"objectId" description:"plantable object to plant in"`
|
||||
PlantID int64 `json:"plantId" description:"plant to place"`
|
||||
XCM float64 `json:"xCm" description:"center x in the object's local frame (cm; 0,0 = center, -y = north)"`
|
||||
YCM float64 `json:"yCm" description:"center y in the object's local frame (cm)"`
|
||||
RadiusCM float64 `json:"radiusCm" description:"plop radius in cm"`
|
||||
Count *int `json:"count" description:"optional explicit plant count; omit to derive from area ÷ spacing²"`
|
||||
ObjectID int64 `json:"objectId" description:"plantable object to plant in"`
|
||||
PlantID int64 `json:"plantId" description:"plant to place"`
|
||||
XCM float64 `json:"xCm" description:"center x in the object's local frame (cm; 0,0 = center, -y = north)"`
|
||||
YCM float64 `json:"yCm" description:"center y in the object's local frame (cm)"`
|
||||
RadiusCM float64 `json:"radiusCm" description:"optional plop radius in cm; omit (0) for one plant at half the plant's spacing"`
|
||||
Count *int `json:"count" description:"optional explicit plant count; omit to derive from area ÷ spacing²"`
|
||||
PlantedAt string `json:"plantedAt" description:"optional planting date, YYYY-MM-DD; defaults to today"`
|
||||
SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) this planting uses, so the lot counts it as used"`
|
||||
}) (any, error) {
|
||||
return a.svc.CreatePlanting(ctx, a.actor, args.ObjectID, service.PlantingInput{
|
||||
PlantID: args.PlantID, XCM: args.XCM, YCM: args.YCM, RadiusCM: args.RadiusCM, Count: args.Count,
|
||||
PlantedAt: a.day(args.PlantedAt), SeedLotID: args.SeedLotID,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *adapter) fillRegion(ctx context.Context, args struct {
|
||||
ObjectID int64 `json:"objectId" description:"plantable object to fill"`
|
||||
Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all"`
|
||||
Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all; leave empty when giving a rectangle"`
|
||||
X0CM *float64 `json:"x0Cm" description:"rectangle instead of region: west edge, local cm (0 = center)"`
|
||||
Y0CM *float64 `json:"y0Cm" description:"rectangle: north edge, local cm (negative is north of center)"`
|
||||
X1CM *float64 `json:"x1Cm" description:"rectangle: east edge, local cm"`
|
||||
Y1CM *float64 `json:"y1Cm" description:"rectangle: south edge, local cm"`
|
||||
PlantID int64 `json:"plantId" description:"plant to fill with"`
|
||||
SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"`
|
||||
Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"`
|
||||
PlantedAt string `json:"plantedAt" description:"optional planting date for every plop, YYYY-MM-DD; defaults to today"`
|
||||
SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) this fill uses, so the lot counts it as used"`
|
||||
}) (any, error) {
|
||||
// nil: the agent runs server-side with no local day, so the fill dates
|
||||
// plops UTC-today like its create_planting does.
|
||||
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode), nil)
|
||||
spec := service.FillSpec{
|
||||
RegionName: args.Region, PlantID: args.PlantID, SpacingOverride: args.SpacingOverride,
|
||||
Layout: service.FillLayout(args.Mode), PlantedAt: a.day(args.PlantedAt), SeedLotID: args.SeedLotID,
|
||||
}
|
||||
rect := []*float64{args.X0CM, args.Y0CM, args.X1CM, args.Y1CM}
|
||||
given := 0
|
||||
for _, v := range rect {
|
||||
if v != nil {
|
||||
given++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case given == 4 && strings.TrimSpace(args.Region) == "":
|
||||
spec.Region = service.Region{MinX: *args.X0CM, MinY: *args.Y0CM, MaxX: *args.X1CM, MaxY: *args.Y1CM}
|
||||
case given == 0 && strings.TrimSpace(args.Region) != "":
|
||||
// the named region
|
||||
case given == 4:
|
||||
return nil, fmt.Errorf("%w: give either a region name or a rectangle, not both", domain.ErrInvalidInput)
|
||||
case given > 0:
|
||||
return nil, fmt.Errorf("%w: a rectangle needs all four of x0Cm, y0Cm, x1Cm, y1Cm", domain.ErrInvalidInput)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: say where to fill — a region name, or a rectangle", domain.ErrInvalidInput)
|
||||
}
|
||||
return a.svc.Fill(ctx, a.actor, args.ObjectID, spec)
|
||||
}
|
||||
|
||||
func (a *adapter) movePlanting(ctx context.Context, args struct {
|
||||
PlantingID int64 `json:"plantingId" description:"plop to move (its id from describe_garden or list_plantings)"`
|
||||
Version int64 `json:"version" description:"the plop's current version"`
|
||||
XCM float64 `json:"xCm" description:"new center x in the destination object's local frame (cm; 0,0 = center, -y = north)"`
|
||||
YCM float64 `json:"yCm" description:"new center y in the destination object's local frame (cm)"`
|
||||
ToObjectID *int64 `json:"toObjectId" description:"optional: another plantable object in the same garden to move it into; omit to move within its current object"`
|
||||
}) (any, error) {
|
||||
return a.svc.MovePlanting(ctx, a.actor, args.PlantingID,
|
||||
service.MoveInput{ToObjectID: args.ToObjectID, XCM: args.XCM, YCM: args.YCM}, args.Version)
|
||||
}
|
||||
|
||||
func (a *adapter) findPlant(ctx context.Context, args struct {
|
||||
@@ -182,7 +299,7 @@ func (a *adapter) createPlant(ctx context.Context, args struct {
|
||||
Icon string `json:"icon" description:"a single emoji to draw it with, e.g. 🧄"`
|
||||
DaysToMaturity *int `json:"daysToMaturity" description:"optional days from planting to harvest"`
|
||||
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where the seed came from"`
|
||||
Vendor string `json:"vendor" description:"optional vendor name, e.g. \"Johnny\u0027s Selected Seeds\""`
|
||||
Vendor string `json:"vendor" description:"optional vendor name, e.g. \"Johnny's Selected Seeds\""`
|
||||
}) (any, error) {
|
||||
return a.svc.CreatePlant(ctx, a.actor, service.PlantInput{
|
||||
Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM,
|
||||
@@ -191,29 +308,58 @@ func (a *adapter) createPlant(ctx context.Context, args struct {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *adapter) updatePlant(ctx context.Context, args struct {
|
||||
PlantID int64 `json:"plantId" description:"plant to change (the user's own, from find_plant)"`
|
||||
Version int64 `json:"version" description:"the plant's current version (from find_plant)"`
|
||||
Name *string `json:"name" description:"optional new name"`
|
||||
Category *string `json:"category" description:"optional: vegetable | herb | flower | fruit | tree_shrub | cover"`
|
||||
SpacingCM *float64 `json:"spacingCm" description:"optional new mature in-row spacing in cm"`
|
||||
Color *string `json:"color" description:"optional new hex color"`
|
||||
DaysToMaturity *int `json:"daysToMaturity" description:"optional days from planting to harvest"`
|
||||
SourceURL *string `json:"sourceUrl" description:"optional http(s) link to where the seed came from"`
|
||||
Vendor *string `json:"vendor" description:"optional vendor name"`
|
||||
Notes *string `json:"notes" description:"optional free-text notes"`
|
||||
}) (any, error) {
|
||||
return a.svc.UpdatePlant(ctx, a.actor, args.PlantID, service.PlantPatch{
|
||||
Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM, Color: args.Color,
|
||||
SetDays: args.DaysToMaturity != nil, DaysToMaturity: args.DaysToMaturity,
|
||||
SourceURL: args.SourceURL, Vendor: args.Vendor, Notes: args.Notes,
|
||||
}, args.Version)
|
||||
}
|
||||
|
||||
func (a *adapter) addJournalEntry(ctx context.Context, args struct {
|
||||
GardenID int64 `json:"gardenId" description:"garden the observation is about"`
|
||||
ObjectID *int64 `json:"objectId" description:"optional bed the observation is about; omit for a garden-level note"`
|
||||
Body string `json:"body" description:"what happened, in plain words"`
|
||||
ObservedAt string `json:"observedAt" description:"optional date it happened, YYYY-MM-DD; defaults to today"`
|
||||
}) (any, error) {
|
||||
in := service.JournalInput{ObjectID: args.ObjectID, Body: args.Body}
|
||||
if args.ObservedAt != "" {
|
||||
in.ObservedAt = &args.ObservedAt
|
||||
}
|
||||
return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, in)
|
||||
return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, service.JournalInput{
|
||||
ObjectID: args.ObjectID, Body: args.Body, ObservedAt: a.day(args.ObservedAt),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *adapter) clearObject(ctx context.Context, args struct {
|
||||
ObjectID int64 `json:"objectId" description:"object to remove all plants from"`
|
||||
}) (any, error) {
|
||||
n, err := a.svc.ClearObject(ctx, a.actor, args.ObjectID)
|
||||
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{RemovedAt: a.day("")})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]int{"cleared": n}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) removePlantings(ctx context.Context, args struct {
|
||||
ObjectID int64 `json:"objectId" description:"object to remove the plant from"`
|
||||
PlantID int64 `json:"plantId" description:"the plant to remove every plop of (from describe_garden)"`
|
||||
}) (any, error) {
|
||||
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID,
|
||||
service.ClearOptions{PlantID: &args.PlantID, RemovedAt: a.day("")})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]int{"removed": n}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) readJournal(ctx context.Context, args struct {
|
||||
GardenID int64 `json:"gardenId" description:"garden whose journal to read"`
|
||||
ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"`
|
||||
@@ -236,6 +382,61 @@ func (a *adapter) readJournal(ctx context.Context, args struct {
|
||||
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
|
||||
}
|
||||
|
||||
// historyEntry is one change set as read_history reports it: the row a person
|
||||
// would read in the History panel, not the revision snapshots behind it.
|
||||
type historyEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
When string `json:"when"`
|
||||
Source string `json:"source"`
|
||||
Who string `json:"who,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Changes string `json:"changes"`
|
||||
Undone bool `json:"undone,omitempty"`
|
||||
// Undo is set when this entry is itself an undo of an earlier one.
|
||||
Undo *int64 `json:"undoOf,omitempty"`
|
||||
}
|
||||
|
||||
func (a *adapter) readHistory(ctx context.Context, args struct {
|
||||
GardenID int64 `json:"gardenId" description:"garden whose history to read"`
|
||||
Limit int `json:"limit" description:"how many of the newest entries to return (default 20, max 100)"`
|
||||
Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"`
|
||||
}) (any, error) {
|
||||
limit := args.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
sets, hasMore, err := a.svc.GardenHistory(ctx, a.actor, args.GardenID, limit, args.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]historyEntry, 0, len(sets))
|
||||
for _, cs := range sets {
|
||||
entries = append(entries, historyEntry{
|
||||
ID: cs.ID, When: cs.CreatedAt, Source: cs.Source, Who: cs.ActorName,
|
||||
Summary: cs.Summary, Changes: describeCounts(cs.Counts),
|
||||
Undone: cs.RevertedByID != nil, Undo: cs.RevertsID,
|
||||
})
|
||||
}
|
||||
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
|
||||
}
|
||||
|
||||
// describeCounts turns a change set's tallies into words: "12 plantings created,
|
||||
// 1 object updated".
|
||||
func describeCounts(counts []domain.ChangeCount) string {
|
||||
parts := make([]string, 0, len(counts))
|
||||
for _, c := range counts {
|
||||
noun := c.EntityType
|
||||
if c.N != 1 {
|
||||
noun += "s"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d %s %sd", c.N, noun, c.Op))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "nothing"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func (a *adapter) updateObject(ctx context.Context, args struct {
|
||||
ObjectID int64 `json:"objectId" description:"object to change"`
|
||||
Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
|
||||
@@ -264,9 +465,9 @@ func (a *adapter) removePlanting(ctx context.Context, args struct {
|
||||
PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"`
|
||||
Version int64 `json:"version" description:"the plop's current version (from describe_garden)"`
|
||||
}) (any, error) {
|
||||
// Soft-remove via the service, so removed_at is stamped from the same
|
||||
// (injectable) clock clear_object uses rather than the adapter's wall clock.
|
||||
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version)
|
||||
// Soft-remove via the service, dated the gardener's local day like every
|
||||
// other tool here (the service clock's UTC day when that isn't known).
|
||||
return a.svc.RemovePlantingOn(ctx, a.actor, args.PlantingID, args.Version, a.day(""))
|
||||
}
|
||||
|
||||
func (a *adapter) listSeedLots(ctx context.Context, args struct {
|
||||
@@ -290,3 +491,10 @@ func (a *adapter) recordSeedLot(ctx context.Context, args struct {
|
||||
PackedForYear: args.PackedForYear, Notes: args.Notes,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *adapter) copyGarden(ctx context.Context, args struct {
|
||||
GardenID int64 `json:"gardenId" description:"garden to duplicate (the user must own it)"`
|
||||
Name string `json:"name" description:"name for the copy; \"<garden name> — <year>\" makes it that garden's plan for the year"`
|
||||
}) (any, error) {
|
||||
return a.svc.CopyGarden(ctx, a.actor, args.GardenID, args.Name)
|
||||
}
|
||||
|
||||
+253
-14
@@ -24,7 +24,7 @@ import (
|
||||
func TestToolboxScenario(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, ownerID := newAgentTestService(t)
|
||||
box := NewToolbox(svc, ownerID)
|
||||
box := NewToolbox(svc, ownerID, "")
|
||||
|
||||
call := func(name string, args any) llm.ToolResult {
|
||||
t.Helper()
|
||||
@@ -78,12 +78,17 @@ func TestToolboxScenario(t *testing.T) {
|
||||
if len(desc.Objects) != 1 {
|
||||
t.Fatalf("objects = %d, want 1", len(desc.Objects))
|
||||
}
|
||||
// Plantings come grouped by plant: a group's Where names the region when the
|
||||
// whole group sits in one, and a small group also lists its plops.
|
||||
seen := map[string]map[string]bool{}
|
||||
for _, p := range desc.Objects[0].Plantings {
|
||||
if seen[p.Plant] == nil {
|
||||
seen[p.Plant] = map[string]bool{}
|
||||
for _, g := range desc.Objects[0].Plantings {
|
||||
if seen[g.Plant] == nil {
|
||||
seen[g.Plant] = map[string]bool{}
|
||||
}
|
||||
seen[g.Plant][g.Where] = true
|
||||
for _, p := range g.Each {
|
||||
seen[g.Plant][p.Location] = true
|
||||
}
|
||||
seen[p.Plant][p.Location] = true
|
||||
}
|
||||
if !seen["Garlic"]["NE corner"] {
|
||||
t.Errorf("garlic at %v, want NE corner", seen["Garlic"])
|
||||
@@ -108,7 +113,7 @@ func TestToolboxScenario(t *testing.T) {
|
||||
if _, err := svc.AddShare(ctx, ownerID, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||
t.Fatalf("share: %v", err)
|
||||
}
|
||||
viewerBox := NewToolbox(svc, viewerUser.ID)
|
||||
viewerBox := NewToolbox(svc, viewerUser.ID, "")
|
||||
vr := viewerBox.Execute(ctx, llm.ToolCall{ID: "2", Name: "fill_region", Arguments: mustJSON(t, map[string]any{
|
||||
"objectId": bed.ID, "region": "all", "plantId": garlic.ID,
|
||||
})})
|
||||
@@ -148,7 +153,7 @@ func mustPlant(t *testing.T, svc *service.Service, owner int64, name string, spa
|
||||
func TestGarlicBedToCucumbers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
box := NewToolbox(svc, owner)
|
||||
box := NewToolbox(svc, owner, "")
|
||||
|
||||
call := func(name string, args any) llm.ToolResult {
|
||||
t.Helper()
|
||||
@@ -233,7 +238,7 @@ func TestGarlicBedToCucumbers(t *testing.T) {
|
||||
func TestFindPlantReturnsCandidatesNotAGuess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
box := NewToolbox(svc, owner)
|
||||
box := NewToolbox(svc, owner, "")
|
||||
|
||||
mustPlant(t, svc, owner, "German Red Garlic", 15, "🧄")
|
||||
|
||||
@@ -272,7 +277,7 @@ func TestCreatePlantIsUserScoped(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
box := NewToolbox(svc, other.ID)
|
||||
box := NewToolbox(svc, other.ID, "")
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"name": "Painted Mountain Corn", "category": "vegetable",
|
||||
@@ -310,7 +315,7 @@ func TestCreatePlantIsUserScoped(t *testing.T) {
|
||||
func TestJournalToolWritesADatedObservation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
box := NewToolbox(svc, owner)
|
||||
box := NewToolbox(svc, owner, "")
|
||||
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
@@ -354,7 +359,7 @@ func TestJournalToolWritesADatedObservation(t *testing.T) {
|
||||
func TestCorrectiveTools(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
box := NewToolbox(svc, owner)
|
||||
box := NewToolbox(svc, owner, "")
|
||||
|
||||
var gid int64 // set once the garden exists; the describe closure reads it.
|
||||
call := func(name string, args any) llm.ToolResult {
|
||||
@@ -405,10 +410,10 @@ func TestCorrectiveTools(t *testing.T) {
|
||||
t.Fatalf("place_planting: %s", r.Content)
|
||||
}
|
||||
d = describe()
|
||||
if len(d.Objects[0].Plantings) != 1 {
|
||||
t.Fatalf("want 1 plop before removal, got %d", len(d.Objects[0].Plantings))
|
||||
if len(d.Objects[0].Plantings) != 1 || len(d.Objects[0].Plantings[0].Each) != 1 {
|
||||
t.Fatalf("want 1 plop before removal, got %+v", d.Objects[0].Plantings)
|
||||
}
|
||||
plop := d.Objects[0].Plantings[0]
|
||||
plop := d.Objects[0].Plantings[0].Each[0]
|
||||
if r := call("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}); r.IsError {
|
||||
t.Fatalf("remove_planting: %s", r.Content)
|
||||
}
|
||||
@@ -487,3 +492,237 @@ func newAgentTestService(t *testing.T) (*service.Service, int64) {
|
||||
}
|
||||
return svc, owner.ID
|
||||
}
|
||||
|
||||
// TestToolsFromTheLiveSweep covers what a day of driving the live assistant
|
||||
// asked for: grouped describes, whole-group removal, moves that keep the
|
||||
// planting date, fills by rectangle, seed attribution, catalog edits, history
|
||||
// reads, plan copies — and every date stamped the gardener's local day rather
|
||||
// than the server's (UTC) or the model's (a year from its training data).
|
||||
func TestToolsFromTheLiveSweep(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
const today = "2026-08-22"
|
||||
box := NewToolbox(svc, owner, today)
|
||||
call := func(name string, args any) llm.ToolResult {
|
||||
t.Helper()
|
||||
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
|
||||
}
|
||||
ok := func(name string, args any) string {
|
||||
t.Helper()
|
||||
r := call(name, args)
|
||||
if r.IsError {
|
||||
t.Fatalf("%s: %s", name, r.Content)
|
||||
}
|
||||
return r.Content
|
||||
}
|
||||
decode := func(raw string, into any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal([]byte(raw), into); err != nil {
|
||||
t.Fatalf("decode %v: %s", err, raw)
|
||||
}
|
||||
}
|
||||
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000, UnitPref: domain.UnitImperial})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄")
|
||||
beet := mustPlant(t, svc, owner, "Beet", 10, "🌱")
|
||||
tomato := mustPlant(t, svc, owner, "Cherokee Purple", 60, "🍅")
|
||||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "South bed", XCM: 1000, YCM: 1000, WidthCM: 240, HeightCM: 120})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
other, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "North bed", XCM: 1000, YCM: 300, WidthCM: 240, HeightCM: 120})
|
||||
if err != nil {
|
||||
t.Fatalf("other bed: %v", err)
|
||||
}
|
||||
lot, err := svc.CreateSeedLot(ctx, owner, service.SeedLotInput{PlantID: beet.ID, Quantity: 500, Unit: "seeds"})
|
||||
if err != nil {
|
||||
t.Fatalf("lot: %v", err)
|
||||
}
|
||||
|
||||
// fill_region by rectangle (the middle third of the bed's width), in grid mode,
|
||||
// charged to the lot, dated today by default.
|
||||
ok("fill_region", map[string]any{
|
||||
"objectId": bed.ID, "plantId": beet.ID, "mode": "grid", "seedLotId": lot.ID,
|
||||
"x0Cm": -40.0, "y0Cm": -60.0, "x1Cm": 40.0, "y1Cm": 60.0,
|
||||
})
|
||||
// Neither a region nor a full rectangle is a mistake the model can read.
|
||||
if r := call("fill_region", map[string]any{"objectId": bed.ID, "plantId": beet.ID, "x0Cm": -40.0}); !r.IsError || !strings.Contains(r.Content, "x0Cm, y0Cm, x1Cm, y1Cm") {
|
||||
t.Errorf("half a rectangle: %+v, want a readable refusal", r)
|
||||
}
|
||||
if r := call("fill_region", map[string]any{"objectId": bed.ID, "plantId": beet.ID}); !r.IsError {
|
||||
t.Error("fill_region with nowhere to fill succeeded")
|
||||
}
|
||||
// place_planting without a radius → one plant at half the spacing; two garlic
|
||||
// cloves along the north edge, dated today.
|
||||
ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": garlic.ID, "xCm": -100, "yCm": -50})
|
||||
ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": garlic.ID, "xCm": 100, "yCm": -50})
|
||||
// And a tomato planted back in May, with an explicit date.
|
||||
ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": tomato.ID, "xCm": 0, "yCm": 0, "plantedAt": "2026-05-20"})
|
||||
|
||||
// describe_garden: one group per plant, dated; only the small ones listed.
|
||||
var d service.DescribeResult
|
||||
groups := func() map[string]service.DescribeGroup {
|
||||
t.Helper()
|
||||
decode(ok("describe_garden", map[string]any{"gardenId": g.ID}), &d)
|
||||
out := map[string]service.DescribeGroup{}
|
||||
for _, o := range d.Objects {
|
||||
if o.ID == bed.ID {
|
||||
for _, gr := range o.Plantings {
|
||||
out[gr.Plant] = gr
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
gs := groups()
|
||||
beets := gs["Beet"]
|
||||
if beets.Plops <= 8 || beets.Each != nil {
|
||||
t.Errorf("beets: %d plops, each=%v; want a large group with no per-plop listing", beets.Plops, beets.Each)
|
||||
}
|
||||
if beets.PlantedAt != today || beets.Plants != beets.Plops {
|
||||
t.Errorf("beets plantedAt %q plants %d; want today and one plant per grid plop", beets.PlantedAt, beets.Plants)
|
||||
}
|
||||
if !strings.Contains(beets.Where, "cm from the centre") {
|
||||
t.Errorf("beets where = %q, want the bounding box of a middle-third fill", beets.Where)
|
||||
}
|
||||
cloves := gs["Garlic"]
|
||||
if cloves.Plops != 2 || len(cloves.Each) != 2 || cloves.Where != "north half" || cloves.PlantedAt != today {
|
||||
t.Errorf("garlic group = %+v, want 2 listed plops in the north half, dated today", cloves)
|
||||
}
|
||||
if r := cloves.Each[0].RadiusCM; r != 7.5 {
|
||||
t.Errorf("a clove placed without a radius got %v, want spacing/2 = 7.5", r)
|
||||
}
|
||||
tom := gs["Cherokee Purple"]
|
||||
if tom.Plops != 1 || tom.Where != "center" || tom.PlantedAt != "2026-05-20" {
|
||||
t.Errorf("tomato group = %+v, want one plop at the center dated 2026-05-20", tom)
|
||||
}
|
||||
|
||||
// The lot counts the beets as used.
|
||||
var lots []struct {
|
||||
Used float64 `json:"used"`
|
||||
Remaining float64 `json:"remaining"`
|
||||
}
|
||||
decode(ok("list_seed_lots", map[string]any{"plantId": beet.ID}), &lots)
|
||||
if len(lots) != 1 || lots[0].Used != float64(beets.Plants) || lots[0].Remaining != 500-float64(beets.Plants) {
|
||||
t.Errorf("lots = %+v, want %d used of 500", lots, beets.Plants)
|
||||
}
|
||||
|
||||
// list_plantings spells the big group out, narrowed to one plant.
|
||||
var listed []service.DescribePlanting
|
||||
decode(ok("list_plantings", map[string]any{"objectId": bed.ID, "plantId": beet.ID}), &listed)
|
||||
if len(listed) != beets.Plops {
|
||||
t.Errorf("list_plantings: %d beets, want %d", len(listed), beets.Plops)
|
||||
}
|
||||
|
||||
// move_planting: the tomato to the north bed, date kept; a within-bed move too.
|
||||
var moved domain.Planting
|
||||
decode(ok("move_planting", map[string]any{
|
||||
"plantingId": tom.Each[0].ID, "version": tom.Each[0].Version, "toObjectId": other.ID, "xCm": 10.0, "yCm": -20.0,
|
||||
}), &moved)
|
||||
if moved.ObjectID != other.ID || moved.PlantedAt == nil || *moved.PlantedAt != "2026-05-20" {
|
||||
t.Errorf("moved tomato = %+v, want it in the north bed with its May date", moved)
|
||||
}
|
||||
decode(ok("move_planting", map[string]any{
|
||||
"plantingId": cloves.Each[0].ID, "version": cloves.Each[0].Version, "xCm": -110.0, "yCm": -55.0,
|
||||
}), &moved)
|
||||
if moved.ObjectID != bed.ID || moved.XCM != -110 {
|
||||
t.Errorf("within-bed move = %+v, want the same bed at x=-110", moved)
|
||||
}
|
||||
|
||||
// remove_plantings: the beets out, the garlic stays — dated today.
|
||||
var removed struct {
|
||||
Removed int `json:"removed"`
|
||||
}
|
||||
decode(ok("remove_plantings", map[string]any{"objectId": bed.ID, "plantId": beet.ID}), &removed)
|
||||
if removed.Removed != beets.Plops {
|
||||
t.Errorf("remove_plantings removed %d, want the %d beets", removed.Removed, beets.Plops)
|
||||
}
|
||||
gs = groups()
|
||||
if _, still := gs["Beet"]; still || gs["Garlic"].Plops != 2 {
|
||||
t.Errorf("after remove_plantings the bed has %+v, want the garlic only", gs)
|
||||
}
|
||||
var pulled domain.Planting
|
||||
decode(ok("remove_planting", map[string]any{"plantingId": gs["Garlic"].Each[0].ID, "version": gs["Garlic"].Each[0].Version}), &pulled)
|
||||
if pulled.RemovedAt == nil || *pulled.RemovedAt != today {
|
||||
t.Errorf("remove_planting dated the removal %v, want today %s", pulled.RemovedAt, today)
|
||||
}
|
||||
|
||||
// read_history sees all of that, newest first, and marks what was undone.
|
||||
var hist struct {
|
||||
Entries []historyEntry `json:"entries"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
decode(ok("read_history", map[string]any{"gardenId": g.ID, "limit": 3}), &hist)
|
||||
if len(hist.Entries) != 3 || !hist.HasMore {
|
||||
t.Fatalf("read_history = %d entries, hasMore=%v; want 3 and more", len(hist.Entries), hist.HasMore)
|
||||
}
|
||||
if e := hist.Entries[1]; !strings.HasPrefix(e.Summary, "Removed Beet from South bed") || !strings.Contains(e.Changes, "planting") || e.Undone {
|
||||
t.Errorf("entry = %+v, want the beet removal, not undone", e)
|
||||
}
|
||||
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, hist.Entries[1].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
decode(ok("read_history", map[string]any{"gardenId": g.ID, "limit": 3}), &hist)
|
||||
if hist.Entries[0].Undo == nil || !hist.Entries[2].Undone {
|
||||
t.Errorf("after an undo: newest = %+v, undone = %+v; want the revert to point at the removal, and the removal marked undone", hist.Entries[0], hist.Entries[2])
|
||||
}
|
||||
|
||||
// update_plant on the user's own plant; a built-in is refused.
|
||||
var matches []struct {
|
||||
ID int64 `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
decode(ok("find_plant", map[string]any{"query": "cherokee"}), &matches)
|
||||
var updated domain.Plant
|
||||
decode(ok("update_plant", map[string]any{"plantId": matches[0].ID, "version": matches[0].Version, "daysToMaturity": 75}), &updated)
|
||||
if updated.DaysToMaturity == nil || *updated.DaysToMaturity != 75 || updated.Name != "Cherokee Purple" {
|
||||
t.Errorf("update_plant = %+v, want days 75 and the name untouched", updated)
|
||||
}
|
||||
decode(ok("find_plant", map[string]any{"query": "basil"}), &matches)
|
||||
if r := call("update_plant", map[string]any{"plantId": matches[0].ID, "version": matches[0].Version, "daysToMaturity": 60}); !r.IsError {
|
||||
t.Error("update_plant changed a built-in")
|
||||
}
|
||||
|
||||
// add_journal_entry is dated today unless told otherwise.
|
||||
ok("add_journal_entry", map[string]any{"gardenId": g.ID, "body": "aphids on the beets"})
|
||||
entries, _, err := svc.ListJournal(ctx, owner, g.ID, service.JournalQuery{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListJournal: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].ObservedAt != today {
|
||||
t.Errorf("journal = %+v, want one entry observed %s", entries, today)
|
||||
}
|
||||
|
||||
// copy_garden makes next year's plan: a whole copy under the plan name.
|
||||
var plan domain.Garden
|
||||
decode(ok("copy_garden", map[string]any{"gardenId": g.ID, "name": "Plot — 2027"}), &plan)
|
||||
if plan.Name != "Plot — 2027" || plan.ID == g.ID {
|
||||
t.Errorf("copy_garden = %+v, want a new garden named for the plan", plan)
|
||||
}
|
||||
decode(ok("describe_garden", map[string]any{"gardenId": plan.ID}), &d)
|
||||
if len(d.Objects) != 2 {
|
||||
t.Errorf("the plan copy has %d objects, want the source's 2", len(d.Objects))
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolsDefaultToTheServiceDayWithoutOne — a toolbox built with no local day
|
||||
// (a bare API caller) still dates everything: the service's UTC today.
|
||||
func TestToolsDefaultToTheServiceDayWithoutOne(t *testing.T) {
|
||||
a := &adapter{today: ""}
|
||||
if d := a.day(""); d != nil {
|
||||
t.Errorf("no day at all → %q, want nil (the service default)", *d)
|
||||
}
|
||||
if d := a.day(" 2026-01-02 "); d == nil || *d != "2026-01-02" {
|
||||
t.Errorf("an explicit day → %v, want it trimmed", d)
|
||||
}
|
||||
a.today = "2026-08-22"
|
||||
if d := a.day(""); d == nil || *d != "2026-08-22" {
|
||||
t.Errorf("the gardener's day → %v, want 2026-08-22", d)
|
||||
}
|
||||
if d := a.day("2026-05-20"); d == nil || *d != "2026-05-20" {
|
||||
t.Errorf("an explicit day beats the default: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user