fix(backend): extract list values from dict for List[List[Any]] block inputs

When a dict (YAML/TOML) is passed to a List[List[Any]]-typed input
(e.g. ConcatenateListsBlock), instead of wrapping the whole dict as
[dict] (which causes pydantic to coerce dict → list of tuples),
extract all list-typed values from the dict as inner lists.

Example: YAML {"fruits": [{...},{...}]} → [[{...},{...}]] instead of
[{"fruits": [...]}] which pydantic would coerce to [(key, val), ...].

Falls back to [dict] wrapping when no list values exist in the dict.
This commit is contained in:
Zamil Majdy
2026-03-13 16:40:41 +07:00
parent bce0214a6a
commit 13985feb3d
2 changed files with 113 additions and 2 deletions

View File

@@ -341,9 +341,19 @@ def _adapt_to_schema(parsed: Any, prop_schema: dict[str, Any] | None) -> Any:
target_type = prop_schema.get("type")
# Dict → array: wrap in a single-element list so the block gets [dict]
# instead of pydantic flattening keys/values into a flat list.
# Dict → array: extract list values from the dict instead of wrapping
# the whole dict (which causes pydantic to coerce dict → list of tuples).
if isinstance(parsed, dict) and target_type == "array":
items_type = (prop_schema.get("items") or {}).get("type")
if items_type == "array":
# Target is List[List[Any]] — extract list-typed values from the
# dict as inner lists. E.g. YAML {"fruits": [{...},...]}} with
# ConcatenateLists (List[List[Any]]) → [[{...},...]].
list_values = [v for v in parsed.values() if isinstance(v, list)]
if list_values:
return list_values
# Fallback: wrap in a single-element list so the block gets [dict]
# instead of pydantic flattening keys/values into a flat list.
return [parsed]
# Tabular list → object: convert to a column-dict

View File

@@ -1102,6 +1102,12 @@ class _DictBlock(pydantic.BaseModel):
data: dict
class _ListOfListsBlock(pydantic.BaseModel):
"""Simulates a block schema with List[List[Any]] (e.g. ConcatenateListsBlock)."""
lists: list[list]
class _AnyBlock(pydantic.BaseModel):
"""Simulates a block schema with an Any-typed input (e.g. FindInDictionaryBlock).
@@ -1313,6 +1319,101 @@ async def test_e2e_toml_dict_to_list_block():
assert expanded["rows"] == [{"name": "test", "count": 42}]
# ---------------------------------------------------------------------------
# E2E: YAML/TOML dict → List[List[Any]] block (ConcatenateListsBlock-style)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_e2e_yaml_dict_with_list_value_to_concat_block():
"""YAML dict with a list value → List[List[Any]] block: extracts list
values from the dict as inner lists, not wrapping the whole dict."""
yaml_content = "fruits:\n - name: apple\n - name: banana\n - name: cherry"
async def _resolve(ref, *a, **kw): # noqa: ARG001
return yaml_content
block_schema = _ListOfListsBlock.model_json_schema()
with patch(
"backend.copilot.sdk.file_ref.resolve_file_ref",
new=AsyncMock(side_effect=_resolve),
):
expanded = await expand_file_refs_in_args(
{"lists": "@@agptfile:workspace:///data.yaml"},
user_id="u1",
session=_make_session(),
input_schema=block_schema,
)
# List values extracted from dict — fruits list becomes an inner list
assert expanded["lists"] == [
[{"name": "apple"}, {"name": "banana"}, {"name": "cherry"}]
]
# Coercion should preserve it
coerce_inputs_to_schema(expanded, _ListOfListsBlock)
assert len(expanded["lists"]) == 1
assert len(expanded["lists"][0]) == 3
@pytest.mark.asyncio
async def test_e2e_toml_dict_with_list_value_to_concat_block():
"""TOML dict with a list value → List[List[Any]] block: extracts list
values, ignoring scalar values like 'title'."""
toml_content = (
'title = "Fruits"\n'
"[[fruits]]\n"
'name = "apple"\n'
"[[fruits]]\n"
'name = "banana"\n'
)
async def _resolve(ref, *a, **kw): # noqa: ARG001
return toml_content
block_schema = _ListOfListsBlock.model_json_schema()
with patch(
"backend.copilot.sdk.file_ref.resolve_file_ref",
new=AsyncMock(side_effect=_resolve),
):
expanded = await expand_file_refs_in_args(
{"lists": "@@agptfile:workspace:///data.toml"},
user_id="u1",
session=_make_session(),
input_schema=block_schema,
)
# Only list-typed values extracted — "title" (str) is excluded
assert expanded["lists"] == [[{"name": "apple"}, {"name": "banana"}]]
@pytest.mark.asyncio
async def test_e2e_yaml_flat_dict_to_concat_block():
"""YAML flat dict (no list values) → List[List[Any]]: fallback to [dict]."""
yaml_content = "name: Alice\nage: 30"
async def _resolve(ref, *a, **kw): # noqa: ARG001
return yaml_content
block_schema = _ListOfListsBlock.model_json_schema()
with patch(
"backend.copilot.sdk.file_ref.resolve_file_ref",
new=AsyncMock(side_effect=_resolve),
):
expanded = await expand_file_refs_in_args(
{"lists": "@@agptfile:workspace:///config.yaml"},
user_id="u1",
session=_make_session(),
input_schema=block_schema,
)
# No list values in dict — fallback to wrapping as [dict]
assert expanded["lists"] == [{"name": "Alice", "age": 30}]
# ---------------------------------------------------------------------------
# E2E: CSV → dict block (column-dict conversion)
# ---------------------------------------------------------------------------