@@ -2115,3 +2115,161 @@ def test_default_search_type_falls_back_to_text_when_semantic_disabled():
21152115
21162116 with patch .object (search_module , "get_container" , return_value = mock_container ):
21172117 assert search_module ._default_search_type () == "text"
2118+
2119+
2120+ # --- Tests for note_types/entity_types/categories comma-split fix (#930, Codex review) ---
2121+
2122+
2123+ def test_search_notes_note_types_annotation_splits_comma_strings ():
2124+ """The note_types parameter annotation must parse every documented input form (#930).
2125+
2126+ Direct function calls bypass the BeforeValidator; validate through the same
2127+ Annotated metadata pydantic applies on the MCP path. The old coerce_list wrapped a
2128+ bare comma string as the single literal type ["note,task"]; parse_str_list splits it.
2129+ """
2130+ annotation = inspect .signature (search_notes ).parameters ["note_types" ].annotation
2131+ adapter = TypeAdapter (annotation )
2132+
2133+ real_list = adapter .validate_python (["note" , "task" ])
2134+ comma_string = adapter .validate_python ("note,task" )
2135+ json_string = adapter .validate_python ('["note", "task"]' )
2136+ single_string = adapter .validate_python ("note" )
2137+ comma_in_list = adapter .validate_python (["note,task" ])
2138+
2139+ assert real_list == ["note" , "task" ]
2140+ assert comma_string == real_list , "comma string must behave like the real list"
2141+ assert json_string == real_list
2142+ assert single_string == ["note" ]
2143+ assert comma_in_list == real_list , "list with comma element must be flattened"
2144+
2145+
2146+ def test_search_notes_entity_types_annotation_splits_comma_strings ():
2147+ """The entity_types parameter annotation must parse every documented input form (#930)."""
2148+ annotation = inspect .signature (search_notes ).parameters ["entity_types" ].annotation
2149+ adapter = TypeAdapter (annotation )
2150+
2151+ real_list = adapter .validate_python (["entity" , "observation" ])
2152+ comma_string = adapter .validate_python ("entity,observation" )
2153+ comma_in_list = adapter .validate_python (["entity,observation" ])
2154+
2155+ assert real_list == ["entity" , "observation" ]
2156+ assert comma_string == real_list
2157+ assert comma_in_list == real_list
2158+
2159+
2160+ def test_search_notes_categories_annotation_splits_comma_strings ():
2161+ """The categories parameter annotation must parse every documented input form (#930)."""
2162+ annotation = inspect .signature (search_notes ).parameters ["categories" ].annotation
2163+ adapter = TypeAdapter (annotation )
2164+
2165+ real_list = adapter .validate_python (["requirement" , "decision" ])
2166+ comma_string = adapter .validate_python ("requirement,decision" )
2167+ comma_in_list = adapter .validate_python (["requirement,decision" ])
2168+
2169+ assert real_list == ["requirement" , "decision" ]
2170+ assert comma_string == real_list
2171+ assert comma_in_list == real_list
2172+
2173+
2174+ def test_search_notes_note_types_annotation_rejects_non_string_list_elements ():
2175+ """note_types=[42] must fail Pydantic validation, not be stringified to ['42'].
2176+
2177+ parse_str_list used str(raw) to coerce list elements, silently accepting [42] as
2178+ ["42"]. The fix guards against non-string list elements and returns the original
2179+ value so Pydantic rejects it with a clear error.
2180+ """
2181+ from pydantic import ValidationError
2182+
2183+ annotation = inspect .signature (search_notes ).parameters ["note_types" ].annotation
2184+ adapter = TypeAdapter (annotation )
2185+
2186+ with pytest .raises (ValidationError ):
2187+ adapter .validate_python ([42 ])
2188+ with pytest .raises (ValidationError ):
2189+ adapter .validate_python (["note" , 42 ])
2190+
2191+ # All-string lists remain valid.
2192+ assert adapter .validate_python (["note" , "task" ]) == ["note" , "task" ]
2193+
2194+
2195+ def test_search_notes_entity_types_annotation_rejects_non_string_list_elements ():
2196+ """entity_types=[42] must fail Pydantic validation, not be stringified."""
2197+ from pydantic import ValidationError
2198+
2199+ annotation = inspect .signature (search_notes ).parameters ["entity_types" ].annotation
2200+ adapter = TypeAdapter (annotation )
2201+
2202+ with pytest .raises (ValidationError ):
2203+ adapter .validate_python ([42 ])
2204+ with pytest .raises (ValidationError ):
2205+ adapter .validate_python (["entity" , 42 ])
2206+
2207+ assert adapter .validate_python (["entity" , "observation" ]) == ["entity" , "observation" ]
2208+
2209+
2210+ def test_search_notes_categories_annotation_rejects_non_string_list_elements ():
2211+ """categories=[42] must fail Pydantic validation, not be stringified."""
2212+ from pydantic import ValidationError
2213+
2214+ annotation = inspect .signature (search_notes ).parameters ["categories" ].annotation
2215+ adapter = TypeAdapter (annotation )
2216+
2217+ with pytest .raises (ValidationError ):
2218+ adapter .validate_python ([42 ])
2219+ with pytest .raises (ValidationError ):
2220+ adapter .validate_python (["requirement" , 42 ])
2221+
2222+ assert adapter .validate_python (["requirement" , "decision" ]) == ["requirement" , "decision" ]
2223+
2224+
2225+ @pytest .mark .asyncio
2226+ async def test_search_notes_direct_call_splits_comma_note_types (client , test_project ):
2227+ """Direct callers bypass the BeforeValidator, so the body must normalize note_types.
2228+
2229+ Regression for the CLI path: `bm tool search-notes --type note,task` calls this
2230+ function directly with Typer's collected list ["note,task"], which must split into
2231+ ["note", "task"] and match the note correctly (#930, Codex review follow-up).
2232+ """
2233+ await write_note (
2234+ project = test_project .name ,
2235+ title = "Direct NoteType Split Note" ,
2236+ directory = "test" ,
2237+ content = "# Direct NoteType Split Note\n NoteTypeSplitToken body" ,
2238+ )
2239+
2240+ async def found (note_types_value : list [str ] | None ) -> bool :
2241+ result = await search_notes (
2242+ project = test_project .name ,
2243+ query = "NoteTypeSplitToken" ,
2244+ search_type = "text" ,
2245+ output_format = "json" ,
2246+ note_types = note_types_value ,
2247+ )
2248+ assert isinstance (result , dict ), f"search failed: { result } "
2249+ return any (r ["title" ] == "Direct NoteType Split Note" for r in result ["results" ])
2250+
2251+ assert await found (None ), "no filter must match (sanity)"
2252+ assert await found (["note" ]), "plain single-type list must match (sanity)"
2253+ # The CLI regression: Typer collects --type note,task as the single element "note,task".
2254+ assert await found (["note,task" ]), "comma list element must be flattened and match 'note'"
2255+ # Negative control: a specific nonexistent type must not match.
2256+ assert not await found (["nonexistent_type" ])
2257+
2258+
2259+ def test_search_notes_parse_str_list_rejects_non_string_list_elements_in_place ():
2260+ """parse_str_list must return non-str list elements unchanged for Pydantic rejection.
2261+
2262+ The old implementation used str(raw) which silently coerced [42] -> ['42'],
2263+ causing bad caller data to become silent no-result searches instead of a
2264+ clear Pydantic validation error.
2265+ """
2266+ from basic_memory .utils import parse_str_list
2267+
2268+ # Non-string list elements pass through unchanged.
2269+ assert parse_str_list ([42 ]) == [42 ] # type: ignore[arg-type]
2270+ assert parse_str_list (["ok" , 42 ]) == ["ok" , 42 ] # type: ignore[arg-type]
2271+ assert parse_str_list ([{"a" : 1 }]) == [{"a" : 1 }] # type: ignore[arg-type]
2272+
2273+ # All-string lists still work correctly.
2274+ assert parse_str_list (["note" , "task" ]) == ["note" , "task" ]
2275+ assert parse_str_list (["note,task" ]) == ["note" , "task" ]
0 commit comments