Skip to content

Commit 2cd2dd7

Browse files
alexkromanclaude
andauthored
Fix error handling and edge cases across auth, keys, and agent systems (#207)
This PR hardens error handling and fixes edge cases across multiple subsystems to prevent crashes and improve user experience. ## Summary Fixes malformed API responses, corrupt configuration handling, invalid base64 audio frames, and platform-specific upgrade detection. Also improves sentence splitting logic to preserve decimals and abbreviations, and corrects environment variable resolution for Voice Agents. ## Key Changes **Auth & Keys** - Split `find_or_create_cli_key` tests into a dedicated module to keep `test_auth_flow.py` under the 500-line gate - Add validation in `keys create` to reject API responses missing or with empty `api_key` field, surfacing clean `APIError` instead of `KeyError` - Improve `find_or_create_cli_key` to use the first project entry that actually has a project object (not just the first entry), handling accounts where the first membership lacks a project **Configuration & Startup** - Guard the onboarding wizard offer against corrupt `config.toml` by checking `deferred_config_error` before attempting to resolve credentials, preventing traceback when config is unparseable - Add test coverage for bare `assembly` command with corrupt config **Agent Session** - Wrap base64 decoding in `on_reply_audio` with try-except to gracefully drop corrupt audio frames instead of crashing the live conversation session **Text Processing** - Refine `split_sentences` to only treat terminators (`.`, `!`, `?`) as sentence boundaries when they end the text or are followed by whitespace, preserving decimals ("$3.50") and abbreviations, and keeping stacked terminators ("...", "?!") intact **Environment & Deployment** - Fix `_active_env_vars` to use the authoritative `agents_host` field from the environment instead of deriving it via string replacement of `streaming_host` - Fix `dev_command` to swap both `python` and `python3` leading tokens for the venv interpreter - Fix `detect_upgrade_command` to only treat `/usr/local/bin` as Homebrew on macOS (darwin), not on Linux where it's a conventional source/manual build prefix - Add platform-specific test coverage for upgrade detection **Code Generation** - Add null-check for LLM gateway message content to prevent crashes during code generation ## Testing - Added 9 new tests covering malformed responses, corrupt config, corrupt audio frames, sentence splitting edge cases, and platform-specific upgrade detection - Moved 7 existing `find_or_create_cli_key` tests to new `test_auth_flow_projects.py` module https://claude.ai/code/session_0119PsP7ZmuXMsoMke7DBBd2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6af8dcf commit 2cd2dd7

19 files changed

Lines changed: 262 additions & 98 deletions

aai_cli/agent/session.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,13 @@ def on_reply_started(self, _event: dict[str, Any]) -> None:
132132

133133
def on_reply_audio(self, event: dict[str, Any]) -> None:
134134
data = event.get("data")
135-
if data:
136-
self.player.enqueue(base64.b64decode(data))
135+
if not data:
136+
return
137+
try:
138+
pcm = base64.b64decode(data)
139+
except (ValueError, TypeError):
140+
return # a single corrupt frame is dropped, not fatal to the session
141+
self.player.enqueue(pcm)
137142

138143
def on_agent_transcript(self, event: dict[str, Any]) -> None:
139144
self.renderer.agent_transcript(

aai_cli/agent_cascade/text.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,19 @@
1414
def split_sentences(text: str) -> list[str]:
1515
"""Split ``text`` into sentences, each ending in ``.``/``!``/``?``.
1616
17-
A trailing fragment with no terminal punctuation is kept as a final sentence,
18-
so no text is ever dropped; empty/whitespace-only pieces are discarded.
17+
A terminator ends a sentence only when it is the last character or is followed by
18+
whitespace — so a ``.`` inside a number ("$3.50") or stacked terminators ("..."/"?!")
19+
don't fragment one spoken sentence into several TTS calls (which both clips audio
20+
mid-number and writes a space-mangled copy back into the LLM history). A trailing
21+
fragment with no terminal punctuation is kept, so no text is ever dropped;
22+
empty/whitespace-only pieces are discarded.
1923
"""
2024
sentences: list[str] = []
2125
start = 0
2226
for index, char in enumerate(text):
23-
if char in _TERMINATORS:
24-
# The slice always includes the terminator at ``index``, so it is never
25-
# blank after stripping the inter-sentence whitespace.
27+
if char in _TERMINATORS and (index + 1 == len(text) or text[index + 1].isspace()):
28+
# Boundary confirmed (end-of-text or a following space); the slice includes
29+
# the terminator, so it is never blank after stripping leading whitespace.
2630
sentences.append(text[start : index + 1].strip())
2731
start = index + 1
2832
tail = text[start:].strip()

aai_cli/app/init_exec.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,9 @@ def _active_env_vars() -> dict[str, str]:
103103
"ASSEMBLYAI_BASE_URL": env.api_base,
104104
"ASSEMBLYAI_LLM_GATEWAY_URL": env.llm_gateway_base,
105105
"ASSEMBLYAI_STREAMING_HOST": env.streaming_host,
106-
# Voice Agent host mirrors the streaming host's naming across environments.
107-
"ASSEMBLYAI_AGENTS_HOST": env.streaming_host.replace("streaming", "agents", 1),
106+
# The environment's authoritative Voice Agent host (not derived from the
107+
# streaming host, which only coincides by naming convention today).
108+
"ASSEMBLYAI_AGENTS_HOST": env.agents_host,
108109
# Streaming-TTS host for the cascade (agent-cascade) template. Empty in
109110
# production, where streaming TTS has no host; that template then refuses to
110111
# run and points at --sandbox.

aai_cli/auth/flow.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,15 +167,17 @@ def _no_project_error() -> APIError:
167167

168168

169169
def find_or_create_cli_key(account_id: int, session_jwt: str) -> str:
170-
"""Return the existing 'AssemblyAI CLI' key, or create one in the first project."""
170+
"""Return the existing 'AssemblyAI CLI' key, or create one in the first usable project."""
171171
projects = _parse(_PROJECT_LIST, ams.list_projects(account_id, session_jwt))
172172
if not projects:
173173
raise _no_project_error()
174174
for entry in projects:
175175
for token in entry.tokens:
176176
if key := _reusable_cli_key(token):
177177
return key
178-
project = projects[0].project
178+
# Mint into the first entry that actually carries a project — an account whose
179+
# first membership has no project can still have a usable one later in the list.
180+
project = next((entry.project for entry in projects if entry.project is not None), None)
179181
if project is None:
180182
raise _no_project_error()
181183
created = ams.create_token(account_id, project.id, endpoints.CLI_TOKEN_NAME, session_jwt)

aai_cli/code_gen/stream.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def run_chain(text: str) -> str:
7474
messages=[{{"role": "user", "content": prompt + "\\n\\nTranscript:\\n" + source}}],
7575
max_tokens={max_tokens},
7676
)
77-
result = response.choices[0].message.content
77+
result = response.choices[0].message.content or ""
7878
return result
7979
8080

aai_cli/commands/keys.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,19 @@ def body(state: AppState, json_mode: bool) -> None:
139139
account_id, jwt = state.resolve_session()
140140
pid = project_id if project_id is not None else _default_project_id(account_id, jwt)
141141
created = ams.create_token(account_id, pid, name, jwt)
142+
# Validate before rendering: a 200 whose body omits api_key (proxy/version
143+
# drift) must surface a clean APIError, not a KeyError traceback.
144+
api_key = created.get("api_key")
145+
if not isinstance(api_key, str) or not api_key:
146+
raise APIError(
147+
"AMS created the key but returned no api_key.",
148+
suggestion="Run 'assembly keys list' to confirm it exists, then try again.",
149+
)
142150
output.emit(
143151
created,
144-
lambda d: (
152+
lambda _d: (
145153
output.success(f"Created API key '{escape(name)}'.")
146-
+ f"\n {escape(str(d['api_key']))}\n"
154+
+ f"\n {escape(api_key)}\n"
147155
+ output.warn("Shown once — copy it now.")
148156
),
149157
json_mode=json_mode,

aai_cli/init/devserver.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,14 @@ def dev_command(target: Path, web: list[str], *, use_uv: bool, host: str = LOCAL
5656
"""The Procfile web process, run in the project venv with live reload.
5757
5858
The Procfile's `web:` line starts with `python -m uvicorn …`. With uv, run it
59-
under `uv run`; without uv, swap a leading `python` for the project's venv
60-
interpreter so it runs inside the scaffolded `.venv`. In both cases the
59+
under `uv run`; without uv, swap a leading `python`/`python3` for the project's
60+
venv interpreter so it runs inside the scaffolded `.venv`. In both cases the
6161
Procfile's `--host 0.0.0.0` is overridden to `host` (loopback by default) so a
6262
local dev run never exposes the server — and the key in `.env` — to the LAN.
6363
"""
6464
argv = _override_host(web, host)
6565
if use_uv:
6666
return ["uv", "run", *argv, "--reload"]
67-
if argv and argv[0] == "python":
67+
if argv and argv[0] in ("python", "python3"):
6868
argv[0] = str(runner.venv_python(target))
6969
return [*argv, "--reload"]

aai_cli/main.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,18 @@ def _sandbox_conflict_warning(sandbox: bool, env: str | None) -> str | None:
110110

111111
def _offer_or_help(ctx: typer.Context, state: AppState) -> None:
112112
"""No subcommand given: offer guided setup to a credential-less, interactive user;
113-
otherwise print help. Never prompts in a non-interactive session, and never on
114-
`--help` (Click handles that eagerly before the callback)."""
113+
otherwise print help. Never prompts in a non-interactive session, never on
114+
`--help` (Click handles that eagerly before the callback), and never when the
115+
stored config is unparseable — a deferred ``invalid_config`` error means
116+
``resolve_api_key``/``resolve_profile`` would re-raise (escaping the callback as
117+
a traceback), and the wizard would only write atop a broken file."""
115118
if not state.quiet:
116119
output.print_banner()
117-
if stdio.interactive_stdio() and not _profile_has_key(state):
120+
if (
121+
state.deferred_config_error is None
122+
and stdio.interactive_stdio()
123+
and not _profile_has_key(state)
124+
):
118125
if not state.quiet:
119126
output.console.print() # blank line so the prompt isn't flush against the banner
120127
if typer.confirm("Welcome to AssemblyAI. Run guided setup now?", default=True):

aai_cli/ui/update_check.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ def is_newer(latest: str, current: str) -> bool:
4444

4545

4646
def _is_homebrew_executable(executable: str) -> bool:
47-
if executable.startswith("/usr/local/"):
47+
# /usr/local/ is Homebrew only on Intel macOS; on Linux it's the conventional
48+
# prefix for source/manually-built interpreters, so don't claim brew there.
49+
if sys.platform == "darwin" and executable.startswith("/usr/local/"):
4850
return True
4951
return any(marker in executable for marker in _HOMEBREW_PATH_MARKERS)
5052

@@ -84,7 +86,7 @@ def fetch_and_cache() -> None:
8486
resp.raise_for_status()
8587
tag = resp.json().get("tag_name")
8688
if isinstance(tag, str) and tag:
87-
latest = tag.lstrip("v")
89+
latest = tag.removeprefix("v")
8890
except (httpx.HTTPError, ValueError, KeyError, OSError):
8991
latest = None
9092
try:

tests/test_agent_cascade_text.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,24 @@ def test_split_sentences_empty_string_is_empty_list():
2121
assert split_sentences("") == []
2222

2323

24-
def test_split_sentences_each_terminator_ends_a_sentence():
25-
# Every terminator closes the current chunk, so consecutive ones each yield one.
26-
assert split_sentences("...") == [".", ".", "."]
24+
def test_split_sentences_terminator_followed_by_space_ends_a_sentence():
25+
# A terminator only closes the chunk when it ends the text or is followed by space.
2726
assert split_sentences(" . ") == ["."]
27+
assert split_sentences("Hi . Bye .") == ["Hi .", "Bye ."]
28+
29+
30+
def test_split_sentences_keeps_decimals_and_abbreviations_intact():
31+
# A '.' wedged between non-space characters is not a sentence boundary, so a
32+
# number ("$3.50") or abbreviation stays one piece instead of fragmenting TTS.
33+
assert split_sentences("It costs $3.50 today.") == ["It costs $3.50 today."]
34+
assert split_sentences("Total 12.5") == ["Total 12.5"]
35+
36+
37+
def test_split_sentences_does_not_split_stacked_terminators():
38+
# Ellipsis and "?!" are followed by non-space chars (or each other), so they
39+
# don't each spawn a separate sentence.
40+
assert split_sentences("...") == ["..."]
41+
assert split_sentences("Wait...what?!") == ["Wait...what?!"]
2842

2943

3044
def test_trim_history_drops_oldest_beyond_limit():

0 commit comments

Comments
 (0)