diff --git a/cueapi/cli.py b/cueapi/cli.py index e81ebaf..d5f394b 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -2174,6 +2174,21 @@ def _resolve_recipient(client, recipient: str) -> str: help="Decoupled reply target (defaults to the sender).", ) @click.option("--metadata", default=None, help="JSON metadata blob") +@click.option( + "--mode", + "mode", + default="auto", + type=click.Choice(["live", "bg", "inbox", "webhook", "auto"]), + show_default=True, + help=( + "Delivery mode hint. live = recipient's attached Live session, bg = " + "spawn a fresh background session, inbox = leave in inbox for pull, " + "webhook = POST to recipient's configured webhook, auto = server " + "picks the best supported mode based on recipient capabilities. " + "Server may downgrade if the requested mode isn't supported — see " + "`Sent via X` in the response." + ), +) @click.option( "--idempotency-key", "idempotency_key", @@ -2195,6 +2210,7 @@ def message_to( expects_reply: bool, reply_to_agent: Optional[str], metadata: Optional[str], + mode: str, idempotency_key: Optional[str], ) -> None: """Send a message to a recipient by name, slug, or agent ID. @@ -2219,6 +2235,12 @@ def message_to( body["metadata"] = json.loads(metadata) except json.JSONDecodeError: raise click.UsageError("--metadata must be valid JSON") + # Default-omit discipline: only send delivery_mode when the user opted + # away from `auto`. Server treats absent == auto, so this avoids payload + # noise on the common path and keeps wire-format identical to pre-Surface-6 + # senders. `auto` is also redundant to send. + if mode != "auto": + body["delivery_mode"] = mode headers: dict = {"X-Cueapi-From-Agent": from_agent} if idempotency_key: @@ -2246,6 +2268,20 @@ def message_to( if m.get("thread_id"): echo_info("Thread:", m["thread_id"]) echo_info("Delivery state:", m.get("delivery_state", "?")) + # Surface the server's chosen delivery mode. The response's + # `effective_delivery_mode` is the mode the server actually + # used, which may differ from the requested `mode` if the + # recipient doesn't support it (e.g. requested live, recipient + # has no live session, downgraded to inbox). + effective = m.get("effective_delivery_mode") + if effective: + if mode != "auto" and effective != mode: + echo_info( + "Sent via:", + f"{effective} (requested {mode}, recipient does not support it)", + ) + else: + echo_info("Sent via:", effective) downgraded_header = None try: downgraded_header = resp.headers.get("X-CueAPI-Priority-Downgraded") diff --git a/tests/test_cli.py b/tests/test_cli.py index 90ca5ff..58fa8f6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,7 @@ from typing import Any, Optional +import pytest from click.testing import CliRunner from cueapi.cli import main @@ -2599,3 +2600,149 @@ def test_top_level_help_lists_message_to(): result = runner.invoke(main, ["--help"]) assert result.exit_code == 0 assert "message-to" in result.output + + +# --- message-to --mode flag (Surface 6 delivery_mode) --- + + +def test_message_to_mode_default_auto_omits_field(monkeypatch): + # Default is `auto` and the server treats absent == auto, so we don't + # send the field on the common path — keeps wire-format identical to + # pre-Surface-6 senders and avoids payload noise. + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_z", "delivery_state": "queued"} + ) + }, + ) + result = runner.invoke( + main, + ["message-to", "agt_x", "--from", "y@z", "--body", "hi"], + ) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert "delivery_mode" not in body + + +def test_message_to_mode_explicit_auto_still_omits_field(monkeypatch): + # User explicitly typing --mode auto is the same wire-format as omitting + # it. No reason to send the redundant field. + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_z", "delivery_state": "queued"} + ) + }, + ) + result = runner.invoke( + main, + ["message-to", "agt_x", "--from", "y@z", "--body", "hi", "--mode", "auto"], + ) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert "delivery_mode" not in body + + +@pytest.mark.parametrize("mode", ["live", "bg", "inbox", "webhook"]) +def test_message_to_mode_non_auto_passed_through(monkeypatch, mode): + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, + { + "id": "msg_z", + "delivery_state": "queued", + "effective_delivery_mode": mode, + }, + ) + }, + ) + result = runner.invoke( + main, + ["message-to", "agt_x", "--from", "y@z", "--body", "hi", "--mode", mode], + ) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert body["delivery_mode"] == mode + # echo_info pads labels — match the components, not the raw concat. + assert "Sent via:" in result.output + assert mode in result.output + + +def test_message_to_mode_invalid_value_rejected_by_click(): + # Click.Choice covers validation; we just confirm the gate is in place + # so a typo doesn't silently sail past as "auto". + result = runner.invoke( + main, + ["message-to", "agt_x", "--from", "y@z", "--body", "hi", "--mode", "bogus"], + ) + assert result.exit_code != 0 + assert "bogus" in result.output or "invalid choice" in result.output.lower() + + +def test_message_to_surfaces_downgraded_delivery_mode(monkeypatch): + # Server downgrade case: requested `live` but recipient has no live + # session, server delivered via inbox. CLI surfaces both the chosen + # mode and the "you asked for X" hint. + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, + { + "id": "msg_z", + "delivery_state": "queued", + "effective_delivery_mode": "inbox", + }, + ) + }, + ) + result = runner.invoke( + main, + ["message-to", "agt_x", "--from", "y@z", "--body", "hi", "--mode", "live"], + ) + assert result.exit_code == 0, result.output + assert "Sent via:" in result.output + assert "inbox" in result.output + assert "requested live" in result.output + + +def test_message_to_omits_sent_via_when_server_does_not_return_it(monkeypatch): + # Pre-Surface-6 server (or auto + no field) returns no + # effective_delivery_mode. The CLI should not emit a "Sent via:" line. + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_z", "delivery_state": "queued"} + ) + }, + ) + result = runner.invoke( + main, + ["message-to", "agt_x", "--from", "y@z", "--body", "hi"], + ) + assert result.exit_code == 0, result.output + assert "Sent via:" not in result.output + + +def test_message_to_help_lists_mode_flag(): + result = runner.invoke(main, ["message-to", "--help"]) + assert result.exit_code == 0 + assert "--mode" in result.output + for choice in ("live", "bg", "inbox", "webhook", "auto"): + assert choice in result.output