Agent: sharing tools that ask first, and a hard delete for a misplaced plop
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 4m18s
Adversarial Review (Gadfly) / review (pull_request) Successful in 4m18s

list_shares, share_garden, remove_share and public_link (get / enable /
rotate / disable) wrap the sharing service. They change who can see a garden
beyond the screen, so they are gated twice: the prompt tells the model to say
exactly what it would do and ask, and the tools refuse without confirmed=true,
which their descriptions allow only after a yes in the conversation. The
refusal names the action, so the question the model asks is precise.

share_garden changes the role of an existing share instead of failing on it;
remove_share takes the email list_shares reports; an unknown email explains
that the person has to sign in once first. public_link returns the address
(PANSY_BASE_URL + /g/<token>, via the new Service.PublicShareURL), never a
bare token.

delete_planting is the hard delete for a plop that was never really planted,
as opposed to remove_planting's "it came out"; it is recorded, so undoable.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-08-23 02:12:51 -04:00
co-authored by Claude Fable 5
parent 97008f5a92
commit f985c264f8
8 changed files with 369 additions and 1 deletions
+137
View File
@@ -1026,3 +1026,140 @@ func TestCatalogAndGardenTools(t *testing.T) {
t.Error("a built-in plant was deleted")
}
}
// TestSharingToolsAskFirst — sharing changes who can see a garden beyond the
// screen, so the tools refuse without confirmed=true, and the refusal names the
// action, which is what the model then asks about. With it they work, and the
// existing-share, unknown-email and not-the-owner cases come back in words.
// delete_planting rides along: a hard delete that is still in the history.
func TestSharingToolsAskFirst(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner, "2026-08-23")
call, mustCall := toolCaller(t, ctx, box)
refused := func(name string, args any, wantWords ...string) {
t.Helper()
res := call(name, args)
if !res.IsError {
t.Fatalf("%s %v succeeded, want a refusal", name, args)
}
for _, w := range wantWords {
if !strings.Contains(res.Content, w) {
t.Errorf("%s refusal = %q, want it to mention %q", name, res.Content, w)
}
}
}
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Home", WidthCM: 1000, HeightCM: 1000})
if err != nil {
t.Fatalf("garden: %v", err)
}
if _, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "Sam", Password: "password123"}); err != nil {
t.Fatalf("register sam: %v", err)
}
// --- share_garden: refused until confirmed, then grants, then changes the role.
refused("share_garden", map[string]any{"gardenId": g.ID, "email": "[email protected]", "role": "editor"}, "ask the user first", "[email protected]", "editor")
var shared struct {
Share shareView `json:"share"`
Note string `json:"note"`
}
mustCall("share_garden", map[string]any{"gardenId": g.ID, "email": "[email protected]", "role": "editor", "confirmed": true}, &shared)
if shared.Share.Role != domain.RoleEditor || shared.Share.Email != "[email protected]" {
t.Errorf("share = %+v, want sam as editor", shared)
}
mustCall("share_garden", map[string]any{"gardenId": g.ID, "email": "[email protected]", "role": "viewer", "confirmed": true}, &shared)
if shared.Share.Role != domain.RoleViewer || !strings.Contains(shared.Note, "now viewer") {
t.Errorf("re-share as viewer = %+v, want the role changed and said so", shared)
}
mustCall("share_garden", map[string]any{"gardenId": g.ID, "email": "[email protected]", "role": "viewer", "confirmed": true}, &shared)
if !strings.Contains(shared.Note, "nothing changed") {
t.Errorf("a no-op re-share = %+v, want a note that nothing changed", shared)
}
refused("share_garden", map[string]any{"gardenId": g.ID, "email": "[email protected]", "role": "viewer", "confirmed": true}, "no account", "sign in")
refused("share_garden", map[string]any{"gardenId": g.ID, "email": "[email protected]", "role": "owner", "confirmed": true})
var listed struct {
Shares []shareView `json:"shares"`
PublicLink map[string]any `json:"publicLink"`
}
mustCall("list_shares", map[string]any{"gardenId": g.ID}, &listed)
if len(listed.Shares) != 1 || listed.Shares[0].Email != "[email protected]" || listed.Shares[0].Role != domain.RoleViewer || listed.PublicLink["enabled"] != false {
t.Errorf("list_shares = %+v", listed)
}
// Not the owner: Sam can see the garden but can't manage its sharing.
sam, err := svc.Login(ctx, "[email protected]", "password123")
if err != nil {
t.Fatalf("login sam: %v", err)
}
if r := NewToolbox(svc, sam.ID, "").Execute(ctx, llm.ToolCall{ID: "2", Name: "list_shares", Arguments: mustJSON(t, map[string]any{"gardenId": g.ID})}); !r.IsError {
t.Error("a viewer listed the garden's shares")
}
// --- remove_share: refused until confirmed; by email; unknown email explained.
refused("remove_share", map[string]any{"gardenId": g.ID, "email": "[email protected]"}, "ask the user first", "removing [email protected]")
refused("remove_share", map[string]any{"gardenId": g.ID, "email": "[email protected]", "confirmed": true}, "not shared with")
mustCall("remove_share", map[string]any{"gardenId": g.ID, "email": "[email protected]", "confirmed": true}, nil)
mustCall("list_shares", map[string]any{"gardenId": g.ID}, &listed)
if len(listed.Shares) != 0 {
t.Errorf("shares after remove = %+v, want none", listed.Shares)
}
// --- public_link: get is free; enable/rotate/disable need a yes.
var link struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
}
mustCall("public_link", map[string]any{"gardenId": g.ID, "action": "get"}, &link)
if link.Enabled || link.URL != "" {
t.Errorf("fresh garden's link = %+v, want off with no url", link)
}
refused("public_link", map[string]any{"gardenId": g.ID, "action": "enable"}, "ask the user first", "anyone with the link")
mustCall("public_link", map[string]any{"gardenId": g.ID, "action": "enable", "confirmed": true}, &link)
if !link.Enabled || !strings.HasPrefix(link.URL, "/g/") {
t.Fatalf("enabled link = %+v, want on with a /g/<token> url", link)
}
first := link.URL
mustCall("public_link", map[string]any{"gardenId": g.ID, "action": "enable", "confirmed": true}, &link)
if link.URL != first {
t.Error("enabling an enabled link changed the url; that is what rotate is for")
}
mustCall("public_link", map[string]any{"gardenId": g.ID, "action": "rotate", "confirmed": true}, &link)
if !link.Enabled || link.URL == first {
t.Errorf("rotated link = %+v, want a different url", link)
}
refused("public_link", map[string]any{"gardenId": g.ID, "action": "disable"}, "stops working")
mustCall("public_link", map[string]any{"gardenId": g.ID, "action": "disable", "confirmed": true}, &link)
if link.Enabled {
t.Error("the link is still on after disable")
}
refused("public_link", map[string]any{"gardenId": g.ID, "action": "share", "confirmed": true}, "get, enable, rotate or disable")
// --- delete_planting: gone from every view, but in the history — undoable.
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "Bed", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200})
if err != nil {
t.Fatalf("bed: %v", err)
}
basil := mustPlant(t, svc, owner, "Basil", 25, "🌿")
var plop domain.Planting
mustCall("place_planting", map[string]any{"objectId": bed.ID, "plantId": basil.ID, "xCm": 0, "yCm": 0}, &plop)
mustCall("delete_planting", map[string]any{"plantingId": plop.ID}, nil)
var desc service.DescribeResult
mustCall("describe_garden", map[string]any{"gardenId": g.ID, "year": 2026}, &desc)
if len(desc.Objects[0].Plantings) != 0 {
t.Errorf("a deleted plop still shows in the season view: %+v", desc.Objects[0].Plantings)
}
var hist struct {
Entries []historyEntry `json:"entries"`
}
mustCall("read_history", map[string]any{"gardenId": g.ID, "limit": 1}, &hist)
if len(hist.Entries) != 1 || !strings.HasPrefix(hist.Entries[0].Summary, "Deleted a planting") {
t.Fatalf("history[0] = %+v, want the deletion", hist.Entries)
}
mustCall("undo_change", map[string]any{"changeSetId": hist.Entries[0].ID}, nil)
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
if len(desc.Objects[0].Plantings) != 1 || desc.Objects[0].Plantings[0].Each[0].ID != plop.ID {
t.Errorf("undoing the delete did not bring the plop back: %+v", desc.Objects[0].Plantings)
}
}