From 927ac05a9b54709f29ebbb7b67a0565719b371ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 04:52:15 +0000 Subject: [PATCH 01/11] Fix QA findings: non-interactive auth, input validation, error hygiene Auth/login: - Auto-login now only runs in interactive sessions; headless/CI runs get a clean not_authenticated error (exit 4) instead of a 120s browser wait - Explicit empty --api-key is a usage error instead of a browser flow - Loopback callback server binds before the browser opens - Browser flow prints a waiting hint with the --api-key alternative - Login timeout is typed not_authenticated; whoami exits 4 on rejected key Transcribe/transcripts/llm/account: - An explicit source plus --sample is now a usage error - A directory passed as the audio source fails fast before credentials - list-transcripts requests no longer carry a bogus model_config param (assemblyai 0.64.4 + pydantic 2.13.4 serialization workaround) - validate_key network errors compact to one line (no httpx Request repr) - transcribe --show-code honors -o (srt/utterances/json/id/status) - New client-side validation: --limit >= 1, --audio-start >= 0, --language-code vs --language-detection conflict, --speakers-expected requires speaker labels, unknown PII policies list valid values - yt-dlp errors no longer print twice (quiet logger) - llm --follow with empty piped stdin is a usage error, not silent exit 0 - usage validates --start/--end dates before session resolution https://claude.ai/code/session_01Uv7cEgJi2LgknkvfHP52g7 --- aai_cli/auth/flow.py | 20 ++-- aai_cli/auth/loopback.py | 64 +++++++++---- aai_cli/client.py | 64 ++++++++++--- aai_cli/code_gen/transcribe.py | 32 ++++++- aai_cli/commands/account.py | 4 +- aai_cli/commands/llm.py | 23 +++-- aai_cli/commands/login.py | 24 ++++- aai_cli/commands/transcribe.py | 45 ++++++++- aai_cli/commands/transcripts.py | 2 +- aai_cli/context.py | 17 +++- aai_cli/youtube.py | 9 ++ tests/test_account_command.py | 16 ++++ tests/test_auth_flow.py | 156 +++++++++++++++++++++++--------- tests/test_auth_loopback.py | 28 ++++++ tests/test_client.py | 55 +++++++++++ tests/test_code_gen.py | 63 +++++++++++++ tests/test_context.py | 125 ++++++++++++++++++++++++- tests/test_keys.py | 1 + tests/test_llm_command.py | 39 ++++++++ tests/test_login.py | 66 +++++++++++++- tests/test_sessions_command.py | 1 + tests/test_source_validation.py | 51 +++++++++++ tests/test_transcribe.py | 115 +++++++++++++++++++++++ tests/test_transcripts.py | 12 +++ tests/test_youtube.py | 41 +++++++++ 25 files changed, 970 insertions(+), 103 deletions(-) diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index c8a086fe..62243eda 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -8,7 +8,7 @@ from aai_cli import output from aai_cli.auth import ams, discovery, endpoints, loopback -from aai_cli.errors import APIError +from aai_cli.errors import APIError, NotAuthenticated @dataclass @@ -97,8 +97,8 @@ def _open_browser(url: str) -> None: ) -def _capture() -> loopback.CallbackResult: - return loopback.capture_callback() +def _start_capture() -> loopback.CallbackCapture: + return loopback.start_capture() def _reusable_cli_key(token: _Token) -> str | None: @@ -137,13 +137,21 @@ def find_or_create_cli_key(account_id: int, session_jwt: str) -> str: def run_login_flow() -> LoginResult: """Drive the full browser + AMS login and return a LoginResult.""" + # Bind the loopback callback server *before* opening the browser: if the port is + # taken, fail cleanly now instead of stranding the user mid-OAuth in a flow that + # can never call back. + capture = _start_capture() _open_browser(discovery.build_start_url()) - result = _capture() + output.error_console.print( + "[aai.muted]Waiting up to 2 minutes for you to finish signing in…[/aai.muted]\n" + "[aai.muted]No browser here? Run 'aai login --api-key ' instead.[/aai.muted]" + ) + result = capture.wait() if result.error == "timeout": - raise APIError( + raise NotAuthenticated( "Login timed out waiting for the browser.", - suggestion="Run 'aai login' again.", + suggestion="Run 'aai login' again, or use 'aai login --api-key '.", ) if result.token_type != "discovery_oauth" or not result.token: # noqa: S105 raise APIError( diff --git a/aai_cli/auth/loopback.py b/aai_cli/auth/loopback.py index b27b77e7..b00b8cac 100644 --- a/aai_cli/auth/loopback.py +++ b/aai_cli/auth/loopback.py @@ -30,15 +30,47 @@ class CallbackResult: error: str | None = None -def capture_callback( - timeout: float = 120.0, # pragma: no mutate (default window; tests pass explicit timeouts) -) -> CallbackResult: - """Bind the fixed loopback port, capture one OAuth callback, return its token. +@dataclass +class CallbackCapture: + """A loopback callback server that is already bound and serving. - Only a callback to the registered path that carries a `token` is accepted; any - other request (a different path, or no token) gets a 4xx and the server keeps - waiting, so a stray request can't end the capture early. Returns a - CallbackResult; `error="timeout"` if no matching callback arrives in time. + Splitting the bind (`start_capture`) from the blocking wait lets the login flow + fail on a taken port *before* it sends the user's browser into the OAuth flow. + `wait()` blocks for one matching callback and always shuts the server down. + """ + + result: CallbackResult + done: threading.Event + server: HTTPServer + thread: threading.Thread + + def wait( + self, + timeout: float = 120.0, # pragma: no mutate (default window; tests pass explicit timeouts) + ) -> CallbackResult: + """Block for one OAuth callback (or the timeout), then shut the server down. + + Returns the CallbackResult; `error="timeout"` if no matching callback + arrived in time. + """ + try: + if not self.done.wait(timeout): + self.result.error = "timeout" + finally: + self.server.shutdown() # stop serve_forever() + self.thread.join(timeout=5) # pragma: no mutate (cleanup grace period only) + self.server.server_close() # close the listening socket (shutdown() leaves it open) + return self.result + + +def start_capture() -> CallbackCapture: + """Bind the fixed loopback port and start serving; the returned capture's + ``wait()`` collects one OAuth callback. + + Raises a clean APIError when the bind fails (port taken) so callers can abort + before opening the browser. Only a callback to the registered path that carries + a `token` is accepted; any other request (a different path, or no token) gets a + 4xx and the server keeps waiting, so a stray request can't end the capture early. """ result = CallbackResult() done = threading.Event() @@ -81,11 +113,11 @@ def log_message(self, format: str, *args: object) -> None: # silence stderr log ) from exc thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() - try: - if not done.wait(timeout): - result.error = "timeout" - finally: - server.shutdown() # stop serve_forever() - thread.join(timeout=5) - server.server_close() # close the listening socket (shutdown() leaves it open) - return result + return CallbackCapture(result=result, done=done, server=server, thread=thread) + + +def capture_callback( + timeout: float = 120.0, # pragma: no mutate (default window; tests pass explicit timeouts) +) -> CallbackResult: + """Bind the port, capture one OAuth callback, and shut down (one-shot helper).""" + return start_capture().wait(timeout) diff --git a/aai_cli/client.py b/aai_cli/client.py index 7e59d4ea..fe327147 100644 --- a/aai_cli/client.py +++ b/aai_cli/client.py @@ -49,19 +49,36 @@ def resolve_audio_source(source: str | None, *, sample: bool, check_local: bool don't have yet is legitimate. """ if sample: + if source: + # Never silently prefer one over the other: the user asked for both. + raise UsageError( + "An audio source and --sample cannot be combined.", + suggestion="Pass the file/URL or --sample, not both.", + ) return SAMPLE_AUDIO_URL if not source: raise UsageError( "Provide an audio path or URL.", suggestion="Or pass --sample to use the hosted demo file.", ) - if check_local and not source.startswith(("http://", "https://")) and not Path(source).exists(): - raise CLIError( - f"File not found: {source}", - error_type="file_not_found", - exit_code=2, - suggestion="Check the path. For remote audio, pass an http(s):// URL.", - ) + if check_local and not source.startswith(("http://", "https://")): + path = Path(source) + if not path.exists(): + raise CLIError( + f"File not found: {source}", + error_type="file_not_found", + exit_code=2, + suggestion="Check the path. For remote audio, pass an http(s):// URL.", + ) + if not path.is_file(): + # A directory (or socket/FIFO) would otherwise fall through to credential + # resolution and fail much later as an opaque upload error. + raise CLIError( + f"Not a file: {source}", + error_type="not_a_file", + exit_code=2, + suggestion="Pass an audio file, not a directory.", + ) return source @@ -90,17 +107,42 @@ def _sdk_errors(message: str) -> Generator[None]: raise APIError(f"{message}: {exc}") from exc +def _list_transcript_params(limit: int) -> aai.ListTranscriptParameters: + """List-transcripts params that serialize without the spurious ``model_config`` key. + + assemblyai==0.64.4 under pydantic==2.13.4: the SDK's pydantic-v1-shim request model + picks up the v2-style ``model_config`` class attribute as a regular field, so the + ``.dict(exclude_none=True)`` the SDK puts on the query string ships a junk + ``?model_config=...`` param on every request. Null the bogus field out so + ``exclude_none`` drops it from the wire. + """ + params = aai.ListTranscriptParameters(limit=limit) + object.__setattr__(params, "model_config", None) + return params + + +# httpx-backed SDK errors embed a multi-line repr ("…\nReason: …\nRequest: "). +_REQUEST_REPR_RE = re.compile(r"Request: <[^>]*>") + + +def _compact_reason(exc: object) -> str: + """``str(exc)`` as a single clean line: drop the trailing ``Request: <…>`` repr and + collapse all whitespace/newlines, keeping the informative reason text.""" + text = _REQUEST_REPR_RE.sub("", str(exc)) + return re.sub(r"\s+", " ", text).strip() + + def validate_key(api_key: str) -> bool: """True if the key authenticates, False on an auth failure. Raises APIError otherwise.""" _configure(api_key) try: - aai.Transcriber().list_transcripts(aai.ListTranscriptParameters(limit=1)) + aai.Transcriber().list_transcripts(_list_transcript_params(1)) except aai.types.AssemblyAIError as exc: if is_auth_failure(exc): return False - raise APIError(f"Could not validate key: {exc}") from exc + raise APIError(f"Could not validate key: {_compact_reason(exc)}") from exc except Exception as exc: - raise APIError(f"Network error contacting AssemblyAI: {exc}") from exc + raise APIError(f"Network error contacting AssemblyAI: {_compact_reason(exc)}") from exc return True @@ -114,7 +156,7 @@ def _item_to_dict(item: Any) -> dict[str, Any]: def list_transcripts(api_key: str, *, limit: int = 10) -> list[dict[str, object]]: _configure(api_key) with _sdk_errors("Could not list transcripts"): - resp = aai.Transcriber().list_transcripts(aai.ListTranscriptParameters(limit=limit)) + resp = aai.Transcriber().list_transcripts(_list_transcript_params(limit)) return [_item_to_dict(item) for item in resp.transcripts] diff --git a/aai_cli/code_gen/transcribe.py b/aai_cli/code_gen/transcribe.py index a35a8723..5527e8c4 100644 --- a/aai_cli/code_gen/transcribe.py +++ b/aai_cli/code_gen/transcribe.py @@ -5,12 +5,27 @@ from aai_cli import environments, llm from aai_cli.code_gen import serialize, snippets +# ``-o/--output`` choice -> printed-result code, mirroring the run path's +# ``client._FIELD_RENDERERS`` semantics: plain fields, the speaker-labeled +# utterances loop, the SRT export endpoint, and the raw ``json_response`` payload. +_OUTPUT_SNIPPETS: dict[str, str] = { + "text": "print(transcript.text)", + "id": "print(transcript.id)", + "status": "print(transcript.status.value)", + "utterances": ( + 'for utt in transcript.utterances or []:\n print(f"Speaker {utt.speaker}: {utt.text}")' + ), + "srt": "print(transcript.export_subtitles_srt())", + "json": "print(json.dumps(transcript.json_response, default=str))", +} + def render( merged: dict[str, object], source: str, *, llm_gateway: dict[str, object] | None = None, + output: str | None = None, ) -> str: """Generate a runnable transcribe script reproducing this CLI invocation. @@ -18,7 +33,13 @@ def render( script transforms the transcript through AssemblyAI's LLM Gateway and prints that result instead of the analysis sections — mirroring how `--llm-gateway-prompt` replaces the normal output. + + When `output` (a ``-o/--output`` field name) is given, the script prints that one + field instead — and, as in the real command, it takes precedence over the LLM chain + and the analysis sections. """ + if output is not None: + llm_gateway = None # `-o` returns before the chain runs in the real command if merged: kwargs = "\n".join(serialize.config_kwarg_lines(merged, indent=4)) config_block = f"config = aai.TranscriptionConfig(\n{kwargs}\n)" @@ -31,8 +52,12 @@ def render( if llm_gateway: imports.append("from openai import OpenAI") + stdlib_imports = ["import os"] + if output == "json": + stdlib_imports.insert(0, "import json") + parts = [ - "import os", + *stdlib_imports, "", *imports, "", @@ -59,7 +84,10 @@ def render( "", ] - if llm_gateway: + if output is not None: + # Unknown names fall back to the plain text, like select_transcript_field does. + parts.append(_OUTPUT_SNIPPETS.get(output, _OUTPUT_SNIPPETS["text"])) + elif llm_gateway: parts += _llm_gateway_block(llm_gateway) else: parts.append(snippets.result_handling(merged)) diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index 445929eb..c507fe3a 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -156,10 +156,12 @@ def usage( """Show usage over a date range (defaults to the last 30 days).""" def body(state: AppState, json_mode: bool) -> None: - _, jwt = resolve_session(state) + # Parse/validate the date flags before any session resolution or network + # work, so a bad --start/--end is a fast usage error even when not logged in. today = datetime.now(UTC).date() start_date = _utc_day_start(start or (today - timedelta(days=30)).isoformat()) end_date = _utc_day_start(end or today.isoformat()) + _, jwt = resolve_session(state) data = ams.get_usage(jwt, start_date, end_date, window) def render(d: dict[str, object]) -> object: diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index 69848246..b971d660 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -1,7 +1,5 @@ from __future__ import annotations -from contextlib import suppress - import typer from rich.markup import escape @@ -14,6 +12,11 @@ app = typer.Typer() +_FOLLOW_STDIN_MESSAGE = ( + "--follow needs transcript text piped on stdin, e.g. " + '`aai stream -o text | aai llm -f "summarize action items as I talk"`.' +) + def _validate_follow_args( prompt: str | None, output_field: str | None, transcript_id: str | None @@ -35,10 +38,7 @@ def _validate_follow_args( "combined with --transcript-id." ) if not stdio.stdin_is_piped(): - raise UsageError( - "--follow needs transcript text piped on stdin, e.g. " - '`aai stream -o text | aai llm -f "summarize action items as I talk"`.' - ) + raise UsageError(_FOLLOW_STDIN_MESSAGE) return prompt @@ -116,13 +116,20 @@ def ask(transcript_text: str) -> str: ) return gateway.content_of(response) + transcript: list[str] = [] + interrupted = False with FollowRenderer(json_mode=json_mode) as render: - transcript: list[str] = [] # Ctrl-C is the normal "stop watching" signal -> exit cleanly (code 0). - with suppress(KeyboardInterrupt): + try: for turn in stdio.iter_piped_stdin_lines(): transcript.append(turn) render(ask("\n".join(transcript)), len(transcript)) + except KeyboardInterrupt: + interrupted = True + if not transcript and not interrupted: + # An empty pipe (`aai llm -f "…" None: if not prompt: diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index f7954de2..0db75ad0 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -6,7 +6,7 @@ from aai_cli import client, config, environments, help_panels, options, output from aai_cli.context import AppState, persist_browser_login, resolve_profile, run_command -from aai_cli.errors import APIError +from aai_cli.errors import APIError, UsageError from aai_cli.help_text import examples_epilog app = typer.Typer() @@ -31,7 +31,20 @@ def login( def body(state: AppState, json_mode: bool) -> None: profile = resolve_profile(state) env = environments.active().name - if api_key: + if api_key is None: + persist_browser_login(profile, env) + elif not api_key.strip(): + # An explicitly-passed empty/whitespace key (e.g. --api-key "$UNSET_VAR") + # must fail loudly, not silently fall into the browser flow as if the + # flag had never been passed. + raise UsageError( + "--api-key was given an empty value.", + suggestion=( + "Pass a real key: aai login --api-key " + "(check that the shell variable you expanded is set)." + ), + ) + else: # Non-interactive escape hatch for CI/automation: no AMS session is # obtained, so account self-service commands won't work for this profile. if not client.validate_key(api_key): @@ -45,8 +58,6 @@ def body(state: AppState, json_mode: bool) -> None: # api-key-only, so account self-service must report it needs a browser # login rather than silently reusing the old (possibly different) identity. config.clear_session(profile) - else: - persist_browser_login(profile, env) output.emit( {"authenticated": True, "profile": profile, "env": env}, lambda _d: ( @@ -138,5 +149,10 @@ def render(_d: dict[str, object]) -> Table: "session": session_label, } output.emit(data, render, json_mode=json_mode) + if not reachable: + # A rejected key must fail the command (exit 4, the auth code used by + # NotAuthenticated) so CI can use whoami as a preflight check; the + # rendered status above still lands on stdout in both modes. + raise typer.Exit(code=4) run_command(ctx, body, json=json_out) diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 5b845947..cc81d0ad 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -19,12 +19,45 @@ transcribe_exec, transcribe_render, ) + +# The package attribute `code_gen.transcribe` is the wrapper function, so the module's +# render() (which also takes the -o output field) is imported from the submodule itself. +from aai_cli.code_gen.transcribe import render as render_transcribe_code from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError from aai_cli.help_text import examples_epilog app = typer.Typer() +# The PII policy strings the SDK accepts, validated client-side so a typo'd +# --redact-pii-policy fails before any upload — mirroring how an unknown --config +# key is rejected with the valid field list. +_PII_POLICY_VALUES = frozenset(policy.value for policy in aai.PIIRedactionPolicy) + + +def _validate_pii_policies(policies: list[str] | None) -> None: + unknown = [p for p in policies or [] if p not in _PII_POLICY_VALUES] + if unknown: + valid = ", ".join(sorted(_PII_POLICY_VALUES)) + raise UsageError(f"Unknown PII policy(s) {unknown}. Valid policies: {valid}.") + + +def _validate_language_flags(language_code: str | None, language_detection: bool | None) -> None: + if language_code and language_detection: + raise UsageError( + "--language-code and --language-detection can't be combined.", + suggestion="Force a language or auto-detect it, not both.", + ) + + +def _validate_speakers_expected(merged: dict[str, object]) -> None: + # Checked on the merged dict so `--config speaker_labels=true` also counts. + if merged.get("speakers_expected") and not merged.get("speaker_labels"): + raise UsageError( + "--speakers-expected only applies when diarization is enabled.", + suggestion="Add --speaker-labels.", + ) + @app.command( rich_help_panel=help_panels.TRANSCRIPTION, @@ -238,6 +271,7 @@ def transcribe( None, "--audio-start", help="Start offset in ms.", + min=0, rich_help_panel=help_panels.OPT_CUSTOMIZATION, ), audio_end: int | None = typer.Option( @@ -332,6 +366,9 @@ def transcribe( """ def body(state: AppState, json_mode: bool) -> None: + _validate_language_flags(language_code, language_detection) + pii_policies = config_builder.split_csv(redact_pii_policy) + _validate_pii_policies(pii_policies) flags: dict[str, object] = { "speech_model": config_builder.enum_value(speech_model), "language_code": language_code, @@ -346,7 +383,7 @@ def body(state: AppState, json_mode: bool) -> None: "speakers_expected": speakers_expected, "multichannel": multichannel, "redact_pii": redact_pii, - "redact_pii_policies": config_builder.split_csv(redact_pii_policy), + "redact_pii_policies": pii_policies, "redact_pii_sub": config_builder.enum_value(redact_pii_sub), "redact_pii_audio": redact_pii_audio, "filter_profanity": filter_profanity, @@ -387,6 +424,8 @@ def body(state: AppState, json_mode: bool) -> None: flags=flags, overrides=config_kv, config_file=config_file ) + _validate_speakers_expected(merged) + if show_code: # Print-only: build the equivalent script and exit without transcribing or # authenticating (raw stdout, so `--show-code > script.py` runs). No @@ -397,7 +436,9 @@ def body(state: AppState, json_mode: bool) -> None: else "your-audio-file.mp3" ) gateway = code_gen.gateway_options(list(llm_prompt or []), model, max_tokens) - output.print_code(code_gen.transcribe(merged, audio, llm_gateway=gateway)) + output.print_code( + render_transcribe_code(merged, audio, llm_gateway=gateway, output=output_field) + ) return tc = config_builder.construct_transcription_config(merged) diff --git a/aai_cli/commands/transcripts.py b/aai_cli/commands/transcripts.py index a753652f..b33fe37c 100644 --- a/aai_cli/commands/transcripts.py +++ b/aai_cli/commands/transcripts.py @@ -73,7 +73,7 @@ def body(state: AppState, json_mode: bool) -> None: ) def list_( ctx: typer.Context, - limit: int = typer.Option(10, "--limit", help="How many transcripts to show."), + limit: int = typer.Option(10, "--limit", help="How many transcripts to show.", min=1), json_out: bool = options.json_option(), ) -> None: """List recent transcripts.""" diff --git a/aai_cli/context.py b/aai_cli/context.py index 5dba568a..c46c764a 100644 --- a/aai_cli/context.py +++ b/aai_cli/context.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import sys from collections.abc import Callable from dataclasses import dataclass @@ -127,10 +128,22 @@ def _rerun_after_login_error() -> CLIError: ) +def _interactive_session() -> bool: + """True only when a human can complete a browser login: stdin and stderr are both + real TTYs and no agent/CI context is detected (`output.is_agentic`).""" + return sys.stdin.isatty() and sys.stderr.isatty() and not output.is_agentic() + + def _should_auto_login(ctx: typer.Context, err: NotAuthenticated) -> bool: command_name = ctx.command.name if ctx.command else None if command_name in {"login", "logout"}: return False + # CI/pipelines/agents have no human to finish a browser sign-in; starting one + # would bind a loopback port and block for up to two minutes. Surface the + # original NotAuthenticated (with its 'aai login' / ASSEMBLYAI_API_KEY + # suggestion) instead. + if not _interactive_session(): + return False # An invalid ASSEMBLYAI_API_KEY would still take precedence after browser login, # so retrying cannot fix that case. return not (os.environ.get(config.ENV_API_KEY) and err.message == REJECTED_KEY_MESSAGE) @@ -153,7 +166,9 @@ def run_command( output.emit_error(err, json_mode=json_mode) raise typer.Exit(code=err.exit_code) from None try: - if not state.quiet: + # Suppressed in json_mode too: --json stderr must stay machine-readable, + # never mix human prose into it. + if not state.quiet and not json_mode: output.error_console.print( "[aai.muted]Not signed in; starting browser login.[/aai.muted]" ) diff --git a/aai_cli/youtube.py b/aai_cli/youtube.py index 86af557a..9c109941 100644 --- a/aai_cli/youtube.py +++ b/aai_cli/youtube.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import re from pathlib import Path @@ -11,6 +12,13 @@ re.IGNORECASE, ) +# yt-dlp's default logger prints its own "ERROR: …" line straight to stderr before the +# CLI can raise its one clean error, duplicating the message. Route yt-dlp's output to +# a swallow-everything logger (NullHandler, no propagation) instead. +_YTDLP_LOGGER = logging.getLogger("aai_cli.youtube.yt_dlp") +_YTDLP_LOGGER.addHandler(logging.NullHandler()) +_YTDLP_LOGGER.propagate = False + def is_youtube_url(source: str | None) -> bool: """True if `source` looks like a YouTube watch/share URL.""" @@ -41,6 +49,7 @@ def download_audio(url: str, dest_dir: Path) -> Path: "quiet": True, "no_warnings": True, "noprogress": True, + "logger": _YTDLP_LOGGER, } try: # yt-dlp types `params` as a private `_Params` TypedDict, but a plain options diff --git a/tests/test_account_command.py b/tests/test_account_command.py index 636afa5b..788d3514 100644 --- a/tests/test_account_command.py +++ b/tests/test_account_command.py @@ -38,6 +38,7 @@ def test_balance_formats_dollars(monkeypatch, mocker): def test_balance_without_session_runs_login(monkeypatch, mocker): + monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) get_balance = mocker.patch( "aai_cli.commands.account.ams.get_balance", @@ -302,6 +303,21 @@ def test_usage_rejects_invalid_date(mocker): get_usage.assert_not_called() +def test_usage_invalid_date_fails_before_session_resolution(monkeypatch, mocker): + # Not logged in + a bad --start/--end: date validation must run before + # resolve_session, so the user gets a fast exit-2 usage error, not a login flow. + def _no_login(): + raise AssertionError("login flow must not start for an invalid date") + + monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.context.run_login_flow", _no_login) + get_usage = mocker.patch("aai_cli.commands.account.ams.get_usage", autospec=True) + result = runner.invoke(app, ["usage", "--end", "not-a-date"]) + assert result.exit_code == 2 + assert "Invalid date 'not-a-date'" in result.output + get_usage.assert_not_called() + + def test_limits_renders_services(monkeypatch, mocker): _auth() _human(monkeypatch) diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index b2d73e58..873f8f30 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -1,7 +1,24 @@ import pytest from aai_cli.auth import flow, loopback -from aai_cli.errors import APIError +from aai_cli.errors import APIError, NotAuthenticated + + +class _FakeCapture: + """Stands in for an already-bound loopback server: wait() returns a canned result.""" + + def __init__(self, result, log=None): + self._result = result + self._log = log + + def wait(self, timeout=120.0): + if self._log is not None: + self._log.append("wait") + return self._result + + +def _fake_start_capture(monkeypatch, result): + monkeypatch.setattr(flow, "_start_capture", lambda: _FakeCapture(result)) def test_find_or_create_reuses_existing_cli_key(monkeypatch): @@ -72,10 +89,10 @@ def test_find_or_create_raises_when_no_projects(monkeypatch): assert "no project" in exc.value.message -def test_capture_delegates_to_loopback(monkeypatch): - sentinel = loopback.CallbackResult(token="tok", token_type="discovery_oauth") - monkeypatch.setattr(flow.loopback, "capture_callback", lambda: sentinel) - assert flow._capture() is sentinel +def test_start_capture_delegates_to_loopback(monkeypatch): + sentinel = object() + monkeypatch.setattr(flow.loopback, "start_capture", lambda: sentinel) + assert flow._start_capture() is sentinel def test_run_login_flow_opens_the_discovery_start_url(monkeypatch): @@ -83,10 +100,8 @@ def test_run_login_flow_opens_the_discovery_start_url(monkeypatch): seen = {} monkeypatch.setattr(flow.discovery, "build_start_url", lambda: "start-url") monkeypatch.setattr(flow, "_open_browser", lambda url: seen.setdefault("url", url)) - monkeypatch.setattr( - flow, - "_capture", - lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + _fake_start_capture( + monkeypatch, loopback.CallbackResult(token="tok", token_type="discovery_oauth") ) monkeypatch.setattr( flow.ams, @@ -109,10 +124,8 @@ def test_run_login_flow_opens_the_discovery_start_url(monkeypatch): def test_run_login_flow_rejects_wrong_token_type(monkeypatch): monkeypatch.setattr(flow, "_open_browser", lambda url: None) - monkeypatch.setattr( - flow, - "_capture", - lambda: loopback.CallbackResult(token="tok", token_type="something_else"), + _fake_start_capture( + monkeypatch, loopback.CallbackResult(token="tok", token_type="something_else") ) with pytest.raises(APIError) as exc: flow.run_login_flow() @@ -122,10 +135,8 @@ def test_run_login_flow_rejects_wrong_token_type(monkeypatch): def test_run_login_flow_happy_path(monkeypatch): opened = {} monkeypatch.setattr(flow, "_open_browser", lambda url: opened.setdefault("url", url)) - monkeypatch.setattr( - flow, - "_capture", - lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + _fake_start_capture( + monkeypatch, loopback.CallbackResult(token="tok", token_type="discovery_oauth") ) monkeypatch.setattr( flow.ams, @@ -147,13 +158,15 @@ def test_run_login_flow_happy_path(monkeypatch): assert opened["url"].startswith("https://") -def test_run_login_flow_timeout_raises(monkeypatch): +def test_run_login_flow_timeout_raises_auth_typed_error(monkeypatch): monkeypatch.setattr(flow, "_open_browser", lambda url: None) - monkeypatch.setattr(flow, "_capture", lambda: loopback.CallbackResult(error="timeout")) - with pytest.raises(APIError) as exc: + _fake_start_capture(monkeypatch, loopback.CallbackResult(error="timeout")) + with pytest.raises(NotAuthenticated) as exc: flow.run_login_flow() assert exc.value.message == "Login timed out waiting for the browser." - assert exc.value.suggestion == "Run 'aai login' again." + assert exc.value.error_type == "not_authenticated" # auth-typed, not api_error + assert exc.value.exit_code == 4 + assert exc.value.suggestion == "Run 'aai login' again, or use 'aai login --api-key '." def test_find_or_create_reuses_token_with_token_name_field(monkeypatch): @@ -188,10 +201,8 @@ def test_run_login_flow_uses_exchange_account(monkeypatch): # The signed-in account comes from exchange()'s response; the flow must not make a # second round-trip to fetch it. monkeypatch.setattr(flow, "_open_browser", lambda url: None) - monkeypatch.setattr( - flow, - "_capture", - lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + _fake_start_capture( + monkeypatch, loopback.CallbackResult(token="tok", token_type="discovery_oauth") ) monkeypatch.setattr( flow.ams, @@ -219,10 +230,8 @@ def fake_find(acct, jwt): def test_run_login_flow_multi_org_notes_selection(monkeypatch, capsys): monkeypatch.setattr(flow, "_open_browser", lambda url: None) - monkeypatch.setattr( - flow, - "_capture", - lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + _fake_start_capture( + monkeypatch, loopback.CallbackResult(token="tok", token_type="discovery_oauth") ) monkeypatch.setattr( flow.ams, @@ -259,10 +268,8 @@ def test_open_browser_prints_fallback_to_stderr(monkeypatch, capsys): def test_run_login_flow_missing_session_token_raises_api_error(monkeypatch): monkeypatch.setattr(flow, "_open_browser", lambda url: None) - monkeypatch.setattr( - flow, - "_capture", - lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + _fake_start_capture( + monkeypatch, loopback.CallbackResult(token="tok", token_type="discovery_oauth") ) monkeypatch.setattr( flow.ams, @@ -275,10 +282,8 @@ def test_run_login_flow_missing_session_token_raises_api_error(monkeypatch): def test_run_login_flow_org_missing_id_raises_api_error(monkeypatch): monkeypatch.setattr(flow, "_open_browser", lambda url: None) - monkeypatch.setattr( - flow, - "_capture", - lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + _fake_start_capture( + monkeypatch, loopback.CallbackResult(token="tok", token_type="discovery_oauth") ) monkeypatch.setattr( flow.ams, @@ -294,10 +299,8 @@ def test_run_login_flow_org_missing_id_raises_api_error(monkeypatch): def test_run_login_flow_zero_orgs_raises(monkeypatch): monkeypatch.setattr(flow, "_open_browser", lambda url: None) - monkeypatch.setattr( - flow, - "_capture", - lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + _fake_start_capture( + monkeypatch, loopback.CallbackResult(token="tok", token_type="discovery_oauth") ) monkeypatch.setattr( flow.ams, @@ -314,10 +317,9 @@ def test_run_login_flow_zero_orgs_raises(monkeypatch): def test_run_login_flow_returns_session_material(monkeypatch): monkeypatch.setattr(flow, "_open_browser", lambda url: None) - monkeypatch.setattr( - flow, - "_capture", - lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth", error=None), + _fake_start_capture( + monkeypatch, + loopback.CallbackResult(token="tok", token_type="discovery_oauth", error=None), ) monkeypatch.setattr( flow.ams, @@ -343,3 +345,67 @@ def test_run_login_flow_returns_session_material(monkeypatch): assert result.session_jwt == "jwt_1" assert result.session_token == "tok_1" assert result.account_id == 99 + + +def _stub_ams_happy_path(monkeypatch): + monkeypatch.setattr( + flow.ams, + "discover", + lambda token: { + "organizations": [{"organization_id": "org_1"}], + "intermediate_session_token": "ist", + }, + ) + monkeypatch.setattr( + flow.ams, + "exchange", + lambda ist, org: {"account": {"id": 9}, "session_jwt": "jwt", "session_token": "t"}, + ) + monkeypatch.setattr(flow, "find_or_create_cli_key", lambda acct, jwt: "sk_final") + + +def test_run_login_flow_binds_loopback_before_opening_browser(monkeypatch): + # The callback server must be bound before the browser launches: a taken port + # has to fail the flow before the user is mid-OAuth. wait() only happens after. + order = [] + + def fake_start(): + order.append("bind") + return _FakeCapture( + loopback.CallbackResult(token="tok", token_type="discovery_oauth"), log=order + ) + + monkeypatch.setattr(flow, "_start_capture", fake_start) + monkeypatch.setattr(flow, "_open_browser", lambda url: order.append("browser")) + _stub_ams_happy_path(monkeypatch) + + assert flow.run_login_flow().api_key == "sk_final" + assert order == ["bind", "browser", "wait"] + + +def test_run_login_flow_bind_failure_never_opens_browser(monkeypatch): + def fail_start(): + raise APIError("Could not start the login callback server on 127.0.0.1:8123.") + + monkeypatch.setattr(flow, "_start_capture", fail_start) + opened = [] + monkeypatch.setattr(flow, "_open_browser", lambda url: opened.append(url)) + + with pytest.raises(APIError, match="callback server"): + flow.run_login_flow() + assert opened == [] # the user is never sent into an OAuth flow that already failed + + +def test_run_login_flow_prints_waiting_hint(monkeypatch, capsys): + # Headless/slow logins must not sit in 120s of silence: the flow says it is + # waiting and names the non-browser alternative. + monkeypatch.setattr(flow, "_open_browser", lambda url: None) + _fake_start_capture( + monkeypatch, loopback.CallbackResult(token="tok", token_type="discovery_oauth") + ) + _stub_ams_happy_path(monkeypatch) + + assert flow.run_login_flow().api_key == "sk_final" + err = capsys.readouterr().err + assert "Waiting up to 2 minutes" in err + assert "aai login --api-key" in err diff --git a/tests/test_auth_loopback.py b/tests/test_auth_loopback.py index a5817caf..6e85c347 100644 --- a/tests/test_auth_loopback.py +++ b/tests/test_auth_loopback.py @@ -157,3 +157,31 @@ def test_capture_raises_clean_error_when_port_unavailable(monkeypatch): loopback.capture_callback(timeout=1.0) finally: busy.close() + + +def test_start_capture_raises_clean_error_when_port_unavailable(monkeypatch): + # The bind failure surfaces from start_capture() itself — i.e. before any + # caller would open a browser — not from the later wait(). + busy = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + busy.bind((endpoints.LOOPBACK_HOST, 0)) + busy.listen(1) + port = busy.getsockname()[1] + monkeypatch.setenv("AAI_AUTH_PORT", str(port)) + try: + with pytest.raises(APIError, match="callback server"): + loopback.start_capture() + finally: + busy.close() + + +def test_start_capture_is_serving_before_wait_is_called(): + # start_capture() returns with the server already bound and answering — the + # whole point of splitting bind from wait. The callback can land before wait(). + capture = loopback.start_capture() + assert capture.thread.daemon is True # never blocks interpreter shutdown + status = _hit("/callback?stytch_token_type=discovery_oauth&token=tok_pre") + assert status == 200 # answered while no one is waiting yet + result = capture.wait(timeout=5.0) + assert result.token == "tok_pre" + assert result.token_type == "discovery_oauth" + assert result.error is None diff --git a/tests/test_client.py b/tests/test_client.py index 13d90311..434701f2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -26,6 +26,61 @@ def test_validate_key_true_on_success(mocker): assert params.limit == 1 +def test_list_transcript_params_serialize_without_model_config(): + # assemblyai==0.64.4 + pydantic==2.13.4: the SDK's own ListTranscriptParameters + # leaks a spurious `model_config` field into .dict(exclude_none=True) — exactly + # what the SDK serializes onto the query string. The helper must drop it. + params = client._list_transcript_params(3) + assert params.dict(exclude_none=True) == {"limit": 3} + + +def test_validate_key_probe_serializes_without_model_config(mocker): + T = mocker.patch.object(client.aai, "Transcriber", autospec=True) + T.return_value.list_transcripts.return_value = mocker.MagicMock() + assert client.validate_key("sk_good") is True + params = T.return_value.list_transcripts.call_args.args[0] + # No junk `model_config` query param on the wire (and still a one-row probe). + assert params.dict(exclude_none=True) == {"limit": 1} + + +def test_list_transcripts_params_serialize_without_model_config(mocker): + resp = mocker.MagicMock() + resp.transcripts = [] + T = mocker.patch.object(client.aai, "Transcriber", autospec=True) + T.return_value.list_transcripts.return_value = resp + assert client.list_transcripts("sk", limit=7) == [] + params = T.return_value.list_transcripts.call_args.args[0] + assert params.dict(exclude_none=True) == {"limit": 7} + + +def test_validate_key_sdk_error_message_is_one_clean_line(mocker): + # httpx-backed SDK failures embed a multi-line repr; the CLI error must keep the + # reason but collapse it to one line and drop the `Request: <…>` tail. + raw = ( + "failed to retrieve transcripts: \n" + "Reason: [Errno -3] Temporary failure in name resolution\n" + "Request: " + ) + T = mocker.patch.object(client.aai, "Transcriber", autospec=True) + T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError(raw) + with pytest.raises(APIError) as exc: + client.validate_key("sk") + assert exc.value.message == ( + "Could not validate key: failed to retrieve transcripts: " + "Reason: [Errno -3] Temporary failure in name resolution" + ) + + +def test_validate_key_network_error_message_is_one_clean_line(mocker): + T = mocker.patch.object(client.aai, "Transcriber", autospec=True) + T.return_value.list_transcripts.side_effect = ConnectionError( + "connection refused\nRequest: " + ) + with pytest.raises(APIError) as exc: + client.validate_key("sk") + assert exc.value.message == "Network error contacting AssemblyAI: connection refused" + + def test_validate_key_false_on_auth_error(mocker): T = mocker.patch.object(client.aai, "Transcriber", autospec=True) T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError( diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index 270e8065..94b0a229 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -2,10 +2,12 @@ from typing import ClassVar +import pytest from hypothesis import given, settings from hypothesis import strategies as st from aai_cli.code_gen import serialize +from aai_cli.code_gen.transcribe import render as render_transcribe_code settings.register_profile("codegen", max_examples=150) settings.load_profile("codegen") @@ -300,6 +302,67 @@ def test_fuzz_result_handling_always_execs(merged): exec(compile(body, "", "exec"), {"transcript": _Stub(), "getattr": getattr}) # noqa: S102 +@pytest.mark.parametrize( + ("field", "fragment"), + [ + ("text", "print(transcript.text)"), + ("id", "print(transcript.id)"), + ("status", "print(transcript.status.value)"), + ("utterances", 'print(f"Speaker {utt.speaker}: {utt.text}")'), + ("srt", "print(transcript.export_subtitles_srt())"), + ("json", "print(json.dumps(transcript.json_response, default=str))"), + ], +) +def test_transcribe_render_output_field_generates_matching_code(field, fragment): + # Each -o choice maps to result code faithful to client._FIELD_RENDERERS. + code = render_transcribe_code({}, "audio.mp3", output=field) + _compiles(code) + assert fragment in code + + +def test_transcribe_render_output_json_imports_json_only_when_needed(): + assert "import json" in render_transcribe_code({}, "audio.mp3", output="json") + assert "import json" not in render_transcribe_code({}, "audio.mp3", output="srt") + assert "import json" not in render_transcribe_code({}, "audio.mp3") + + +def test_transcribe_render_output_replaces_analysis_result_handling(): + # -o overrides the analysis sections, exactly like the real command's output path. + code = render_transcribe_code({"speaker_labels": True}, "audio.mp3", output="srt") + _compiles(code) + assert "print(transcript.export_subtitles_srt())" in code + assert "transcript.utterances" not in code + + +def test_transcribe_render_output_takes_precedence_over_llm_gateway(): + # The real command returns the -o field before the LLM chain runs; the generated + # script mirrors that and stays free of an unused OpenAI import. + code = render_transcribe_code( + {}, + "audio.mp3", + llm_gateway={"prompts": ["summarize"], "model": "m", "max_tokens": 5}, + output="srt", + ) + _compiles(code) + assert "print(transcript.export_subtitles_srt())" in code + assert "from openai import OpenAI" not in code + + +def test_transcribe_render_unknown_output_falls_back_to_text(): + # Mirrors select_transcript_field's fallback for unrecognized field names. + code = render_transcribe_code({}, "audio.mp3", output="bogus") + _compiles(code) + assert "print(transcript.text)" in code + + +@given( + merged=merged_strategy(config_builder.TRANSCRIBE_COERCE), + field=st.sampled_from(["text", "id", "status", "utterances", "srt", "json"]), +) +def test_fuzz_transcribe_output_fields_always_compile(merged, field): + _compiles(render_transcribe_code(merged, "audio.mp3", output=field)) + + def test_transcribe_show_code_includes_llm_gateway_transform(): code = code_gen.transcribe( {"speaker_labels": True}, diff --git a/tests/test_context.py b/tests/test_context.py index 84d39564..c13be304 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,9 +1,13 @@ +import json +import sys + +import pytest import typer from typer.testing import CliRunner from aai_cli import config, environments from aai_cli.auth.flow import LoginResult -from aai_cli.context import AppState, env_override_warning, run_command +from aai_cli.context import AppState, _interactive_session, env_override_warning, run_command from aai_cli.errors import APIError, NotAuthenticated, auth_failure runner = CliRunner() @@ -23,6 +27,94 @@ def go(ctx: typer.Context): return app +def _force_interactive(monkeypatch): + """Pretend a human is at the terminal (CliRunner/pytest streams are never TTYs).""" + monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) + + +class _TtyProbe: + def __init__(self, tty): + self._tty = tty + + def isatty(self): + return self._tty + + +@pytest.mark.parametrize( + ("stdin_tty", "stderr_tty", "agentic", "expected"), + [ + (True, True, False, True), # a real terminal session + (False, True, False, False), # stdin piped/redirected (CI input) + (True, False, False, False), # stderr redirected (logged pipeline) + (True, True, True, False), # agent/CI env detected despite TTYs + ], +) +def test_interactive_session_requires_both_ttys_and_no_agent( + monkeypatch, stdin_tty, stderr_tty, agentic, expected +): + monkeypatch.setattr(sys, "stdin", _TtyProbe(stdin_tty)) + monkeypatch.setattr(sys, "stderr", _TtyProbe(stderr_tty)) + monkeypatch.setattr("aai_cli.output.is_agentic", lambda: agentic) + assert _interactive_session() is expected + + +def test_run_command_skips_auto_login_when_session_not_interactive(monkeypatch): + # CliRunner/pytest streams are not TTYs, so this is a genuine non-interactive + # session: no browser login may start (it would bind a port and block 120s), + # and the ORIGINAL NotAuthenticated must surface with its actionable suggestion. + monkeypatch.setattr( + "aai_cli.context.run_login_flow", + lambda: (_ for _ in ()).throw(AssertionError("non-interactive must not auto-login")), + ) + + def body(state, json_mode): + raise NotAuthenticated() + + result = runner.invoke(_make_app(body), ["go"]) + assert result.exit_code == 4 + assert "starting browser login" not in result.output + assert "You're not signed in." in result.output + assert "aai login" in result.output + assert "ASSEMBLYAI_API_KEY" in result.output + + +def test_run_command_not_interactive_json_keeps_clean_error_shape(monkeypatch): + monkeypatch.setattr( + "aai_cli.context.run_login_flow", + lambda: (_ for _ in ()).throw(AssertionError("non-interactive must not auto-login")), + ) + + def body(state, json_mode): + raise NotAuthenticated() + + result = runner.invoke(_make_app(body, json=True), ["go"]) + assert result.exit_code == 4 + payload = json.loads(result.output) # the only output line is machine-readable + assert payload["error"]["type"] == "not_authenticated" + assert "aai login" in payload["error"]["suggestion"] + assert "ASSEMBLYAI_API_KEY" in payload["error"]["suggestion"] + + +def test_run_command_auto_login_notice_suppressed_in_json_mode(monkeypatch): + # Even when auto-login runs, --json stderr must stay machine-readable: the + # human "starting browser login" prose is suppressed and only the JSON error + # shape is emitted. + _force_interactive(monkeypatch) + monkeypatch.setattr( + "aai_cli.context.run_login_flow", + lambda: LoginResult(api_key="sk_auto", session_jwt="j", session_token="t", account_id=1), + ) + + def body(state, json_mode): + raise NotAuthenticated() + + result = runner.invoke(_make_app(body, json=True), ["go"]) + assert result.exit_code == 4 + assert "starting browser login" not in result.output + payload = json.loads(result.output) + assert payload["error"]["type"] == "login_required" + + def test_run_command_maps_cli_error_to_exit_code(): def body(state, json_mode): raise NotAuthenticated() @@ -32,6 +124,7 @@ def body(state, json_mode): def test_run_command_auto_logs_in_and_asks_for_rerun(monkeypatch): + _force_interactive(monkeypatch) monkeypatch.setattr( "aai_cli.context.run_login_flow", lambda: LoginResult( @@ -59,6 +152,7 @@ def body(state, json_mode): def test_run_command_auto_login_persistence_failure_is_clean(monkeypatch): + _force_interactive(monkeypatch) monkeypatch.setattr( "aai_cli.context.run_login_flow", lambda: LoginResult( @@ -83,8 +177,10 @@ def body(state, json_mode): def test_run_command_auto_login_failure_is_clean(monkeypatch): + _force_interactive(monkeypatch) + def fail_login(): - raise APIError("Login timed out waiting for the browser.") + raise APIError("Login failed: the server returned an unexpected response.") monkeypatch.setattr("aai_cli.context.run_login_flow", fail_login) @@ -93,10 +189,31 @@ def body(state, json_mode): result = runner.invoke(_make_app(body), ["go"]) assert result.exit_code == 1 - assert "Login timed out" in result.output + assert "Login failed" in result.output + + +def test_run_command_auto_login_timeout_maps_to_auth_error(monkeypatch): + # The loopback timeout is an auth failure (not_authenticated, exit 4), not a + # generic api_error. + _force_interactive(monkeypatch) + + def fail_login(): + raise NotAuthenticated("Login timed out waiting for the browser.") + + monkeypatch.setattr("aai_cli.context.run_login_flow", fail_login) + + def body(state, json_mode): + raise NotAuthenticated() + + result = runner.invoke(_make_app(body, json=True), ["go"]) + assert result.exit_code == 4 + payload = json.loads(result.output) + assert payload["error"]["type"] == "not_authenticated" + assert "Login timed out" in payload["error"]["message"] def test_run_command_skips_auto_login_for_rejected_env_key(monkeypatch): + _force_interactive(monkeypatch) monkeypatch.setenv(config.ENV_API_KEY, "sk_bad") monkeypatch.setattr( "aai_cli.context.run_login_flow", @@ -111,6 +228,7 @@ def body(state, json_mode): def test_run_command_never_auto_logs_in_login_command(monkeypatch): + _force_interactive(monkeypatch) monkeypatch.setattr( "aai_cli.context.run_login_flow", lambda: (_ for _ in ()).throw(AssertionError("login command must not auto-login")), @@ -240,6 +358,7 @@ def test_run_command_auto_logs_in_when_env_key_set_but_error_is_not_a_rejection( # ENV key present but the failure is a generic NotAuthenticated (not a key # rejection): a browser login can still fix it, so we DO auto-login. This pins # the `and` in _should_auto_login — an `or` would wrongly skip the retry here. + _force_interactive(monkeypatch) monkeypatch.setenv(config.ENV_API_KEY, "sk_env") ran = {"login": 0} diff --git a/tests/test_keys.py b/tests/test_keys.py index 2fb306f9..fead920a 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -85,6 +85,7 @@ def test_keys_create_rejects_default_project_without_int_id(mocker): def test_keys_list_without_session_runs_login(monkeypatch, mocker): + monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) list_projects = mocker.patch( "aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=[] diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index e7044e19..9d4b1567 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -140,6 +140,7 @@ def test_llm_missing_prompt_exits_2(monkeypatch): def test_llm_unauthenticated_runs_login(monkeypatch): + monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): @@ -283,6 +284,44 @@ def test_llm_follow_requires_piped_stdin(monkeypatch): assert "stdin" in result.output.lower() +def test_llm_follow_empty_stdin_exits_2(monkeypatch): + # `aai llm -f "…" ", "exec") # the emitted script is runnable + assert "print(transcript.export_subtitles_srt())" in result.output + assert "print(transcript.text)" not in result.output + + +def test_transcribe_show_code_output_utterances_generates_loop(monkeypatch): + def _boom(*a, **k): + raise AssertionError("must not transcribe") + + monkeypatch.setattr("aai_cli.commands.transcribe.client.transcribe", _boom) + result = runner.invoke(app, ["transcribe", "--sample", "-o", "utterances", "--show-code"]) + assert result.exit_code == 0 + compile(result.output, "", "exec") + assert 'print(f"Speaker {utt.speaker}: {utt.text}")' in result.output + + +def test_transcribe_negative_audio_start_exits_2(mocker): + _auth() + tx = mocker.patch("aai_cli.commands.transcribe.client.transcribe", autospec=True) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--audio-start", "-100"]) + assert result.exit_code == 2 + tx.assert_not_called() + + +def test_transcribe_language_code_with_detection_exits_2(mocker): + _auth() + tx = mocker.patch("aai_cli.commands.transcribe.client.transcribe", autospec=True) + result = runner.invoke( + app, + ["transcribe", "audio.mp3", "--language-code", "en_us", "--language-detection"], + ) + assert result.exit_code == 2 + assert "--language-code and --language-detection can't be combined." in result.output + tx.assert_not_called() + + +def test_transcribe_language_flags_alone_are_accepted(mocker): + # Only the combination is contradictory; each flag works on its own. + _auth() + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--language-code", "en_us"]) + assert result.exit_code == 0 + assert tx.call_args.kwargs["config"].language_code == "en_us" + result = runner.invoke(app, ["transcribe", "audio.mp3", "--language-detection"]) + assert result.exit_code == 0 + assert tx.call_args.kwargs["config"].language_detection is True + + +def test_transcribe_speakers_expected_without_labels_exits_2(mocker): + _auth() + tx = mocker.patch("aai_cli.commands.transcribe.client.transcribe", autospec=True) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--speakers-expected", "2"]) + assert result.exit_code == 2 + assert "--speakers-expected only applies when diarization is enabled." in result.output + assert "Add --speaker-labels." in result.output + tx.assert_not_called() + + +def test_transcribe_speakers_expected_with_labels_is_accepted(mocker): + _auth() + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke( + app, ["transcribe", "audio.mp3", "--speaker-labels", "--speakers-expected", "2"] + ) + assert result.exit_code == 0 + assert tx.call_args.kwargs["config"].speakers_expected == 2 + + +def test_transcribe_speakers_expected_with_config_speaker_labels_is_accepted(mocker): + # Diarization enabled through the --config escape hatch counts too: the check + # runs on the merged config, not just the curated flag. + _auth() + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke( + app, + ["transcribe", "audio.mp3", "--config", "speaker_labels=true", "--speakers-expected", "2"], + ) + assert result.exit_code == 0 + assert tx.call_args.kwargs["config"].speakers_expected == 2 + + +def test_transcribe_unknown_pii_policy_exits_2_and_lists_valid(mocker): + _auth() + tx = mocker.patch("aai_cli.commands.transcribe.client.transcribe", autospec=True) + result = runner.invoke( + app, + ["transcribe", "audio.mp3", "--redact-pii", "--redact-pii-policy", "not_a_policy"], + ) + assert result.exit_code == 2 + assert "Unknown PII policy(s) ['not_a_policy']" in result.output + assert "person_name" in result.output # the valid values are listed + tx.assert_not_called() + + def test_transcribe_renders_summary_human(monkeypatch, mocker): _auth() monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index ebfaaf13..802aa5ad 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -92,6 +92,7 @@ def test_list_renders_rows(mocker): def test_list_unauthenticated_runs_login(monkeypatch, mocker): + monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) rows = [{"id": "t1", "status": "completed"}] list_ = mocker.patch( @@ -104,6 +105,17 @@ def test_list_unauthenticated_runs_login(monkeypatch, mocker): assert "Run the same command again" in result.output +def test_list_limit_must_be_at_least_one(mocker): + # min=1 on --limit: 0 and negatives are rejected client-side, before any request. + config.set_api_key("default", "sk_live") + list_ = mocker.patch("aai_cli.commands.transcripts.client.list_transcripts", autospec=True) + for bad in ("0", "-3"): + result = runner.invoke(app, ["transcripts", "list", "--limit", bad]) + assert result.exit_code == 2 + assert "limit" in result.output.lower() + list_.assert_not_called() + + def test_list_human_mode_renders_table(monkeypatch, mocker): config.set_api_key("default", "sk_live") monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) diff --git a/tests/test_youtube.py b/tests/test_youtube.py index f9625a9d..37a10ba8 100644 --- a/tests/test_youtube.py +++ b/tests/test_youtube.py @@ -56,6 +56,47 @@ def prepare_filename(self, info): assert captured["download"] is True +def test_download_audio_routes_ytdlp_output_to_silent_logger(tmp_path, monkeypatch, capsys): + # yt-dlp's default logger writes its own "ERROR: …" line to stderr before the CLI's + # clean error, duplicating the message; the passed logger must swallow everything. + import logging + + captured = {} + + class FakeYDL: + def __init__(self, opts): + captured["opts"] = opts + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def extract_info(self, url, download): + (tmp_path / "x.m4a").write_bytes(b"audio") + return {"id": "x", "ext": "m4a"} + + def prepare_filename(self, info): + return str(tmp_path / "x.m4a") + + _fake_ytdlp(monkeypatch, FakeYDL) + youtube.download_audio("https://youtu.be/x", tmp_path) + logger = captured["opts"]["logger"] + # Structurally quiet: no propagation to root, only swallow-everything handlers. + assert logger.name == "aai_cli.youtube.yt_dlp" + assert logger.propagate is False + assert logger.handlers + assert all(isinstance(h, logging.NullHandler) for h in logger.handlers) + # Behaviorally quiet: even an ERROR record produces no console output. + logger.error("ERROR: [youtube] nope: Video unavailable") + logger.warning("WARNING: noisy") + logger.debug("[debug] noise") + out = capsys.readouterr() + assert out.err == "" + assert out.out == "" + + def test_download_audio_falls_back_to_landed_file(tmp_path, monkeypatch): landed = tmp_path / "actual.webm" From 047455b6dc3b040942b5b54c5fa325af14a2ca15 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 04:52:48 +0000 Subject: [PATCH 02/11] Fix QA findings in stream/agent: show-code fidelity, error hygiene - stream --show-code generates code for the actual source: files/URLs decode through ffmpeg to PCM at the requested rate (mirroring the run path), stdin reads sys.stdin.buffer, mic stays mic; the hardcoded 16 kHz override is gone and --sample-rate/--config sample_rate win - agent --show-code warns on stderr that the snippet is mic-based - stream --show-code is a clean usage error - websockets logger silenced so reader-thread EOFs never dump tracebacks - agent maps a handshake 403 to api_error like stream; 401/policy-close still read as a rejected key - --json with -o text is rejected on stream and agent - the agent headphones notice routes to stderr in non-JSON modes - mic-open failures name the default microphone and suggest fixes https://claude.ai/code/session_01Uv7cEgJi2LgknkvfHP52g7 --- aai_cli/agent/render.py | 12 ++-- aai_cli/agent/session.py | 41 +++++++++++- aai_cli/code_gen/__init__.py | 8 ++- aai_cli/code_gen/stream.py | 123 +++++++++++++++++++++++++++++----- aai_cli/commands/agent.py | 15 ++++- aai_cli/commands/stream.py | 38 +++++++++-- aai_cli/microphone.py | 12 +++- aai_cli/streaming/session.py | 16 ++++- tests/test_agent_command.py | 56 ++++++++++++++++ tests/test_agent_render.py | 19 +++++- tests/test_agent_session.py | 89 ++++++++++++++++++++++++ tests/test_code_gen_stream.py | 117 ++++++++++++++++++++++++++++++++ tests/test_microphone.py | 20 ++++++ tests/test_stream_command.py | 99 +++++++++++++++++++++++++++ 14 files changed, 628 insertions(+), 37 deletions(-) create mode 100644 tests/test_code_gen_stream.py diff --git a/aai_cli/agent/render.py b/aai_cli/agent/render.py index e10c4351..5c62cf7d 100644 --- a/aai_cli/agent/render.py +++ b/aai_cli/agent/render.py @@ -40,13 +40,15 @@ def connected(self) -> None: self._line(Text("Connected — start talking. (Ctrl-C to stop)", style="aai.muted")) def notice(self, text: str) -> None: - """Print a human-facing notice (suppressed in JSON; to stderr in text mode).""" + """Print a human-facing notice: suppressed in JSON, to stderr otherwise. + + Stderr in *every* non-JSON mode (not just ``-o text``): the default human + mode is also piped sometimes (``aai agent | head``), and a notice on stdout + would be consumed as transcript data there. + """ if self.json_mode: return - if self.text_mode: - self._status(text.rstrip("\n")) - else: - self._line(text.rstrip("\n")) + self._status(text.rstrip("\n")) # --- user -------------------------------------------------------------- def user_partial(self, text: str) -> None: diff --git a/aai_cli/agent/session.py b/aai_cli/agent/session.py index 77e52674..f5375430 100644 --- a/aai_cli/agent/session.py +++ b/aai_cli/agent/session.py @@ -3,6 +3,7 @@ import base64 import contextlib import json +import logging import threading from collections.abc import Callable from dataclasses import dataclass @@ -31,6 +32,9 @@ def ws_url() -> str: # session.error codes that mean the connection is unauthorized -> exit 2. _AUTH_ERROR_CODES = {"UNAUTHORIZED", "FORBIDDEN"} +# A pre-upgrade HTTP 403 on the WebSocket handshake (see _is_rejected_key). +_HTTP_FORBIDDEN = 403 + # The websocket connection, the `connect` factory, and the renderer/player/mic I/O # objects come from libraries/modules with no usable type stubs. Alias that untyped # boundary here so each role is named in signatures and `Any` stays in one place. @@ -189,10 +193,44 @@ def _send_audio_loop(ws: _WebSocket, session: VoiceAgentSession, mic: _IO) -> No return +# The sync websockets client logs through these; both are silenced for the session +# (the parent covers any future child logger, the client logger is the one that fires). +_WEBSOCKETS_LOGGERS = ("websockets", "websockets.client") + + +def _silence_websockets_logging() -> None: + """Keep websockets' internal logging off the user's stderr for the session. + + The sync client's background reader thread logs unhandled teardown errors (e.g. + ``EOFError: stream ended``) as "unexpected internal error" + traceback through the + ``websockets.client`` logger, which would land on stderr right next to our clean + CLIError. Those internals are never user-actionable from the CLI, so raise the + loggers above every level they emit at. Idempotent: re-setting the level is a no-op. + """ + for name in _WEBSOCKETS_LOGGERS: + logging.getLogger(name).setLevel(logging.CRITICAL) + + +def _is_rejected_key(exc: Exception) -> bool: + """Is this connect/session failure auth-shaped (the key itself was rejected)? + + Mirrors how `stream` classifies handshake failures: a plain HTTP 403 on the + WebSocket upgrade stays an API error there ("Streaming error: WebSocket handshake + rejected (HTTP 403)"), so it must not become "Your API key was rejected" here — + 403 also covers non-credential blocks (WAF, region, plan). Only 401, the Voice + Agent's 1008 policy-violation close, or an explicitly auth-worded message + (`is_auth_failure`'s text hints) count as a rejected key. + """ + status = getattr(getattr(exc, "response", None), "status_code", None) + if status == _HTTP_FORBIDDEN: + return False + return is_auth_failure(exc) + + def _auth_or_api_error(exc: Exception, message: str) -> CLIError: """Map a connect/session exception to the right CLIError: a rejected key becomes auth_failure(), anything else becomes APIError(f"{message}: {exc}").""" - if is_auth_failure(exc): + if _is_rejected_key(exc): return auth_failure() return APIError(f"{message}: {exc}") @@ -243,6 +281,7 @@ def run_session( the agent's first reply to the spoken input and the capture thread waits for session.ready before streaming the source. """ + _silence_websockets_logging() if connect is None: from websockets.sync.client import connect diff --git a/aai_cli/code_gen/__init__.py b/aai_cli/code_gen/__init__.py index 5ed4cbaa..7758414d 100644 --- a/aai_cli/code_gen/__init__.py +++ b/aai_cli/code_gen/__init__.py @@ -42,11 +42,15 @@ def stream( merged: dict[str, object], *, llm: dict[str, object] | None = None, + source: str | None = None, ) -> str: """Generate runnable Python that reproduces this streaming invocation. - With `llm` (a dict of ``prompts``/``model``/``max_tokens``/``interval``), the script + ``source`` mirrors the CLI argument: ``None`` streams the microphone, ``"-"`` + reads raw PCM16 from stdin, and anything else is a file path/URL decoded through + ffmpeg — so the generated script reads the same input the real run would. With + `llm` (a dict of ``prompts``/``model``/``max_tokens``/``interval``), the script refreshes a prompt-chain over the growing transcript every ``interval`` seconds (0 = every turn) — the live sibling of `transcribe --llm` — mirroring how `stream --llm` runs. """ - return _stream.render(merged, llm=llm) + return _stream.render(merged, llm=llm, source=source) diff --git a/aai_cli/code_gen/stream.py b/aai_cli/code_gen/stream.py index e02bd0fd..91b036aa 100644 --- a/aai_cli/code_gen/stream.py +++ b/aai_cli/code_gen/stream.py @@ -15,7 +15,7 @@ "TurnEvent", ] -_PREAMBLE = """import os +_PREAMBLE = """{stdlib_imports} import assemblyai as aai from assemblyai.streaming.v3 import ( @@ -39,8 +39,7 @@ def on_turn(client: StreamingClient, event: TurnEvent) -> None: client.on(StreamingEvents.Turn, on_turn) """ -_LLM_PREAMBLE = """import os -import time +_LLM_PREAMBLE = """{stdlib_imports} import assemblyai as aai from assemblyai.streaming.v3 import ( @@ -108,9 +107,9 @@ def on_turn(client: StreamingClient, event: TurnEvent) -> None: """ _FOOTER = """ -print("Listening… press Ctrl-C to stop.") +{setup}print({banner}) try: - client.stream(aai.extras.MicrophoneStream(sample_rate={rate})) + client.stream({stream_expr}) finally: client.disconnect(terminate=True) """ @@ -118,14 +117,56 @@ def on_turn(client: StreamingClient, event: TurnEvent) -> None: # Same as _FOOTER, but flushes a closing summary (incl. on Ctrl-C) so the turns since the # last interval tick are reflected before disconnecting. _LLM_FOOTER = """ -print("Listening… press Ctrl-C to stop.") +{setup}print({banner}) try: - client.stream(aai.extras.MicrophoneStream(sample_rate={rate})) + client.stream({stream_expr}) finally: summarize(final=True) client.disconnect(terminate=True) """ +# Source-specific audio plumbing. The v3 client accepts any iterable of PCM16 byte +# chunks, so the non-mic variants define a small generator and stream that instead of +# aai.extras.MicrophoneStream. Both mirror what the CLI itself runs: StdinSource reads +# raw PCM16 off stdin, and FileSource decodes any file/URL through ffmpeg. +_STDIN_SETUP = """ +# Raw PCM16 mono at {rate} Hz piped on stdin, e.g.: +# ffmpeg -i input.mp4 -f s16le -acodec pcm_s16le -ac 1 -ar {rate} - | python script.py +def stdin_chunks(): + chunk_bytes = {rate} * 2 // 10 # ~100 ms of 16-bit mono PCM + while True: + data = sys.stdin.buffer.read(chunk_bytes) + if not data: + return + yield data + + +""" + +_FILE_SETUP = """ +# Decode the source (any local file or http(s) URL ffmpeg can read) to PCM16 mono at +# {rate} Hz and pace it at ~real time — the same pipeline `aai stream ` runs. +def file_chunks(): + chunk_bytes = {rate} * 2 // 10 # ~100 ms of 16-bit mono PCM + ffmpeg = subprocess.Popen( + ["ffmpeg", "-nostdin", "-loglevel", "error", "-i", {source}, + "-f", "s16le", "-acodec", "pcm_s16le", "-ac", "1", "-ar", "{rate}", "-"], + stdout=subprocess.PIPE, + ) + try: + while True: + data = ffmpeg.stdout.read(chunk_bytes) + if not data: + return + yield data + time.sleep(len(data) / ({rate} * 2)) # ~real-time pacing + finally: + ffmpeg.terminate() + ffmpeg.wait() + + +""" + def _imports_block(merged: dict[str, object]) -> str: """Sorted streaming-class import lines; SpeechModel only when a model kwarg is emitted.""" @@ -135,7 +176,7 @@ def _imports_block(merged: dict[str, object]) -> str: return "\n".join(f" {name}," for name in sorted(names)) -def _build_preamble(imports: str, llm: dict[str, object] | None) -> str: +def _build_preamble(imports: str, llm: dict[str, object] | None, stdlib_imports: str) -> str: """Pick and fill the plain vs. LLM-Gateway preamble for the given imports. Hosts come from the active environment, so a sandbox run generates a script @@ -145,6 +186,7 @@ def _build_preamble(imports: str, llm: dict[str, object] | None) -> str: if llm: prompts = "\n".join(f" {p!r}," for p in cast("list[str]", llm["prompts"])) return _LLM_PREAMBLE.format( + stdlib_imports=stdlib_imports, imports=imports, api_host=env.streaming_host, base_url=env.llm_gateway_base, @@ -153,7 +195,9 @@ def _build_preamble(imports: str, llm: dict[str, object] | None) -> str: max_tokens=llm["max_tokens"], interval=llm.get("interval", 0.0), ) - return _PREAMBLE.format(imports=imports, api_host=env.streaming_host) + return _PREAMBLE.format( + stdlib_imports=stdlib_imports, imports=imports, api_host=env.streaming_host + ) def _build_connect(merged: dict[str, object]) -> str: @@ -165,16 +209,61 @@ def _build_connect(merged: dict[str, object]) -> str: return f"client.connect(\n StreamingParameters(\n{kwargs}\n )\n)" -def render(merged: dict[str, object], *, llm: dict[str, object] | None = None) -> str: - """Generate a runnable microphone-streaming script with the given params. +def _source_parts(source: str | None, rate: object) -> tuple[set[str], str, str, str]: + """The (stdlib imports, setup block, banner text, stream expression) for a source. - With `llm`, the script transforms the live transcript through the LLM Gateway, - refreshing a prompt chain on every finalized turn (the live sibling of - `transcribe --llm`). + ``source`` mirrors the CLI argument: ``None`` is the microphone, ``"-"`` is raw + PCM16 on stdin, anything else is a file path or URL decoded through ffmpeg. + """ + if source == "-": + return ( + {"sys"}, + _STDIN_SETUP.format(rate=rate), + f"Reading raw PCM16 mono audio at {rate} Hz from stdin…", + "stdin_chunks()", + ) + if source is not None: + return ( + {"subprocess", "time"}, + _FILE_SETUP.format(rate=rate, source=repr(source)), + f"Streaming {source}…", + "file_chunks()", + ) + return ( + set(), + "", + "Listening… press Ctrl-C to stop.", + (f"aai.extras.MicrophoneStream(sample_rate={rate})"), + ) + + +def render( + merged: dict[str, object], + *, + llm: dict[str, object] | None = None, + source: str | None = None, +) -> str: + """Generate a runnable streaming script with the given params. + + ``source`` selects the audio input the script reads, mirroring the CLI run path: + ``None`` captures the microphone, ``"-"`` reads raw PCM16 from stdin, and anything + else is a file path or URL decoded to PCM through ffmpeg (the same pipeline a real + `aai stream ` run uses). With `llm`, the script transforms the live + transcript through the LLM Gateway, refreshing a prompt chain on every finalized + turn (the live sibling of `transcribe --llm`). """ - preamble = _build_preamble(_imports_block(merged), llm) - # Mic capture rate must match StreamingParameters.sample_rate, else audio is corrupt. + # Capture/decode rate must match StreamingParameters.sample_rate, else audio is corrupt. rate = merged.get("sample_rate", 16000) + source_stdlib, setup, banner, stream_expr = _source_parts(source, rate) + stdlib = {"os"} | source_stdlib | ({"time"} if llm else set()) + stdlib_imports = "\n".join(f"import {name}" for name in sorted(stdlib)) + preamble = _build_preamble(_imports_block(merged), llm, stdlib_imports) connect = _build_connect(merged) footer = _LLM_FOOTER if llm else _FOOTER - return preamble + "\n" + connect + "\n" + footer.format(rate=rate) + return ( + preamble + + "\n" + + connect + + "\n" + + footer.format(setup=setup, banner=repr(banner), stream_expr=stream_expr) + ) diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index e6240f97..a7cd7d3e 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -19,6 +19,7 @@ from aai_cli.context import AppState, run_command from aai_cli.errors import CLIError, UsageError from aai_cli.help_text import examples_epilog +from aai_cli.streaming.session import validate_output_flags from aai_cli.streaming.sources import FileSource app = typer.Typer() @@ -56,7 +57,8 @@ def _open_audio( # One full-duplex stream for mic + speaker: macOS rejects two separate # streams on a device, which silently kills capture. duplex = DuplexAudio(target_rate=SAMPLE_RATE, device=device) - # notice() self-suppresses in JSON mode and routes to stderr in text mode. + # notice() self-suppresses in JSON mode and routes to stderr otherwise, so a + # piped `aai agent | …` never reads this advisory as transcript data. renderer.notice( "Use headphones — the mic stays open while the agent speaks, " "so speakers would let it hear itself.\n" @@ -131,6 +133,7 @@ def agent( raise typer.Exit(code=0) def body(state: AppState, json_mode: bool) -> None: + validate_output_flags(json_mode=json_mode, output_field=output_field) text_mode, json_mode = output.stream_output_modes(output_field, json_mode=json_mode) if voice not in VOICES: raise UsageError( @@ -142,6 +145,16 @@ def body(state: AppState, json_mode: bool) -> None: if show_code: # Print-only: emit the equivalent agent script from the flags and exit # without authenticating or opening audio. Raw stdout for `> script.py`. + if source or sample: + # A faithful file-driven agent script would need the CLI's whole + # ffmpeg-decode + ready-gate + exit-after-reply machinery, which is + # impractical to inline; the snippet is microphone-driven, so say so + # on stderr instead of silently dropping the source. stderr keeps + # `--show-code > script.py` byte-clean. + output.error_console.print( + "[aai.warn]Note:[/aai.warn] the generated script uses the microphone; " + "it does not stream the audio source you passed." + ) output.print_code(code_gen.agent(voice, system_prompt_text, greeting)) return diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index e413ca7d..5d4c397c 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -25,7 +25,12 @@ from aai_cli.microphone import MicrophoneSource from aai_cli.streaming.macos import MacSystemAudioSource from aai_cli.streaming.render import StreamRenderer -from aai_cli.streaming.session import SourceOptions, StreamSession, validate_sources +from aai_cli.streaming.session import ( + SourceOptions, + StreamSession, + validate_output_flags, + validate_sources, +) from aai_cli.streaming.sources import TARGET_RATE, FileSource, StdinSource app = typer.Typer() @@ -343,6 +348,7 @@ def stream( """ def body(state: AppState, json_mode: bool) -> None: + validate_output_flags(json_mode=json_mode, output_field=output_field) text_mode, json_mode = output.stream_output_modes(output_field, json_mode=json_mode) opts = SourceOptions( source=source, @@ -380,20 +386,40 @@ def body(state: AppState, json_mode: bool) -> None: base_flags.update(config_builder.auth_header_flags(webhook_auth_header)) if show_code: - # Print-only: emit the canonical microphone-streaming script (16 kHz) from - # the flags and exit without opening audio or authenticating. Raw stdout so - # `--show-code > script.py` yields a runnable file. + # Print-only: emit a script faithful to the requested source — mic + # (default), stdin (-), or a file/URL — and exit without opening audio or + # authenticating. Raw stdout so `--show-code > script.py` is runnable. + # The same source validation as a real run, so e.g. a file + --sample-rate + # conflict errors here too instead of silently generating mic code. + validate_sources(opts, has_llm=bool(llm_prompt), text_mode=text_mode) if opts.from_system_audio: raise UsageError("--show-code does not support macOS system audio capture yet.") + if opts.source and youtube.is_youtube_url(opts.source): + raise UsageError( + "--show-code does not support YouTube sources yet.", + suggestion="Download the audio first (e.g. yt-dlp) and pass the local file.", + ) + code_source: str | None = None + if opts.from_stdin: + code_source = "-" + elif opts.from_file: + # check_local=False: generating code for a file you don't have yet is fine. + code_source = client.resolve_audio_source( + opts.source, sample=opts.sample, check_local=False + ) merged = config_builder.merge_streaming_params( - flags=base_flags | {"sample_rate": TARGET_RATE}, + # sample_rate precedence: --sample-rate (None is dropped by the merge) + # beats --config/--config-file, which beat the 16 kHz default below — + # so an explicit `--config sample_rate=…` is honored, not overridden. + flags=base_flags | {"sample_rate": opts.sample_rate}, overrides=config_kv, config_file=config_file, ) + merged.setdefault("sample_rate", TARGET_RATE) gateway = code_gen.gateway_options( list(llm_prompt or []), model, max_tokens, interval=llm_interval ) - output.print_code(code_gen.stream(merged, llm=gateway)) + output.print_code(code_gen.stream(merged, llm=gateway, source=code_source)) return # Validate the requested sources (including that a local file exists) before diff --git a/aai_cli/microphone.py b/aai_cli/microphone.py index 1ea500da..ba45ba9e 100644 --- a/aai_cli/microphone.py +++ b/aai_cli/microphone.py @@ -153,10 +153,20 @@ def __iter__(self) -> Iterator[bytes]: except ImportError as exc: raise audio_missing_error() from exc except Exception as exc: + # "device None" reads like a bug; name the default mic in plain words. + target = ( + "the default microphone" + if self.device is None + else f"microphone device {self.device}" + ) raise CLIError( - f"Could not open the microphone (device {self.device}): {exc}", + f"Could not open {target}: {exc}", error_type="mic_error", exit_code=1, + suggestion=( + "Check your OS microphone permissions for this terminal, or pick " + "another input with --device (list devices: python -m sounddevice)." + ), ) from exc if self._on_open is not None: self._on_open() # the device is open and recording now diff --git a/aai_cli/streaming/session.py b/aai_cli/streaming/session.py index bc6b2c4e..1ef8aa04 100644 --- a/aai_cli/streaming/session.py +++ b/aai_cli/streaming/session.py @@ -9,7 +9,7 @@ import typer -from aai_cli import client, config_builder, llm, output +from aai_cli import choices, client, config_builder, llm, output from aai_cli.errors import CLIError, UsageError from aai_cli.follow import FollowRenderer from aai_cli.streaming.render import StreamRenderer, speaker_prefix @@ -51,6 +51,16 @@ def has_capture_overrides(self) -> bool: return self.sample_rate is not None or self.device is not None +def validate_output_flags(*, json_mode: bool, output_field: choices.TextOrJson | None) -> None: + """Reject --json combined with -o text, shared by `stream` and `agent`. + + Same precedent as --llm + -o text: contradictory output shapes are a clean + usage error, not a silent coin-flip between plain text and NDJSON. + """ + if json_mode and output_field is choices.TextOrJson.text: + raise UsageError("--json can't be combined with -o text; pick one output format.") + + def validate_sources(opts: SourceOptions, *, has_llm: bool, text_mode: bool) -> None: """Reject flag combinations that can't be honored, before any audio is opened.""" if opts.system_audio and opts.system_audio_only: @@ -72,6 +82,10 @@ def _validate_input_source(opts: SourceOptions) -> None: "--sample-rate and --device require microphone input; use --system-audio." ) elif opts.from_stdin: + if opts.sample: + # The stdin branch wins dispatch over --sample, so without this the + # hosted clip would be silently ignored in favor of the pipe. + raise UsageError("- (stdin) cannot be combined with --sample.") if opts.device is not None: raise UsageError("--device applies only to microphone input.") elif opts.from_file and opts.has_capture_overrides: diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 96794f6f..abe38229 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -1,5 +1,7 @@ import json +import click.testing +import typer.main from typer.testing import CliRunner from aai_cli import config @@ -9,6 +11,11 @@ runner = CliRunner() +def _invoke_split(args): + """Invoke with stdout/stderr captured separately (typer's runner always mixes).""" + return click.testing.CliRunner(mix_stderr=False).invoke(typer.main.get_command(app), args) + + def _login_result(): return LoginResult( api_key="sk_from_oauth", session_jwt="jwt", session_token="tok", account_id=7 @@ -35,6 +42,7 @@ def fake_run_session(*a, **k): def test_agent_unauthenticated_runs_login(monkeypatch): + monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) monkeypatch.setattr("aai_cli.commands.agent.FileSource", lambda src: f"filesrc:{src}") @@ -255,6 +263,54 @@ def test_agent_show_code_prints_without_session(monkeypatch): assert 'os.environ["ASSEMBLYAI_API_KEY"]' in result.output +def test_agent_show_code_file_source_warns_on_stderr(monkeypatch): + # No faithful file-driven agent snippet exists yet; the mic-driven script must + # come with an explicit stderr note instead of silently ignoring the source. + def _boom(*a, **k): + raise AssertionError("must not run a session") + + monkeypatch.setattr("aai_cli.commands.agent.run_session", _boom) + result = _invoke_split(["agent", "clip.wav", "--show-code"]) + assert result.exit_code == 0 + assert "uses the microphone" in result.stderr + # (the console wraps the line, so assert a fragment that fits in 80 cols) + assert "does not stream the audio" in result.stderr + assert "uses the microphone" not in result.stdout # stdout stays a clean script + compile(result.stdout, "", "exec") + + +def test_agent_show_code_sample_warns_on_stderr(): + result = _invoke_split(["agent", "--sample", "--show-code"]) + assert result.exit_code == 0 + assert "uses the microphone" in result.stderr + + +def test_agent_show_code_mic_emits_no_warning(): + result = _invoke_split(["agent", "--show-code"]) + assert result.exit_code == 0 + assert result.stderr == "" # nothing to warn about: the script matches the run + compile(result.stdout, "", "exec") + + +def test_agent_json_with_text_output_is_usage_error(): + # Contradictory output shapes (--json + -o text) are rejected like stream's. + result = runner.invoke(app, ["agent", "--json", "-o", "text"]) + assert result.exit_code == 2 + assert "can't be combined with -o text" in result.output + + +def test_agent_headphones_notice_routes_to_stderr(monkeypatch): + # `aai agent | head` must not eat the advisory as transcript data: in the + # default human mode the notice goes to stderr, stdout stays transcript-only. + config.set_api_key("default", "sk_live") + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("aai_cli.commands.agent.run_session", lambda *a, **k: None) + result = _invoke_split(["agent"]) + assert result.exit_code == 0 + assert "headphones" in result.stderr.lower() + assert "headphones" not in result.stdout.lower() + + def test_agent_show_code_ignores_json_flag(monkeypatch): def _boom(*a, **k): raise AssertionError("must not run a session") diff --git a/tests/test_agent_render.py b/tests/test_agent_render.py index c697d8c9..9821fcd8 100644 --- a/tests/test_agent_render.py +++ b/tests/test_agent_render.py @@ -131,10 +131,23 @@ def test_human_close_commits_open_partial(): assert "half a sentence" in buf.getvalue() # committed, not dropped -def test_human_notice_rendered(): - r, buf = _human() +def test_human_notice_goes_to_stderr_not_stdout(): + # Human (default) mode is also piped sometimes (`aai agent | head`); the notice + # must land on stderr in every non-JSON mode so stdout carries only transcript. + out, err = io.StringIO(), io.StringIO() + console = theme.make_console(file=out, force_terminal=True, width=80) + r = AgentRenderer(json_mode=False, out=out, err=err, console=console) r.notice("Half-duplex note.\n") - assert "Half-duplex note." in buf.getvalue() + assert "Half-duplex note." in err.getvalue() + assert out.getvalue() == "" + + +def test_json_notice_is_suppressed(): + out, err = io.StringIO(), io.StringIO() + r = AgentRenderer(json_mode=True, out=out, err=err) + r.notice("Half-duplex note.\n") + assert out.getvalue() == "" + assert err.getvalue() == "" def test_human_connected_and_stopped_announce(): diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py index ae0821c4..dce45d31 100644 --- a/tests/test_agent_session.py +++ b/tests/test_agent_session.py @@ -1,9 +1,12 @@ import base64 import json +import logging +import types import pytest from aai_cli.agent.session import ( + _WEBSOCKETS_LOGGERS, AgentRunConfig, VoiceAgentSession, _send_audio_loop, @@ -309,6 +312,92 @@ def start(self): assert player.closed is False # never opened, so never closed +class _HandshakeRejected(Exception): + """Mimics websockets' InvalidStatus: a structured HTTP status on ``.response``.""" + + def __init__(self, status): + super().__init__(f"server rejected WebSocket connection: HTTP {status}") + self.response = types.SimpleNamespace(status_code=status) + + +def _run_with_connect(connect): + run_session( + "sk", + renderer=FakeRenderer(), + player=FakePlayer(), + mic=[], + config=AgentRunConfig(voice="ivy", system_prompt="x", greeting="hi"), + connect=connect, + ) + + +def test_run_session_handshake_403_is_api_error_like_stream(): + # Harmonized with `stream`: a plain handshake 403 is an API error (exit 1), not + # "Your API key was rejected" — 403 also covers non-credential blocks. + def reject(url, **kwargs): + raise _HandshakeRejected(403) + + with pytest.raises(APIError) as exc: + _run_with_connect(reject) + assert exc.value.error_type == "api_error" + assert exc.value.exit_code == 1 + assert "HTTP 403" in exc.value.message + + +def test_run_session_handshake_401_is_still_auth_failure(): + # A genuinely auth-shaped rejection (HTTP 401) keeps the rejected-key path. + def reject(url, **kwargs): + raise _HandshakeRejected(401) + + with pytest.raises(NotAuthenticated) as exc: + _run_with_connect(reject) + assert exc.value.exit_code == 4 + + +def test_run_session_auth_worded_failure_is_still_auth_failure(): + # The text heuristic ("unauthorized" etc.) keeps working for real bad keys. + def reject(url, **kwargs): + raise RuntimeError("connection rejected: Unauthorized") + + with pytest.raises(NotAuthenticated): + _run_with_connect(reject) + + +class _CleanWS: + def send(self, _msg): + pass + + def __iter__(self): + return iter(()) + + def close(self): + pass + + +def test_run_session_silences_websockets_loggers(): + # websockets' sync reader thread logs teardown errors (EOFError tracebacks) via + # its own loggers; run_session must mute them so they never hit the user's stderr. + loggers = [logging.getLogger(name) for name in _WEBSOCKETS_LOGGERS] + previous = [lg.level for lg in loggers] + try: + for lg in loggers: + lg.setLevel(logging.NOTSET) + _run_with_connect(lambda url, **kwargs: _CleanWS()) + for lg in loggers: + assert lg.level == logging.CRITICAL + assert not lg.isEnabledFor(logging.ERROR) # an ERROR record is dropped + finally: + for lg, level in zip(loggers, previous, strict=True): + lg.setLevel(level) + + +def test_websockets_logger_names_cover_the_sync_client(): + # The sync client logs through "websockets.client"; pin that the silenced set + # covers it (and the parent, for any future child loggers). + assert "websockets.client" in _WEBSOCKETS_LOGGERS + assert "websockets" in _WEBSOCKETS_LOGGERS + + def test_run_session_non_auth_failure_stays_api_error(): def boom(url, **kwargs): raise RuntimeError("network unreachable") diff --git a/tests/test_code_gen_stream.py b/tests/test_code_gen_stream.py new file mode 100644 index 00000000..2f66faf4 --- /dev/null +++ b/tests/test_code_gen_stream.py @@ -0,0 +1,117 @@ +"""Source fidelity of `stream --show-code` generation (mic vs stdin vs file/URL). + +The generated script must read the same audio input the real run would, at the +same sample rate, and every variant must compile (`python -m py_compile` parity). +""" + +from __future__ import annotations + +from hypothesis import given +from hypothesis import strategies as st + +from aai_cli import code_gen + +_LLM = {"prompts": ["summarize"], "model": "m", "max_tokens": 100, "interval": 5.0} + + +def _compiles(code: str) -> None: + # compile() is stricter than ast.parse() and is what `python file.py` runs through. + compile(code, "", "exec") + + +# --- microphone (default) ---------------------------------------------------- +def test_mic_variant_is_unchanged_and_has_no_source_plumbing(): + code = code_gen.stream({"sample_rate": 16000}) + _compiles(code) + assert "client.stream(aai.extras.MicrophoneStream(sample_rate=16000))" in code + assert "print('Listening… press Ctrl-C to stop.')" in code + assert "import subprocess" not in code + assert "import sys" not in code + assert "stdin_chunks" not in code + assert "file_chunks" not in code + + +def test_mic_variant_honors_sample_rate(): + code = code_gen.stream({"sample_rate": 8000}) + _compiles(code) + assert "MicrophoneStream(sample_rate=8000)" in code + assert "sample_rate=8000," in code # StreamingParameters matches the capture rate + + +# --- stdin (`-`) --------------------------------------------------------------- +def test_stdin_variant_reads_stdin_not_the_mic(): + code = code_gen.stream({"sample_rate": 16000}, source="-") + _compiles(code) + assert "client.stream(stdin_chunks())" in code + assert "sys.stdin.buffer.read(chunk_bytes)" in code + assert "import sys" in code + assert "MicrophoneStream" not in code + + +def test_stdin_variant_honors_sample_rate(): + code = code_gen.stream({"sample_rate": 8000}, source="-") + _compiles(code) + assert "chunk_bytes = 8000 * 2 // 10" in code + assert "-ar 8000" in code # the example ffmpeg pipe matches the declared rate + assert "sample_rate=8000," in code + + +# --- file / URL --------------------------------------------------------------- +def test_file_variant_decodes_that_file_through_ffmpeg(): + code = code_gen.stream({"sample_rate": 16000}, source="rec.wav") + _compiles(code) + assert "client.stream(file_chunks())" in code + assert "'rec.wav'" in code # the source is embedded as the ffmpeg input + assert '"-ar", "16000"' in code + assert "chunk_bytes = 16000 * 2 // 10" in code + assert "time.sleep(len(data) / (16000 * 2))" in code # ~real-time pacing + assert "import subprocess" in code + assert "import time" in code + assert "print('Streaming rec.wav…')" in code + assert "MicrophoneStream" not in code + + +def test_file_variant_honors_sample_rate(): + code = code_gen.stream({"sample_rate": 8000}, source="clip.mp3") + _compiles(code) + assert '"-ar", "8000"' in code # decode rate == StreamingParameters.sample_rate + assert "sample_rate=8000," in code + + +def test_url_source_is_passed_to_ffmpeg_verbatim(): + code = code_gen.stream({}, source="https://assembly.ai/wildfires.mp3") + _compiles(code) + assert "'https://assembly.ai/wildfires.mp3'" in code + assert "file_chunks()" in code + + +def test_file_variant_with_quotes_in_name_still_compiles(): + code = code_gen.stream({}, source='rec\'s "weird" name.wav') + _compiles(code) + + +# --- --llm composition --------------------------------------------------------- +def test_llm_with_file_source_streams_file_and_flushes_summary(): + code = code_gen.stream({"sample_rate": 16000}, llm=_LLM, source="rec.wav") + _compiles(code) + assert "client.stream(file_chunks())" in code + assert "run_chain" in code + assert "summarize(final=True)" in code + assert code.count("import time") == 1 # llm + file both need time; imported once + + +def test_llm_with_stdin_source_keeps_both_imports(): + code = code_gen.stream({}, llm=_LLM, source="-") + _compiles(code) + assert "client.stream(stdin_chunks())" in code + assert "import sys" in code + assert "import time" in code + + +# --- fuzz: every source shape always compiles ---------------------------------- +@given(st.text(st.characters(blacklist_categories=["Cs"]), max_size=40) | st.none()) +def test_fuzz_any_source_always_compiles(source): + # Arbitrary file names (quotes, newlines, braces, unicode), "-" (stdin), "" + # and None (mic) must all yield a compilable script. + _compiles(code_gen.stream({"sample_rate": 16000}, source=source)) + _compiles(code_gen.stream({"sample_rate": 16000}, llm=_LLM, source=source)) diff --git a/tests/test_microphone.py b/tests/test_microphone.py index 1a26fe6b..7e2f141c 100644 --- a/tests/test_microphone.py +++ b/tests/test_microphone.py @@ -101,6 +101,26 @@ def boom(*, sample_rate, device): list(mic) assert exc.value.error_type == "mic_error" assert exc.value.exit_code == 1 + assert "microphone device 99" in exc.value.message # names the explicit device + assert "Invalid device" in exc.value.message # keeps the underlying cause + assert exc.value.suggestion is not None + assert "--device" in exc.value.suggestion + + +def test_default_device_error_names_default_microphone(): + # device=None must read as "the default microphone", not the raw "device None", + # and carry an actionable suggestion (permissions / pick another device). + def boom(*, sample_rate, device): + raise OSError("Error querying device -1") + + mic = MicrophoneSource(capture_rate=16000, stream_factory=boom) + with pytest.raises(CLIError) as exc: + list(mic) + assert "the default microphone" in exc.value.message + assert "device None" not in exc.value.message + assert exc.value.suggestion is not None + assert "permissions" in exc.value.suggestion + assert "python -m sounddevice" in exc.value.suggestion def test_closes_closeable_stream_in_finally(): diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 85593ccf..142e0a9f 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -124,6 +124,7 @@ def fake(api_key, source, *, params, on_begin=None, **_kwargs): def test_stream_unauthenticated_runs_login(monkeypatch): + monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) def fake_stream_audio(api_key, source, *, params, **_kwargs): @@ -389,6 +390,104 @@ def test_stream_show_code_prints_without_streaming(monkeypatch): assert 'os.environ["ASSEMBLYAI_API_KEY"]' in result.output +def test_stream_show_code_file_source_streams_that_file(): + # A file source must generate file-streaming code, not silently emit mic code. + # The file need not exist: generating code for it is legitimate (check_local=False). + result = runner.invoke(app, ["stream", "rec.wav", "--show-code"]) + assert result.exit_code == 0 + assert "client.stream(file_chunks())" in result.output + assert "'rec.wav'" in result.output # the ffmpeg input is the file passed + assert "MicrophoneStream" not in result.output + compile(result.output, "", "exec") # the printed script is runnable + + +def test_stream_show_code_stdin_source_reads_stdin(): + result = runner.invoke(app, ["stream", "-", "--show-code"]) + assert result.exit_code == 0 + assert "client.stream(stdin_chunks())" in result.output + assert "sys.stdin.buffer" in result.output + assert "MicrophoneStream" not in result.output + compile(result.output, "", "exec") + + +def test_stream_show_code_sample_streams_hosted_clip(): + result = runner.invoke(app, ["stream", "--sample", "--show-code"]) + assert result.exit_code == 0 + assert "wildfires.mp3" in result.output + assert "file_chunks()" in result.output + assert "MicrophoneStream" not in result.output + + +def test_stream_show_code_honors_sample_rate_flag(): + result = runner.invoke(app, ["stream", "--sample-rate", "8000", "--show-code"]) + assert result.exit_code == 0 + assert "MicrophoneStream(sample_rate=8000)" in result.output + assert "sample_rate=8000," in result.output # params match the capture rate + + +def test_stream_show_code_honors_config_sample_rate(): + # An explicit `--config sample_rate=…` must not be overridden by the 16 kHz default. + result = runner.invoke(app, ["stream", "--config", "sample_rate=8000", "--show-code"]) + assert result.exit_code == 0 + assert "MicrophoneStream(sample_rate=8000)" in result.output + assert "sample_rate=8000," in result.output + + +def test_stream_show_code_sample_rate_flag_beats_config(): + result = runner.invoke( + app, ["stream", "--sample-rate", "8000", "--config", "sample_rate=44100", "--show-code"] + ) + assert result.exit_code == 0 + assert "MicrophoneStream(sample_rate=8000)" in result.output + assert "44100" not in result.output + + +def test_stream_show_code_file_with_mic_flags_rejected(): + # --show-code applies the same source validation as a real run, so the + # file + --sample-rate conflict errors instead of generating mic code. + result = runner.invoke(app, ["stream", "rec.wav", "--sample-rate", "8000", "--show-code"]) + assert result.exit_code == 2 + assert "--sample-rate" in result.output + + +def test_stream_show_code_rejects_youtube_sources(): + result = runner.invoke(app, ["stream", "https://youtu.be/abc", "--show-code"]) + assert result.exit_code == 2 + assert "YouTube" in result.output + + +def test_stream_json_with_text_output_is_usage_error(): + # Contradictory output shapes (--json + -o text) are rejected up front, before + # credentials, like the --llm + -o text precedent. + result = runner.invoke(app, ["stream", "--json", "-o", "text"]) + assert result.exit_code == 2 + assert "can't be combined with -o text" in result.output + + +def test_stream_stdin_with_sample_rejected(): + config.set_api_key("default", "sk_live") + result = runner.invoke(app, ["stream", "-", "--sample"], input=b"\x00\x00") + assert result.exit_code == 2 + assert "--sample" in result.output + + +def test_stream_file_source_with_sample_rejected(monkeypatch, tmp_path): + # A real source plus --sample is a conflict (the file would silently lose), + # surfaced by resolve_audio_source as a usage error before any streaming. + config.set_api_key("default", "sk_live") + + def _boom(*a, **k): + raise AssertionError("must not stream a conflicting source") + + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", _boom) + wav = tmp_path / "a.wav" + wav.write_bytes(b"RIFF") + result = runner.invoke(app, ["stream", str(wav), "--sample"]) + assert result.exit_code == 2 + assert "--sample" in result.output + assert "cannot be combined" in result.output + + def test_stream_show_code_ignores_json_flag(monkeypatch): def _boom(*a, **k): raise AssertionError("must not stream") From e5bcdbd33963e5ba86da06e8e1fd685ea7a766f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 04:53:17 +0000 Subject: [PATCH 03/11] Regenerate help-text snapshots for new transcribe/transcripts flags https://claude.ai/code/session_01Uv7cEgJi2LgknkvfHP52g7 --- .../test_cli_output_snapshots.ambr | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index dfe6b85b..7c08045f 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -170,6 +170,9 @@ ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --port INTEGER Local server port. [default: 3000] │ + │ --host TEXT Interface to bind. Loopback by default; pass │ + │ 0.0.0.0 to expose on your network. │ + │ [default: 127.0.0.1] │ │ --no-open Launch, but don't open the browser. │ │ --no-install Skip dependency install; launch directly. │ │ --json Output raw JSON. │ @@ -224,7 +227,8 @@ conversation and writes no code. ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ - │ template [TEMPLATE] Template to scaffold (omit to pick │ + │ template [TEMPLATE] Template to scaffold: audio-transcription, │ + │ live-captions, voice-agent (omit to pick │ │ interactively). │ │ directory [DIRECTORY] Target directory (default: