fix(agents): coerce a non-string description in TOML command rendering (#3799)

CommandRegistrar.render_toml_command passes the raw frontmatter `description`
straight into `_render_basic_toml_string`, which iterates the value and calls
ord() on each character. Frontmatter comes from yaml.safe_load, so description
can be any YAML type:

    description='ok string' -> description = "ok string"
    description=None        -> TypeError: 'NoneType' object is not iterable
    description=42          -> TypeError: 'int' object is not iterable
    description=True        -> TypeError: 'bool' object is not iterable
    description=['a','b']   -> description = "ab"     <- silently WRONG value

This is a format-branch asymmetry: it is the only renderer reached from
register_commands' format branches that does not normalise description.
render_yaml_command (same class, ~70 lines below) already does exactly
`if not isinstance(description, str): description = str(description) if
description is not None else ""`, render_markdown_command goes through
yaml.dump which handles any type, and TomlIntegration._extract_description
returns "" for a non-str. So only extension/preset commands rendered for the two
TOML agents were affected.

Apply the same coercion the sibling uses. After: None -> "", 42 -> "42",
True -> "True", ['a','b'] -> "['a', 'b']", each still valid parseable TOML.
String descriptions are untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali jawwad
2026-07-29 19:07:29 +05:00
committed by GitHub
parent de54ff73fe
commit 2ef96532d2
2 changed files with 43 additions and 1 deletions

View File

@@ -302,8 +302,20 @@ class CommandRegistrar:
toml_lines = []
if "description" in frontmatter:
# Frontmatter comes from ``yaml.safe_load``, so ``description`` can
# be any YAML type: ``description:`` with no value yields None,
# ``description: 2`` an int, an unquoted ``true`` a bool.
# ``_render_basic_toml_string`` iterates the value and calls ord()
# on each character, so a non-string raises a raw TypeError -- and a
# list of single-character items is silently concatenated into a
# wrong value (``["a", "b"]`` -> ``"ab"``). Coerce first, matching
# ``render_yaml_command`` below and ``TomlIntegration
# ._extract_description``, which both normalise it already.
description = frontmatter["description"]
if not isinstance(description, str):
description = str(description) if description is not None else ""
toml_lines.append(
f"description = {self._render_basic_toml_string(frontmatter['description'])}"
f"description = {self._render_basic_toml_string(description)}"
)
toml_lines.append("")

View File

@@ -2720,6 +2720,36 @@ Real body starts here.
assert parsed["description"] == "first line\nsecond line\n"
@pytest.mark.parametrize(
("description", "expected"),
[
(None, ""), # "description:" with no value
(42, "42"), # unquoted number
(True, "True"), # unquoted boolean
(["a", "b"], "['a', 'b']"), # was silently concatenated to "ab"
],
)
def test_render_toml_command_coerces_non_string_description(
self, description, expected
):
"""Frontmatter comes from yaml.safe_load, so description can be any type.
_render_basic_toml_string iterates the value and calls ord() per
character, so a non-string raised a raw TypeError and a list of
single-character items was silently concatenated into a wrong value.
render_yaml_command (same class) already coerces; this brings the TOML
branch to parity.
"""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
output = registrar.render_toml_command(
{"description": description}, "body", "extension:test-ext"
)
parsed = tomllib.loads(output)
assert parsed["description"] == expected
def test_render_toml_command_escapes_control_characters(self):
"""Control characters and a lone CR must be escaped so the TOML parses.