diff --git a/aai_cli/agent/audio.py b/aai_cli/agent/audio.py index e400c87a..601df15f 100644 --- a/aai_cli/agent/audio.py +++ b/aai_cli/agent/audio.py @@ -22,96 +22,8 @@ def _output_default_rate(device: int | None = None) -> int: return _default_rate("output", device) -def _default_output_stream(rate: int) -> Any: - """Open a sounddevice PCM16 mono output stream (imported lazily to keep startup fast).""" - try: - import sounddevice as sd - except ImportError as exc: - raise audio_missing_error() from exc - try: - stream = sd.RawOutputStream(samplerate=rate, channels=1, dtype="int16") - stream.start() - except Exception as exc: - raise CLIError( - f"Could not open the audio output device: {exc}", - error_type="audio_output_error", - exit_code=1, - suggestion="Check your speaker/output device, then run 'aai doctor'.", - ) from exc - return stream - - -class Player: - """Plays queued PCM16 audio chunks through a speaker output stream.""" - - def __init__( - self, - *, - sample_rate: int = SAMPLE_RATE, - stream_factory: Callable[[int], object] | None = None, - output_rate: int | None = None, - rate_query: Callable[[int | None], int] | None = None, - ) -> None: - self._source_rate = sample_rate # rate of enqueued audio (agent = 24 kHz) - self._factory = stream_factory or _default_output_stream - query = rate_query or _output_default_rate - # Open the speaker at its native rate; resample agent audio to it. - self._device_rate = output_rate if output_rate is not None else query(None) - self._queue: queue.Queue[bytes | None] = queue.Queue() - # sounddevice stream (or a test double); typed Any since sounddevice ships no stubs. - self._stream: Any = None - self._thread: threading.Thread | None = None - - def start(self) -> None: - self._stream = self._factory(self._device_rate) - self._thread = threading.Thread(target=self._run, daemon=True) - self._thread.start() - - def _run(self) -> None: - state: Any = None - while True: - chunk = self._queue.get() - if chunk is None: - return - if self._device_rate != self._source_rate: - chunk, state = _resample( - chunk, state, src_rate=self._source_rate, dst_rate=self._device_rate - ) - try: - self._stream.write(chunk) - except Exception: # noqa: BLE001 - stream may be torn down mid-write - return - - def enqueue(self, pcm: bytes) -> None: - self._queue.put(pcm) - - def flush(self) -> None: - """Discard pending audio (barge-in / interruption).""" - try: - while True: - self._queue.get_nowait() - except queue.Empty: - pass - - def pending(self) -> int: - return self._queue.qsize() - - def close(self) -> None: - self._queue.put(None) - # Stop the stream first so any in-flight write() raises and the worker - # thread returns promptly, avoiding a teardown race with the join below. - if self._stream is not None: - with contextlib.suppress(Exception): - self._stream.stop() - if self._thread is not None: - self._thread.join(timeout=2) - if self._stream is not None: - with contextlib.suppress(Exception): - self._stream.close() - - class NullPlayer: - """A Player look-alike that discards audio instead of opening a speaker. + """A player look-alike that discards audio instead of opening a speaker. Used by file-driven agent runs (`aai agent `), which only need the transcript events: there is no human listening, and headless/CI hosts have @@ -168,7 +80,7 @@ class DuplexAudio: directions through one `sd.RawStream` callback avoids that. Audio is captured at the device's native rate and resampled to `target_rate` (the agent's 24 kHz) for the mic side; playback is resampled back to the device rate. Exposes a - `Player`-compatible `player` and an iterable `mic` so `run_session` is unchanged. + player-compatible `player` and an iterable `mic` so `run_session` is unchanged. """ def __init__( @@ -262,7 +174,7 @@ def close(self) -> None: class _DuplexPlayer: - """Player-compatible facade over a DuplexAudio's playback side.""" + """A player-compatible facade over a DuplexAudio's playback side.""" def __init__(self, duplex: DuplexAudio) -> None: self._duplex = duplex diff --git a/aai_cli/auth/ams.py b/aai_cli/auth/ams.py index 37bae2f7..84b640d0 100644 --- a/aai_cli/auth/ams.py +++ b/aai_cli/auth/ams.py @@ -57,13 +57,6 @@ def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, return cast(dict[str, Any], _json_or_raise(resp)) -def get_auth(session_jwt: str) -> dict[str, Any]: - """GET /v1/auth (session cookie) -> account incl. `id`.""" - with _client(session_jwt) as client: - resp = client.get("/v1/auth") - return cast(dict[str, Any], _json_or_raise(resp)) - - def list_projects(account_id: int, session_jwt: str) -> list[dict[str, Any]]: """GET /v1/users/accounts/{id}/projects -> [{project, tokens[]}].""" with _client(session_jwt) as client: diff --git a/aai_cli/config.py b/aai_cli/config.py index 3917be6c..93fa016c 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -70,14 +70,6 @@ def get_active_profile() -> str: return str(_load().get("active_profile", DEFAULT_PROFILE)) -def set_active_profile(name: str) -> None: - _validate_profile(name) - data = _load() - data["active_profile"] = name - data.setdefault("profiles", {}).setdefault(name, {}) - _dump(data) - - def set_api_key(profile: str, api_key: str) -> None: _validate_profile(profile) keyring.set_password(KEYRING_SERVICE, profile, api_key) diff --git a/aai_cli/config_builder.py b/aai_cli/config_builder.py index 25dd8f4a..dee5cf5e 100644 --- a/aai_cli/config_builder.py +++ b/aai_cli/config_builder.py @@ -272,14 +272,6 @@ def construct_transcription_config(merged: dict[str, typing.Any]) -> aai.Transcr raise UsageError(f"Invalid transcription config: {exc}") from exc -def build_transcription_config( - *, flags: dict[str, object], overrides: list[str], config_file: str | None -) -> aai.TranscriptionConfig: - return construct_transcription_config( - merge_transcribe_config(flags=flags, overrides=overrides, config_file=config_file) - ) - - def merge_streaming_params( *, flags: dict[str, object], overrides: list[str], config_file: str | None ) -> dict[str, object]: @@ -307,14 +299,6 @@ def construct_streaming_params(merged: dict[str, typing.Any]) -> StreamingParame raise UsageError(f"Invalid streaming config: {exc}") from exc -def build_streaming_params( - *, flags: dict[str, object], overrides: list[str], config_file: str | None -) -> StreamingParameters: - return construct_streaming_params( - merge_streaming_params(flags=flags, overrides=overrides, config_file=config_file) - ) - - def split_csv(value: str | None) -> list[str] | None: """Split a comma-separated flag value into a list, or None if empty.""" if not value: diff --git a/aai_cli/environments.py b/aai_cli/environments.py index 4554464c..0b99e373 100644 --- a/aai_cli/environments.py +++ b/aai_cli/environments.py @@ -57,10 +57,6 @@ class Environment: _active: Environment | None = None -def known() -> tuple[str, ...]: - return tuple(ENVIRONMENTS) - - def get(name: str) -> Environment: """The named environment, or a clean CLIError if it's unknown.""" env = ENVIRONMENTS.get(name) diff --git a/tests/test_agent_audio.py b/tests/test_agent_audio.py index 667cc922..3856178a 100644 --- a/tests/test_agent_audio.py +++ b/tests/test_agent_audio.py @@ -4,7 +4,6 @@ import pytest -from aai_cli.agent.audio import Player, _default_output_stream from aai_cli.errors import CLIError @@ -24,106 +23,6 @@ def close(self): self.closed = True -def test_player_writes_enqueued_audio(): - fake = FakeStream() - p = Player(sample_rate=24000, output_rate=24000, stream_factory=lambda rate: fake) - p.start() - p.enqueue(b"\x01\x02") - p.enqueue(b"\x03\x04") - p.close() # drains the queue, then tears down - assert b"\x01\x02" in fake.writes - assert b"\x03\x04" in fake.writes - assert fake.stopped - assert fake.closed - - -def test_player_flush_discards_pending_audio(): - fake = FakeStream() - p = Player(sample_rate=24000, output_rate=24000, stream_factory=lambda rate: fake) - # Do NOT start the worker; queue items directly so flush is deterministic. - p.enqueue(b"stale-1") - p.enqueue(b"stale-2") - p.flush() - assert p.pending() == 0 - - -def test_player_worker_survives_write_error(): - class BoomStream(FakeStream): - def write(self, data): - raise RuntimeError("device gone") - - p = Player(sample_rate=24000, output_rate=24000, stream_factory=lambda rate: BoomStream()) - p.start() - p.enqueue(b"\x01\x02") - p.close() # must return (join has a timeout); thread must not be alive - assert p._thread is not None and not p._thread.is_alive() - - -def test_default_output_stream_opens_started_sounddevice_stream(monkeypatch): - created = {} - - class FakeOut: - def __init__(self, **kwargs): - created.update(kwargs) - self.started = False - - def start(self): - self.started = True - - fake_sd: Any = types.ModuleType("sounddevice") - fake_sd.RawOutputStream = lambda **kw: FakeOut(**kw) - monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) - - stream = _default_output_stream(24000) - assert stream.started - assert created["samplerate"] == 24000 - assert created["channels"] == 1 - - -def test_default_output_stream_missing_sounddevice_raises_mic_missing(monkeypatch): - monkeypatch.setitem(sys.modules, "sounddevice", None) # import -> ImportError - with pytest.raises(CLIError) as exc: - _default_output_stream(24000) - assert exc.value.error_type == "mic_missing" - - -def test_default_output_stream_open_failure_raises_audio_output_error(monkeypatch): - def boom(**kw): - raise OSError("no output device") - - fake_sd: Any = types.ModuleType("sounddevice") - fake_sd.RawOutputStream = boom - monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) - with pytest.raises(CLIError) as exc: - _default_output_stream(24000) - assert exc.value.error_type == "audio_output_error" - assert exc.value.exit_code == 1 - - -def test_player_opens_stream_at_device_rate(): - seen = {} - - def factory(rate): - seen["rate"] = rate - return FakeStream() - - p = Player(sample_rate=24000, output_rate=48000, stream_factory=factory) - p.start() - p.close() - assert seen["rate"] == 48000 # speaker opened at its native rate, not forced to 24 kHz - - -def test_player_resamples_source_to_device_rate(): - # Agent audio is 24 kHz; when the speaker opens at 48 kHz the worker upsamples. - fake = FakeStream() - p = Player(sample_rate=24000, output_rate=48000, stream_factory=lambda rate: fake) - p.start() - p.enqueue(b"\x00\x00" * 240) # 10 ms of 24 kHz silence - p.close() - written = b"".join(fake.writes) - assert len(written) > 240 * 2 # upsampled to ~48 kHz -> more bytes than the 24 kHz input - - from aai_cli.agent.audio import DuplexAudio # noqa: E402 diff --git a/tests/test_auth_ams.py b/tests/test_auth_ams.py index ad95e533..3f1dc4fc 100644 --- a/tests/test_auth_ams.py +++ b/tests/test_auth_ams.py @@ -38,19 +38,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert "tok_abc" in seen["body"] -def test_get_auth_sends_session_cookie(monkeypatch): - seen = {} - - def handler(request: httpx.Request) -> httpx.Response: - seen["cookie"] = request.headers.get("cookie", "") - return httpx.Response(200, json={"id": 42, "email": "a@b.com"}) - - _patch_transport(monkeypatch, handler) - out = ams.get_auth("sess_jwt_xyz") - assert out["id"] == 42 - assert "stytch_session_jwt=sess_jwt_xyz" in seen["cookie"] - - @pytest.mark.parametrize("status", [401, 403]) def test_auth_4xx_raises_not_authenticated(monkeypatch, status): def handler(request: httpx.Request) -> httpx.Response: diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index 09c671ff..98a74fac 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -68,7 +68,6 @@ def test_run_login_flow_happy_path(monkeypatch): "exchange", lambda ist, org: {"account": {"id": 9}, "session_jwt": "jwt", "session_token": "t"}, ) - monkeypatch.setattr(flow.ams, "get_auth", lambda jwt: {"id": 9}) monkeypatch.setattr(flow, "find_or_create_cli_key", lambda acct, jwt: "sk_final") assert flow.run_login_flow() == "sk_final" @@ -112,7 +111,9 @@ def test_find_or_create_creates_when_existing_token_has_no_api_key(monkeypatch): assert flow.find_or_create_cli_key(1, "jwt") == "sk_new" -def test_run_login_flow_uses_exchange_account_without_get_auth(monkeypatch): +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, @@ -132,9 +133,6 @@ def test_run_login_flow_uses_exchange_account_without_get_auth(monkeypatch): "exchange", lambda ist, org: {"account": {"id": 42}, "session_jwt": "jwt"}, ) - monkeypatch.setattr( - flow.ams, "get_auth", lambda jwt: pytest.fail("get_auth is a redundant round-trip") - ) captured = {} def fake_find(acct, jwt): diff --git a/tests/test_config.py b/tests/test_config.py index 0d44a1c1..7fdbdc12 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -35,11 +35,6 @@ def test_active_profile_defaults_to_default(): assert config.get_active_profile() == "default" -def test_set_active_profile_persists(): - config.set_active_profile("staging") - assert config.get_active_profile() == "staging" - - def test_logout_clears_key(): config.set_api_key("default", "sk_abc") config.clear_api_key("default") @@ -72,7 +67,7 @@ def test_empty_api_key_flag_rejected(): def test_invalid_profile_name_has_suggestion(): with pytest.raises(CLIError) as exc: - config.set_active_profile("bad name!") + config.set_api_key("bad name!", "sk_x") assert exc.value.message.startswith("Invalid profile name") assert exc.value.suggestion == "Use only letters, digits, '-' or '_'." @@ -86,7 +81,6 @@ def test_malformed_config_raises_clean_error(tmp_config): def test_config_roundtrips_after_special_value(tmp_path, monkeypatch): - # active profile name is validated; this checks tomli_w writes valid TOML for normal data - config.set_api_key("default", "sk_x") - config.set_active_profile("staging") + # profile names are validated; this checks tomli_w writes valid TOML for normal data + config.set_api_key("staging", "sk_x") assert config.get_active_profile() == "staging" diff --git a/tests/test_config_builder.py b/tests/test_config_builder.py index be23d38c..abc0e309 100644 --- a/tests/test_config_builder.py +++ b/tests/test_config_builder.py @@ -58,21 +58,23 @@ def test_parse_config_overrides_requires_equals(): cb.parse_config_overrides(cb.TRANSCRIBE_FIELDS, ["speaker_labels"]) -def test_build_transcription_config_layer_precedence(tmp_path): +def test_transcribe_config_layer_precedence(tmp_path): cfg = tmp_path / "c.json" cfg.write_text(json.dumps({"speaker_labels": False, "speakers_expected": 5})) - tc = cb.build_transcription_config( - flags={"speaker_labels": True}, # flag beats file - overrides=["speakers_expected=3"], # --config beats file - config_file=str(cfg), + tc = cb.construct_transcription_config( + cb.merge_transcribe_config( + flags={"speaker_labels": True}, # flag beats file + overrides=["speakers_expected=3"], # --config beats file + config_file=str(cfg), + ) ) assert tc.speaker_labels is True assert tc.raw.speakers_expected == 3 -def test_build_transcription_config_ignores_unset_flags(): - tc = cb.build_transcription_config( - flags={"speaker_labels": None}, overrides=[], config_file=None +def test_transcribe_config_ignores_unset_flags(): + tc = cb.construct_transcription_config( + cb.merge_transcribe_config(flags={"speaker_labels": None}, overrides=[], config_file=None) ) assert tc.speaker_labels is None # None means "not set", does not override @@ -109,23 +111,27 @@ def test_translation_request_shape(): assert "es" in json.dumps(su, default=lambda o: getattr(o, "__dict__", str(o))) -def test_build_transcription_config_with_translate_payload(): +def test_transcribe_config_with_translate_payload(): # The SDK must accept the translation payload for speech_understanding without raising. - tc = cb.build_transcription_config( - flags={"speech_understanding": cb.translation_request(["es", "fr"])}, - overrides=[], - config_file=None, + tc = cb.construct_transcription_config( + cb.merge_transcribe_config( + flags={"speech_understanding": cb.translation_request(["es", "fr"])}, + overrides=[], + config_file=None, + ) ) assert "es" in json.dumps( tc.raw.speech_understanding, default=lambda o: getattr(o, "__dict__", str(o)) ) -def test_build_streaming_params_minimal(): - sp = cb.build_streaming_params( - flags={"sample_rate": 16000, "speech_model": "universal_streaming_multilingual"}, - overrides=["max_turn_silence=400"], - config_file=None, +def test_streaming_params_minimal(): + sp = cb.construct_streaming_params( + cb.merge_streaming_params( + flags={"sample_rate": 16000, "speech_model": "universal_streaming_multilingual"}, + overrides=["max_turn_silence=400"], + config_file=None, + ) ) assert sp.sample_rate == 16000 assert sp.max_turn_silence == 400 @@ -144,8 +150,8 @@ def test_build_streaming_params_minimal(): ], ) def test_transcribe_field_coercion_matrix(field, raw, expected, extra): - tc = cb.build_transcription_config( - flags={}, overrides=[f"{field}={raw}", *extra], config_file=None + tc = cb.construct_transcription_config( + cb.merge_transcribe_config(flags={}, overrides=[f"{field}={raw}", *extra], config_file=None) ) assert getattr(tc.raw, field) == expected @@ -203,17 +209,6 @@ def test_merge_streaming_params_coerces_speech_model_enum(): assert merged["sample_rate"] == 16000 -def test_build_transcription_config_still_works(): - import assemblyai as aai - - from aai_cli import config_builder - - tc = config_builder.build_transcription_config( - flags={"speaker_labels": True}, overrides=[], config_file=None - ) - assert isinstance(tc, aai.TranscriptionConfig) - - # The full field -> coercion-kind mapping is frozen here. Kinds are derived from the # SDK model annotations, but the curated field set and every resulting kind must stay # exactly as below; this guards the whole table, not just the fields sampled above. diff --git a/tests/test_environments.py b/tests/test_environments.py index 3c9d29a8..817ab193 100644 --- a/tests/test_environments.py +++ b/tests/test_environments.py @@ -4,11 +4,6 @@ from aai_cli.errors import CLIError -def test_known_includes_production_and_sandbox(): - assert "production" in environments.known() - assert "sandbox000" in environments.known() - - def test_get_returns_named_environment(): env = environments.get("sandbox000") assert env.name == "sandbox000"