diff --git a/cueapi/cli.py b/cueapi/cli.py index 6f55d2b..12d078a 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -1945,6 +1945,35 @@ def messages() -> None: "--send-at (PR #618). Sent as a BODY field on POST /v1/messages." ), ) +@click.option( + "--notify", + "notify", + multiple=True, + help=( + "§17 BCC-light (hosted PR #619): each agent in the list gets a stripped " + "notification copy (subject + sender + recipient + 1-line summary, no full body) " + "alongside the main delivery, sharing the main message's thread_id so notify " + "recipients can reply into the conversation. Repeat the flag for multiple " + "recipients (max 10). Each entry must be a fully-qualified agent ref (opaque " + "agt_xxx or slug-form agent@user). Self-bcc is silently de-duped server-side. " + "Notifications skip the monthly quota and are pinned to priority=3." + ), +) +@click.option( + "--mode", + "mode", + default="auto", + type=click.Choice(["live", "bg", "inbox", "webhook", "auto"]), + show_default=True, + help=( + "Surface 6 v2 delivery_mode hint (parity with `message-to`). " + "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 based on recipient capabilities " + "and may downgrade. The CLI omits the field on the wire when set to " + "`auto` (or omitted) — server treats absent === auto." + ), +) @click.pass_context def messages_send( ctx: click.Context, @@ -1959,6 +1988,8 @@ def messages_send( metadata: Optional[str], idempotency_key: Optional[str], send_at: Optional[str], + notify: tuple, + mode: str, ) -> None: """Send a message.""" body: dict = {"to": to, "body": body_text} @@ -1984,6 +2015,19 @@ def messages_send( # body field). Different from idempotency_key, which is a header. if send_at: body["send_at"] = send_at + # §17 BCC-light: list of agent refs gets a stripped notification copy + # alongside the main delivery (server contract: MessageCreate.notify + # field, max-10 server-validated). Default-omit when empty so the + # wire format matches pre-#619 senders (no payload noise on the + # common path). + if notify: + if len(notify) > 10: + raise click.UsageError("--notify accepts at most 10 entries (server cap)") + body["notify"] = list(notify) + # Surface 6 v2 delivery_mode — default-omit `auto` to keep wire-format + # identical to pre-Surface-6 senders. Server treats absent === auto. + if mode != "auto": + body["delivery_mode"] = mode headers: dict = {"X-Cueapi-From-Agent": from_agent} if idempotency_key: @@ -2269,6 +2313,18 @@ def _resolve_recipient(client, recipient: str) -> str: "--send-at (PR #618). Sent as a BODY field on POST /v1/messages." ), ) +@click.option( + "--notify", + "notify", + multiple=True, + help=( + "§17 BCC-light (hosted PR #619): each agent in the list gets a stripped " + "notification copy alongside the main delivery, sharing the main message's " + "thread_id. Repeat the flag for multiple recipients (max 10). Each entry " + "must be a fully-qualified agent ref. Self-bcc silently de-duped server-side. " + "Notifications skip the monthly quota and are pinned to priority=3." + ), +) @click.pass_context def message_to( ctx: click.Context, @@ -2284,6 +2340,7 @@ def message_to( mode: str, idempotency_key: Optional[str], send_at: Optional[str], + notify: tuple, ) -> None: """Send a message to a recipient by name, slug, or agent ID. @@ -2318,6 +2375,13 @@ def message_to( # which is a header. if send_at: body["send_at"] = send_at + # §17 BCC-light: list of agent refs gets a stripped notification copy + # alongside the main delivery (server contract: MessageCreate.notify, + # max-10 server-validated). Default-omit when empty. + if notify: + if len(notify) > 10: + raise click.UsageError("--notify accepts at most 10 entries (server cap)") + body["notify"] = list(notify) headers: dict = {"X-Cueapi-From-Agent": from_agent} if idempotency_key: diff --git a/tests/test_cli.py b/tests/test_cli.py index a196494..4cef516 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2045,6 +2045,155 @@ def test_messages_send_omits_expects_reply_when_unset(monkeypatch): assert "expects_reply" not in body +def test_messages_send_notify_passed_as_body_list(monkeypatch): + # §17 BCC-light: notify flows in body as a list (server contract: + # MessageCreate.notify, app/schemas/message.py). + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp(201, {"id": "msg_x", "delivery_state": "queued"}) + }, + ) + result = runner.invoke( + main, + ["messages", "send", "--from", "x", "--to", "y", "--body", "hi", + "--notify", "agt_a", "--notify", "agt_b"], + ) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert body["notify"] == ["agt_a", "agt_b"] + + +def test_messages_send_notify_omitted_when_unset(monkeypatch): + # Default-omit: empty notify must not appear on the wire (matches + # pre-#619 senders). + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp(201, {"id": "msg_x", "delivery_state": "queued"}) + }, + ) + result = runner.invoke( + main, + ["messages", "send", "--from", "x", "--to", "y", "--body", "hi"], + ) + assert result.exit_code == 0 + body = holder["client"].calls[-1][2] + assert "notify" not in body + + +def test_messages_send_notify_max_10_enforced_client_side(): + # Server caps at 10; CLI rejects 11+ before hitting the wire to + # surface the error at parse time instead of as a 422. + eleven = [arg for ref in [f"agt_{i}" for i in range(11)] for arg in ("--notify", ref)] + result = runner.invoke( + main, + ["messages", "send", "--from", "x", "--to", "y", "--body", "hi", *eleven], + ) + assert result.exit_code != 0 + assert "at most 10" in result.output + + +def test_messages_send_mode_default_omitted(monkeypatch): + # Surface 6 v2: default mode=auto is omitted on the wire (matches + # pre-Surface-6 senders). + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp(201, {"id": "msg_x", "delivery_state": "queued"}) + }, + ) + result = runner.invoke( + main, + ["messages", "send", "--from", "x", "--to", "y", "--body", "hi"], + ) + assert result.exit_code == 0 + body = holder["client"].calls[-1][2] + assert "delivery_mode" not in body + + +def test_messages_send_mode_explicit_passed_through(monkeypatch): + # Explicit non-auto modes flow as body.delivery_mode. + for mode_value in ("live", "bg", "inbox", "webhook"): + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp(201, {"id": "msg_x", "delivery_state": "queued"}) + }, + ) + result = runner.invoke( + main, + ["messages", "send", "--from", "x", "--to", "y", "--body", "hi", "--mode", mode_value], + ) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert body["delivery_mode"] == mode_value + + +def test_messages_send_mode_auto_explicitly_omitted(monkeypatch): + # Even when caller explicitly passes --mode auto, omit on the wire. + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp(201, {"id": "msg_x", "delivery_state": "queued"}) + }, + ) + result = runner.invoke( + main, + ["messages", "send", "--from", "x", "--to", "y", "--body", "hi", "--mode", "auto"], + ) + assert result.exit_code == 0 + body = holder["client"].calls[-1][2] + assert "delivery_mode" not in body + + +def test_message_to_notify_passed_as_body_list(monkeypatch): + 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", + "--notify", "agt_a", "--notify", "agt_b"], + ) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert body["notify"] == ["agt_a", "agt_b"] + + +def test_message_to_notify_omitted_when_unset(monkeypatch): + 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 + body = holder["client"].calls[-1][2] + assert "notify" not in body + + def test_messages_send_priority_validated_by_click_intrange(): result = runner.invoke( main,