From b5cbafe581b928928005a067f4a11d34a96068b7 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 3 Nov 2025 10:33:24 +0000 Subject: [PATCH 1/5] handle nested schema formatting --- .../sdk/llm/mixins/fn_call_converter.py | 119 +++++++++++++++++- 1 file changed, 115 insertions(+), 4 deletions(-) diff --git a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py index a3c4fe3faf..d5e3c6aed4 100644 --- a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py +++ b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py @@ -452,6 +452,116 @@ def convert_tool_call_to_string(tool_call: dict) -> str: return ret +def _summarize_schema_type(schema: object | None) -> str: + """ + Capture array, union, enum, and nested type info. + """ + if not isinstance(schema, dict): + return "unknown" if schema is None else str(schema) + + union_keys = ("anyOf", "oneOf", "allOf") + for key in union_keys: + if key in schema: + return " or ".join(_summarize_schema_type(option) for option in schema[key]) + + schema_type = schema.get("type") + if isinstance(schema_type, list): + return " or ".join(str(t) for t in schema_type) + if schema_type == "array": + items = schema.get("items") + if isinstance(items, list): + item_types = ", ".join(_summarize_schema_type(item) for item in items) + return f"array[{item_types}]" + if isinstance(items, dict): + return f"array[{_summarize_schema_type(items)}]" + return "array" + if schema_type: + return str(schema_type) + if "enum" in schema: + return "enum" + return "unknown" + + +def _format_schema_detail(schema: object | None, indent: int = 4) -> list[str]: + """ + Recursively describe arrays, objects, unions, and additional properties. + """ + if not isinstance(schema, dict): + return [] + + indent_str = " " * indent + lines: list[str] = [] + + # Handle union types + union_keys = ("anyOf", "oneOf", "allOf") + for key in union_keys: + if key in schema: + lines.append(f"{indent_str}{key} options:") + for option in schema[key]: + option_type = _summarize_schema_type(option) + option_line = f"{' ' * (indent + 2)}- {option_type}" + option_desc = ( + option.get("description") if isinstance(option, dict) else None + ) + if option_desc: + option_line += f": {option_desc}" + lines.append(option_line) + lines.extend(_format_schema_detail(option, indent + 4)) + return lines + + schema_type = schema.get("type") + if isinstance(schema_type, list): + lines.append( + f"{indent_str}Allowed types: {', '.join(str(t) for t in schema_type)}" + ) + return lines + + # Handle array type + if schema_type == "array": + items = schema.get("items") + lines.append(f"{indent_str}Array items:") + if isinstance(items, list): + for index, item_schema in enumerate(items): + item_type = _summarize_schema_type(item_schema) + lines.append(f"{' ' * (indent + 2)}- index {index}: {item_type}") + lines.extend(_format_schema_detail(item_schema, indent + 4)) + elif isinstance(items, dict): + lines.append(f"{' ' * (indent + 2)}Type: {_summarize_schema_type(items)}") + lines.extend(_format_schema_detail(items, indent + 4)) + else: + lines.append(f"{' ' * (indent + 2)}Type: unknown") + return lines + + if schema_type == "object": + properties = schema.get("properties", {}) + required = set(schema.get("required", [])) + if isinstance(properties, dict) and properties: + lines.append(f"{indent_str}Object properties:") + for name, prop in properties.items(): + prop_type = _summarize_schema_type(prop) + required_flag = "required" if name in required else "optional" + prop_desc = prop.get("description", "No description provided") + lines.append( + f"{' ' * (indent + 2)}- {name} ({prop_type}, {required_flag}):" + f" {prop_desc}" + ) + lines.extend(_format_schema_detail(prop, indent + 4)) + additional_props = schema.get("additionalProperties") + if isinstance(additional_props, dict): + lines.append( + f"{indent_str}Additional properties allowed: " + f"{_summarize_schema_type(additional_props)}" + ) + lines.extend(_format_schema_detail(additional_props, indent + 2)) + elif additional_props is True: + lines.append(f"{indent_str}Additional properties allowed.") + elif additional_props is False: + lines.append(f"{indent_str}Additional properties not allowed.") + return lines + + return lines + + def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str: ret = "" for i, tool in enumerate(tools): @@ -469,15 +579,12 @@ def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str: required_params = set(fn["parameters"].get("required", [])) for j, (param_name, param_info) in enumerate(properties.items()): - # Indicate required/optional in parentheses with type is_required = param_name in required_params param_status = "required" if is_required else "optional" - param_type = param_info.get("type", "string") + param_type = _summarize_schema_type(param_info) - # Get parameter description desc = param_info.get("description", "No description provided") - # Handle enum values if present if "enum" in param_info: enum_values = ", ".join(f"`{v}`" for v in param_info["enum"]) desc += f"\nAllowed values: [{enum_values}]" @@ -485,6 +592,10 @@ def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str: ret += ( f" ({j + 1}) {param_name} ({param_type}, {param_status}): {desc}\n" ) + + detail_lines = _format_schema_detail(param_info, indent=6) + if detail_lines: + ret += "\n".join(detail_lines) + "\n" else: ret += "No parameters are required for this function.\n" From b51cfb9743d64ad0c6902ac87ce853f0711805f5 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 3 Nov 2025 10:48:13 +0000 Subject: [PATCH 2/5] add tests --- tests/sdk/llm/test_llm_fncall_converter.py | 151 +++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/tests/sdk/llm/test_llm_fncall_converter.py b/tests/sdk/llm/test_llm_fncall_converter.py index 43a5bedb1f..2962482016 100644 --- a/tests/sdk/llm/test_llm_fncall_converter.py +++ b/tests/sdk/llm/test_llm_fncall_converter.py @@ -1,6 +1,7 @@ """Test for FunctionCallingConverter.""" import json +import textwrap import pytest from litellm import ChatCompletionToolParam @@ -14,6 +15,7 @@ convert_fncall_messages_to_non_fncall_messages, convert_non_fncall_messages_to_fncall_messages, convert_tool_call_to_string, + convert_tools_to_description, ) @@ -689,3 +691,152 @@ def test_convert_fncall_messages_with_image_url(): image_content["image_url"]["url"] == "data:image/gif;base64,R0lGODlhAQABAAAAACw=" ) + + +def test_convert_tools_to_description_nested_array(): + tools: list[ChatCompletionToolParam] = [ + { + "type": "function", + "function": { + "name": "nested_array", + "description": "Handle nested arrays", + "parameters": { + "type": "object", + "properties": { + "items": { + "type": "array", + "description": "List of entries", + "items": { + "type": "object", + "properties": { + "value": { + "type": "integer", + "description": "The numeric value", + } + }, + "required": ["value"], + }, + } + }, + "required": ["items"], + }, + }, + } + ] + + result = convert_tools_to_description(tools) + + expected = textwrap.dedent( + """\ + ---- BEGIN FUNCTION #1: nested_array ---- + Description: Handle nested arrays + Parameters: + (1) items (array[object], required): List of entries + Array items: + Type: object + Object properties: + - value (integer, required): The numeric value + ---- END FUNCTION #1 ---- + """ + ) + + assert result.strip() == expected.strip() + + +def test_convert_tools_to_description_union_options(): + tools: list[ChatCompletionToolParam] = [ + { + "type": "function", + "function": { + "name": "union_tool", + "description": "Test union parameter", + "parameters": { + "type": "object", + "properties": { + "filters": { + "description": "Supported filters", + "anyOf": [ + {"type": "string", "description": "match by name"}, + {"type": "integer", "description": "match by id"}, + ], + } + }, + }, + }, + } + ] + + result = convert_tools_to_description(tools) + + expected = textwrap.dedent( + """\ + ---- BEGIN FUNCTION #1: union_tool ---- + Description: Test union parameter + Parameters: + (1) filters (string or integer, optional): Supported filters + anyOf options: + - string: match by name + - integer: match by id + ---- END FUNCTION #1 ---- + """ + ) + + assert result.strip() == expected.strip() + + +def test_convert_tools_to_description_object_details(): + tools: list[ChatCompletionToolParam] = [ + { + "type": "function", + "function": { + "name": "object_tool", + "description": "Test object parameter", + "parameters": { + "type": "object", + "properties": { + "config": { + "type": "object", + "description": "Configuration payload", + "properties": { + "name": { + "type": "string", + "description": "Friendly name", + }, + "thresholds": { + "type": "array", + "description": "Threshold list", + "items": {"type": "number"}, + }, + }, + "required": ["name"], + "additionalProperties": { + "type": "string", + "description": "Extra properties", + }, + } + }, + "required": ["config"], + }, + }, + } + ] + + result = convert_tools_to_description(tools) + + expected = textwrap.dedent( + """\ + ---- BEGIN FUNCTION #1: object_tool ---- + Description: Test object parameter + Parameters: + (1) config (object, required): Configuration payload + Object properties: + - name (string, required): Friendly name + - thresholds (array[number], optional): Threshold list + Array items: + Type: number + Additional properties allowed: string + ---- END FUNCTION #1 ---- + """ + ) + + assert result.strip() == expected.strip() From 62d8f12ea5e3dad32730b2036a7bf29bf73d10c1 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 3 Nov 2025 14:45:27 +0000 Subject: [PATCH 3/5] add in-context examples --- .../sdk/llm/mixins/fn_call_converter.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py index d5e3c6aed4..49d5487b8b 100644 --- a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py +++ b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py @@ -329,6 +329,34 @@ def index(): """ # noqa: E501 }, + "task_tracker": { + "view": """ +ASSISTANT: +Let me check the current task list first: + +view + +""", + "plan": """I'll create or update the full plan based on your requirements and current progress: + +plan + +[ + { + "title": "Initialize repo", + "status": "done", + "notes": "Repository created and README added.", + }, + { + "title": "Implement nested param parsing", + "status": "in_progress", + "notes": "Add recursive parsing for array-typed parameters.", + }, +] + + +""", # noqa: E501 + }, } @@ -348,6 +376,8 @@ def get_example_for_tools(tools: list[ChatCompletionToolParam]) -> str: available_tools.add("finish") elif name == LLM_BASED_EDIT_TOOL_NAME: available_tools.add("edit_file") + elif name == TASK_TRACKER_TOOL_NAME: + available_tools.add("task_tracker") if not available_tools: return "" @@ -389,6 +419,12 @@ def get_example_for_tools(tools: list[ChatCompletionToolParam]) -> str: if "finish" in available_tools: example += TOOL_EXAMPLES["finish"]["example"] + if "task_tracker" in available_tools: + example += ( + TOOL_EXAMPLES["task_tracker"]["view"] + + TOOL_EXAMPLES["task_tracker"]["plan"] + ) + example += """ --------------------- END OF EXAMPLE --------------------- From ce135de5f2825857c180642d57223da0b47b7313 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Tue, 4 Nov 2025 06:40:41 +0000 Subject: [PATCH 4/5] fix trailing commas & improve clarity for _format_schema_detail --- .../sdk/llm/mixins/fn_call_converter.py | 188 +++++++++++------- 1 file changed, 117 insertions(+), 71 deletions(-) diff --git a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py index 49d5487b8b..bdce5b2bef 100644 --- a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py +++ b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py @@ -11,7 +11,7 @@ import re import sys from collections.abc import Iterable -from typing import Literal, NotRequired, TypedDict, cast +from typing import Any, Literal, NotRequired, TypedDict, cast from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk @@ -41,6 +41,11 @@ class TextPart(TypedDict): TASK_TRACKER_TOOL_NAME = "task_tracker" # Inspired by: https://docs.together.ai/docs/llama-3-function-calling#function-calling-w-llama-31-70b +MISSING_DESCRIPTION_PLACEHOLDER = "No description provided" +SCHEMA_INDENT_STEP = 2 +SCHEMA_UNION_KEYS = ("anyOf", "oneOf", "allOf") + + system_message_suffix_TEMPLATE = """ You have access to the following functions: @@ -345,13 +350,13 @@ def index(): { "title": "Initialize repo", "status": "done", - "notes": "Repository created and README added.", + "notes": "Repository created and README added." }, { "title": "Implement nested param parsing", "status": "in_progress", - "notes": "Add recursive parsing for array-typed parameters.", - }, + "notes": "Add recursive parsing for array-typed parameters." + } ] @@ -495,8 +500,7 @@ def _summarize_schema_type(schema: object | None) -> str: if not isinstance(schema, dict): return "unknown" if schema is None else str(schema) - union_keys = ("anyOf", "oneOf", "allOf") - for key in union_keys: + for key in SCHEMA_UNION_KEYS: if key in schema: return " or ".join(_summarize_schema_type(option) for option in schema[key]) @@ -518,84 +522,124 @@ def _summarize_schema_type(schema: object | None) -> str: return "unknown" -def _format_schema_detail(schema: object | None, indent: int = 4) -> list[str]: +def _indent(indent: int) -> str: + return " " * indent + + +def _nested_indent(indent: int, levels: int = 1) -> int: + return indent + SCHEMA_INDENT_STEP * levels + + +def _get_description(schema: dict[str, object] | None) -> str: """ - Recursively describe arrays, objects, unions, and additional properties. + Extract description from schema, or return placeholder if missing. """ if not isinstance(schema, dict): - return [] + return MISSING_DESCRIPTION_PLACEHOLDER + description = schema.get("description") + if isinstance(description, str) and description.strip(): + return description + return MISSING_DESCRIPTION_PLACEHOLDER + + +def _format_union_details(schema: dict[str, object], indent: int) -> list[str] | None: + for key in SCHEMA_UNION_KEYS: + options = schema.get(key) + if not isinstance(options, list): + continue + lines = [f"{_indent(indent)}{key} options:"] + for option in options: + option_type = _summarize_schema_type(option) + option_line = f"{_indent(_nested_indent(indent))}- {option_type}" + option_line += ( + f": {_get_description(option if isinstance(option, dict) else None)}" + ) + lines.append(option_line) + lines.extend(_format_schema_detail(option, _nested_indent(indent, 2))) + return lines + return None + - indent_str = " " * indent +def _format_array_details(schema: dict[str, object], indent: int) -> list[str]: + lines = [f"{_indent(indent)}Array items:"] + items = schema.get("items") + if isinstance(items, list): + for index, item_schema in enumerate(items): + item_type = _summarize_schema_type(item_schema) + lines.append( + f"{_indent(_nested_indent(indent))}- index {index}: {item_type}" + ) + lines.extend(_format_schema_detail(item_schema, _nested_indent(indent, 2))) + elif isinstance(items, dict): + lines.append( + f"{_indent(_nested_indent(indent))}Type: {_summarize_schema_type(items)}" + ) + lines.extend(_format_schema_detail(items, _nested_indent(indent, 2))) + else: + lines.append(f"{_indent(_nested_indent(indent))}Type: unknown") + return lines + + +def _format_additional_properties( + additional_props: object | None, indent: int +) -> list[str]: + if isinstance(additional_props, dict): + line = ( + f"{_indent(indent)}Additional properties allowed: " + f"{_summarize_schema_type(additional_props)}" + ) + lines = [line] + lines.extend(_format_schema_detail(additional_props, _nested_indent(indent))) + return lines + if additional_props is True: + return [f"{_indent(indent)}Additional properties allowed."] + if additional_props is False: + return [f"{_indent(indent)}Additional properties not allowed."] + return [] + + +def _format_object_details(schema: dict[str, Any], indent: int) -> list[str]: lines: list[str] = [] + properties = schema.get("properties", {}) + required = set(schema.get("required", [])) + if isinstance(properties, dict) and properties: + lines.append(f"{_indent(indent)}Object properties:") + for name, prop in properties.items(): + prop_type = _summarize_schema_type(prop) + required_flag = "required" if name in required else "optional" + prop_desc = _get_description(prop if isinstance(prop, dict) else None) + lines.append( + f"{_indent(_nested_indent(indent))}- {name} ({prop_type}," + f" {required_flag}): {prop_desc}" + ) + lines.extend(_format_schema_detail(prop, _nested_indent(indent, 2))) + lines.extend( + _format_additional_properties(schema.get("additionalProperties"), indent) + ) + return lines - # Handle union types - union_keys = ("anyOf", "oneOf", "allOf") - for key in union_keys: - if key in schema: - lines.append(f"{indent_str}{key} options:") - for option in schema[key]: - option_type = _summarize_schema_type(option) - option_line = f"{' ' * (indent + 2)}- {option_type}" - option_desc = ( - option.get("description") if isinstance(option, dict) else None - ) - if option_desc: - option_line += f": {option_desc}" - lines.append(option_line) - lines.extend(_format_schema_detail(option, indent + 4)) - return lines + +def _format_schema_detail(schema: object | None, indent: int = 4) -> list[str]: + """Recursively describe arrays, objects, unions, and additional properties.""" + if not isinstance(schema, dict): + return [] + + union_lines = _format_union_details(schema, indent) + if union_lines is not None: + return union_lines schema_type = schema.get("type") if isinstance(schema_type, list): - lines.append( - f"{indent_str}Allowed types: {', '.join(str(t) for t in schema_type)}" - ) - return lines + allowed_types = ", ".join(str(t) for t in schema_type) + return [f"{_indent(indent)}Allowed types: {allowed_types}"] - # Handle array type if schema_type == "array": - items = schema.get("items") - lines.append(f"{indent_str}Array items:") - if isinstance(items, list): - for index, item_schema in enumerate(items): - item_type = _summarize_schema_type(item_schema) - lines.append(f"{' ' * (indent + 2)}- index {index}: {item_type}") - lines.extend(_format_schema_detail(item_schema, indent + 4)) - elif isinstance(items, dict): - lines.append(f"{' ' * (indent + 2)}Type: {_summarize_schema_type(items)}") - lines.extend(_format_schema_detail(items, indent + 4)) - else: - lines.append(f"{' ' * (indent + 2)}Type: unknown") - return lines + return _format_array_details(schema, indent) if schema_type == "object": - properties = schema.get("properties", {}) - required = set(schema.get("required", [])) - if isinstance(properties, dict) and properties: - lines.append(f"{indent_str}Object properties:") - for name, prop in properties.items(): - prop_type = _summarize_schema_type(prop) - required_flag = "required" if name in required else "optional" - prop_desc = prop.get("description", "No description provided") - lines.append( - f"{' ' * (indent + 2)}- {name} ({prop_type}, {required_flag}):" - f" {prop_desc}" - ) - lines.extend(_format_schema_detail(prop, indent + 4)) - additional_props = schema.get("additionalProperties") - if isinstance(additional_props, dict): - lines.append( - f"{indent_str}Additional properties allowed: " - f"{_summarize_schema_type(additional_props)}" - ) - lines.extend(_format_schema_detail(additional_props, indent + 2)) - elif additional_props is True: - lines.append(f"{indent_str}Additional properties allowed.") - elif additional_props is False: - lines.append(f"{indent_str}Additional properties not allowed.") - return lines + return _format_object_details(schema, indent) - return lines + return [] def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str: @@ -619,7 +663,9 @@ def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str: param_status = "required" if is_required else "optional" param_type = _summarize_schema_type(param_info) - desc = param_info.get("description", "No description provided") + desc = _get_description( + param_info if isinstance(param_info, dict) else None + ) if "enum" in param_info: enum_values = ", ".join(f"`{v}`" for v in param_info["enum"]) From fbe7f94c42b4df1396fb25afabfc73db866a8027 Mon Sep 17 00:00:00 2001 From: enyst Date: Fri, 7 Nov 2025 20:10:34 +0000 Subject: [PATCH 5/5] Remove test-only compatibility shim from convert_tools_to_description and adapt tests - Drop special-case label for task_list; rely on generic recursive formatter - Update test expectations to match nested array/object formatting Co-authored-by: openhands --- .../sdk/llm/mixins/fn_call_converter.py | 55 +------------------ tests/sdk/llm/test_llm_fncall_converter.py | 18 +++--- 2 files changed, 10 insertions(+), 63 deletions(-) diff --git a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py index 5df5d0f158..3b81d14363 100644 --- a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py +++ b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py @@ -661,11 +661,6 @@ def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str: is_required = param_name in required_params param_status = "required" if is_required else "optional" param_type = _summarize_schema_type(param_info) - display_param_type = ( - "array" - if param_name == "task_list" and param_type.startswith("array") - else param_type - ) desc = _get_description( param_info if isinstance(param_info, dict) else None @@ -676,60 +671,12 @@ def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str: desc += f"\nAllowed values: [{enum_values}]" ret += ( - " (" - + str(j + 1) - + ") " - + param_name - + " (" - + display_param_type - + ", " - + param_status - + "): " - + desc - + "\n" + f" ({j + 1}) {param_name} ({param_type}, {param_status}): {desc}\n" ) detail_lines = _format_schema_detail(param_info, indent=6) if detail_lines: ret += "\n".join(detail_lines) + "\n" - # For backward compatibility with tests expecting a static label - if param_name == "task_list" and param_type.startswith("array"): - # Ensure the legacy label exists in output to avoid - # brittle assertions - ret += " task_list array item structure:\n" - # Re-emit a simplified listing for first-level object - # properties if present - if isinstance(param_info, dict): - items = param_info.get("items") - if ( - isinstance(items, dict) - and items.get("type") == "object" - ): - item_properties = items.get("properties", {}) - item_required = set(items.get("required", [])) - for ( - item_param_name, - item_param_info, - ) in item_properties.items(): - item_is_required = item_param_name in item_required - item_status = ( - "required" if item_is_required else "optional" - ) - item_type = item_param_info.get("type", "string") - item_desc = item_param_info.get( - "description", MISSING_DESCRIPTION_PLACEHOLDER - ) - if "enum" in item_param_info: - item_enum_values = ", ".join( - f"`{v}`" for v in item_param_info["enum"] - ) - item_desc += ( - f" Allowed values: [{item_enum_values}]" - ) - ret += ( - f" - {item_param_name} ({item_type}, " - f"{item_status}): {item_desc}\n" - ) else: ret += "No parameters are required for this function.\n" diff --git a/tests/sdk/llm/test_llm_fncall_converter.py b/tests/sdk/llm/test_llm_fncall_converter.py index 08c376c5d5..191ba5ddd4 100644 --- a/tests/sdk/llm/test_llm_fncall_converter.py +++ b/tests/sdk/llm/test_llm_fncall_converter.py @@ -531,24 +531,24 @@ def test_convert_tools_to_description_array_items(): "Allowed values: [`view`, `plan`]\n" ) assert expected_command_line in description + # Top-level parameter line should reflect the summarized array type assert ( - " (2) task_list (array, optional): The full task list. Required parameter of `plan` command.\n" # noqa: E501 + " (2) task_list (array[object], optional): The full task list. Required parameter of `plan` command.\n" # noqa: E501 in description ) - assert " task_list array item structure:\n" in description + # Nested structure should be shown via the generic recursive formatter + assert "Object properties:" in description + assert "- title (string, required): A brief title for the task." in description assert ( - " - title (string, required): A brief title for the task.\n" + "- notes (string, optional): Additional details or notes about the task." in description ) assert ( - " - notes (string, optional): Additional details or notes about the task.\n" # noqa: E501 + "- status (string, optional): The current status of the task. One of 'todo', 'in_progress', or 'done'." # noqa: E501 in description ) - expected_status_line = ( - " - status (string, optional): The current status of the task. " - "One of 'todo', 'in_progress', or 'done'. Allowed values: [`todo`, `in_progress`, `done`]\n" # noqa: E501 - ) - assert expected_status_line in description + # Nested enum values are described inline in the field description; no separate + # "Allowed values" line is required. @pytest.mark.parametrize(