Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions aai_cli/agent/voices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
11 changes: 4 additions & 7 deletions aai_cli/agent_cascade/voices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
12 changes: 11 additions & 1 deletion aai_cli/app/agent_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 2 additions & 5 deletions aai_cli/commands/agent/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 2 additions & 5 deletions aai_cli/commands/agent_cascade/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions aai_cli/core/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions tests/test_choices.py
Original file line number Diff line number Diff line change
@@ -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([]) == ""
Loading