@@ -348,6 +348,8 @@ func (s *Server) callChangeShoppingListTool(
return s.replaceShoppingListSelection(ctx, arguments)
case "add_selection":
return s.addShoppingListSelection(ctx, arguments)
+ case "remove_selection":
+ return s.removeShoppingListSelection(ctx, arguments)
case "":
return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
default:
@@ -456,6 +458,28 @@ func (s *Server) addShoppingListSelection(
)
}
+func (s *Server) removeShoppingListSelection(
+ ctx context.Context,
+ arguments ChangeShoppingListArguments,
+) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
+ ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
+ if len(ids) == 0 {
+ return nil, ChangeShoppingListOutput{}, fmt.Errorf(
+ "product_group_ids is required when action is remove_selection",
+ )
+ }
+
+ shoppingList, err := s.backend.ReadShoppingList(ctx)
+ if err != nil {
+ return nil, ChangeShoppingListOutput{}, err
+ }
+
+ return s.setShoppingListSelection(
+ ctx,
+ subtractProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
+ )
+}
+
func (s *Server) setShoppingListSelection(
ctx context.Context,
ids []string,
@@ -536,7 +560,7 @@ func changeShoppingListTool() *sdk.Tool {
return &sdk.Tool{
Name: changeShoppingListToolName,
Title: "Change shopping list",
- Description: "Change the Cooked shopping list. This slice supports action add with newline-separated ingredients and optional recipe_id, action remove, replace_selection, or add_selection with product_group_ids, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.",
+ Description: "Change the Cooked shopping list. This slice supports action add with newline-separated ingredients and optional recipe_id, action remove, replace_selection, add_selection, or remove_selection with product_group_ids, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.",
Annotations: &sdk.ToolAnnotations{
Title: "Change shopping list",
DestructiveHint: &destructive,
@@ -607,6 +631,28 @@ func appendMissingProductGroupIDs(existing, additions []string) []string {
return result
}
+func subtractProductGroupIDs(existing, removals []string) []string {
+ removed := make(map[string]struct{}, len(removals))
+ for _, id := range removals {
+ removed[id] = struct{}{}
+ }
+
+ seen := make(map[string]struct{}, len(existing))
+ result := make([]string, 0, len(existing))
+ for _, id := range existing {
+ if _, ok := removed[id]; ok {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ result = append(result, id)
+ }
+
+ return result
+}
+
func formatShoppingList(shoppingList cooked.ShoppingList) string {
if len(shoppingList.Aisles) == 0 {
return "Shopping list is empty."
@@ -806,10 +852,10 @@ type DeleteRecipeOutput struct {
// ChangeShoppingListArguments contains change_shopping_list tool arguments.
type ChangeShoppingListArguments struct {
- Action string `json:"action" jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, add_selection, and clear."`
+ Action string `json:"action" jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, add_selection, remove_selection, and clear."`
Ingredients string `json:"ingredients,omitempty" jsonschema:"Newline-separated ingredients for action add."`
RecipeID string `json:"recipe_id,omitempty" jsonschema:"Optional saved recipe ID or import draft ID for action add."`
- ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, or add_selection."`
+ ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
}
// ChangeShoppingListOutput is the structured output for change_shopping_list.
@@ -824,6 +824,75 @@ func TestCallToolACIDShoppingListSelection7RequiresAddSelectionProductGroupIDs(t
}
}
+func TestCallToolACIDShoppingListSelection3And4And5And6And10RemovesSelectedSet(t *testing.T) {
+ backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
+ ProductGroups: []cooked.ProductGroup{
+ {ID: "pasta", Selected: true},
+ {ID: "tomato", Selected: true},
+ {ID: "salt", Selected: true},
+ {ID: "pepper"},
+ },
+ }}}}
+ server := NewServer(backend, "test")
+
+ _, output, err := server.callChangeShoppingListTool(
+ context.Background(),
+ nil,
+ ChangeShoppingListArguments{
+ Action: " remove_selection ",
+ ProductGroupIDs: []string{" tomato ", "pepper", " "},
+ },
+ )
+ if err != nil {
+ t.Fatalf("callChangeShoppingListTool() error = %v", err)
+ }
+
+ if backend.readShoppingListCalls != 1 {
+ t.Fatalf("read shopping-list calls = %d, want 1", backend.readShoppingListCalls)
+ }
+ if backend.replaceSelectionCalls != 1 {
+ t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
+ }
+ expectedIDs := []string{"pasta", "salt"}
+ if !slices.Equal(backend.replaceSelectionProductGroupIDs, expectedIDs) {
+ t.Fatalf("backend selected IDs = %#v, want %#v", backend.replaceSelectionProductGroupIDs, expectedIDs)
+ }
+ if !slices.Equal(output.SelectedProductGroupIDs, expectedIDs) {
+ t.Fatalf("output selected IDs = %#v, want %#v", output.SelectedProductGroupIDs, expectedIDs)
+ }
+}
+
+func TestCallToolACIDShoppingListSelection7RequiresRemoveSelectionProductGroupIDs(t *testing.T) {
+ tests := []struct {
+ name string
+ arguments ChangeShoppingListArguments
+ }{
+ {name: "nil", arguments: ChangeShoppingListArguments{Action: "remove_selection"}},
+ {
+ name: "blank",
+ arguments: ChangeShoppingListArguments{Action: "remove_selection", ProductGroupIDs: []string{" "}},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ _, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
+ if err == nil {
+ t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
+ }
+ if backend.readShoppingListCalls != 0 {
+ t.Fatalf("read shopping-list calls = %d, want none", backend.readShoppingListCalls)
+ }
+ if backend.replaceSelectionCalls != 0 {
+ t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls)
+ }
+ })
+ }
+}
+
func TestCallToolACIDShoppingListSafety1RequiresRemoveProductGroupIDs(t *testing.T) {
tests := []struct {
name string