diff --git a/aai_cli/app/context.py b/aai_cli/app/context.py index 6508c538..9e0abe1f 100644 --- a/aai_cli/app/context.py +++ b/aai_cli/app/context.py @@ -7,7 +7,7 @@ import keyring.errors import typer -from aai_cli.core import config, debuglog, env, environments, stdio, telemetry +from aai_cli.core import config, debuglog, env, environments, errors, stdio, telemetry from aai_cli.core.environments import Environment from aai_cli.core.errors import APIError, CLIError, NotAuthenticated from aai_cli.ui import output, update_check @@ -196,6 +196,30 @@ def _auto_login_and_exit(state: AppState, *, json_mode: bool) -> NoReturn: ) +def _run_body( + ctx: typer.Context, + fn: Callable[[AppState, bool], None], + *, + json_mode: bool, + has_deferred: bool, +) -> None: + """Run the command body, wrapped (or not) by the telemetry/update-check machinery. + + Called inside ``run_command``'s ``try`` so telemetry sees the raw CLIError (and its + error_type) before it's folded into a ``typer.Exit``. ``config path`` opts into + ``has_deferred`` (a corrupt config.toml): it reports a contents-independent location, + so it runs just the body and skips the telemetry/update-check wrappers that would + re-parse the broken config. + """ + state: AppState = ctx.obj + if has_deferred: + fn(state, json_mode) + return + with telemetry.track(ctx.command_path): + fn(state, json_mode) + update_check.maybe_notify(json_mode=json_mode) + + def run_command( ctx: typer.Context, fn: Callable[[AppState, bool], None], @@ -219,23 +243,20 @@ def run_command( # exit, without telemetry/update-check, both of which would just re-parse it. _fail(deferred, json_mode=json_mode) try: - if deferred is not None: - # `config path` opted in (tolerate_unreadable_config): it reports a - # contents-independent location, so run just the body and skip the - # telemetry/update-check wrappers that re-parse the broken config. - fn(state, json_mode) - else: - # Inside the try so telemetry sees the raw CLIError (and its error_type) - # before it's folded into a typer.Exit below. - with telemetry.track(ctx.command_path): - fn(state, json_mode) - update_check.maybe_notify(json_mode=json_mode) + _run_body(ctx, fn, json_mode=json_mode, has_deferred=deferred is not None) except NotAuthenticated as err: if not auto_login or not _should_auto_login(err): _fail(err, json_mode=json_mode) _auto_login_and_exit(state, json_mode=json_mode) except CLIError as err: _fail(err, json_mode=json_mode) + except KeyboardInterrupt: + # Ctrl-C (and the SIGTERM that core.signals routes through the same path) is a + # cancel, not a crash: exit 130 so `assembly … && next` composes correctly, + # instead of the bare "Aborted!" / exit 1 Click would otherwise produce. The + # interactive commands print their own "Stopped." before re-raising; this is the + # single place the cancel maps to its exit code. + raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None except (typer.Exit, typer.Abort, BrokenPipeError): # Deliberate control flow (and the closed-pipe contract handled in main.run); # these must reach Click/the entry point untouched. diff --git a/aai_cli/commands/agent/_exec.py b/aai_cli/commands/agent/_exec.py index 7bd2ebe0..9434e918 100644 --- a/aai_cli/commands/agent/_exec.py +++ b/aai_cli/commands/agent/_exec.py @@ -22,7 +22,7 @@ from aai_cli.agent.voices import VOICE_NAMES from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt from aai_cli.app.context import AppState -from aai_cli.core import choices, client, signals +from aai_cli.core import choices, client, errors, signals from aai_cli.core.errors import UsageError from aai_cli.streaming.session import resolve_output_modes from aai_cli.streaming.sources import FileSource @@ -135,7 +135,10 @@ def run_agent(opts: AgentOptions, state: AppState, *, json_mode: bool) -> None: with signals.terminate_as_interrupt(): run_session(api_key, renderer=renderer, player=player, mic=audio, config=run_config) except KeyboardInterrupt: + # Ctrl-C (or a supervisor's SIGTERM) ends the session cleanly, then exits 130 + # (cancel) so the interrupt isn't reported to a caller as success. renderer.stopped() + raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None except BrokenPipeError as exc: # Downstream consumer (e.g. `| head`) closed the pipe; stop quietly. raise typer.Exit(code=0) from exc diff --git a/aai_cli/commands/agent_cascade/_exec.py b/aai_cli/commands/agent_cascade/_exec.py index bff21eff..b2340511 100644 --- a/aai_cli/commands/agent_cascade/_exec.py +++ b/aai_cli/commands/agent_cascade/_exec.py @@ -22,7 +22,7 @@ from aai_cli.agent_cascade.config import DEFAULT_MAX_HISTORY, CascadeConfig from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt from aai_cli.app.context import AppState -from aai_cli.core import choices, client, config_builder, llm, signals +from aai_cli.core import choices, client, config_builder, errors, llm, signals from aai_cli.core.errors import UsageError from aai_cli.streaming import turn_presets from aai_cli.streaming.session import resolve_output_modes @@ -218,7 +218,10 @@ def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode: with signals.terminate_as_interrupt(): engine.run_cascade(renderer=renderer, player=player, config=config, deps=deps) except KeyboardInterrupt: + # Ctrl-C (or a supervisor's SIGTERM) ends the cascade cleanly, then exits 130 + # (cancel) so the interrupt isn't reported to a caller as success. renderer.stopped() + raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None except BrokenPipeError as exc: # Downstream consumer (e.g. `| head`) closed the pipe; stop quietly. raise typer.Exit(code=0) from exc diff --git a/aai_cli/commands/dictate/_exec.py b/aai_cli/commands/dictate/_exec.py index fe23c941..73c6307a 100644 --- a/aai_cli/commands/dictate/_exec.py +++ b/aai_cli/commands/dictate/_exec.py @@ -12,8 +12,10 @@ from dataclasses import dataclass +import typer + from aai_cli.app.context import AppState -from aai_cli.core import choices, stdio, sync_stt +from aai_cli.core import choices, errors, stdio, sync_stt from aai_cli.core.config_builder import split_csv from aai_cli.core.hotkey import CTRL_C, CTRL_D, ESC, TerminalKeys from aai_cli.core.microphone import MicrophoneSource @@ -222,5 +224,7 @@ def run_dictate(opts: DictateOptions, state: AppState, *, json_mode: bool) -> No ) _session(keys, api_key, opts, state, json_mode=json_mode, single=single) except KeyboardInterrupt: - # Ctrl-C is the normal "done dictating" signal: end cleanly, not as an error. - return + # Ctrl-C cancels dictation, so it exits 130 (cancel) — distinct from `q`, which + # ends the session normally (exit 0). The with-block above already restored the + # terminal on the way out. + raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None diff --git a/aai_cli/commands/llm/_exec.py b/aai_cli/commands/llm/_exec.py index af3dd3da..cb067cf5 100644 --- a/aai_cli/commands/llm/_exec.py +++ b/aai_cli/commands/llm/_exec.py @@ -12,10 +12,11 @@ from dataclasses import dataclass from pathlib import Path +import typer from rich.markup import escape from aai_cli.app.context import AppState -from aai_cli.core import choices, client, stdio +from aai_cli.core import choices, client, errors, stdio from aai_cli.core import llm as gateway from aai_cli.core.errors import UsageError from aai_cli.ui import output @@ -161,16 +162,16 @@ def ask(transcript_text: str) -> str: return gateway.content_of(response) transcript: list[str] = [] - interrupted = False with FollowRenderer(json_mode=json_mode) as render: - # Ctrl-C is the normal "stop watching" signal -> exit cleanly (code 0). + # Ctrl-C is the normal "stop watching" signal: exit 130 (cancel) rather than + # masquerading as a clean finish — the renderer's panel closes on the way out. try: for turn in stdio.iter_piped_stdin_lines(): transcript.append(turn) render(ask("\n".join(transcript)), len(transcript)) except KeyboardInterrupt: - interrupted = True - if not transcript and not interrupted: + raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None + if not transcript: # An empty pipe (`assembly llm -f "…" None: output.emit(payload, _render_share, json_mode=json_mode) server.wait() except KeyboardInterrupt: - # Ctrl-C is the expected way to stop a foreground share; the finally - # block below tears down the tunnel and server. - pass + # Ctrl-C is the expected way to stop a foreground share: tear down (finally, + # below) then exit 130 (cancel) so it isn't reported to a caller as success. + raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None finally: tunnel.terminate(proxy) tunnel.terminate(server) diff --git a/aai_cli/commands/webhooks/_listen.py b/aai_cli/commands/webhooks/_listen.py index e62e7a4b..1bffe476 100644 --- a/aai_cli/commands/webhooks/_listen.py +++ b/aai_cli/commands/webhooks/_listen.py @@ -10,7 +10,6 @@ from __future__ import annotations -import contextlib import json import threading from collections.abc import Callable @@ -180,9 +179,10 @@ def run_listen(opts: ListenOptions, *, json_mode: bool) -> None: "check it for errors.", ) _announce(public_url, port, json_mode=json_mode) - # Ctrl-C is the expected way to stop a foreground listener. - with contextlib.suppress(KeyboardInterrupt): - server.serve_forever() + # Ctrl-C is the expected way to stop a foreground listener; let it propagate so + # the command exits 130 (cancel). The finally below still closes the socket and + # tears down the tunnel. + server.serve_forever() finally: # Close here, not via `with server:` — a tunnel failure raises before the # serve block, and the bound listening socket must not outlive the command. diff --git a/aai_cli/core/errors.py b/aai_cli/core/errors.py index 98d86f4a..4a5cd613 100644 --- a/aai_cli/core/errors.py +++ b/aai_cli/core/errors.py @@ -21,6 +21,12 @@ from aai_cli.core import jsonshape +# The conventional Unix "cancelled by Ctrl-C" code (128 + SIGINT). Not a CLIError +# exit_code — a cancel isn't a failure with a machine-readable error type — but the +# single source of truth scripts can rely on for "the user (or a supervisor's SIGTERM, +# routed through the same clean-stop path) interrupted the command". +CANCELLED_EXIT_CODE = 130 + class CLIError(Exception): """Base error carrying an exit code, a machine-readable type, and an optional diff --git a/aai_cli/core/signals.py b/aai_cli/core/signals.py index 449cc4b8..0ad3376e 100644 --- a/aai_cli/core/signals.py +++ b/aai_cli/core/signals.py @@ -2,8 +2,8 @@ `stream` (and the other realtime commands) treat Ctrl-C as a clean "user stopped" signal: SIGINT arrives as a ``KeyboardInterrupt`` that the streaming lifecycle catches -to flush its closing state and exit 0. This module lets an *external* stop reach that -same path — see ``terminate_as_interrupt``. +to flush its closing state and exit 130 (the cancel code). This module lets an +*external* stop reach that same path — see ``terminate_as_interrupt``. """ from __future__ import annotations diff --git a/aai_cli/core/telemetry.py b/aai_cli/core/telemetry.py index f1447aaa..814174df 100644 --- a/aai_cli/core/telemetry.py +++ b/aai_cli/core/telemetry.py @@ -26,7 +26,7 @@ import typer from aai_cli import __version__ -from aai_cli.core import argscan, config, env, procs +from aai_cli.core import argscan, config, env, errors, procs from aai_cli.core.errors import CLIError ENV_DISABLED = "AAI_TELEMETRY_DISABLED" @@ -236,14 +236,25 @@ def _safe_dispatch( return +def _exit_outcome(code: int) -> str: + """Map a ``typer.Exit`` code to a telemetry outcome: 0 succeeds, 130 is a cancel + (the Ctrl-C path that exits via ``typer.Exit``), anything else is an error.""" + if code == 0: + return "success" + if code == errors.CANCELLED_EXIT_CODE: + return "cancelled" + return "error" + + @contextmanager def track(command: str) -> Generator[None]: """Record one command run, deriving the outcome from whatever escapes the body. CLIErrors keep their machine-readable ``error_type`` as the outcome; a - deliberate ``typer.Exit`` maps through its code; anything else is the - catch-all ``internal_error``. The body's exception always re-raises — - tracking observes control flow, never alters it. + deliberate ``typer.Exit`` maps through its code (a 130 reads as ``cancelled``); + a Ctrl-C is ``cancelled``; anything else is the catch-all ``internal_error``. + The body's exception always re-raises — tracking observes control flow, never + alters it. """ if not is_enabled(): yield @@ -263,8 +274,13 @@ def track(command: str) -> Generator[None]: raise except typer.Exit as exc: code = exc.exit_code - outcome = "success" if code == 0 else "error" - _safe_dispatch(command, started, outcome=outcome, exit_code=code) + _safe_dispatch(command, started, outcome=_exit_outcome(code), exit_code=code) + raise + except KeyboardInterrupt: + # A Ctrl-C that reached here (a command with no interactive handler) is a + # cancel, not an internal_error — record it as such so it doesn't inflate the + # crash rate, then let run_command map it to exit 130. + _safe_dispatch(command, started, outcome="cancelled", exit_code=errors.CANCELLED_EXIT_CODE) raise except BaseException as exc: _safe_dispatch( diff --git a/aai_cli/init/runner.py b/aai_cli/init/runner.py index 785c73fa..d0e4793c 100644 --- a/aai_cli/init/runner.py +++ b/aai_cli/init/runner.py @@ -9,7 +9,7 @@ import webbrowser from pathlib import Path -from aai_cli.core.errors import CLIError +from aai_cli.core.errors import CANCELLED_EXIT_CODE, CLIError from aai_cli.ui import output @@ -143,8 +143,10 @@ def run_server( ) -> int: """Run a prebuilt server command, wait for the port, open the browser, block until Ctrl-C. - Returns the process exit code (0 on a clean Ctrl-C shutdown). `env=None` inherits - the current environment; pass a full dict (e.g. `{**os.environ, "PORT": ...}`) to override. + Returns the process exit code: the cancel code (130) on a Ctrl-C shutdown so a + `dev && next` chain doesn't proceed past the interrupt, else the child's own code. + `env=None` inherits the current environment; pass a full dict (e.g. + `{**os.environ, "PORT": ...}`) to override. """ proc = subprocess.Popen(command, cwd=target, env=env) try: @@ -154,7 +156,7 @@ def run_server( except KeyboardInterrupt: proc.terminate() proc.wait() - return 0 + return CANCELLED_EXIT_CODE return proc.returncode diff --git a/aai_cli/streaming/session.py b/aai_cli/streaming/session.py index 2513b5e4..fdc7c766 100644 --- a/aai_cli/streaming/session.py +++ b/aai_cli/streaming/session.py @@ -9,7 +9,7 @@ import typer -from aai_cli.core import choices, client, config_builder, llm +from aai_cli.core import choices, client, config_builder, errors, llm from aai_cli.core.errors import ( APIError, CLIError, @@ -327,8 +327,8 @@ def stream_one( def _guarded(self, work: Callable[[], None], *, handle_interrupt: bool = True) -> None: """Run a streaming body with the shared lifecycle handling: enter the - FollowRenderer's live panel if present, treat Ctrl-C as a clean stop, exit 0 on - a closed downstream pipe, and always close the renderer. + FollowRenderer's live panel if present, treat Ctrl-C as a clean stop (exit 130), + exit 0 on a closed downstream pipe, and always close the renderer. ``handle_interrupt=False`` lets a Ctrl-C or a closed pipe propagate instead of being swallowed here — the batch driver owns those signals across the whole @@ -352,10 +352,13 @@ def _guarded(self, work: Callable[[], None], *, handle_interrupt: bool = True) - except KeyboardInterrupt: if not handle_interrupt: raise - # Ctrl-C is a normal "user stopped" signal -> exit 0. + # Ctrl-C is a clean "user stopped" signal: flush the closing state, print + # "Stopped.", then exit 130 (the cancel code) so a `stream && next` chain + # doesn't treat the interrupt as success. if self.follow is None: self.renderer.close() self.renderer.stopped() + raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None except BrokenPipeError: if not handle_interrupt: raise @@ -441,8 +444,9 @@ def stream_batch_sources( as a per-source failure so the batch carries on — except ``NotAuthenticated``, which re-raises to abort the whole batch (one rejected key fails every source identically). - A Ctrl-C or a closed downstream pipe stops the batch cleanly (exit 0). When any - source failed, raises a ``CLIError`` at the end so a script can trust the exit code. + A Ctrl-C stops the batch with the cancel code (exit 130); a closed downstream pipe + stops it quietly (exit 0). When any source failed, raises a ``CLIError`` at the end + so a script can trust the exit code. """ total = len(sources) failures: list[str] = [] @@ -458,9 +462,10 @@ def stream_batch_sources( failures.append(source) output.emit_warning(f"{source}: {exc.message}", json_mode=json_mode) except KeyboardInterrupt: - # One Ctrl-C stops the whole batch, not just the current source -> exit 0. + # One Ctrl-C stops the whole batch, not just the current source. Exit 130 + # (cancel) so the interrupt isn't mistaken for a clean run of every source. renderer.stopped() - return + raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None except BrokenPipeError: # Downstream consumer (e.g. `| head`) closed the pipe; stop quietly. raise typer.Exit(code=0) from None diff --git a/tests/test_agent_cascade_command.py b/tests/test_agent_cascade_command.py index d66b17b5..513dc1cc 100644 --- a/tests/test_agent_cascade_command.py +++ b/tests/test_agent_cascade_command.py @@ -260,7 +260,11 @@ def boom(**kwargs): raise KeyboardInterrupt rendered = _wire_run(monkeypatch, boom) - run_agent_cascade(_opts(source="clip.wav"), AppState(), json_mode=False) + # Ctrl-C ends the cascade cleanly (Stopped., renderer closed) but exits 130 (cancel), + # not success, so a caller can tell an interrupt from a normal finish. + with pytest.raises(typer.Exit) as exc: + run_agent_cascade(_opts(source="clip.wav"), AppState(), json_mode=False) + assert exc.value.exit_code == 130 assert rendered["r"].stopped_called is True assert rendered["r"].closed is True diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index c5abce4e..ae7f7875 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -135,7 +135,8 @@ def raise_kbd(*a, **k): monkeypatch.setattr("aai_cli.commands.agent._exec.run_session", raise_kbd) result = runner.invoke(app, ["agent"]) - assert result.exit_code == 0 + # Ctrl-C is a cancel, not success: exit 130. + assert result.exit_code == 130 def test_agent_installs_sigterm_handler_around_session(monkeypatch): diff --git a/tests/test_command_options_seam.py b/tests/test_command_options_seam.py index cd623599..1a7d657d 100644 --- a/tests/test_command_options_seam.py +++ b/tests/test_command_options_seam.py @@ -171,8 +171,9 @@ def __init__(self, *, target_rate=None, device=None): self.player = object() -def test_run_agent_ctrl_c_stops_cleanly(monkeypatch): - # Ctrl-C is the normal "user hung up" signal: the session ends without an error. +def test_run_agent_ctrl_c_exits_with_cancel_code(monkeypatch): + # Ctrl-C is the "user hung up" signal: the session ends cleanly but exits 130 + # (cancel), so a caller can tell an interrupt from a clean finish. config.set_api_key("default", "sk_live") def raise_interrupt(api_key, *, renderer, player, mic, config): @@ -180,7 +181,9 @@ def raise_interrupt(api_key, *, renderer, player, mic, config): monkeypatch.setattr(agent_exec, "run_session", raise_interrupt) monkeypatch.setattr(agent_exec, "DuplexAudio", _FakeDuplex) - agent_exec.run_agent(AGENT_DEFAULTS, AppState(), json_mode=True) # no exception + with pytest.raises(typer.Exit) as exc: + agent_exec.run_agent(AGENT_DEFAULTS, AppState(), json_mode=True) + assert exc.value.exit_code == 130 def test_run_agent_broken_pipe_exits_zero(monkeypatch): diff --git a/tests/test_context.py b/tests/test_context.py index e0d0285b..ce3e4475 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -437,6 +437,16 @@ def body(state, json_mode): assert "Unexpected error" not in result.output +def test_run_command_maps_keyboard_interrupt_to_cancel_code(): + # A Ctrl-C reaching run_command (a command with no interactive handler of its own) + # is a cancel: exit 130, not Click's bare "Aborted!" / exit 1. + def body(state, json_mode): + raise KeyboardInterrupt + + result = runner.invoke(_make_app(body), ["go"]) + assert result.exit_code == 130 + + def test_run_command_lets_broken_pipe_propagate(): # The closed-pipe contract is owned by main.run(); run_command must not eat it. def body(state, json_mode): diff --git a/tests/test_dictate_exec.py b/tests/test_dictate_exec.py index 7be9a069..112c4a71 100644 --- a/tests/test_dictate_exec.py +++ b/tests/test_dictate_exec.py @@ -13,6 +13,7 @@ import json import pytest +import typer from aai_cli.app.context import AppState from aai_cli.commands.dictate import _exec as dictate_exec @@ -342,10 +343,13 @@ def fake_status(message, *, json_mode, quiet=False): assert seen == {"message": "Transcribing…", "json_mode": False, "quiet": True} -def test_ctrl_c_ends_the_session_cleanly(seams): +def test_ctrl_c_exits_with_cancel_code(seams): keys = RaisingKeys([]) seams["keys"] = keys - _run() # no exception + # Ctrl-C cancels dictation: exit 130 (distinct from `q`, which finishes with 0). + with pytest.raises(typer.Exit) as exc: + _run() + assert exc.value.exit_code == 130 assert keys.exited # the with-block unwound, restoring the terminal diff --git a/tests/test_init_runner.py b/tests/test_init_runner.py index 49a31287..16a4f11d 100644 --- a/tests/test_init_runner.py +++ b/tests/test_init_runner.py @@ -214,7 +214,7 @@ def test_launch_and_open_handles_keyboard_interrupt(monkeypatch): monkeypatch.setattr(runner, "wait_for_port", lambda port: True) monkeypatch.setattr(runner.webbrowser, "open", lambda url: None) rc = runner.launch_and_open(Path("/proj"), port=3000, use_uv=True, open_browser=False) - assert rc == 0 # clean Ctrl-C shutdown + assert rc == 130 # Ctrl-C is a cancel, not a clean (0) shutdown assert proc.terminated is True diff --git a/tests/test_llm_follow.py b/tests/test_llm_follow.py index 9755eec8..e5b5e598 100644 --- a/tests/test_llm_follow.py +++ b/tests/test_llm_follow.py @@ -155,9 +155,9 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, e assert calls == [] # no API call was made -def test_llm_follow_interrupt_before_first_turn_still_exits_0(monkeypatch): - # Ctrl-C before any turn arrives is the normal "stop watching" signal, not the - # empty-stdin usage error. +def test_llm_follow_interrupt_before_first_turn_exits_cancel(monkeypatch): + # Ctrl-C before any turn arrives is a cancel (exit 130), not the empty-stdin usage + # error — the user stopped watching, they didn't misuse the flag. _auth() class _InterruptIter: @@ -172,7 +172,7 @@ def __next__(self): ) monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) result = runner.invoke(app, ["llm", "summarize", "--follow", "--json"], input="") - assert result.exit_code == 0 + assert result.exit_code == 130 assert "--follow needs transcript text piped on stdin" not in result.output @@ -190,8 +190,8 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, e result = runner.invoke( app, ["llm", "summarize", "--follow", "--json"], input="alpha\nbeta\ngamma\n" ) - # Ctrl-C is a normal stop, not an error. - assert result.exit_code == 0 + # Ctrl-C is a cancel (exit 130), not a clean finish. + assert result.exit_code == 130 updates = [json.loads(line) for line in result.output.splitlines() if line.strip()] assert len(updates) == 1 assert updates[0]["turns"] == 1 diff --git a/tests/test_share.py b/tests/test_share.py index ade6a312..dc51dea4 100644 --- a/tests/test_share.py +++ b/tests/test_share.py @@ -254,7 +254,7 @@ def await_and_remove(log_path, **kwargs): assert result.exit_code == 0, result.output -def test_share_keyboard_interrupt_is_clean(tmp_path, monkeypatch): +def test_share_keyboard_interrupt_exits_cancel_and_cleans_up(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) _make_project(tmp_path) server, proxy = _stub( @@ -263,7 +263,8 @@ def test_share_keyboard_interrupt_is_clean(tmp_path, monkeypatch): proxy=_FakeProc(poll_rc=None), ) result = runner.invoke(app, ["share"]) - assert result.exit_code == 0 + # Ctrl-C cancels the foreground share: exit 130, and the tunnel/server still torn down. + assert result.exit_code == 130 assert server.terminated is True assert proxy.terminated is True diff --git a/tests/test_stream_batch.py b/tests/test_stream_batch.py index da586b24..6a58ecc4 100644 --- a/tests/test_stream_batch.py +++ b/tests/test_stream_batch.py @@ -129,9 +129,9 @@ def fake_stream_audio(api_key, source, *, params, **_kwargs): _patch_batch_inputs(monkeypatch, fake_stream_audio) result = runner.invoke(app, ["stream", "--from-stdin"], input="a.wav\nb.wav\n") - # One Ctrl-C stops the whole batch (exit 0), not just the current source. + # One Ctrl-C stops the whole batch (exit 130, cancel), not just the current source. assert streamed == ["a.wav"] - assert result.exit_code == 0 + assert result.exit_code == 130 assert "Stopped." in result.output diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index e081a882..1148c793 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -179,7 +179,7 @@ def test_stream_url_source_uses_filesource(monkeypatch): assert seen["source"].source == "https://example.com/clip.mp3" -def test_stream_ctrl_c_exits_cleanly(monkeypatch): +def test_stream_ctrl_c_exits_with_cancel_code(monkeypatch): config.set_api_key("default", "sk_live") def raise_kbd(*a, **k): @@ -187,7 +187,8 @@ def raise_kbd(*a, **k): monkeypatch.setattr("aai_cli.commands.stream._exec.client.stream_audio", raise_kbd) result = runner.invoke(app, ["stream"]) - assert result.exit_code == 0 + # Ctrl-C is a cancel, not success: exit 130 so `stream && next` doesn't run `next`. + assert result.exit_code == 130 def test_stream_ctrl_c_human_mode_prints_stopped(monkeypatch): @@ -198,7 +199,7 @@ def raise_kbd(*a, **k): monkeypatch.setattr("aai_cli.commands.stream._exec.client.stream_audio", raise_kbd) result = runner.invoke(app, ["stream"]) - assert result.exit_code == 0 + assert result.exit_code == 130 assert "Stopped." in result.output diff --git a/tests/test_stream_system_audio.py b/tests/test_stream_system_audio.py index 8ffecfa6..5cc54b3e 100644 --- a/tests/test_stream_system_audio.py +++ b/tests/test_stream_system_audio.py @@ -363,7 +363,7 @@ def start(self): monkeypatch.setattr("aai_cli.commands.stream._exec.MicrophoneSource", FakeMic) monkeypatch.setattr("aai_cli.streaming.session.threading.Thread", InterruptingThread) result = runner.invoke(app, ["stream", "--system-audio"]) - assert result.exit_code == 0 + assert result.exit_code == 130 assert "Stopped." in result.output diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 9b3fc167..36acfb97 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -359,16 +359,30 @@ def test_track_cli_error_keeps_error_type_and_reraises(events): @pytest.mark.parametrize( - ("code", "outcome"), [(0, "success"), (3, "error")], ids=["exit-0", "exit-3"] + ("code", "outcome"), + [(0, "success"), (3, "error"), (130, "cancelled")], + ids=["exit-0", "exit-3", "exit-130"], ) def test_track_typer_exit_maps_code(events, code, outcome): + # 130 (the Ctrl-C cancel code, e.g. an interactive command's own handler) reads as + # "cancelled", not a generic "error", so it doesn't inflate the crash rate. with pytest.raises(typer.Exit), telemetry.track("aai login"): _raise(typer.Exit(code=code)) (event,) = events assert event["outcome"] == outcome assert event["exit_code"] == code # A bare typer.Exit carries no message, so the failure event has only the kind. - assert event.get("error") == ({"kind": "error"} if code else None) + assert event.get("error") == ({"kind": outcome} if code else None) + + +def test_track_keyboard_interrupt_is_cancelled(events): + # A raw Ctrl-C reaching track() (a command with no interactive handler) is recorded + # as a cancel at exit 130, never as an internal_error crash. + with pytest.raises(KeyboardInterrupt), telemetry.track("aai stream"): + _raise(KeyboardInterrupt()) + (event,) = events + assert event["outcome"] == "cancelled" + assert event["exit_code"] == 130 def test_track_unexpected_exception_is_internal_error(events): diff --git a/tests/test_webhook_listen.py b/tests/test_webhook_listen.py index c735e5b0..de26bdaa 100644 --- a/tests/test_webhook_listen.py +++ b/tests/test_webhook_listen.py @@ -328,7 +328,8 @@ def test_listen_public_prints_tunnel_url_and_cleans_up(tmp_path, monkeypatch): monkeypatch, tmp_path, url="https://hook-slug.trycloudflare.com" ) result = runner.invoke(app, ["webhooks", "listen"]) - assert result.exit_code == 0, result.output + # Ctrl-C stops the foreground listener: exit 130 (cancel), still cleaning up below. + assert result.exit_code == 130, result.output assert seen["preferred"] == 8989 # the documented default port assert "Listening for webhooks https://hook-slug.trycloudflare.com" in result.output # Rich wraps the long hint line mid-token; compare with whitespace removed. @@ -356,7 +357,7 @@ def test_listen_accepts_explicit_max_events_zero(monkeypatch): "aai_cli.commands.webhooks._listen.ThreadingHTTPServer.serve_forever", _raise_interrupt ) result = runner.invoke(app, ["webhooks", "listen", "--no-tunnel", "--max-events", "0"]) - assert result.exit_code == 0, result.output + assert result.exit_code == 130, result.output # Ctrl-C cancel assert "Listening for webhooks" in result.output