diff --git a/aai_cli/agent/session.py b/aai_cli/agent/session.py index 6c6e629b..bfeeaffd 100644 --- a/aai_cli/agent/session.py +++ b/aai_cli/agent/session.py @@ -132,8 +132,13 @@ def on_reply_started(self, _event: dict[str, Any]) -> None: def on_reply_audio(self, event: dict[str, Any]) -> None: data = event.get("data") - if data: - self.player.enqueue(base64.b64decode(data)) + if not data: + return + try: + pcm = base64.b64decode(data) + except (ValueError, TypeError): + return # a single corrupt frame is dropped, not fatal to the session + self.player.enqueue(pcm) def on_agent_transcript(self, event: dict[str, Any]) -> None: self.renderer.agent_transcript( diff --git a/aai_cli/agent_cascade/text.py b/aai_cli/agent_cascade/text.py index 66b38ea7..cfd2226f 100644 --- a/aai_cli/agent_cascade/text.py +++ b/aai_cli/agent_cascade/text.py @@ -14,15 +14,19 @@ def split_sentences(text: str) -> list[str]: """Split ``text`` into sentences, each ending in ``.``/``!``/``?``. - A trailing fragment with no terminal punctuation is kept as a final sentence, - so no text is ever dropped; empty/whitespace-only pieces are discarded. + A terminator ends a sentence only when it is the last character or is followed by + whitespace — so a ``.`` inside a number ("$3.50") or stacked terminators ("..."/"?!") + don't fragment one spoken sentence into several TTS calls (which both clips audio + mid-number and writes a space-mangled copy back into the LLM history). A trailing + fragment with no terminal punctuation is kept, so no text is ever dropped; + empty/whitespace-only pieces are discarded. """ sentences: list[str] = [] start = 0 for index, char in enumerate(text): - if char in _TERMINATORS: - # The slice always includes the terminator at ``index``, so it is never - # blank after stripping the inter-sentence whitespace. + if char in _TERMINATORS and (index + 1 == len(text) or text[index + 1].isspace()): + # Boundary confirmed (end-of-text or a following space); the slice includes + # the terminator, so it is never blank after stripping leading whitespace. sentences.append(text[start : index + 1].strip()) start = index + 1 tail = text[start:].strip() diff --git a/aai_cli/app/init_exec.py b/aai_cli/app/init_exec.py index 5397466d..45ca204a 100644 --- a/aai_cli/app/init_exec.py +++ b/aai_cli/app/init_exec.py @@ -103,8 +103,9 @@ def _active_env_vars() -> dict[str, str]: "ASSEMBLYAI_BASE_URL": env.api_base, "ASSEMBLYAI_LLM_GATEWAY_URL": env.llm_gateway_base, "ASSEMBLYAI_STREAMING_HOST": env.streaming_host, - # Voice Agent host mirrors the streaming host's naming across environments. - "ASSEMBLYAI_AGENTS_HOST": env.streaming_host.replace("streaming", "agents", 1), + # The environment's authoritative Voice Agent host (not derived from the + # streaming host, which only coincides by naming convention today). + "ASSEMBLYAI_AGENTS_HOST": env.agents_host, # Streaming-TTS host for the cascade (agent-cascade) template. Empty in # production, where streaming TTS has no host; that template then refuses to # run and points at --sandbox. diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index 689e1c80..35a174c5 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -167,7 +167,7 @@ def _no_project_error() -> APIError: def find_or_create_cli_key(account_id: int, session_jwt: str) -> str: - """Return the existing 'AssemblyAI CLI' key, or create one in the first project.""" + """Return the existing 'AssemblyAI CLI' key, or create one in the first usable project.""" projects = _parse(_PROJECT_LIST, ams.list_projects(account_id, session_jwt)) if not projects: raise _no_project_error() @@ -175,7 +175,9 @@ def find_or_create_cli_key(account_id: int, session_jwt: str) -> str: for token in entry.tokens: if key := _reusable_cli_key(token): return key - project = projects[0].project + # Mint into the first entry that actually carries a project — an account whose + # first membership has no project can still have a usable one later in the list. + project = next((entry.project for entry in projects if entry.project is not None), None) if project is None: raise _no_project_error() created = ams.create_token(account_id, project.id, endpoints.CLI_TOKEN_NAME, session_jwt) diff --git a/aai_cli/code_gen/stream.py b/aai_cli/code_gen/stream.py index e42593a6..7e852cef 100644 --- a/aai_cli/code_gen/stream.py +++ b/aai_cli/code_gen/stream.py @@ -74,7 +74,7 @@ def run_chain(text: str) -> str: messages=[{{"role": "user", "content": prompt + "\\n\\nTranscript:\\n" + source}}], max_tokens={max_tokens}, ) - result = response.choices[0].message.content + result = response.choices[0].message.content or "" return result diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index 5f3941b6..429f3fd5 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -139,11 +139,19 @@ def body(state: AppState, json_mode: bool) -> None: account_id, jwt = state.resolve_session() pid = project_id if project_id is not None else _default_project_id(account_id, jwt) created = ams.create_token(account_id, pid, name, jwt) + # Validate before rendering: a 200 whose body omits api_key (proxy/version + # drift) must surface a clean APIError, not a KeyError traceback. + api_key = created.get("api_key") + if not isinstance(api_key, str) or not api_key: + raise APIError( + "AMS created the key but returned no api_key.", + suggestion="Run 'assembly keys list' to confirm it exists, then try again.", + ) output.emit( created, - lambda d: ( + lambda _d: ( output.success(f"Created API key '{escape(name)}'.") - + f"\n {escape(str(d['api_key']))}\n" + + f"\n {escape(api_key)}\n" + output.warn("Shown once — copy it now.") ), json_mode=json_mode, diff --git a/aai_cli/init/devserver.py b/aai_cli/init/devserver.py index 23829281..79d64a1b 100644 --- a/aai_cli/init/devserver.py +++ b/aai_cli/init/devserver.py @@ -56,14 +56,14 @@ def dev_command(target: Path, web: list[str], *, use_uv: bool, host: str = LOCAL """The Procfile web process, run in the project venv with live reload. The Procfile's `web:` line starts with `python -m uvicorn …`. With uv, run it - under `uv run`; without uv, swap a leading `python` for the project's venv - interpreter so it runs inside the scaffolded `.venv`. In both cases the + under `uv run`; without uv, swap a leading `python`/`python3` for the project's + venv interpreter so it runs inside the scaffolded `.venv`. In both cases the Procfile's `--host 0.0.0.0` is overridden to `host` (loopback by default) so a local dev run never exposes the server — and the key in `.env` — to the LAN. """ argv = _override_host(web, host) if use_uv: return ["uv", "run", *argv, "--reload"] - if argv and argv[0] == "python": + if argv and argv[0] in ("python", "python3"): argv[0] = str(runner.venv_python(target)) return [*argv, "--reload"] diff --git a/aai_cli/main.py b/aai_cli/main.py index 991e87aa..1a03262b 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -110,11 +110,18 @@ def _sandbox_conflict_warning(sandbox: bool, env: str | None) -> str | None: def _offer_or_help(ctx: typer.Context, state: AppState) -> None: """No subcommand given: offer guided setup to a credential-less, interactive user; - otherwise print help. Never prompts in a non-interactive session, and never on - `--help` (Click handles that eagerly before the callback).""" + otherwise print help. Never prompts in a non-interactive session, never on + `--help` (Click handles that eagerly before the callback), and never when the + stored config is unparseable — a deferred ``invalid_config`` error means + ``resolve_api_key``/``resolve_profile`` would re-raise (escaping the callback as + a traceback), and the wizard would only write atop a broken file.""" if not state.quiet: output.print_banner() - if stdio.interactive_stdio() and not _profile_has_key(state): + if ( + state.deferred_config_error is None + and stdio.interactive_stdio() + and not _profile_has_key(state) + ): if not state.quiet: output.console.print() # blank line so the prompt isn't flush against the banner if typer.confirm("Welcome to AssemblyAI. Run guided setup now?", default=True): diff --git a/aai_cli/ui/update_check.py b/aai_cli/ui/update_check.py index 3b88c13b..889a7202 100644 --- a/aai_cli/ui/update_check.py +++ b/aai_cli/ui/update_check.py @@ -44,7 +44,9 @@ def is_newer(latest: str, current: str) -> bool: def _is_homebrew_executable(executable: str) -> bool: - if executable.startswith("/usr/local/"): + # /usr/local/ is Homebrew only on Intel macOS; on Linux it's the conventional + # prefix for source/manually-built interpreters, so don't claim brew there. + if sys.platform == "darwin" and executable.startswith("/usr/local/"): return True return any(marker in executable for marker in _HOMEBREW_PATH_MARKERS) @@ -84,7 +86,7 @@ def fetch_and_cache() -> None: resp.raise_for_status() tag = resp.json().get("tag_name") if isinstance(tag, str) and tag: - latest = tag.lstrip("v") + latest = tag.removeprefix("v") except (httpx.HTTPError, ValueError, KeyError, OSError): latest = None try: diff --git a/tests/test_agent_cascade_text.py b/tests/test_agent_cascade_text.py index dd48028a..7817853c 100644 --- a/tests/test_agent_cascade_text.py +++ b/tests/test_agent_cascade_text.py @@ -21,10 +21,24 @@ def test_split_sentences_empty_string_is_empty_list(): assert split_sentences("") == [] -def test_split_sentences_each_terminator_ends_a_sentence(): - # Every terminator closes the current chunk, so consecutive ones each yield one. - assert split_sentences("...") == [".", ".", "."] +def test_split_sentences_terminator_followed_by_space_ends_a_sentence(): + # A terminator only closes the chunk when it ends the text or is followed by space. assert split_sentences(" . ") == ["."] + assert split_sentences("Hi . Bye .") == ["Hi .", "Bye ."] + + +def test_split_sentences_keeps_decimals_and_abbreviations_intact(): + # A '.' wedged between non-space characters is not a sentence boundary, so a + # number ("$3.50") or abbreviation stays one piece instead of fragmenting TTS. + assert split_sentences("It costs $3.50 today.") == ["It costs $3.50 today."] + assert split_sentences("Total 12.5") == ["Total 12.5"] + + +def test_split_sentences_does_not_split_stacked_terminators(): + # Ellipsis and "?!" are followed by non-space chars (or each other), so they + # don't each spawn a separate sentence. + assert split_sentences("...") == ["..."] + assert split_sentences("Wait...what?!") == ["Wait...what?!"] def test_trim_history_drops_oldest_beyond_limit(): diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py index 802345b6..7d8919f0 100644 --- a/tests/test_agent_session.py +++ b/tests/test_agent_session.py @@ -154,6 +154,14 @@ def test_reply_audio_without_data_is_ignored(): assert s.player.enqueued == [] +def test_reply_audio_with_corrupt_base64_is_dropped_not_fatal(): + # A single malformed audio frame must be skipped, not raise out of the receive + # loop and tear down the whole live conversation. + s = _session() + s.dispatch({"type": "reply.audio", "data": "a"}) # invalid base64 (bad padding) + assert s.player.enqueued == [] + + def test_should_send_audio_only_when_ready_and_unmuted(): s = _session() assert s.should_send_audio() is False # not ready yet diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index e2b2319a..1d80ec6a 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -21,74 +21,6 @@ def _fake_start_capture(monkeypatch, result): monkeypatch.setattr(flow, "_start_capture", lambda: _FakeCapture(result)) -def test_find_or_create_reuses_existing_cli_key(monkeypatch): - projects = [ - { - "project": {"id": 7}, - "tokens": [ - {"name": "Default Token", "api_key": "sk_default", "is_disabled": False}, - {"name": "AssemblyAI CLI", "api_key": "sk_cli", "is_disabled": False}, - ], - } - ] - monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) - monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create")) - assert flow.find_or_create_cli_key(1, "jwt") == "sk_cli" - - -def test_find_or_create_rejects_malformed_project_list(monkeypatch): - # A 200 with an unexpected shape (here: a project id that isn't an int) becomes a - # clean "run login again" APIError rather than a traceback. - monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: [{"project": {"id": "x"}}]) - with pytest.raises(APIError): - flow.find_or_create_cli_key(1, "jwt") - - -def test_find_or_create_creates_when_absent(monkeypatch): - projects = [{"project": {"id": 7}, "tokens": []}] - monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) - - created = {} - - def fake_create(account_id, project_id, token_name, session_jwt): - created.update(project_id=project_id, token_name=token_name) - return {"api_key": "sk_new"} - - monkeypatch.setattr(flow.ams, "create_token", fake_create) - assert flow.find_or_create_cli_key(1, "jwt") == "sk_new" - assert created == {"project_id": 7, "token_name": "AssemblyAI CLI"} - - -def test_find_or_create_raises_when_first_project_has_no_project(monkeypatch): - # A project entry that omits its "project" object can't be created into; surface a - # clean APIError instead of crashing when reaching for the project id. - monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: [{"tokens": []}]) - monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create")) - with pytest.raises(APIError): - flow.find_or_create_cli_key(1, "jwt") - - -def test_find_or_create_creates_when_existing_cli_token_disabled(monkeypatch): - projects = [ - { - "project": {"id": 5}, - "tokens": [{"name": "AssemblyAI CLI", "api_key": "sk_old", "is_disabled": True}], - } - ] - monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) - monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: {"api_key": "sk_fresh"}) - assert flow.find_or_create_cli_key(1, "jwt") == "sk_fresh" - - -def test_find_or_create_raises_when_no_projects(monkeypatch): - # An account with zero projects can't hold a key; surface a clean APIError. - monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: []) - monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create")) - with pytest.raises(APIError) as exc: - flow.find_or_create_cli_key(1, "jwt") - assert "no project" in exc.value.message - - def test_start_capture_delegates_to_loopback(monkeypatch): sentinel = object() monkeypatch.setattr(flow.loopback, "start_capture", lambda: sentinel) diff --git a/tests/test_auth_flow_projects.py b/tests/test_auth_flow_projects.py new file mode 100644 index 00000000..97495308 --- /dev/null +++ b/tests/test_auth_flow_projects.py @@ -0,0 +1,99 @@ +"""`flow.find_or_create_cli_key` project-resolution tests. + +Split out of test_auth_flow.py to keep that module under the 500-line gate. These +cover reusing an existing CLI key, minting into the first usable project, and the +clean APIErrors for the no-project / malformed-list shapes. +""" + +import pytest + +from aai_cli.auth import flow +from aai_cli.core.errors import APIError + + +def test_find_or_create_reuses_existing_cli_key(monkeypatch): + projects = [ + { + "project": {"id": 7}, + "tokens": [ + {"name": "Default Token", "api_key": "sk_default", "is_disabled": False}, + {"name": "AssemblyAI CLI", "api_key": "sk_cli", "is_disabled": False}, + ], + } + ] + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) + monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create")) + assert flow.find_or_create_cli_key(1, "jwt") == "sk_cli" + + +def test_find_or_create_rejects_malformed_project_list(monkeypatch): + # A 200 with an unexpected shape (here: a project id that isn't an int) becomes a + # clean "run login again" APIError rather than a traceback. + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: [{"project": {"id": "x"}}]) + with pytest.raises(APIError): + flow.find_or_create_cli_key(1, "jwt") + + +def test_find_or_create_creates_when_absent(monkeypatch): + projects = [{"project": {"id": 7}, "tokens": []}] + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) + + created = {} + + def fake_create(account_id, project_id, token_name, session_jwt): + created.update(project_id=project_id, token_name=token_name) + return {"api_key": "sk_new"} + + monkeypatch.setattr(flow.ams, "create_token", fake_create) + assert flow.find_or_create_cli_key(1, "jwt") == "sk_new" + assert created == {"project_id": 7, "token_name": "AssemblyAI CLI"} + + +def test_find_or_create_raises_when_first_project_has_no_project(monkeypatch): + # A project entry that omits its "project" object can't be created into; surface a + # clean APIError instead of crashing when reaching for the project id. + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: [{"tokens": []}]) + monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create")) + with pytest.raises(APIError): + flow.find_or_create_cli_key(1, "jwt") + + +def test_find_or_create_uses_first_entry_that_has_a_project(monkeypatch): + # The first membership carries no project object; a usable project later in the + # list must still be minted into rather than failing with "no project". + projects = [ + {"tokens": []}, + {"project": {"id": 9}, "tokens": []}, + ] + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) + + created = {} + + def fake_create(account_id, project_id, token_name, session_jwt): + created.update(project_id=project_id) + return {"api_key": "sk_later"} + + monkeypatch.setattr(flow.ams, "create_token", fake_create) + assert flow.find_or_create_cli_key(1, "jwt") == "sk_later" + assert created == {"project_id": 9} + + +def test_find_or_create_creates_when_existing_cli_token_disabled(monkeypatch): + projects = [ + { + "project": {"id": 5}, + "tokens": [{"name": "AssemblyAI CLI", "api_key": "sk_old", "is_disabled": True}], + } + ] + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) + monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: {"api_key": "sk_fresh"}) + assert flow.find_or_create_cli_key(1, "jwt") == "sk_fresh" + + +def test_find_or_create_raises_when_no_projects(monkeypatch): + # An account with zero projects can't hold a key; surface a clean APIError. + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: []) + monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create")) + with pytest.raises(APIError) as exc: + flow.find_or_create_cli_key(1, "jwt") + assert "no project" in exc.value.message diff --git a/tests/test_code_gen_stream.py b/tests/test_code_gen_stream.py index 2f66faf4..83da8876 100644 --- a/tests/test_code_gen_stream.py +++ b/tests/test_code_gen_stream.py @@ -96,6 +96,8 @@ def test_llm_with_file_source_streams_file_and_flushes_summary(): _compiles(code) assert "client.stream(file_chunks())" in code assert "run_chain" in code + # A null gateway message content must not crash the generated chain (str concat). + assert 'message.content or ""' in code assert "summarize(final=True)" in code assert code.count("import time") == 1 # llm + file both need time; imported once diff --git a/tests/test_devserver.py b/tests/test_devserver.py index 0bb4a02e..f8a65cba 100644 --- a/tests/test_devserver.py +++ b/tests/test_devserver.py @@ -81,6 +81,18 @@ def test_dev_command_venv_swaps_python(): ] +def test_dev_command_venv_swaps_python3(): + # A Procfile edited to `python3 -m uvicorn …` must still run inside the project + # venv, not the system interpreter (which lacks the installed deps). + from aai_cli.init import runner + + cmd = devserver.dev_command( + Path("/proj"), ["python3", "-m", "uvicorn", "api.index:app"], use_uv=False + ) + assert cmd[0] == str(runner.venv_python(Path("/proj"))) + assert cmd[1:] == ["-m", "uvicorn", "api.index:app", "--host", "127.0.0.1", "--reload"] + + def test_dev_command_venv_leaves_non_python_first_token(): # The `python`-swap only fires on a leading `python`; anything else passes through # (covers the False branch of the swap condition). diff --git a/tests/test_init_command.py b/tests/test_init_command.py index e5ec3cd7..84de34a5 100644 --- a/tests/test_init_command.py +++ b/tests/test_init_command.py @@ -337,17 +337,18 @@ def test_pick_template_errors_when_either_stream_not_a_tty(monkeypatch, stdin_tt assert exc.value.exit_code == 2 -def test_active_env_vars_agents_host_replaces_only_first_streaming(monkeypatch): - # The agents host is derived by swapping the FIRST "streaming" token for "agents" - # (replace count=1); a host containing it twice must keep the later occurrence. +def test_active_env_vars_uses_authoritative_agents_host(monkeypatch): + # The Voice Agent host comes straight from the environment's agents_host field, + # not a string-munge of streaming_host that only coincides by naming convention. fake_env = types.SimpleNamespace( api_base="https://api.x", llm_gateway_base="https://llm.x", - streaming_host="streaming.streaming.example.com", + streaming_host="streaming.example.com", + agents_host="voice-agent.example.com", streaming_tts_host="", ) monkeypatch.setattr(init_exec.environments, "active", lambda: fake_env) - assert init_exec._active_env_vars()["ASSEMBLYAI_AGENTS_HOST"] == "agents.streaming.example.com" + assert init_exec._active_env_vars()["ASSEMBLYAI_AGENTS_HOST"] == "voice-agent.example.com" def test_active_env_vars_includes_streaming_tts_host(monkeypatch): @@ -355,6 +356,7 @@ def test_active_env_vars_includes_streaming_tts_host(monkeypatch): api_base="https://api.x", llm_gateway_base="https://llm.x/v1", streaming_host="streaming.x", + agents_host="agents.x", streaming_tts_host="streaming-tts.x", ) monkeypatch.setattr(init_exec.environments, "active", lambda: fake_env) diff --git a/tests/test_keys.py b/tests/test_keys.py index d2e6d49b..80c22eb0 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -96,6 +96,32 @@ def test_keys_list_without_session_runs_login(monkeypatch, mocker): assert "Run the same command again" in result.output +def test_keys_create_rejects_response_without_api_key(mocker): + # A 200 whose body omits api_key (proxy/version drift) must surface a clean + # APIError, not a KeyError traceback out of the human render path. + _auth() + projects = [{"project": {"id": 1, "name": "Default"}, "tokens": []}] + mocker.patch("aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=projects) + mocker.patch("aai_cli.commands.keys.ams.create_token", autospec=True, return_value={"id": 11}) + result = runner.invoke(app, ["keys", "create", "--name", "ci"]) + assert result.exit_code == 1 + assert "no api_key" in result.output + + +def test_keys_create_rejects_empty_api_key(mocker): + # api_key present but empty (a distinct failure from a missing key): still a clean + # APIError, never an empty key printed as if it were real. + _auth() + projects = [{"project": {"id": 1, "name": "Default"}, "tokens": []}] + mocker.patch("aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=projects) + mocker.patch( + "aai_cli.commands.keys.ams.create_token", autospec=True, return_value={"api_key": ""} + ) + result = runner.invoke(app, ["keys", "create", "--name", "ci"]) + assert result.exit_code == 1 + assert "no api_key" in result.output + + def test_keys_create_prints_new_key(mocker): _auth() projects = [{"project": {"id": 1, "name": "Default"}, "tokens": []}] diff --git a/tests/test_onboard_command.py b/tests/test_onboard_command.py index c9b70a48..0de118fe 100644 --- a/tests/test_onboard_command.py +++ b/tests/test_onboard_command.py @@ -276,6 +276,29 @@ def test_bare_aai_interactive_with_key_shows_help_no_offer( assert "Usage" in result.output or "Commands" in result.output +def test_bare_aai_with_corrupt_config_shows_help_without_crashing( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + # A corrupt config.toml is deferred by the root callback. Bare `assembly` must still + # print help cleanly — without the deferral guard, _profile_has_key -> resolve_api_key + # re-raises invalid_config (which it doesn't catch) and the callback dumps a traceback. + from aai_cli.core import config + + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + (tmp_path / "config.toml").write_text("this is not = valid = toml ][", encoding="utf-8") + monkeypatch.delenv("ASSEMBLYAI_API_KEY", raising=False) + monkeypatch.delenv("AAI_ENV", raising=False) + monkeypatch.setattr("aai_cli.core.stdio.interactive_stdio", lambda: True) + confirmed = {"v": False} + monkeypatch.setattr( + "aai_cli.main.typer.confirm", lambda *a, **k: confirmed.__setitem__("v", True) + ) + result = CliRunner().invoke(app, []) + assert result.exit_code == 0, result.output + assert confirmed["v"] is False # wizard never offered atop a broken config + assert "Usage" in result.output or "Commands" in result.output + + def test_bare_aai_declined_offer_shows_help(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("aai_cli.core.stdio.interactive_stdio", lambda: True) monkeypatch.setattr("aai_cli.main.typer.confirm", lambda *a, **k: False) diff --git a/tests/test_update_check.py b/tests/test_update_check.py index b6429715..e8ba2d20 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -62,10 +62,27 @@ def test_is_newer(latest, current, expected): ], ) def test_detect_upgrade_command(exe, expected, monkeypatch): + # These are macOS-style install paths; pin the platform so the /usr/local + # Intel-Homebrew heuristic applies (it is gated off on non-macOS). + monkeypatch.setattr(sys, "platform", "darwin") monkeypatch.setattr(sys, "executable", exe) assert update_check.detect_upgrade_command() == expected +def test_usr_local_bin_is_not_homebrew_off_macos(monkeypatch): + # On Linux, /usr/local/bin/python is a source/manual build, not Homebrew — so we + # must not tell the user to run `brew upgrade` (which they don't have). + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(sys, "executable", "/usr/local/bin/python") + assert update_check.detect_upgrade_command() == "" + + +def test_usr_local_bin_is_homebrew_on_macos(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(sys, "executable", "/usr/local/bin/python") + assert update_check.detect_upgrade_command() == "brew upgrade assembly" + + def _fake_response(payload: dict[str, object]) -> types.SimpleNamespace: return types.SimpleNamespace(json=lambda: payload, raise_for_status=lambda: None)