diff --git a/aai_cli/agent/voices.py b/aai_cli/agent/voices.py index f53bb20..b084eca 100644 --- a/aai_cli/agent/voices.py +++ b/aai_cli/agent/voices.py @@ -63,12 +63,11 @@ class Voice: def format_voice_list() -> str: """Human-readable voice IDs for --list-voices, grouped by language.""" - groups = dict.fromkeys(voice.language for voice in VOICES) - blocks: list[str] = [] - for language in groups: - names = "\n".join(f" {voice.name}" for voice in VOICES if voice.language == language) - blocks.append(f"{language}:\n{names}") - return "\n\n".join(blocks) + languages = dict.fromkeys(voice.language for voice in VOICES) + return choices.render_grouped( + (language, [voice.name for voice in VOICES if voice.language == language]) + for language in languages + ) def complete_voice(incomplete: str) -> list[str]: diff --git a/aai_cli/agent_cascade/voices.py b/aai_cli/agent_cascade/voices.py index b6abb6e..3023b38 100644 --- a/aai_cli/agent_cascade/voices.py +++ b/aai_cli/agent_cascade/voices.py @@ -35,10 +35,7 @@ def complete_voice(incomplete: str) -> list[str]: def format_voice_list() -> str: """Human-readable voice ids for ``--list-voices``, grouped by language.""" - blocks: list[str] = [] - for code, label in _LANGUAGE_LABELS.items(): - names = [name for name in VOICE_NAMES if tts_voices.VOICE_LANGUAGES[name] == code] - if names: - listing = "\n".join(f" {name}" for name in names) - blocks.append(f"{label}:\n{listing}") - return "\n\n".join(blocks) + return choices.render_grouped( + (label, [name for name in VOICE_NAMES if tts_voices.VOICE_LANGUAGES[name] == code]) + for code, label in _LANGUAGE_LABELS.items() + ) diff --git a/aai_cli/app/agent_shared.py b/aai_cli/app/agent_shared.py index 188ea75..86ad1ec 100644 --- a/aai_cli/app/agent_shared.py +++ b/aai_cli/app/agent_shared.py @@ -7,9 +7,19 @@ from __future__ import annotations +from collections.abc import Container from pathlib import Path -from aai_cli.core.errors import CLIError +from aai_cli.core.errors import CLIError, UsageError + + +def validate_voice(voice: str, valid_names: Container[str], *, command: str) -> None: + """Reject an unknown ``--voice`` with a usage error pointing at ``--list-voices``.""" + if voice not in valid_names: + raise UsageError( + f"Unknown voice {voice!r}.", + suggestion=f"Run 'assembly {command} --list-voices' to see the options.", + ) def resolve_system_prompt(system_prompt: str, system_prompt_file: Path | None) -> str: diff --git a/aai_cli/commands/agent/_exec.py b/aai_cli/commands/agent/_exec.py index 814154c..74fbcfc 100644 --- a/aai_cli/commands/agent/_exec.py +++ b/aai_cli/commands/agent/_exec.py @@ -21,6 +21,7 @@ from aai_cli.agent.session import AgentRunConfig, run_session from aai_cli.agent.voices import VOICE_NAMES from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt +from aai_cli.app.agent_shared import validate_voice from aai_cli.app.context import AppState from aai_cli.core import choices, client, errors, signals from aai_cli.core.errors import UsageError @@ -94,11 +95,7 @@ def _print_show_code(opts: AgentOptions, system_prompt_text: str) -> None: def run_agent(opts: AgentOptions, state: AppState, *, json_mode: bool) -> None: """Execute one `assembly agent` conversation from already-parsed flags.""" text_mode, json_mode = resolve_output_modes(opts.output_field, json_mode=json_mode) - if opts.voice not in VOICE_NAMES: - raise UsageError( - f"Unknown voice {opts.voice!r}.", - suggestion="Run 'assembly agent --list-voices' to see the options.", - ) + validate_voice(opts.voice, VOICE_NAMES, command="agent") system_prompt_text = _resolve_system_prompt(opts.system_prompt, opts.system_prompt_file) if opts.show_code: diff --git a/aai_cli/commands/agent_cascade/_exec.py b/aai_cli/commands/agent_cascade/_exec.py index 022affd..0b97e23 100644 --- a/aai_cli/commands/agent_cascade/_exec.py +++ b/aai_cli/commands/agent_cascade/_exec.py @@ -21,6 +21,7 @@ from aai_cli.agent_cascade import engine, voices from aai_cli.agent_cascade.config import DEFAULT_MAX_HISTORY, CascadeConfig from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt +from aai_cli.app.agent_shared import validate_voice from aai_cli.app.context import AppState from aai_cli.core import choices, client, config_builder, errors, llm, signals from aai_cli.core.errors import UsageError @@ -168,11 +169,7 @@ def _print_show_code(opts: AgentCascadeOptions, system_prompt_text: str) -> None def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode: bool) -> None: """Execute one `assembly agent-cascade` cascade from already-parsed flags.""" text_mode, json_mode = resolve_output_modes(opts.output_field, json_mode=json_mode) - if opts.voice not in voices.VOICE_NAMES: - raise UsageError( - f"Unknown voice {opts.voice!r}.", - suggestion="Run 'assembly agent-cascade --list-voices' to see the options.", - ) + validate_voice(opts.voice, voices.VOICE_NAMES, command="agent-cascade") # Streaming TTS has no production host, so the whole cascade is sandbox-only. tts_session.require_available("agent-cascade") system_prompt_text = _resolve_system_prompt(opts.system_prompt, opts.system_prompt_file) diff --git a/aai_cli/core/choices.py b/aai_cli/core/choices.py index fe3d4da..ed843d6 100644 --- a/aai_cli/core/choices.py +++ b/aai_cli/core/choices.py @@ -9,6 +9,21 @@ def complete_prefix(options: Iterable[str], incomplete: str) -> list[str]: return [option for option in options if option.startswith(incomplete)] +def render_grouped(groups: Iterable[tuple[str, Iterable[str]]]) -> str: + """Render labelled groups of names as an indented, blank-line-separated block list. + + Each non-empty group renders as a ``label:`` header followed by its names + indented two spaces, one per line; groups are separated by a blank line and + empty groups are skipped. Backs the voice commands' grouped ``--list-voices``. + """ + blocks: list[str] = [] + for label, names in groups: + listing = "\n".join(f" {name}" for name in names) + if listing: + blocks.append(f"{label}:\n{listing}") + return "\n\n".join(blocks) + + # CLI-owned closed value sets for ``-o/--output``. ``StrEnum`` members *are* their # string values: Typer renders them as choices in ``--help`` (e.g. # [text|id|status|utterances|srt|json]), validates input with a clean listing error, diff --git a/tests/test_choices.py b/tests/test_choices.py new file mode 100644 index 0000000..1c5518b --- /dev/null +++ b/tests/test_choices.py @@ -0,0 +1,29 @@ +"""Tests for the shared choice/listing helpers in `aai_cli.core.choices`.""" + +from __future__ import annotations + +from aai_cli.core import choices + + +def test_complete_prefix_filters_by_prefix(): + assert choices.complete_prefix(["alpha", "alto", "beta"], "al") == ["alpha", "alto"] + + +def test_render_grouped_headers_indentation_and_separator(): + rendered = choices.render_grouped([("English", ["jane", "michael"]), ("Italian", ["giovanni"])]) + # A blank line separates groups; each name is indented two spaces under its header. + assert rendered == "English:\n jane\n michael\n\nItalian:\n giovanni" + + +def test_render_grouped_skips_empty_groups(): + # A label with no names contributes nothing — no dangling "French:" header and + # no extra blank-line separator around it. + rendered = choices.render_grouped( + [("English", ["jane"]), ("French", []), ("Italian", ["luca"])] + ) + assert rendered == "English:\n jane\n\nItalian:\n luca" + assert "French" not in rendered + + +def test_render_grouped_empty_input_is_empty_string(): + assert choices.render_grouped([]) == ""