From 7d42887a80750de2b744c233b2c284bc8c9b8269 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 14:13:00 +0000 Subject: [PATCH] fix(cli): classify broken pipes as user aborts Co-authored-by: Blaine Kasten --- src/together/lib/cli/__init__.py | 8 +++++++- src/together/lib/cli/utils/_console.py | 16 +++++++++++++++- tests/cli/test_command_telemetry.py | 19 +++++++++++++++++++ tests/cli/test_console_theme.py | 11 +++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/together/lib/cli/__init__.py b/src/together/lib/cli/__init__.py index 7e5d24274..eb42e848f 100644 --- a/src/together/lib/cli/__init__.py +++ b/src/together/lib/cli/__init__.py @@ -24,7 +24,7 @@ from together.lib.cli.utils._exit import CliDiagnosticExit from together.lib.cli.utils.config import CLIConfig from together.lib.cli.utils._prompt import PromptParameter -from together.lib.cli.utils._console import console +from together.lib.cli.utils._console import CliBrokenPipeError, console from together.lib.cli.utils._api_error import try_handle_server_error_message from together.lib.cli.utils._completion import _is_agent_or_ci, install_completion from together.lib.cli.utils._help_examples import ( @@ -357,6 +357,12 @@ async def run_command() -> None: {"command": parsed_command, "arguments": explicit_args, "is_beta_command": is_beta_command}, ) command_succeeded = True + except CliBrokenPipeError: + track_cli( + CliTrackingEvents.CommandUserAborted, + {"command": parsed_command, "arguments": explicit_args, "is_beta_command": is_beta_command}, + ) + sys.exit(1) except KeyboardInterrupt: track_cli( CliTrackingEvents.CommandUserAborted, diff --git a/src/together/lib/cli/utils/_console.py b/src/together/lib/cli/utils/_console.py index 7b8d96ad8..b6fc4461e 100644 --- a/src/together/lib/cli/utils/_console.py +++ b/src/together/lib/cli/utils/_console.py @@ -3,6 +3,7 @@ import os import sys from typing import Any, TextIO, Literal, cast +from typing_extensions import override from rich.theme import Theme from rich.console import Console @@ -11,6 +12,19 @@ StreamName = Literal["stdout", "stderr"] +class CliBrokenPipeError(BrokenPipeError): + """Raised when Rich stops rendering because an output consumer closed its pipe.""" + + +class _CliConsole(Console): + @override + def on_broken_pipe(self) -> None: + try: + super().on_broken_pipe() + except SystemExit: + raise CliBrokenPipeError from None + + class _EncodingSafeStream: """Proxy writes through the current stdio stream without encoding crashes.""" @@ -141,7 +155,7 @@ def build_theme(theme_name: CliThemeName | None = None) -> Theme: def create_console(theme_name: CliThemeName | None = None, *, stderr: bool = False) -> Console: stream_name: StreamName = "stderr" if stderr else "stdout" stream = cast(TextIO, _EncodingSafeStream(stream_name)) - return Console(theme=build_theme(theme_name), highlight=False, file=stream, stderr=stderr) + return _CliConsole(theme=build_theme(theme_name), highlight=False, file=stream, stderr=stderr) cli_theme_name: CliThemeName = resolve_cli_theme() diff --git a/tests/cli/test_command_telemetry.py b/tests/cli/test_command_telemetry.py index 83e990112..fe65656b8 100644 --- a/tests/cli/test_command_telemetry.py +++ b/tests/cli/test_command_telemetry.py @@ -12,6 +12,7 @@ from tests.cli.utils import CliRunner from together.lib.cli import app as tg_app from together.lib.cli._track_cli import CliTrackingEvents +from together.lib.cli.utils._console import CliBrokenPipeError def _reset_telemetry_command_specs() -> None: @@ -211,3 +212,21 @@ def _interrupt() -> None: CliTrackingEvents.CommandStarted.value, CliTrackingEvents.CommandUserAborted.value, ] + + +@pytest.mark.usefixtures("isolated_cli_config") +def test_command_broken_pipe_emits_started_then_user_aborted( + track_cli_capture: list[tuple[CliTrackingEvents, dict[str, Any]]], + cli_runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _broken_pipe() -> None: + raise CliBrokenPipeError + + monkeypatch.setattr("together.lib.cli.api.telemetry.status.status", _broken_pipe) + r = cli_runner.invoke(["telemetry", "status"]) + assert r.exit_code == 1 + assert _event_kinds(track_cli_capture) == [ + CliTrackingEvents.CommandStarted.value, + CliTrackingEvents.CommandUserAborted.value, + ] diff --git a/tests/cli/test_console_theme.py b/tests/cli/test_console_theme.py index b5b2873cd..9b47c1989 100644 --- a/tests/cli/test_console_theme.py +++ b/tests/cli/test_console_theme.py @@ -9,6 +9,7 @@ from together.lib.cli.utils._console import ( _DARK_STYLES, _LIGHT_STYLES, + CliBrokenPipeError, build_theme, create_console, resolve_cli_theme, @@ -127,3 +128,13 @@ def test_console_replaces_characters_unsupported_by_stream_encoding(monkeypatch: cp1252_output.flush() assert raw_output.getvalue().decode("cp1252") == "? ? café\n" + + +def test_console_exposes_rich_broken_pipe_as_distinct_exception(monkeypatch: pytest.MonkeyPatch) -> None: + def raise_system_exit(_console: Console) -> None: + raise SystemExit(1) + + monkeypatch.setattr(Console, "on_broken_pipe", raise_system_exit) + + with pytest.raises(CliBrokenPipeError): + create_console().on_broken_pipe()