Agent: a turn that changed nothing cannot say it did #132

Merged
steve merged 2 commits from feat/agent-honest-turns into main 2026-08-23 06:52:43 +00:00
4 changed files with 243 additions and 19 deletions
+7
View File
@@ -192,6 +192,13 @@ Conventions that follow from it:
the argument is what makes the rule visible in the schema, the prompt is what the argument is what makes the rule visible in the schema, the prompt is what
makes the model ask. Neither is a guarantee, and a new outward-facing tool makes the model ask. Neither is a guarantee, and a new outward-facing tool
gets the same pair. gets the same pair.
- **A turn that changed nothing cannot say it did.** `honestReply` in
`runtime.go` appends a correction when the reply claims a change ("Done —
I've deleted…") and no non-read-only tool call succeeded in the run. Live,
glm-5.2 did this twice in one session (a journal entry, then a seed lot:
"Done", nothing deleted). The prompt rule stays; the guard is for when the
model ignores it. `readOnlyTools` must list every tool that changes nothing
— a new read-only tool left out of it makes a turn look like it acted.
- **Garden notes are the assistant's memory.** `systemPrompt` quotes - **Garden notes are the assistant's memory.** `systemPrompt` quotes
`Garden.Notes` (owner-written, `%q`) as standing context, and `update_garden` `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, is how the model adds "we're in zone 6a" to them. Notes are replaced whole,
+97 -1
View File
@@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"regexp"
"strings" "strings"
"time" "time"
@@ -168,6 +169,12 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, toda
if result != nil { if result != nil {
turn.Reply = result.Output turn.Reply = result.Output
turn.Steps = len(result.Steps) turn.Steps = len(result.Steps)
if corrected := honestReply(turn.Reply, result, tools); corrected != turn.Reply {
// The steps are logged so the mechanism can be read off the log
// next time — which tool it tried, what came back, what it said.
slog.Warn("agent: reply claimed a change no tool made", "run", runID, "garden", gardenID, "steps", describeSteps(result))
turn.Reply = corrected
}
} }
if turn.Reply == "" { if turn.Reply == "" {
turn.Reply = fallbackReply(turn) turn.Reply = fallbackReply(turn)
@@ -175,6 +182,92 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, toda
return turn, nil return turn, nil
} }
// readOnlyTools are the tools whose success changes nothing — a turn made of
// these alone has not done anything, whatever its reply says.
var readOnlyTools = map[string]bool{
Review

🟠 public_link action=get bypasses honestReply: acted() returns true for a read-only call, silencing the false-claim correction

correctness, maintainability · flagged by 3 models

  • internal/agent/runtime.go:187–191public_link action=get bypasses the honest-reply guard (confirmed)

🪰 Gadfly · advisory

🟠 **public_link action=get bypasses honestReply: acted() returns true for a read-only call, silencing the false-claim correction** _correctness, maintainability · flagged by 3 models_ - **`internal/agent/runtime.go:187–191` — `public_link action=get` bypasses the honest-reply guard** (confirmed) <sub>🪰 Gadfly · advisory</sub>
"list_gardens": true, "describe_garden": true, "list_years": true, "list_plantings": true,
"find_plant": true, "read_journal": true, "read_history": true, "list_seed_lots": true,
"list_shares": true,
}
// selfReportingTools succeed without necessarily changing anything —
// public_link with action=get reads, and undo_change with nothing left to
// revert reverts nothing — so their results don't count; the adapter says
// whether they changed something (adapter.changed).
var selfReportingTools = map[string]bool{"public_link": true, "undo_change": true}
// changeVerbs are the past participles a claim of change is made of. One
// list, used by both shapes the claim takes.
const changeVerbs = `deleted|removed|updated|changed|added|saved|moved|planted|filled|cleared|corrected|recorded|marked|shared|renamed|reverted|undone|set|put|pulled|replaced|swapped|rotated|rewrote|rewritten|edited|created|started|attached|restored|made`
// changeClaim matches a reply that reports a change as made: a "Done"/"Fixed"/
// "Undone" opener, or a first-person past-tense claim ("I've deleted", "I
// moved"). A question or an offer ("want me to delete it?", "I'll remove it")
// does not match — only a claim of something already done. The opener list is
// short on purpose: "Updated totals:" opening a read-only answer must not
// trip it, and those replies say "I've …" when they mean a change.
var changeClaim = regexp.MustCompile(`(?i)(?:^\s*(?:done|fixed|undone)\b|\bI(?:'ve| have)? (?:just |now |also |already )?(?:` + changeVerbs + `)\b)`)
// unbackedClaim is what the person reads under a claim no tool backs up.
const unbackedClaim = "\n\n_Correction: nothing actually changed in this turn — no tool call that changes anything succeeded. Ask again and I'll do it properly._"
// describeSteps summarizes a run for a log line: per step, the tools it
// called (with ! on a failure) and the start of what the model said.
func describeSteps(r *agent.Result) string {
parts := make([]string, 0, len(r.Steps))
for _, st := range r.Steps {
var b strings.Builder
Review

text[:80] byte-slice can split a multibyte rune, producing invalid UTF-8 in the log line

error-handling · flagged by 2 models

  • internal/agent/runtime.go:219text[:80] slices on a byte boundary and can split a multibyte rune. Model text often contains non-ASCII (em-dashes, accented plant names); truncating at byte 80 can cut mid-rune, yielding invalid UTF-8 in the %q-formatted log line. No panic and no user impact (log-only; %q escapes the bad bytes), but trivially wrong. Fix: truncate on a rune boundary. Severity trivial.

🪰 Gadfly · advisory

⚪ **text[:80] byte-slice can split a multibyte rune, producing invalid UTF-8 in the log line** _error-handling · flagged by 2 models_ - **`internal/agent/runtime.go:219` — `text[:80]` slices on a byte boundary and can split a multibyte rune.** Model text often contains non-ASCII (em-dashes, accented plant names); truncating at byte 80 can cut mid-rune, yielding invalid UTF-8 in the `%q`-formatted log line. No panic and no user impact (log-only; `%q` escapes the bad bytes), but trivially wrong. Fix: truncate on a rune boundary. Severity trivial. <sub>🪰 Gadfly · advisory</sub>
fmt.Fprintf(&b, "%d:", st.Index)
for _, res := range st.Results {
b.WriteString(" " + res.Name)
if res.IsError {
b.WriteString("!")
}
}
if st.Response != nil {
if text := strings.Join(strings.Fields(st.Response.Text()), " "); text != "" {
// Cut on a rune boundary, like turnSummary: a byte slice can
// split a multibyte character and log invalid UTF-8.
if runes := []rune(text); len(runes) > 80 {
text = string(runes[:80]) + "…"
}
fmt.Fprintf(&b, " %q", text)
Review

🟠 undo_change that reverts nothing (cs==nil) counts as 'acted', so honestReply misses a false 'I've undone it' claim

error-handling · flagged by 1 model

  • internal/agent/runtime.go:234 — a successful tool that changed nothing still counts as "acted", so the guard has a hole for exactly the case it targets. acted returns true for any !IsError && !readOnlyTools[name] result. But undo_change returns a non-error result when it reverted nothing: undoChange (tools.go:716) handles cs == nil ("every revision was a conflict, or the set was empty") by returning (res, nil) with res.Changes = "nothing". So a turn where the model ca…

🪰 Gadfly · advisory

🟠 **undo_change that reverts nothing (cs==nil) counts as 'acted', so honestReply misses a false 'I've undone it' claim** _error-handling · flagged by 1 model_ - **`internal/agent/runtime.go:234` — a successful tool that changed nothing still counts as "acted", so the guard has a hole for exactly the case it targets.** `acted` returns true for any `!IsError && !readOnlyTools[name]` result. But `undo_change` returns a **non-error** result when it reverted nothing: `undoChange` (`tools.go:716`) handles `cs == nil` ("every revision was a conflict, or the set was empty") by returning `(res, nil)` with `res.Changes = "nothing"`. So a turn where the model ca… <sub>🪰 Gadfly · advisory</sub>
}
}
parts = append(parts, b.String())
}
return strings.Join(parts, " | ")
}
// acted reports whether the run changed anything: a successful call to a tool
// that is neither read-only nor self-reporting, or a self-reporting tool that
// told the adapter it changed something.
func acted(r *agent.Result, tools *adapter) bool {
for _, st := range r.Steps {
for _, res := range st.Results {
if !res.IsError && !readOnlyTools[res.Name] && !selfReportingTools[res.Name] {
return true
}
}
Review

🟡 changeClaim opener false-positives on read-only informational replies starting with 'Done'/'Updated', appending a bogus correction

error-handling · flagged by 1 model

  • internal/agent/runtime.go:251 — the guard false-positives on a read-only turn whose reply merely opens with a change verb. The ^\s*(?:done|fixed|updated|…)\b opener matches replies like "Done — here's your garden: …" or "Updated: you have 12 beds" to a pure information request answered with only read-only tools (acted() == false). Such a turn gets "nothing actually changed in this turn … Ask again and I'll do it properly" appended even though the user never asked for a change —…

🪰 Gadfly · advisory

🟡 **changeClaim opener false-positives on read-only informational replies starting with 'Done'/'Updated', appending a bogus correction** _error-handling · flagged by 1 model_ - **`internal/agent/runtime.go:251` — the guard false-positives on a read-only turn whose reply merely opens with a change verb.** The `^\s*(?:done|fixed|updated|…)\b` opener matches replies like *"Done — here's your garden: …"* or *"Updated: you have 12 beds"* to a pure information request answered with only read-only tools (`acted() == false`). Such a turn gets *"nothing actually changed in this turn … Ask again and I'll do it properly"* appended even though the user never asked for a change —… <sub>🪰 Gadfly · advisory</sub>
}
return tools != nil && tools.didChange()
}
// honestReply appends a correction to a reply that claims a change when no
// tool call in the run made one. The prompt already forbids this, and the
// live model did it anyway: asked to delete a journal entry it answered
// "Done — I've deleted it" having deleted nothing, and the entry was found
// still there a turn later. The person should hear that from the app, not
// discover it. A reply that claims nothing, or a run in which some change
// succeeded, passes through unchanged — this cannot tell a true claim from a
// false one once anything at all was done, so it only speaks when nothing was.
func honestReply(reply string, r *agent.Result, tools *adapter) string {
if r == nil || acted(r, tools) || !changeClaim.MatchString(reply) {
return reply
}
return reply + unbackedClaim
}
// isLoopLimit reports whether an error is one of majordomo's loop guards firing // isLoopLimit reports whether an error is one of majordomo's loop guards firing
// rather than a genuine failure. Those runs have a partial result worth keeping. // rather than a genuine failure. Those runs have a partial result worth keeping.
func isLoopLimit(err error) bool { func isLoopLimit(err error) bool {
@@ -318,7 +411,10 @@ How to work:
How to behave: How to behave:
- Only claim what a tool actually did. If a tool failed, or there is no tool for what was asked, - 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, and never say something is undone say so plainly — never describe a change you did not make, and never say something is undone
unless undo_change did it. unless undo_change did it. A tool result that is an error means the thing did not happen: say
it failed and why, and what you will try instead. Before you say you deleted, changed or added
something, there must be a successful tool result for it in THIS turn — an earlier turn does
not count, and neither does meaning to.
- Every reply of yours that changed the garden has an "Undo this" button under it, and the - 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. 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" - When a request could mean materially different things — "swap the cucumbers and the melons"
+78
View File
@@ -591,3 +591,81 @@ func TestSystemPromptCarriesTheGardenersNotes(t *testing.T) {
t.Error("a garden without notes doesn't say so") t.Error("a garden without notes doesn't say so")
} }
} }
// TestAClaimedChangeNoToolMadeIsCorrected — the live model, asked to delete a
// journal entry, answered "Done — I've deleted it" having called nothing, and
// the entry was found still there a turn later. The prompt forbids that; the
// run now catches it too: a reply that claims a change, in a turn where no
// tool call changed anything, gets a correction the person can read.
func TestAClaimedChangeNoToolMadeIsCorrected(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)
}
entry, err := svc.CreateJournalEntry(ctx, owner, g.ID, service.JournalInput{Body: "Aphids on the cucumbers."})
if err != nil {
t.Fatalf("journal: %v", err)
}
run := func(steps ...fake.Step) string {
t.Helper()
turn, err := scriptedRunner(t, svc, steps...).Run(ctx, owner, g.ID, "delete that note", "", nil, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
return turn.Reply
}
corrected := func(reply string) bool { return strings.Contains(reply, "nothing actually changed") }
// No tool at all, a confident claim: corrected.
if r := run(fake.Reply("Done — I've deleted the journal entry about the aphids.")); !corrected(r) {
t.Errorf("a claim with no tool call passed uncorrected: %q", r)
}
// Only reads, then a claim: corrected.
if r := run(toolCall("read_journal", map[string]any{"gardenId": g.ID}), fake.Reply("I've deleted it.")); !corrected(r) {
t.Errorf("a claim over read-only calls passed uncorrected: %q", r)
}
// A tool that FAILED, then a claim: corrected — and the failure names the
// entry and where the ids come from, so a model that reads it has no
// excuse to guess again.
box := NewToolbox(svc, owner, "")
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "delete_journal_entry", Arguments: mustJSON(t, map[string]any{"entryId": 999})})
if !res.IsError || !strings.Contains(res.Content, "read_journal") || !strings.Contains(res.Content, "nothing was changed") {
t.Errorf("deleting a missing entry = %q, want a refusal naming read_journal and saying nothing changed", res.Content)
}
if r := run(toolCall("delete_journal_entry", map[string]any{"entryId": 999}), fake.Reply("Done — it's gone.")); !corrected(r) {
t.Errorf("a claim over a failed call passed uncorrected: %q", r)
}
// No claim: nothing appended, whatever the tools did.
if r := run(fake.Reply("That note is still there — want me to delete it?")); corrected(r) {
t.Errorf("an offer was corrected as if it were a claim: %q", r)
}
// Reading the public link is not a change, whatever its tool name.
if r := run(toolCall("public_link", map[string]any{"gardenId": g.ID, "action": "get"}), fake.Reply("Done — I've turned the public link on.")); !corrected(r) {
t.Errorf("a claim over public_link get passed uncorrected: %q", r)
}
// An undo that had nothing to revert is not a change either.
history, _, _ := svc.GardenHistory(ctx, owner, g.ID, 1, 0)
if len(history) > 0 {
if _, _, err := svc.RevertChangeSet(ctx, owner, history[0].ID, domain.SourceUI); err != nil {
t.Fatalf("pre-revert: %v", err)
}
if r := run(toolCall("undo_change", map[string]any{"changeSetId": history[0].ID}), fake.Reply("Undone — it's back the way it was.")); !corrected(r) {
t.Errorf("a claim over an undo that reverted nothing passed uncorrected: %q", r)
}
}
// A real deletion: the claim stands.
r := run(toolCall("delete_journal_entry", map[string]any{"entryId": entry.ID}), fake.Reply("Done — I've deleted the journal entry."))
if corrected(r) {
t.Errorf("a true claim was corrected: %q", r)
}
// A read-only answer that happens to open with a participle is left alone.
if r := run(toolCall("describe_garden", map[string]any{"gardenId": g.ID}), fake.Reply("Updated totals: 0 plantings. Nothing is in the ground.")); corrected(r) {
t.Errorf("an informational reply was corrected: %q", r)
}
if _, _, err := svc.ListJournal(ctx, owner, g.ID, service.JournalQuery{}); err != nil {
t.Fatalf("journal after: %v", err)
}
}
+61 -18
View File
@@ -157,7 +157,8 @@ func newToolbox(svc *service.Service, actorID int64, today string) (*llm.Toolbox
a.updateJournalEntry), a.updateJournalEntry),
llm.DefineTool("delete_journal_entry", llm.DefineTool("delete_journal_entry",
"Delete a journal entry, by its id from read_journal. This is permanent — the journal is "+ "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.", "not in the undo history — so delete only the entry the user pointed at. Get the id "+
"from read_journal in this turn: ids from earlier turns are not in front of you.",
a.deleteJournalEntry), a.deleteJournalEntry),
llm.DefineTool("read_history", llm.DefineTool("read_history",
"Read the garden's change history: every change anyone made — by hand in the editor, or "+ "Read the garden's change history: every change anyone made — by hand in the editor, or "+
@@ -227,7 +228,8 @@ func newToolbox(svc *service.Service, actorID int64, today string) (*llm.Toolbox
llm.DefineTool("delete_seed_lot", llm.DefineTool("delete_seed_lot",
"Delete a seed lot the user recorded, by its id from list_seed_lots. Plantings attributed "+ "Delete a seed lot the user recorded, by its id from list_seed_lots. Plantings attributed "+
"to it stay in the garden, just no longer linked to a purchase. Permanent — seed lots "+ "to it stay in the garden, just no longer linked to a purchase. Permanent — seed lots "+
"are not in the undo history — so delete only the lot the user pointed at.", "are not in the undo history — so delete only the lot the user pointed at. Get the id "+
"from list_seed_lots in this turn: ids from earlier turns are not in front of you.",
a.deleteSeedLot), a.deleteSeedLot),
llm.DefineTool("delete_plant", llm.DefineTool("delete_plant",
"Delete a plant from the user's own catalog — a duplicate or a mistake. Refused while "+ "Delete a plant from the user's own catalog — a duplicate or a mistake. Refused while "+
@@ -275,6 +277,10 @@ type adapter struct {
today string today string
mu sync.Mutex mu sync.Mutex
// changed is set by the tools whose success does not itself mean a change
// — public_link (get reads) and undo_change (nothing left to revert) — when
// they did change something, for Run's honesty check.
changed bool
// reverts is every change set undo_change produced this turn. A revert is // 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 // 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 // marked undone), so it never joins the turn's scope — which leaves a turn
@@ -283,6 +289,20 @@ type adapter struct {
reverts []int64 reverts []int64
} }
// noteChange records that a self-reporting tool changed something.
func (a *adapter) noteChange() {
a.mu.Lock()
a.changed = true
a.mu.Unlock()
}
// didChange reports whether a self-reporting tool changed something this turn.
func (a *adapter) didChange() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.changed
}
// lastRevert is the newest change set undo_change produced this turn, if any. // lastRevert is the newest change set undo_change produced this turn, if any.
func (a *adapter) lastRevert() *int64 { func (a *adapter) lastRevert() *int64 {
a.mu.Lock() a.mu.Lock()
@@ -391,8 +411,9 @@ func (a *adapter) moveObject(ctx context.Context, args struct {
YCM float64 `json:"yCm" description:"new center y in garden cm"` YCM float64 `json:"yCm" description:"new center y in garden cm"`
Version int64 `json:"version" description:"the object's current version (from describe_garden)"` Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
}) (any, error) { }) (any, error) {
return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, o, err := a.svc.UpdateObject(ctx, a.actor, args.ObjectID,
service.ObjectPatch{XCM: &args.XCM, YCM: &args.YCM}, args.Version) service.ObjectPatch{XCM: &args.XCM, YCM: &args.YCM}, args.Version)
return o, whenMissing(err, "object", args.ObjectID, "describe_garden")
} }
func (a *adapter) placePlanting(ctx context.Context, args struct { func (a *adapter) placePlanting(ctx context.Context, args struct {
@@ -468,8 +489,9 @@ func (a *adapter) movePlanting(ctx context.Context, args struct {
YCM float64 `json:"yCm" description:"new center y in the destination object's local frame (cm)"` 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"` 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) { }) (any, error) {
return a.svc.MovePlanting(ctx, a.actor, args.PlantingID, pl, err := a.svc.MovePlanting(ctx, a.actor, args.PlantingID,
service.MoveInput{ToObjectID: args.ToObjectID, XCM: args.XCM, YCM: args.YCM}, args.Version) service.MoveInput{ToObjectID: args.ToObjectID, XCM: args.XCM, YCM: args.YCM}, args.Version)
return pl, whenMissing(err, "planting", args.PlantingID, "list_plantings")
} }
func (a *adapter) findPlant(ctx context.Context, args struct { func (a *adapter) findPlant(ctx context.Context, args struct {
@@ -507,11 +529,12 @@ func (a *adapter) updatePlant(ctx context.Context, args struct {
Vendor *string `json:"vendor" description:"optional vendor name"` Vendor *string `json:"vendor" description:"optional vendor name"`
Notes *string `json:"notes" description:"optional free-text notes"` Notes *string `json:"notes" description:"optional free-text notes"`
}) (any, error) { }) (any, error) {
return a.svc.UpdatePlant(ctx, a.actor, args.PlantID, service.PlantPatch{ p, err := a.svc.UpdatePlant(ctx, a.actor, args.PlantID, service.PlantPatch{
Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM, Color: args.Color, Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM, Color: args.Color,
SetDays: args.DaysToMaturity != nil, DaysToMaturity: args.DaysToMaturity, SetDays: args.DaysToMaturity != nil, DaysToMaturity: args.DaysToMaturity,
SourceURL: args.SourceURL, Vendor: args.Vendor, Notes: args.Notes, SourceURL: args.SourceURL, Vendor: args.Vendor, Notes: args.Notes,
}, args.Version) }, args.Version)
return p, whenMissing(err, "plant of the user's own", args.PlantID, "find_plant")
} }
func (a *adapter) addJournalEntry(ctx context.Context, args struct { func (a *adapter) addJournalEntry(ctx context.Context, args struct {
@@ -539,7 +562,7 @@ func (a *adapter) clearObject(ctx context.Context, args struct {
} }
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{RemovedAt: on}) n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{RemovedAt: on})
if err != nil { if err != nil {
return nil, err return nil, whenMissing(err, "object", args.ObjectID, "describe_garden")
} }
return map[string]int{"cleared": n}, nil return map[string]int{"cleared": n}, nil
} }
@@ -560,7 +583,7 @@ func (a *adapter) removePlantings(ctx context.Context, args struct {
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID,
service.ClearOptions{PlantID: &args.PlantID, RemovedAt: on}) service.ClearOptions{PlantID: &args.PlantID, RemovedAt: on})
if err != nil { if err != nil {
return nil, err return nil, whenMissing(err, "object", args.ObjectID, "describe_garden")
} }
return map[string]int{"removed": n}, nil return map[string]int{"removed": n}, nil
} }
@@ -608,7 +631,19 @@ func (a *adapter) updatePlanting(ctx context.Context, args struct {
case args.SeedLotID != nil: case args.SeedLotID != nil:
patch.SetSeedLotID, patch.SeedLotID = true, args.SeedLotID patch.SetSeedLotID, patch.SeedLotID = true, args.SeedLotID
} }
return a.svc.UpdatePlanting(ctx, a.actor, args.PlantingID, patch, args.Version) pl, err := a.svc.UpdatePlanting(ctx, a.actor, args.PlantingID, patch, args.Version)
return pl, whenMissing(err, "planting", args.PlantingID, "list_plantings")
}
// whenMissing turns the store's bare "not found" into a message that says what
// was not found and where the ids come from, so the model re-reads instead of
// guessing at another id — or, as the live one did, reporting success over
// the error.
func whenMissing(err error, what string, id int64, from string) error {
if errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("%w: no %s with id %d that you can act on — %s lists the ids; nothing was changed", domain.ErrNotFound, what, id, from)
}
return err
} }
// parseDay checks a date the model typed, so a malformed one fails with a // parseDay checks a date the model typed, so a malformed one fails with a
@@ -660,15 +695,16 @@ func (a *adapter) updateJournalEntry(ctx context.Context, args struct {
} }
observed = &on observed = &on
} }
return a.svc.UpdateJournalEntry(ctx, a.actor, args.EntryID, e, err := a.svc.UpdateJournalEntry(ctx, a.actor, args.EntryID,
service.JournalPatch{Body: args.Body, ObservedAt: observed}, args.Version) service.JournalPatch{Body: args.Body, ObservedAt: observed}, args.Version)
return e, whenMissing(err, "journal entry", args.EntryID, "read_journal")
} }
func (a *adapter) deleteJournalEntry(ctx context.Context, args struct { func (a *adapter) deleteJournalEntry(ctx context.Context, args struct {
EntryID int64 `json:"entryId" description:"journal entry to delete (its id from read_journal)"` EntryID int64 `json:"entryId" description:"journal entry to delete (its id from read_journal)"`
}) (any, error) { }) (any, error) {
if err := a.svc.DeleteJournalEntry(ctx, a.actor, args.EntryID); err != nil { if err := a.svc.DeleteJournalEntry(ctx, a.actor, args.EntryID); err != nil {
return nil, err return nil, whenMissing(err, "journal entry", args.EntryID, "read_journal")
} }
return map[string]any{"deleted": args.EntryID}, nil return map[string]any{"deleted": args.EntryID}, nil
} }
@@ -694,7 +730,7 @@ func (a *adapter) undoChange(ctx context.Context, args struct {
} }
cs, conflicts, err := a.svc.RevertChangeSet(ctx, a.actor, args.ChangeSetID, domain.SourceAgent) cs, conflicts, err := a.svc.RevertChangeSet(ctx, a.actor, args.ChangeSetID, domain.SourceAgent)
if err != nil { if err != nil {
return nil, err return nil, whenMissing(err, "change", args.ChangeSetID, "read_history")
} }
res := undoResult{UndoneID: args.ChangeSetID, Conflicts: conflicts} res := undoResult{UndoneID: args.ChangeSetID, Conflicts: conflicts}
if cs == nil { if cs == nil {
@@ -711,6 +747,7 @@ func (a *adapter) undoChange(ctx context.Context, args struct {
} }
a.mu.Lock() a.mu.Lock()
a.reverts = append(a.reverts, cs.ID) a.reverts = append(a.reverts, cs.ID)
a.changed = true
a.mu.Unlock() a.mu.Unlock()
return res, nil return res, nil
} }
@@ -779,17 +816,18 @@ func (a *adapter) updateObject(ctx context.Context, args struct {
RotationDeg *float64 `json:"rotationDeg" description:"optional new rotation in degrees"` RotationDeg *float64 `json:"rotationDeg" description:"optional new rotation in degrees"`
Plantable *bool `json:"plantable" description:"optional: whether the object can hold plants"` Plantable *bool `json:"plantable" description:"optional: whether the object can hold plants"`
}) (any, error) { }) (any, error) {
return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, service.ObjectPatch{ o, err := a.svc.UpdateObject(ctx, a.actor, args.ObjectID, service.ObjectPatch{
Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM, Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM,
RotationDeg: args.RotationDeg, Plantable: args.Plantable, RotationDeg: args.RotationDeg, Plantable: args.Plantable,
}, args.Version) }, args.Version)
return o, whenMissing(err, "object", args.ObjectID, "describe_garden")
} }
func (a *adapter) deleteObject(ctx context.Context, args struct { func (a *adapter) deleteObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to delete (with its plantings)"` ObjectID int64 `json:"objectId" description:"object to delete (with its plantings)"`
}) (any, error) { }) (any, error) {
if err := a.svc.DeleteObject(ctx, a.actor, args.ObjectID); err != nil { if err := a.svc.DeleteObject(ctx, a.actor, args.ObjectID); err != nil {
return nil, err return nil, whenMissing(err, "object", args.ObjectID, "describe_garden")
} }
return map[string]any{"deleted": args.ObjectID}, nil return map[string]any{"deleted": args.ObjectID}, nil
} }
@@ -806,7 +844,8 @@ func (a *adapter) removePlanting(ctx context.Context, args struct {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, on) pl, err := a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, on)
return pl, whenMissing(err, "planting", args.PlantingID, "list_plantings")
} }
func (a *adapter) listSeedLots(ctx context.Context, args struct { func (a *adapter) listSeedLots(ctx context.Context, args struct {
@@ -934,14 +973,15 @@ func (a *adapter) updateSeedLot(ctx context.Context, args struct {
SetPackedForYear: args.PackedForYear != nil, PackedForYear: args.PackedForYear, SetPackedForYear: args.PackedForYear != nil, PackedForYear: args.PackedForYear,
SetGerminationPct: args.GerminationPct != nil, GerminationPct: args.GerminationPct, SetGerminationPct: args.GerminationPct != nil, GerminationPct: args.GerminationPct,
} }
return a.svc.UpdateSeedLot(ctx, a.actor, args.LotID, patch, args.Version) l, err := a.svc.UpdateSeedLot(ctx, a.actor, args.LotID, patch, args.Version)
return l, whenMissing(err, "seed lot", args.LotID, "list_seed_lots")
} }
func (a *adapter) deleteSeedLot(ctx context.Context, args struct { func (a *adapter) deleteSeedLot(ctx context.Context, args struct {
LotID int64 `json:"lotId" description:"seed lot to delete (its id from list_seed_lots)"` LotID int64 `json:"lotId" description:"seed lot to delete (its id from list_seed_lots)"`
}) (any, error) { }) (any, error) {
if err := a.svc.DeleteSeedLot(ctx, a.actor, args.LotID); err != nil { if err := a.svc.DeleteSeedLot(ctx, a.actor, args.LotID); err != nil {
return nil, err return nil, whenMissing(err, "seed lot", args.LotID, "list_seed_lots")
} }
return map[string]any{"deleted": args.LotID}, nil return map[string]any{"deleted": args.LotID}, nil
} }
@@ -956,7 +996,7 @@ func (a *adapter) deletePlant(ctx context.Context, args struct {
return nil, fmt.Errorf("%w: the plant is still used — by plantings (past seasons count) or a seed lot — so it stays; tell the user rather than removing those", domain.ErrPlantInUse) return nil, fmt.Errorf("%w: the plant is still used — by plantings (past seasons count) or a seed lot — so it stays; tell the user rather than removing those", domain.ErrPlantInUse)
} }
if err != nil { if err != nil {
return nil, err return nil, whenMissing(err, "plant of the user's own", args.PlantID, "find_plant")
} }
return map[string]any{"deleted": args.PlantID}, nil return map[string]any{"deleted": args.PlantID}, nil
} }
@@ -965,7 +1005,7 @@ func (a *adapter) deletePlanting(ctx context.Context, args struct {
PlantingID int64 `json:"plantingId" description:"plop to delete outright (its id from describe_garden or list_plantings)"` PlantingID int64 `json:"plantingId" description:"plop to delete outright (its id from describe_garden or list_plantings)"`
}) (any, error) { }) (any, error) {
if err := a.svc.DeletePlanting(ctx, a.actor, args.PlantingID); err != nil { if err := a.svc.DeletePlanting(ctx, a.actor, args.PlantingID); err != nil {
return nil, err return nil, whenMissing(err, "planting", args.PlantingID, "list_plantings")
} }
return map[string]any{"deleted": args.PlantingID}, nil return map[string]any{"deleted": args.PlantingID}, nil
} }
@@ -1146,5 +1186,8 @@ func (a *adapter) publicLink(ctx context.Context, args struct {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if action != "get" {
a.noteChange()
}
return a.linkOf(link), nil return a.linkOf(link), nil
} }