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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions aai_cli/agent/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 9 additions & 5 deletions aai_cli/agent_cascade/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions aai_cli/app/init_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions aai_cli/auth/flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,15 +167,17 @@ 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()
for entry in projects:
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)
Expand Down
2 changes: 1 addition & 1 deletion aai_cli/code_gen/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
12 changes: 10 additions & 2 deletions aai_cli/commands/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions aai_cli/init/devserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
13 changes: 10 additions & 3 deletions aai_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 4 additions & 2 deletions aai_cli/ui/update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
20 changes: 17 additions & 3 deletions tests/test_agent_cascade_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
8 changes: 8 additions & 0 deletions tests/test_agent_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 0 additions & 68 deletions tests/test_auth_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
99 changes: 99 additions & 0 deletions tests/test_auth_flow_projects.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions tests/test_code_gen_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading