Agent: undo for real, past seasons, and tools that correct the record
Six tools the live assistant kept needing and a prompt that knows about them: - undo_change wraps RevertChangeSet(source=agent). A revert is its own change set, so Run reports the last one as the turn's handle when the turn changed nothing else — an undo-only reply keeps its "Undo this", which is now a redo. - describe_garden takes a year: the season view (GardenFull(year)), pulled plops included, with removed/removedAt per group and per plop; list_years says which years have records. Rotation questions finally have data. - update_planting corrects a plop's date, count, label, radius or seed lot in place; remove_planting, remove_plantings and clear_object take a removedAt so a harvest can be backdated. - update_journal_entry / delete_journal_entry correct a note instead of stacking a contradicting one. - update_garden renames/resizes/re-units a garden and rewrites its notes — and the notes now go into the system prompt as the gardener's standing facts, so "remember we're in zone 6a" persists across conversations. describe_garden also reports the garden's notes, version and grid, which the new tools need. Prompt, CLAUDE.md and DESIGN.md updated to match; UI step labels for the new tools. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -335,7 +335,7 @@ func TestPartialWorkSurvivesATimeout(t *testing.T) {
|
||||
if _, conflicts, rerr := svc.RevertChangeSet(ctx, owner, after[0].ID, domain.SourceUI); rerr != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", rerr, conflicts)
|
||||
}
|
||||
o, _ := svc.DescribeGarden(ctx, owner, g.ID)
|
||||
o, _ := svc.DescribeGarden(ctx, owner, g.ID, nil)
|
||||
if len(o.Objects) > 0 && o.Objects[0].XCM != bed.XCM {
|
||||
t.Errorf("undo left the bed at %v, want %v", o.Objects[0].XCM, bed.XCM)
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func TestSystemPromptKnowsTheDayAndTheGardenersUnits(t *testing.T) {
|
||||
"feet and inches",
|
||||
"24.0 x 24.0 ft",
|
||||
"never describe a change you did not make",
|
||||
"You cannot undo",
|
||||
"undo_change",
|
||||
"Undo this",
|
||||
"rather than clearing beds on a guess",
|
||||
"not to narrate your own planting",
|
||||
@@ -495,8 +495,96 @@ func TestTurnOnAnotherGardenFilesHistoryThere(t *testing.T) {
|
||||
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, afterB[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
d, _ := svc.DescribeGarden(ctx, owner, b.ID)
|
||||
d, _ := svc.DescribeGarden(ctx, owner, b.ID, nil)
|
||||
if len(d.Objects) != 1 || d.Objects[0].Name != "Bed" {
|
||||
t.Errorf("after undo B's bed is %+v, want its original name back", d.Objects)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnThatOnlyUndoesIsItselfUndoable — a revert is its own change set,
|
||||
// outside the turn's scope, so a turn that did nothing but undo would come back
|
||||
// with no change of its own; the reply would then be the one change in the
|
||||
// conversation without an "Undo this". It gets the revert instead — a redo.
|
||||
func TestTurnThatOnlyUndoesIsItselfUndoable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
beets := mustPlant(t, svc, owner, "Beets", 10, "")
|
||||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
// The beets went in by hand in the editor: the change the person wants undone.
|
||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", beets.ID, nil, service.FillClump, nil); err != nil {
|
||||
t.Fatalf("plant beets: %v", err)
|
||||
}
|
||||
history, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||
if err != nil || len(history) == 0 {
|
||||
t.Fatalf("history: %v (%d entries)", err, len(history))
|
||||
}
|
||||
planted := history[0]
|
||||
|
||||
r := scriptedRunner(t, svc,
|
||||
toolCall("read_history", map[string]any{"gardenId": g.ID}),
|
||||
toolCall("undo_change", map[string]any{"changeSetId": planted.ID}),
|
||||
fake.Reply("Undone — the beets are out of the bed again."),
|
||||
)
|
||||
turn, err := r.Run(ctx, owner, g.ID, "undo the beets", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
full, err := svc.GardenFull(ctx, owner, g.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GardenFull: %v", err)
|
||||
}
|
||||
if len(full.Plantings) != 0 {
|
||||
t.Fatalf("%d beets still in the bed after the undo", len(full.Plantings))
|
||||
}
|
||||
after, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||
if len(after) != len(history)+1 {
|
||||
t.Fatalf("history grew by %d, want exactly the revert", len(after)-len(history))
|
||||
}
|
||||
revert := after[0]
|
||||
if revert.Source != domain.SourceAgent || revert.RevertsID == nil || *revert.RevertsID != planted.ID {
|
||||
t.Errorf("newest entry = %+v; want the agent's revert of %d", revert, planted.ID)
|
||||
}
|
||||
if turn.ChangeSetID == nil || *turn.ChangeSetID != revert.ID {
|
||||
t.Fatalf("turn.ChangeSetID = %v, want the revert %d so the reply can offer a redo", turn.ChangeSetID, revert.ID)
|
||||
}
|
||||
// And "Undo this" on that reply is a redo.
|
||||
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, *turn.ChangeSetID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("redo: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
full, _ = svc.GardenFull(ctx, owner, g.ID, nil)
|
||||
if len(full.Plantings) == 0 {
|
||||
t.Error("redoing the turn did not put the beets back")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemPromptCarriesTheGardenersNotes — the notes are the assistant's
|
||||
// memory: what the gardener told it about the place comes back on every turn,
|
||||
// quoted as their words rather than pasted as instructions.
|
||||
func TestSystemPromptCarriesTheGardenersNotes(t *testing.T) {
|
||||
with := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitMetric,
|
||||
Notes: "Zone 6a.\nLast frost \"usually\" May 10."}, "2026-08-23")
|
||||
for _, want := range []string{
|
||||
`"Zone 6a.\nLast frost \"usually\" May 10."`,
|
||||
"update_garden",
|
||||
"undo_change",
|
||||
"describe_garden with a year",
|
||||
} {
|
||||
if !strings.Contains(with, want) {
|
||||
t.Errorf("prompt is missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(with, "You cannot undo") {
|
||||
t.Error("the prompt still says the assistant cannot undo")
|
||||
}
|
||||
without := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitMetric}, "2026-08-23")
|
||||
if !strings.Contains(without, "no notes") {
|
||||
t.Error("a garden without notes doesn't say so")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user