Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions cueapi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
149 changes: 149 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down