diff --git a/.claude/settings.json b/.claude/settings.json index 5c2b4e44..09db6b35 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -95,7 +95,7 @@ "hooks": [ { "type": "command", - "command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.py) uv run ruff check --fix \"$f\" >/dev/null 2>&1; uv run ruff format \"$f\" >/dev/null 2>&1;; esac; exit 0" + "command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.py) uv run ruff check --fix --unfixable F401 \"$f\" >/dev/null 2>&1; uv run ruff format \"$f\" >/dev/null 2>&1;; esac; exit 0" } ] } diff --git a/.importlinter b/.importlinter index e489e646..f333b96c 100644 --- a/.importlinter +++ b/.importlinter @@ -21,6 +21,7 @@ source_modules = aai_cli.init aai_cli.llm aai_cli.microphone + aai_cli.options aai_cli.output aai_cli.render aai_cli.stdio diff --git a/AGENTS.md b/AGENTS.md index a21ccaac..67d90627 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ uv run aai --help # run the CLI from the locked environment ./scripts/check.sh # the full gate CI runs (scripts/check.sh is the source of truth) ``` -`scripts/check.sh` is the authoritative gate; keep this list in sync with it. It runs, in order: `uv lock --check` → `ruff check` → `ruff format --check` → `mypy` → `pyright` (src strict) → `pyright` (tests) → `vulture` (dead code) → `deptry` (dependency hygiene) → `lint-imports` (import-linter architecture contracts) → `xenon` (cyclomatic complexity, max grade B / project avg A) → `swiftlint` + swift compile (macOS only, skipped elsewhere) → `markdownlint` → `prettier` (init template JS/CSS) → `shellcheck` → generated `--show-code` compile gate → init template contract gate → `pytest` (90% branch coverage) → `diff-cover` (100% patch coverage vs `origin/main`) → a "no new escape hatches" diff gate (`# type: ignore` / `# noqa` / `pragma: no cover` / net-new `Any` / `cast(`) → `uv build` + `twine check --strict`. The `vulture`/`deptry`/`lint-imports`/`xenon` and patch-coverage stages catch the failures that `ruff`+`mypy` alone won't — don't claim the gate is green until the script prints `All checks passed.` +`scripts/check.sh` is the authoritative gate; keep this list in sync with it. It runs, in order: `uv lock --check` → `ruff check` → `ruff format --check` → `mypy` → `pyright` (src strict) → `pyright` (tests) → `vulture` (dead code) → `deptry` (dependency hygiene) → `lint-imports` (import-linter architecture contracts) → max-file-length (500 lines) → `xenon` (cyclomatic complexity, max grade B / project avg A) → `swiftlint` + swift compile (macOS only, skipped elsewhere) → `markdownlint` → `prettier` (init template JS/CSS) → `shellcheck` → `actionlint` + `zizmor` (workflow lint/audit) → `gitleaks` (secret scan) → generated `--show-code` compile gate → init template contract gate → `pytest` (90% branch coverage) → `diff-cover` (100% patch coverage vs `origin/main`) → **mutation gate** (diff-scoped: mutates each changed line and reruns the tests that cover it — a surviving mutant fails the gate, so changed lines need assertions that would *fail* if the line broke, not just coverage; suppress a genuinely unassertable line with `# pragma: no mutate`) → a "no new escape hatches" diff gate (`# type: ignore` / `# noqa` / `pragma: no cover` / net-new `Any` / `cast(`) → `uv build` + `twine check --strict`. The `vulture`/`deptry`/`lint-imports`/`xenon`, patch-coverage, and mutation stages catch the failures that `ruff`+`mypy` alone won't — don't claim the gate is green until the script prints `All checks passed.` Individual tools (all via `uv run`): @@ -41,6 +41,8 @@ uv run pytest -m install_script # builds a wheel and runs install.sh for real; CLI output is pinned by **syrupy snapshot tests** (`tests/__snapshots__/*.ambr`). Changing help text, tables, or rendered output will fail those tests until you regenerate them with `uv run pytest --snapshot-update` and commit the updated `.ambr` files. The auto-format hook only touches `*.py`, and pre-commit's whitespace fixers deliberately skip `tests/__snapshots__/` (syrupy's indentation must stay byte-for-byte), so never hand-edit a snapshot — always regenerate. +The post-edit hook (`.claude/settings.json`) runs `ruff check --fix --unfixable F401` + `ruff format` on every edited `*.py`. `--unfixable F401` means a just-added import is **not** auto-deleted while it's momentarily unused — so adding an import in one edit and its usage in the next is safe. The flip side: a genuinely unused import survives the hook and only fails at `ruff check` in the gate, so still prefer making the import and its first usage land in the same edit. + The suite is hermetic by construction, enforced three ways (`tests/conftest.py` + `pyproject.toml` `[tool.pytest.ini_options]`): **pytest-randomly** shuffles order, an autouse `pin_timezone` fixture pins `TZ` to a fixed non-UTC zone (UTC-normalized rendering must be unaffected; use **time-machine** to freeze `now`), and **pytest-socket** (`--disable-socket`) blocks real network so an unmocked SDK/HTTP call fails loudly instead of hitting the API. A test that only binds a loopback server opts back in with the tight `@pytest.mark.allow_hosts(["127.0.0.1"])` (still blocks external hosts). The `e2e`/`install`/`install_script` marker suites legitimately reach the real network in-process (PyPI reachability probes, real-API runs), so a `pytest_collection_modifyitems` hook in `conftest.py` auto-grants them full sockets — adding a network marker is all that's needed, no per-test `enable_socket`. ## Naming & packaging gotchas diff --git a/aai_cli/agent/audio.py b/aai_cli/agent/audio.py index 4421d7fb..7363183e 100644 --- a/aai_cli/agent/audio.py +++ b/aai_cli/agent/audio.py @@ -91,6 +91,7 @@ def __init__( device_rate: int | None = None, stream_factory: Callable[..., Any] | None = None, rate_query: Callable[[int | None], int] | None = None, + poll_timeout: float = 1.0, ) -> None: query = rate_query or _output_default_rate self._device_rate = device_rate if device_rate is not None else query(device) @@ -104,6 +105,9 @@ def __init__( # access goes through `_lock`. `_out_state` (the target->device ratecv state) # is touched ONLY by feed(), never the callback, so it needs no lock. self._in: queue.Queue[bytes | None] = queue.Queue() + # How long capture_frames() waits for a chunk before checking whether the + # device stream silently died (e.g. unplugged); injectable for fast tests. + self._poll_timeout = poll_timeout self._out = bytearray() self._out_state: Any = None self._lock = threading.Lock() @@ -156,10 +160,26 @@ def pending(self) -> int: return len(self._out) // 2 def capture_frames(self) -> Iterator[bytes]: - """Yield target-rate PCM captured from the device until closed.""" + """Yield target-rate PCM captured from the device until closed. + + Waits in short timeouts rather than blocking forever: if the PortAudio + stream dies without close() being called (device unplugged mid-session), + no sentinel ever arrives, so a bare get() would hang the capture thread — + and with it the whole agent session — instead of surfacing an error. + """ state: Any = None while True: - chunk = self._in.get() + try: + chunk = self._in.get(timeout=self._poll_timeout) + except queue.Empty: + if self._started and not getattr(self._stream, "active", True): + raise CLIError( + "The audio device stopped producing input.", + error_type="audio_input_error", + exit_code=1, + suggestion="Check your microphone/output device, then run 'aai doctor'.", + ) from None + continue if chunk is None: return if self._device_rate != self._target: diff --git a/aai_cli/agent/session.py b/aai_cli/agent/session.py index 27a08df6..77e52674 100644 --- a/aai_cli/agent/session.py +++ b/aai_cli/agent/session.py @@ -12,8 +12,12 @@ from aai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure -def _ws_url() -> str: - """Voice Agent socket URL for the active environment (set at CLI startup).""" +def ws_url() -> str: + """Voice Agent socket URL for the active environment (set at CLI startup). + + Shared with code_gen so generated agent scripts target the same host the + CLI itself would connect to. + """ return f"wss://{environments.active().agents_host}/v1/ws" @@ -196,7 +200,7 @@ def _auth_or_api_error(exc: Exception, message: str) -> CLIError: def _open_ws(connect: _Connect, api_key: str) -> _WebSocket: """Open the Voice Agent socket, mapping a connect failure to a clean CLIError.""" try: - return connect(_ws_url(), additional_headers={"Authorization": f"Bearer {api_key}"}) + return connect(ws_url(), additional_headers={"Authorization": f"Bearer {api_key}"}) except Exception as exc: raise _auth_or_api_error(exc, "Could not connect to the voice agent") from exc diff --git a/aai_cli/client.py b/aai_cli/client.py index 772c1a7f..9c996f2f 100644 --- a/aai_cli/client.py +++ b/aai_cli/client.py @@ -138,12 +138,8 @@ def _transcript_text(transcript: Any) -> str: return str(getattr(transcript, "text", "") or "") -def _objects(value: object) -> list[object]: - return jsonshape.object_list(value) - - def _render_utterances(transcript: Any) -> str: - utterances = _objects(getattr(transcript, "utterances", None)) + utterances = jsonshape.object_list(getattr(transcript, "utterances", None)) if not utterances: return _transcript_text(transcript) return "\n".join( diff --git a/aai_cli/code_gen/agent.py b/aai_cli/code_gen/agent.py index fd599337..1264aeca 100644 --- a/aai_cli/code_gen/agent.py +++ b/aai_cli/code_gen/agent.py @@ -2,9 +2,11 @@ import json -# Injected fields ({voice}/{system_prompt}/{greeting}) are substituted with json.dumps -# values via str.format in a single pass, so prompt text can't collide with the -# template's own braces. Every other literal brace below is doubled for str.format. +from aai_cli.agent import session + +# Injected fields ({ws_url}/{voice}/{system_prompt}/{greeting}) are substituted with +# json.dumps values via str.format in a single pass, so prompt text can't collide with +# the template's own braces. Every other literal brace below is doubled for str.format. _TEMPLATE = """# Live two-way voice conversation with an AssemblyAI voice agent. # Requires audio support: pip install sounddevice websockets # Tip: use headphones — the mic stays open while the agent speaks. @@ -18,7 +20,7 @@ from websockets.sync.client import connect API_KEY = os.environ["ASSEMBLYAI_API_KEY"] -WS_URL = "wss://agents.assemblyai.com/v1/ws" +WS_URL = {ws_url} RATE = 24000 # Voice Agent native PCM16 mono sample rate # ONE full-duplex stream (mic + speaker together) at the agent's native 24 kHz. Opening @@ -87,8 +89,13 @@ def send_mic(ws): def render(voice: str, system_prompt: str, greeting: str) -> str: - """Generate a runnable voice-agent script with the given session settings.""" + """Generate a runnable voice-agent script with the given session settings. + + The socket URL comes from the active environment, so a sandbox run generates + a script that targets the sandbox its key was minted for, not production. + """ return _TEMPLATE.format( + ws_url=json.dumps(session.ws_url()), voice=json.dumps(voice), system_prompt=json.dumps(system_prompt), greeting=json.dumps(greeting), diff --git a/aai_cli/code_gen/stream.py b/aai_cli/code_gen/stream.py index cb54d3ad..e02bd0fd 100644 --- a/aai_cli/code_gen/stream.py +++ b/aai_cli/code_gen/stream.py @@ -2,7 +2,7 @@ from typing import cast -from aai_cli import llm as gateway +from aai_cli import environments from aai_cli.code_gen import serialize # Streaming-class imports always used by the generated scaffold. SpeechModel is added @@ -34,7 +34,7 @@ def on_turn(client: StreamingClient, event: TurnEvent) -> None: client = StreamingClient( - StreamingClientOptions(api_key=API_KEY, api_host="streaming.assemblyai.com") + StreamingClientOptions(api_key=API_KEY, api_host={api_host!r}) ) client.on(StreamingEvents.Turn, on_turn) """ @@ -102,7 +102,7 @@ def on_turn(client: StreamingClient, event: TurnEvent) -> None: client = StreamingClient( - StreamingClientOptions(api_key=API_KEY, api_host="streaming.assemblyai.com") + StreamingClientOptions(api_key=API_KEY, api_host={api_host!r}) ) client.on(StreamingEvents.Turn, on_turn) """ @@ -136,18 +136,24 @@ def _imports_block(merged: dict[str, object]) -> str: def _build_preamble(imports: str, llm: dict[str, object] | None) -> str: - """Pick and fill the plain vs. LLM-Gateway preamble for the given imports.""" + """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 + that targets the sandbox its key was minted for, not production. + """ + env = environments.active() if llm: prompts = "\n".join(f" {p!r}," for p in cast("list[str]", llm["prompts"])) return _LLM_PREAMBLE.format( imports=imports, - base_url=gateway.GATEWAY_BASE_URL, + api_host=env.streaming_host, + base_url=env.llm_gateway_base, prompts=prompts, model=llm["model"], max_tokens=llm["max_tokens"], interval=llm.get("interval", 0.0), ) - return _PREAMBLE.format(imports=imports) + return _PREAMBLE.format(imports=imports, api_host=env.streaming_host) def _build_connect(merged: dict[str, object]) -> str: diff --git a/aai_cli/code_gen/transcribe.py b/aai_cli/code_gen/transcribe.py index 3d97439b..a35a8723 100644 --- a/aai_cli/code_gen/transcribe.py +++ b/aai_cli/code_gen/transcribe.py @@ -2,7 +2,7 @@ from typing import cast -from aai_cli import llm +from aai_cli import environments, llm from aai_cli.code_gen import serialize, snippets @@ -38,6 +38,13 @@ def render( "", '# Export your key first: export ASSEMBLYAI_API_KEY=""', 'aai.settings.api_key = os.environ["ASSEMBLYAI_API_KEY"]', + ] + # The SDK ships pointing at production, so only a non-default environment + # (e.g. --sandbox) needs its api base spelled out in the generated script. + env = environments.active() + if env.api_base != environments.get(environments.DEFAULT_ENV).api_base: + parts.append(f"aai.settings.base_url = {env.api_base!r}") + parts += [ "", "transcriber = aai.Transcriber()", ] @@ -75,7 +82,7 @@ def _llm_gateway_block(llm_gateway: dict[str, object]) -> list[str]: "# Each prompt runs on the previous response; the first runs on the transcript.", "gateway = OpenAI(", ' api_key=os.environ["ASSEMBLYAI_API_KEY"],', - f" base_url={llm.GATEWAY_BASE_URL!r},", + f" base_url={environments.active().llm_gateway_base!r},", ")", "prompts = [", prompt_lines, diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index b363add9..445929eb 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -7,7 +7,7 @@ from rich.markup import escape from rich.text import Text -from aai_cli import help_panels, jsonshape, output, timeparse +from aai_cli import help_panels, jsonshape, options, output, timeparse from aai_cli.auth import ams from aai_cli.context import AppState, resolve_session, run_command from aai_cli.errors import UsageError @@ -112,7 +112,7 @@ def _line_items_summary(item: Mapping[str, object]) -> str: ) def balance( ctx: typer.Context, - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Show your remaining account balance.""" @@ -151,7 +151,7 @@ def usage( end: str | None = typer.Option(None, "--end", help="End date (YYYY-MM-DD). Default: today."), window: str | None = typer.Option(None, "--window", help="Window size, e.g. 'day' or 'month'."), include_zero: bool = typer.Option(False, "--all", help="Include zero-usage windows."), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Show usage over a date range (defaults to the last 30 days).""" @@ -225,7 +225,7 @@ def render(d: dict[str, object]) -> object: ) def limits( ctx: typer.Context, - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Show your account's rate limits per service.""" diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index 00b5084c..9fea9dc7 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -6,7 +6,7 @@ import typer -from aai_cli import choices, client, code_gen, config, help_panels, output +from aai_cli import choices, client, code_gen, config, help_panels, options, output from aai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer from aai_cli.agent.render import AgentRenderer from aai_cli.agent.session import ( @@ -103,7 +103,7 @@ def agent( greeting: str = typer.Option(DEFAULT_GREETING, "--greeting", help="Spoken greeting."), device: int | None = typer.Option(None, "--device", help="Microphone device index."), list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit."), - json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), + json_out: bool = options.json_option("Emit newline-delimited JSON events."), output_field: choices.TextOrJson | None = typer.Option( None, "-o", diff --git a/aai_cli/commands/audit.py b/aai_cli/commands/audit.py index 33b261ce..4b7282b9 100644 --- a/aai_cli/commands/audit.py +++ b/aai_cli/commands/audit.py @@ -5,7 +5,7 @@ import typer from rich.markup import escape -from aai_cli import help_panels, jsonshape, output, timeparse +from aai_cli import help_panels, jsonshape, options, output, timeparse from aai_cli.auth import ams from aai_cli.context import AppState, resolve_session, run_command from aai_cli.help_text import examples_epilog @@ -93,7 +93,7 @@ def audit( include_logins: bool = typer.Option( False, "--include-logins", help="Show successful login events." ), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """List recent audit-log entries for your account.""" diff --git a/aai_cli/commands/dev.py b/aai_cli/commands/dev.py index 8b456526..0c5cb2e9 100644 --- a/aai_cli/commands/dev.py +++ b/aai_cli/commands/dev.py @@ -7,7 +7,7 @@ import typer from rich.markup import escape -from aai_cli import help_panels, output, steps +from aai_cli import help_panels, options, output, steps from aai_cli.context import AppState, run_command from aai_cli.help_text import examples_epilog from aai_cli.init import devserver, procfile, runner @@ -66,7 +66,7 @@ def dev( no_install: bool = typer.Option( False, "--no-install", help="Skip dependency install; launch directly." ), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Launch the dev server for the app in the current directory. diff --git a/aai_cli/commands/doctor.py b/aai_cli/commands/doctor.py index 6894f4b8..b6fe12e9 100644 --- a/aai_cli/commands/doctor.py +++ b/aai_cli/commands/doctor.py @@ -8,7 +8,7 @@ import typer from rich.markup import escape -from aai_cli import client, config, help_panels, output, theme +from aai_cli import client, config, help_panels, options, output, theme from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.errors import CLIError, NotAuthenticated from aai_cli.help_text import examples_epilog @@ -224,7 +224,7 @@ def render(data: DoctorResult) -> str: ) def doctor( ctx: typer.Context, - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Check that your environment is ready to use AssemblyAI.""" diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index a4ccccd6..11297cd3 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -7,7 +7,7 @@ import typer from rich.markup import escape -from aai_cli import __version__, environments, help_panels, output, steps +from aai_cli import __version__, environments, help_panels, options, output, steps from aai_cli.context import AppState, run_command from aai_cli.errors import CLIError from aai_cli.help_text import examples_epilog @@ -255,7 +255,7 @@ def init( force: bool = typer.Option(False, "--force", help="Overwrite a non-empty target directory."), here: bool = typer.Option(False, "--here", help="Scaffold into the current directory."), port: int = typer.Option(3000, "--port", help="Local server port."), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Scaffold a new project from a template, then launch it. diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index 06ea514e..2020c62a 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -4,7 +4,7 @@ from rich.markup import escape from rich.table import Table -from aai_cli import jsonshape, output +from aai_cli import jsonshape, options, output from aai_cli.auth import ams from aai_cli.context import AppState, resolve_session, run_command from aai_cli.errors import APIError @@ -53,7 +53,7 @@ def _default_project_id(account_id: int, jwt: str) -> int: ) def list_( ctx: typer.Context, - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """List API keys across your projects (keys shown masked).""" @@ -110,7 +110,7 @@ def create( project_id: int | None = typer.Option( None, "--project", help="Project id to create the key in (defaults to your first)." ), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Create a new API key. Prints the key value once — copy it now.""" @@ -142,7 +142,7 @@ def rename( ctx: typer.Context, token_id: int = typer.Argument(..., help="The key id (see `aai keys list`)."), new_name: str = typer.Argument(..., help="The new label."), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Rename an existing API key.""" diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index cf0c2e68..69848246 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -5,7 +5,7 @@ import typer from rich.markup import escape -from aai_cli import choices, config, help_panels, output, stdio +from aai_cli import choices, config, help_panels, options, output, stdio from aai_cli import llm as gateway from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError @@ -91,9 +91,7 @@ def llm( gateway.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens to generate." ), list_models: bool = typer.Option(False, "--list-models", help="Print known models and exit."), - json_out: bool = typer.Option( - False, "--json", help="Output raw JSON (one object per turn in --follow mode)." - ), + json_out: bool = options.json_option("Output raw JSON (one object per turn in --follow mode)."), ) -> None: """Send a prompt to AssemblyAI's LLM Gateway and print the response. diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index eb53e4e4..4f1d8a67 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -4,7 +4,7 @@ from rich.markup import escape from rich.table import Table -from aai_cli import client, config, environments, help_panels, output +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, NotAuthenticated from aai_cli.help_text import examples_epilog @@ -24,7 +24,7 @@ def login( ctx: typer.Context, api_key: str | None = typer.Option(None, "--api-key", help="Provide key non-interactively."), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Authenticate via your browser; stores a CLI API key.""" @@ -70,7 +70,7 @@ def body(state: AppState, json_mode: bool) -> None: ) def logout( ctx: typer.Context, - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Clear stored credentials for the active profile.""" @@ -101,7 +101,7 @@ def body(state: AppState, json_mode: bool) -> None: ) def whoami( ctx: typer.Context, - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Show the active profile and whether its key is usable.""" diff --git a/aai_cli/commands/onboard.py b/aai_cli/commands/onboard.py index 26c92434..efce5fdd 100644 --- a/aai_cli/commands/onboard.py +++ b/aai_cli/commands/onboard.py @@ -4,7 +4,7 @@ import typer -from aai_cli import help_panels, output +from aai_cli import help_panels, options, output from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.help_text import examples_epilog from aai_cli.onboard import wizard @@ -34,7 +34,7 @@ def build_prompter(*, non_interactive: bool = False) -> Prompter: ) def onboard( ctx: typer.Context, - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), non_interactive: bool = typer.Option( False, "--non-interactive", diff --git a/aai_cli/commands/sessions.py b/aai_cli/commands/sessions.py index 0771a36e..9c054a44 100644 --- a/aai_cli/commands/sessions.py +++ b/aai_cli/commands/sessions.py @@ -4,7 +4,7 @@ from rich.markup import escape from rich.table import Table -from aai_cli import jsonshape, output, theme +from aai_cli import jsonshape, options, output, theme from aai_cli.auth import ams from aai_cli.context import AppState, resolve_session, run_command from aai_cli.help_text import examples_epilog @@ -53,7 +53,7 @@ def list_( status: str | None = typer.Option( None, "--status", help="Filter: created, completed, or error." ), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """List recent streaming sessions.""" @@ -100,7 +100,7 @@ def render(data: list[dict[str, object]]) -> Table: def get( ctx: typer.Context, session_id: str = typer.Argument(..., help="Streaming session id."), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Show details for one streaming session.""" diff --git a/aai_cli/commands/setup.py b/aai_cli/commands/setup.py index 9feeaf50..8f699814 100644 --- a/aai_cli/commands/setup.py +++ b/aai_cli/commands/setup.py @@ -8,7 +8,7 @@ import typer -from aai_cli import choices, output +from aai_cli import choices, options, output from aai_cli.context import AppState, run_command from aai_cli.help_text import examples_epilog from aai_cli.steps import Step, render_steps @@ -305,7 +305,7 @@ def install( help="Config scope to register the MCP under. Presence is detected across all scopes.", ), force: bool = typer.Option(False, "--force", help="Reinstall even if already present."), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Set up your coding agent for AssemblyAI by installing three things: @@ -333,7 +333,7 @@ def body(_state: AppState, json_mode: bool) -> None: ) def status( ctx: typer.Context, - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Show whether the AssemblyAI MCP server and skills are set up in your coding agent.""" @@ -362,7 +362,7 @@ def remove( "Default: remove from whichever scope it exists in." ), ), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Remove the AssemblyAI MCP server and skills from your coding agent.""" diff --git a/aai_cli/commands/share.py b/aai_cli/commands/share.py index c5f056e3..6e506bdc 100644 --- a/aai_cli/commands/share.py +++ b/aai_cli/commands/share.py @@ -10,7 +10,7 @@ import typer from rich.markup import escape -from aai_cli import help_panels, output, steps +from aai_cli import config, help_panels, options, output, steps from aai_cli.context import AppState, run_command from aai_cli.errors import CLIError from aai_cli.help_text import examples_epilog @@ -72,7 +72,12 @@ def run_share(*, port: int, no_install: bool, json_mode: bool) -> None: fd, name = tempfile.mkstemp(prefix="aai-tunnel-", suffix=".log") os.close(fd) log_path = Path(name) - proxy = runner.spawn(tunnel.tunnel_command(chosen_port), cwd=target, log_path=log_path) + # The tunnel binary only proxies the port; don't hand it the API key the + # dev server needs (keeps the secret out of cloudflared's logs/diagnostics). + tunnel_env = {k: v for k, v in os.environ.items() if k != config.ENV_API_KEY} + proxy = runner.spawn( + tunnel.tunnel_command(chosen_port), cwd=target, env=tunnel_env, log_path=log_path + ) public = tunnel.await_url(log_path) if public is None: raise CLIError( @@ -110,7 +115,7 @@ def share( no_install: bool = typer.Option( False, "--no-install", help="Skip dependency install; launch directly." ), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Boot the app and expose it on a public URL via a cloudflared tunnel. diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 12c9e68b..7db2757d 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -14,6 +14,7 @@ config_builder, help_panels, llm, + options, output, youtube, ) @@ -316,7 +317,7 @@ def stream( exists=True, dir_okay=False, ), - json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), + json_out: bool = options.json_option("Emit newline-delimited JSON events."), output_field: choices.TextOrJson | None = typer.Option( None, "-o", diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index aa8b271c..ae48c946 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -14,6 +14,7 @@ config_builder, help_panels, llm, + options, output, transcribe_exec, transcribe_render, @@ -300,11 +301,9 @@ def transcribe( help="Max tokens.", rich_help_panel=help_panels.OPT_LLM, ), - json_out: bool = typer.Option( - False, - "--json", - help="Output the full result as JSON. Text stays the default even when piped; " - "opt in here (same as -o json).", + json_out: bool = options.json_option( + "Output the full result as JSON. Text stays the default even when piped; " + "opt in here (same as -o json)." ), output_field: choices.TranscriptOutput | None = typer.Option( None, diff --git a/aai_cli/commands/transcripts.py b/aai_cli/commands/transcripts.py index 47f293de..a753652f 100644 --- a/aai_cli/commands/transcripts.py +++ b/aai_cli/commands/transcripts.py @@ -4,7 +4,7 @@ from rich.markup import escape from rich.table import Table -from aai_cli import choices, client, config, output, theme +from aai_cli import choices, client, config, options, output, theme from aai_cli.context import AppState, run_command from aai_cli.errors import APIError from aai_cli.help_text import examples_epilog @@ -31,7 +31,7 @@ def get( "--output", help="Print one field of the result.", ), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """Fetch a past transcript by id and print its text.""" @@ -74,7 +74,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."), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = options.json_option(), ) -> None: """List recent transcripts.""" diff --git a/aai_cli/config.py b/aai_cli/config.py index 68544dd9..ab76519f 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -73,10 +73,23 @@ def _config_file() -> Path: return config_dir() / "config.toml" +# Parsed-config cache: path -> (mtime_ns, size, parsed). The several _load() +# calls in one CLI invocation (profile, env, key resolution) then don't each +# re-read and re-parse the same unchanged TOML; _dump() bumps the mtime, which +# invalidates it naturally. Callers mutate the returned Config (and persist_login +# snapshots one for rollback), so hand out deep copies, never the cached object. +_load_cache: dict[Path, tuple[int, int, Config]] = {} + + def _load() -> Config: path = _config_file() - if not path.exists(): + try: + stat = path.stat() + except OSError: return Config() + cached = _load_cache.get(path) + if cached is not None and cached[0] == stat.st_mtime_ns and cached[1] == stat.st_size: + return cached[2].model_copy(deep=True) with path.open("rb") as fh: try: data = tomllib.load(fh) @@ -89,7 +102,7 @@ def _load() -> Config: exit_code=2, ) from exc try: - return Config.model_validate(data) + cfg = Config.model_validate(data) except ValidationError as exc: from aai_cli.errors import CLIError @@ -98,6 +111,8 @@ def _load() -> Config: error_type="invalid_config", exit_code=2, ) from exc + _load_cache[path] = (stat.st_mtime_ns, stat.st_size, cfg) + return cfg.model_copy(deep=True) def _dump(cfg: Config) -> None: @@ -113,6 +128,10 @@ def _dump(cfg: Config) -> None: with os.fdopen(fd, "wb") as fh: tomli_w.dump(cfg.model_dump(exclude_none=True), fh) tmp.replace(path) + # The mtime/size key usually invalidates on its own, but drop the entry + # explicitly so a same-size rewrite on a coarse-mtime filesystem can't + # serve the pre-write parse. + _load_cache.pop(path, None) except BaseException: with contextlib.suppress(OSError): tmp.unlink() diff --git a/aai_cli/llm.py b/aai_cli/llm.py index 42efc880..5f0808ac 100644 --- a/aai_cli/llm.py +++ b/aai_cli/llm.py @@ -10,9 +10,7 @@ from openai.types.chat import ChatCompletion # The LLM Gateway is OpenAI-compatible, so we talk to it through the OpenAI SDK -# pointed at this base URL. This is the production host used in generated code -# snippets (code_gen); runtime calls use the active environment's gateway base. -GATEWAY_BASE_URL = "https://llm-gateway.assemblyai.com/v1" +# pointed at the active environment's gateway base (see _client / code_gen). DEFAULT_MODEL = "claude-haiku-4-5-20251001" DEFAULT_MAX_TOKENS = 1000 diff --git a/aai_cli/options.py b/aai_cli/options.py new file mode 100644 index 00000000..a9472684 --- /dev/null +++ b/aai_cli/options.py @@ -0,0 +1,15 @@ +"""Shared Typer option factories for flags every command repeats. + +Centralizing them keeps the flag name, default, and help text uniform across +the ~26 command signatures instead of copy-pasting (and drifting) per command. +""" + +from __future__ import annotations + +import typer + + +def json_option(help_text: str = "Output raw JSON.") -> bool: + """The standard ``--json`` flag; pass ``help_text`` where the output shape differs.""" + flag: bool = typer.Option(False, "--json", help=help_text) + return flag diff --git a/aai_cli/streaming/session.py b/aai_cli/streaming/session.py index 0ad62c94..bc6b2c4e 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 +from aai_cli import 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 @@ -109,6 +109,11 @@ class StreamSession: # -inf so the very first finalized turn always produces an immediate summary. _summarized_len: int = 0 _last_summary_at: float = float("-inf") + # First CLIError raised by an --llm refresh. The chain runs on the SDK reader + # thread, where raising would dump a thread traceback instead of reaching + # run_command, so the error is recorded (and warned once) there and re-raised + # from the final main-thread flush to fail the command cleanly. + _llm_error: CLIError | None = None @property def on_open(self) -> Callable[[], None]: @@ -157,6 +162,12 @@ def _maybe_summarize(self, *, final: bool = False) -> None: follow = self.follow if follow is None: return + if self._llm_error is not None: + # The chain already failed; don't keep hammering a failing gateway. The + # final flush runs on the main thread, so re-raise there to fail cleanly. + if final: + raise self._llm_error + return with self._callback_lock: turns = len(self.transcript) if turns <= self._summarized_len: @@ -168,13 +179,24 @@ def _maybe_summarize(self, *, final: bool = False) -> None: transcript_text = " ".join(self.transcript) self._summarized_len = turns self._last_summary_at = now - answer = llm.run_chain( - self.api_key, - self.llm_prompts, - transcript_text=transcript_text, - model=self.model, - max_tokens=self.max_tokens, - ) + try: + answer = llm.run_chain( + self.api_key, + self.llm_prompts, + transcript_text=transcript_text, + model=self.model, + max_tokens=self.max_tokens, + ) + except CLIError as exc: + self._llm_error = exc + if final: + raise + # Reader thread: raising would print a thread traceback, so warn on + # stderr now and let the final flush surface the error + exit code. + output.error_console.print( + f"[aai.muted]--llm refresh failed: {exc.message}[/aai.muted]" + ) + return follow(answer, turns) def stream_one( diff --git a/aai_cli/transcribe_render.py b/aai_cli/transcribe_render.py index 380f46c2..fe13286d 100644 --- a/aai_cli/transcribe_render.py +++ b/aai_cli/transcribe_render.py @@ -10,8 +10,12 @@ def _fmt_ms(ms: int) -> str: + """``MM:SS`` for sub-hour timestamps, ``H:MM:SS`` past that (so 1h isn't '60:00').""" total = int(ms) // 1000 - return f"{total // 60:02d}:{total % 60:02d}" + hours, rem = divmod(total, 3600) + if hours: + return f"{hours}:{rem // 60:02d}:{rem % 60:02d}" + return f"{rem // 60:02d}:{rem % 60:02d}" def _enum_value(obj: object) -> str: @@ -24,10 +28,6 @@ def _nested(transcript: object, outer: str, inner: str) -> object | None: return getattr(mid, inner, None) if mid else None -def _objects(value: object) -> list[object]: - return jsonshape.object_list(value) - - def _mapping(value: object) -> dict[str, object]: return jsonshape.as_mapping(value) or {} @@ -46,7 +46,7 @@ def render_transcript_result(transcript: object, console: Console) -> None: def _render_text(transcript: object, console: Console) -> None: """Print per-speaker utterances when present, else the flat transcript text.""" - utterances = _objects(getattr(transcript, "utterances", None)) + utterances = jsonshape.object_list(getattr(transcript, "utterances", None)) if utterances: line = Text() for i, u in enumerate(utterances): @@ -69,7 +69,7 @@ def _render_summary(transcript: object, console: Console) -> None: def _render_chapters(transcript: object, console: Console) -> None: - chapters = _objects(getattr(transcript, "chapters", None)) + chapters = jsonshape.object_list(getattr(transcript, "chapters", None)) if not chapters: return console.print("\n[bold]Chapters:[/bold]") @@ -79,7 +79,7 @@ def _render_chapters(transcript: object, console: Console) -> None: def _render_highlights(transcript: object, console: Console) -> None: - results = _objects(_nested(transcript, "auto_highlights", "results")) + results = jsonshape.object_list(_nested(transcript, "auto_highlights", "results")) if not results: return console.print("\n[bold]Highlights:[/bold]") @@ -88,17 +88,34 @@ def _render_highlights(transcript: object, console: Console) -> None: def _render_sentiment(transcript: object, console: Console) -> None: - results = _objects(getattr(transcript, "sentiment_analysis", None)) + results = jsonshape.object_list(getattr(transcript, "sentiment_analysis", None)) if not results: return counts = Counter(_enum_value(getattr(r, "sentiment", "")).lower() for r in results) - total = sum(counts.values()) or 1 - parts = [f"{pct * 100 // total}% {label}" for label, pct in counts.items()] + parts = [f"{pct}% {label}" for label, pct in _percentages(counts).items()] console.print("\n[bold]Sentiment:[/bold] " + ", ".join(parts)) +def _percentages(counts: Counter[str]) -> dict[str, int]: + """Integer percentages that sum to exactly 100, in ``counts`` order. + + Plain flooring loses the rounding remainder (three equal counts -> 33+33+33 = 99), + so the leftover points go to the labels with the largest fractional parts. + """ + total = sum(counts.values()) + if not total: + return {} + shares = {label: count * 100 / total for label, count in counts.items()} + pcts = {label: int(share) for label, share in shares.items()} + leftover = 100 - sum(pcts.values()) + by_fraction = sorted(shares, key=lambda label: shares[label] - pcts[label], reverse=True) + for label in by_fraction[:leftover]: + pcts[label] += 1 + return pcts + + def _render_entities(transcript: object, console: Console) -> None: - entities = _objects(getattr(transcript, "entities", None)) + entities = jsonshape.object_list(getattr(transcript, "entities", None)) if not entities: return console.print("\n[bold]Entities:[/bold]") diff --git a/pyproject.toml b/pyproject.toml index 2da7eef3..e7047623 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -227,6 +227,9 @@ max-statements = 40 "aai_cli/commands/**" = ["FBT001", "FBT003", "PLR0913"] # The root callback is also a Typer command signature. "aai_cli/main.py" = ["FBT001", "FBT003"] +# Shared Typer option factories take the same boolean positional default the +# command signatures do. +"aai_cli/options.py" = ["FBT003"] # Raw stdout/stderr writes are centralized here; command modules call output helpers. "aai_cli/output.py" = ["T201"] # The active environment is process-global startup state by design. diff --git a/tests/test_agent_audio.py b/tests/test_agent_audio.py index 916c4ca5..8f70adcf 100644 --- a/tests/test_agent_audio.py +++ b/tests/test_agent_audio.py @@ -12,6 +12,7 @@ def __init__(self): self.writes = [] self.stopped = False self.closed = False + self.active = True def write(self, data): self.writes.append(data) @@ -199,3 +200,60 @@ def boom(**kw): _default_duplex_stream(rate=24000, blocksize=2400, callback=lambda *a: None, device=None) assert exc.value.error_type == "audio_output_error" assert exc.value.exit_code == 1 + + +def test_capture_frames_raises_when_stream_dies_without_close(): + # A device that vanishes mid-session stops the PortAudio callback without a + # close(): no sentinel ever arrives, so the consumer must time out and surface + # a clean error instead of blocking the capture thread forever. + fake = FakeStream() + fake.active = False + d = DuplexAudio(device_rate=24000, stream_factory=lambda **kwargs: fake, poll_timeout=0.01) + d.player.start() + with pytest.raises(CLIError) as excinfo: + next(d.capture_frames()) + assert excinfo.value.error_type == "audio_input_error" + assert excinfo.value.exit_code == 1 + + +def test_poll_timeout_defaults_to_one_second(): + # The default must stay long enough to never fire during normal silence but + # short enough that a dead device surfaces quickly. + d = DuplexAudio(device_rate=24000, stream_factory=lambda **kwargs: FakeStream()) + assert d._poll_timeout == 1.0 + + +def test_capture_frames_tolerates_streams_without_active_flag(): + # Fake/file-driven streams may not expose `active`; an empty queue then means + # "keep waiting", not "device died" — only the sentinel ends capture. + import threading + + class MinimalStream: + pass + + d = DuplexAudio( + device_rate=24000, stream_factory=lambda **kwargs: MinimalStream(), poll_timeout=0.01 + ) + d.player.start() + threading.Timer(0.05, d._in.put, args=(None,)).start() + assert list(d.capture_frames()) == [] + + +def test_capture_frames_keeps_waiting_while_stream_is_alive(): + # An empty queue with a healthy stream is just silence: the poll loop keeps + # waiting (no error) and only raises once the stream actually reports dead. + class FlippingStream: + def __init__(self): + self.polls = 0 + + @property + def active(self): + self.polls += 1 + return self.polls == 1 # alive on the first poll, dead afterwards + + fake = FlippingStream() + d = DuplexAudio(device_rate=24000, stream_factory=lambda **kwargs: fake, poll_timeout=0.01) + d.player.start() + with pytest.raises(CLIError): + next(d.capture_frames()) + assert fake.polls >= 2 # first timeout passed the alive check and kept waiting diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index 79627f26..270e8065 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -416,3 +416,29 @@ def test_stream_show_code_without_llm_is_plain_scaffold(): ast.parse(code) assert "from openai import OpenAI" not in code # no gateway when --llm absent assert "MicrophoneStream" in code + + +def test_generated_code_targets_active_environment(): + # --show-code embeds hosts from the active environment, so a sandbox user's + # generated script talks to the sandbox that minted their key, not production. + from aai_cli import environments + + sandbox = environments.get("sandbox000") + environments.set_active(sandbox) + + assert sandbox.streaming_host in code_gen.stream({}) + llm_code = code_gen.stream({}, llm={"prompts": ["p"], "model": "m", "max_tokens": 5}) + assert sandbox.streaming_host in llm_code + assert sandbox.llm_gateway_base in llm_code + assert sandbox.agents_host in code_gen.agent("aura", "be brief", "hi") + transcribe_code = code_gen.transcribe( + {}, source="a.mp3", llm_gateway={"prompts": ["p"], "model": "m", "max_tokens": 5} + ) + assert sandbox.llm_gateway_base in transcribe_code + assert f"aai.settings.base_url = {sandbox.api_base!r}" in transcribe_code + + +def test_generated_transcribe_omits_base_url_on_production(): + # The SDK already defaults to the production api base, so the default + # environment's generated script stays free of redundant settings lines. + assert "base_url" not in code_gen.transcribe({}, source="a.mp3") diff --git a/tests/test_config.py b/tests/test_config.py index 489d89bc..2cf3ca01 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -233,3 +233,41 @@ def boom(_data, _fh): names = sorted(p.name for p in tmp_config.iterdir()) assert names == ["config.toml"] # no .config-*.toml.tmp left behind assert config.get_api_key("default") == "sk_abc" # original untouched + + +def test_load_caches_parsed_config_between_calls(monkeypatch): + # One CLI invocation reads the config several times (profile, env, key); the + # parse must happen once, with later reads served from the mtime-keyed cache. + config.set_profile_env("default", "production") + parses = {"n": 0} + real_load = config.tomllib.load + + def counting_load(fh): + parses["n"] += 1 + return real_load(fh) + + monkeypatch.setattr(config.tomllib, "load", counting_load) + assert config.get_active_profile() == "default" + assert config.get_profile_env("default") == "production" + assert parses["n"] == 1 + + +def test_write_invalidates_load_cache(monkeypatch): + # A _dump must never leave a stale parse behind: the next read reflects it. + config.set_profile_env("default", "production") + assert config.get_profile_env("default") == "production" # primes the cache + config.set_profile_env("default", "sandbox000") + assert config.get_profile_env("default") == "sandbox000" + + +def test_load_returns_independent_copies(): + # Callers mutate the returned Config (persist_login snapshots one for rollback), + # so the cache must hand out copies — a caller's mutation can't poison it. + config.set_api_key("default", "sk_abc") + first = config._load() # cache miss: parses and primes the cache + first.active_profile = "mutated" + first.profiles.clear() + assert config._load().active_profile == "default" + hit = config._load() # cache hit: must also be a *deep* copy + hit.profiles.clear() + assert "default" in config._load().profiles diff --git a/tests/test_llm.py b/tests/test_llm.py index a16351c9..377ca98e 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -6,10 +6,11 @@ import pytest from openai.types.chat import ChatCompletion -from aai_cli import llm +from aai_cli import environments, llm from aai_cli.errors import APIError -_REQUEST = httpx.Request("POST", f"{llm.GATEWAY_BASE_URL}/chat/completions") +_GATEWAY_BASE = environments.get(environments.DEFAULT_ENV).llm_gateway_base +_REQUEST = httpx.Request("POST", f"{_GATEWAY_BASE}/chat/completions") def _response(content: "str | None" = "hi there", usage=None) -> ChatCompletion: diff --git a/tests/test_share.py b/tests/test_share.py index a41ed95c..bcd776b5 100644 --- a/tests/test_share.py +++ b/tests/test_share.py @@ -141,3 +141,26 @@ def test_share_json_emits_url(tmp_path, monkeypatch): assert result.exit_code == 0, result.output assert '"url"' in result.output assert "happy-slug.trycloudflare.com" in result.output + + +def test_share_tunnel_env_excludes_api_key(tmp_path, monkeypatch): + # The tunnel binary only proxies a port; the API key the dev server inherits + # must not reach cloudflared's environment (logs/diagnostics could leak it). + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk-secret") + server, proxy = _stub(monkeypatch) + seq = iter([server, proxy]) + spawn_envs = [] + + def spawn(command, **kwargs): + spawn_envs.append(kwargs.get("env")) + return next(seq) + + monkeypatch.setattr("aai_cli.init.runner.spawn", spawn) + result = runner.invoke(app, ["share"]) + assert result.exit_code == 0, result.output + dev_env, tunnel_env = spawn_envs + assert dev_env["ASSEMBLYAI_API_KEY"] == "sk-secret" # the user's app needs it + assert "ASSEMBLYAI_API_KEY" not in tunnel_env + assert tunnel_env["PATH"] == dev_env["PATH"] # everything else passes through diff --git a/tests/test_stream_llm.py b/tests/test_stream_llm.py index a466792b..5f160752 100644 --- a/tests/test_stream_llm.py +++ b/tests/test_stream_llm.py @@ -233,3 +233,54 @@ def test_maybe_summarize_is_noop_without_follow(): assert session.llm_interval == 0.0 # the default cadence is per-turn until set session.transcript.append("x") session._maybe_summarize(final=True) # must not raise or call the gateway + + +def test_stream_llm_chain_error_is_latched_on_reader_thread(monkeypatch, capsys): + # A gateway failure mid-stream surfaces on the SDK reader thread, where raising + # would dump a thread traceback. The session must swallow it there (one stderr + # warning), stop hammering the failing gateway, and keep the panel alive. + import pytest + + from aai_cli.errors import APIError + + emitted: list[dict] = [] + session = _llm_session( + interval=0.0, clock=lambda: 0.0, monkeypatch=monkeypatch, emitted=emitted + ) + calls = {"n": 0} + + def boom(*args, **kwargs): + calls["n"] += 1 + raise APIError("gateway down") + + monkeypatch.setattr("aai_cli.streaming.session.llm.run_chain", boom) + session.on_turn(_eot_turn("a")) # reader-thread path: must not raise + session.on_turn(_eot_turn("b")) # error latched -> the chain is not re-run + assert calls["n"] == 1 + assert emitted == [] + assert "gateway down" in capsys.readouterr().err + # The closing flush runs on the main thread, where the latched error can + # propagate to run_command for clean rendering + a non-zero exit. + with pytest.raises(APIError): + session._maybe_summarize(final=True) + + +def test_stream_llm_chain_error_on_final_flush_raises(monkeypatch): + # When the failure first happens on the closing flush itself (main thread), + # it propagates immediately instead of being deferred. + import pytest + + from aai_cli.errors import APIError + + emitted: list[dict] = [] + session = _llm_session( + interval=30.0, clock=lambda: 0.0, monkeypatch=monkeypatch, emitted=emitted + ) + + def boom(*args, **kwargs): + raise APIError("gateway down") + + session.transcript.append("tail") + monkeypatch.setattr("aai_cli.streaming.session.llm.run_chain", boom) + with pytest.raises(APIError): + session._maybe_summarize(final=True) diff --git a/tests/test_transcribe_render.py b/tests/test_transcribe_render.py index d40ae194..98eb7baa 100644 --- a/tests/test_transcribe_render.py +++ b/tests/test_transcribe_render.py @@ -95,9 +95,9 @@ def test_renders_sentiment_aggregate(): ) out = _render(transcript).lower() assert "sentiment:" in out - # Exact aggregated percentages pin the `* 100 // total` math and the `or 1` - # guard: 2 of 3 positive -> 66%, 1 of 3 negative -> 33%. - assert "66% positive" in out + # Largest-remainder rounding makes the percentages sum to exactly 100: + # 2 of 3 positive -> 67% (66.67 takes the leftover point), 1 of 3 -> 33%. + assert "67% positive" in out assert "33% negative" in out @@ -122,3 +122,33 @@ def test_renders_entities_topics_content_safety_highlights(): # ...and both lists are sorted most-relevant-first (pins reverse=True). assert out.index("Technology") < out.index("Health") assert out.index("profanity") < out.index("alcohol") + + +def test_fmt_ms_rolls_over_to_hours(): + # Sub-hour stays MM:SS; at an hour the format grows an hours field so a + # 1h chapter reads 1:00:00 instead of the ambiguous 60:00. + from aai_cli.transcribe_render import _fmt_ms + + assert _fmt_ms(0) == "00:00" + assert _fmt_ms(59_000) == "00:59" + assert _fmt_ms(3_599_000) == "59:59" + assert _fmt_ms(3_600_000) == "1:00:00" + assert _fmt_ms(4_530_000) == "1:15:30" + assert _fmt_ms(3_720_000) == "1:02:00" # minutes division boundary past the hour + + +def test_sentiment_percentages_always_sum_to_100(): + # Largest-remainder rounding: three equal counts split 34/33/33, not 33x3=99. + from collections import Counter + + from aai_cli.transcribe_render import _percentages + + pcts = _percentages(Counter(positive=1, neutral=1, negative=1)) + assert sum(pcts.values()) == 100 + assert sorted(pcts.values()) == [33, 33, 34] + assert _percentages(Counter()) == {} + assert _percentages(Counter(positive=2, negative=1)) == {"positive": 67, "negative": 33} + assert _percentages(Counter(positive=4)) == {"positive": 100} + # The leftover point goes to the largest *fraction* (positive, .714), which here + # is not the largest share -- pins the remainder ordering. + assert _percentages(Counter(positive=5, negative=9)) == {"positive": 36, "negative": 64}