Skip to content

Commit a9151b1

Browse files
authored
feat(cli): add --notify (PR #619 BCC-light) + --mode parity to messages send + --notify to message-to (#49)
Closes the cli-side coverage gap that was blocking action ports for --mode and --notify on `messages send`. ## --notify (hosted PR #619, §17 BCC-light) Both `messages send` and `message-to` now accept `--notify <agent_ref>` (repeatable, max 10). Each ref gets a stripped notification copy alongside the main delivery, sharing thread_id. Server contract: MessageCreate.notify (List[str], max-10, app/schemas/message.py). CLI enforces the max-10 cap client-side via UsageError so the failure surfaces at parse time instead of as a server 422. ## --mode parity for messages send cueapi-cli #41 added `--mode` to `message-to` (Surface 6 v2 delivery_mode) but `messages send` was left without it — a cross-product gap surfaced when porting cueapi-action #12 (action couldn't wire --mode through messages-send). Mirroring the existing shape from `message-to`: - click.Choice(["live","bg","inbox","webhook","auto"]), default "auto" - Default-omit: only emit `delivery_mode` on the wire when caller opts away from `auto`. Server treats absent === auto, so the wire format stays identical to pre-Surface-6 senders for the common path. Pinned in regression-guard tests so explicit `--mode auto` is also omitted (not just default omission). ## Wire format Both fields are body fields on POST /v1/messages: - `notify`: List[str], default omit when empty - `delivery_mode`: enum, default omit when "auto" Different from `idempotency_key` (header) and `from_agent` (X-Cueapi-From-Agent header). Verify-server-transport-per-endpoint discipline applied. ## Tests 8 new tests: - messages_send_notify_passed_as_body_list - messages_send_notify_omitted_when_unset - messages_send_notify_max_10_enforced_client_side - messages_send_mode_default_omitted - messages_send_mode_explicit_passed_through (live/bg/inbox/webhook) - messages_send_mode_auto_explicitly_omitted - message_to_notify_passed_as_body_list - message_to_notify_omitted_when_unset 179/179 pass total. ## Why now This unblocks 2 follow-on cueapi-action ports: - action `messages-send` --notify wire-through - action `messages-send` --mode wire-through (reusing existing `mode` input from PR #13 which already wires it for `message-to`) Will chain a small action follow-up PR after this lands.
1 parent 1b7b970 commit a9151b1

2 files changed

Lines changed: 213 additions & 0 deletions

File tree

cueapi/cli.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1945,6 +1945,35 @@ def messages() -> None:
19451945
"--send-at (PR #618). Sent as a BODY field on POST /v1/messages."
19461946
),
19471947
)
1948+
@click.option(
1949+
"--notify",
1950+
"notify",
1951+
multiple=True,
1952+
help=(
1953+
"§17 BCC-light (hosted PR #619): each agent in the list gets a stripped "
1954+
"notification copy (subject + sender + recipient + 1-line summary, no full body) "
1955+
"alongside the main delivery, sharing the main message's thread_id so notify "
1956+
"recipients can reply into the conversation. Repeat the flag for multiple "
1957+
"recipients (max 10). Each entry must be a fully-qualified agent ref (opaque "
1958+
"agt_xxx or slug-form agent@user). Self-bcc is silently de-duped server-side. "
1959+
"Notifications skip the monthly quota and are pinned to priority=3."
1960+
),
1961+
)
1962+
@click.option(
1963+
"--mode",
1964+
"mode",
1965+
default="auto",
1966+
type=click.Choice(["live", "bg", "inbox", "webhook", "auto"]),
1967+
show_default=True,
1968+
help=(
1969+
"Surface 6 v2 delivery_mode hint (parity with `message-to`). "
1970+
"live = recipient's attached Live session; bg = spawn a fresh background "
1971+
"session; inbox = leave in inbox for pull; webhook = POST to recipient's "
1972+
"configured webhook; auto = server picks based on recipient capabilities "
1973+
"and may downgrade. The CLI omits the field on the wire when set to "
1974+
"`auto` (or omitted) — server treats absent === auto."
1975+
),
1976+
)
19481977
@click.pass_context
19491978
def messages_send(
19501979
ctx: click.Context,
@@ -1959,6 +1988,8 @@ def messages_send(
19591988
metadata: Optional[str],
19601989
idempotency_key: Optional[str],
19611990
send_at: Optional[str],
1991+
notify: tuple,
1992+
mode: str,
19621993
) -> None:
19631994
"""Send a message."""
19641995
body: dict = {"to": to, "body": body_text}
@@ -1984,6 +2015,19 @@ def messages_send(
19842015
# body field). Different from idempotency_key, which is a header.
19852016
if send_at:
19862017
body["send_at"] = send_at
2018+
# §17 BCC-light: list of agent refs gets a stripped notification copy
2019+
# alongside the main delivery (server contract: MessageCreate.notify
2020+
# field, max-10 server-validated). Default-omit when empty so the
2021+
# wire format matches pre-#619 senders (no payload noise on the
2022+
# common path).
2023+
if notify:
2024+
if len(notify) > 10:
2025+
raise click.UsageError("--notify accepts at most 10 entries (server cap)")
2026+
body["notify"] = list(notify)
2027+
# Surface 6 v2 delivery_mode — default-omit `auto` to keep wire-format
2028+
# identical to pre-Surface-6 senders. Server treats absent === auto.
2029+
if mode != "auto":
2030+
body["delivery_mode"] = mode
19872031

19882032
headers: dict = {"X-Cueapi-From-Agent": from_agent}
19892033
if idempotency_key:
@@ -2269,6 +2313,18 @@ def _resolve_recipient(client, recipient: str) -> str:
22692313
"--send-at (PR #618). Sent as a BODY field on POST /v1/messages."
22702314
),
22712315
)
2316+
@click.option(
2317+
"--notify",
2318+
"notify",
2319+
multiple=True,
2320+
help=(
2321+
"§17 BCC-light (hosted PR #619): each agent in the list gets a stripped "
2322+
"notification copy alongside the main delivery, sharing the main message's "
2323+
"thread_id. Repeat the flag for multiple recipients (max 10). Each entry "
2324+
"must be a fully-qualified agent ref. Self-bcc silently de-duped server-side. "
2325+
"Notifications skip the monthly quota and are pinned to priority=3."
2326+
),
2327+
)
22722328
@click.pass_context
22732329
def message_to(
22742330
ctx: click.Context,
@@ -2284,6 +2340,7 @@ def message_to(
22842340
mode: str,
22852341
idempotency_key: Optional[str],
22862342
send_at: Optional[str],
2343+
notify: tuple,
22872344
) -> None:
22882345
"""Send a message to a recipient by name, slug, or agent ID.
22892346
@@ -2318,6 +2375,13 @@ def message_to(
23182375
# which is a header.
23192376
if send_at:
23202377
body["send_at"] = send_at
2378+
# §17 BCC-light: list of agent refs gets a stripped notification copy
2379+
# alongside the main delivery (server contract: MessageCreate.notify,
2380+
# max-10 server-validated). Default-omit when empty.
2381+
if notify:
2382+
if len(notify) > 10:
2383+
raise click.UsageError("--notify accepts at most 10 entries (server cap)")
2384+
body["notify"] = list(notify)
23212385

23222386
headers: dict = {"X-Cueapi-From-Agent": from_agent}
23232387
if idempotency_key:

tests/test_cli.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2045,6 +2045,155 @@ def test_messages_send_omits_expects_reply_when_unset(monkeypatch):
20452045
assert "expects_reply" not in body
20462046

20472047

2048+
def test_messages_send_notify_passed_as_body_list(monkeypatch):
2049+
# §17 BCC-light: notify flows in body as a list (server contract:
2050+
# MessageCreate.notify, app/schemas/message.py).
2051+
holder: dict = {}
2052+
_patch_messages_client(
2053+
monkeypatch,
2054+
holder,
2055+
responses={
2056+
("POST", "/messages"): lambda: _FakeResp(201, {"id": "msg_x", "delivery_state": "queued"})
2057+
},
2058+
)
2059+
result = runner.invoke(
2060+
main,
2061+
["messages", "send", "--from", "x", "--to", "y", "--body", "hi",
2062+
"--notify", "agt_a", "--notify", "agt_b"],
2063+
)
2064+
assert result.exit_code == 0, result.output
2065+
body = holder["client"].calls[-1][2]
2066+
assert body["notify"] == ["agt_a", "agt_b"]
2067+
2068+
2069+
def test_messages_send_notify_omitted_when_unset(monkeypatch):
2070+
# Default-omit: empty notify must not appear on the wire (matches
2071+
# pre-#619 senders).
2072+
holder: dict = {}
2073+
_patch_messages_client(
2074+
monkeypatch,
2075+
holder,
2076+
responses={
2077+
("POST", "/messages"): lambda: _FakeResp(201, {"id": "msg_x", "delivery_state": "queued"})
2078+
},
2079+
)
2080+
result = runner.invoke(
2081+
main,
2082+
["messages", "send", "--from", "x", "--to", "y", "--body", "hi"],
2083+
)
2084+
assert result.exit_code == 0
2085+
body = holder["client"].calls[-1][2]
2086+
assert "notify" not in body
2087+
2088+
2089+
def test_messages_send_notify_max_10_enforced_client_side():
2090+
# Server caps at 10; CLI rejects 11+ before hitting the wire to
2091+
# surface the error at parse time instead of as a 422.
2092+
eleven = [arg for ref in [f"agt_{i}" for i in range(11)] for arg in ("--notify", ref)]
2093+
result = runner.invoke(
2094+
main,
2095+
["messages", "send", "--from", "x", "--to", "y", "--body", "hi", *eleven],
2096+
)
2097+
assert result.exit_code != 0
2098+
assert "at most 10" in result.output
2099+
2100+
2101+
def test_messages_send_mode_default_omitted(monkeypatch):
2102+
# Surface 6 v2: default mode=auto is omitted on the wire (matches
2103+
# pre-Surface-6 senders).
2104+
holder: dict = {}
2105+
_patch_messages_client(
2106+
monkeypatch,
2107+
holder,
2108+
responses={
2109+
("POST", "/messages"): lambda: _FakeResp(201, {"id": "msg_x", "delivery_state": "queued"})
2110+
},
2111+
)
2112+
result = runner.invoke(
2113+
main,
2114+
["messages", "send", "--from", "x", "--to", "y", "--body", "hi"],
2115+
)
2116+
assert result.exit_code == 0
2117+
body = holder["client"].calls[-1][2]
2118+
assert "delivery_mode" not in body
2119+
2120+
2121+
def test_messages_send_mode_explicit_passed_through(monkeypatch):
2122+
# Explicit non-auto modes flow as body.delivery_mode.
2123+
for mode_value in ("live", "bg", "inbox", "webhook"):
2124+
holder: dict = {}
2125+
_patch_messages_client(
2126+
monkeypatch,
2127+
holder,
2128+
responses={
2129+
("POST", "/messages"): lambda: _FakeResp(201, {"id": "msg_x", "delivery_state": "queued"})
2130+
},
2131+
)
2132+
result = runner.invoke(
2133+
main,
2134+
["messages", "send", "--from", "x", "--to", "y", "--body", "hi", "--mode", mode_value],
2135+
)
2136+
assert result.exit_code == 0, result.output
2137+
body = holder["client"].calls[-1][2]
2138+
assert body["delivery_mode"] == mode_value
2139+
2140+
2141+
def test_messages_send_mode_auto_explicitly_omitted(monkeypatch):
2142+
# Even when caller explicitly passes --mode auto, omit on the wire.
2143+
holder: dict = {}
2144+
_patch_messages_client(
2145+
monkeypatch,
2146+
holder,
2147+
responses={
2148+
("POST", "/messages"): lambda: _FakeResp(201, {"id": "msg_x", "delivery_state": "queued"})
2149+
},
2150+
)
2151+
result = runner.invoke(
2152+
main,
2153+
["messages", "send", "--from", "x", "--to", "y", "--body", "hi", "--mode", "auto"],
2154+
)
2155+
assert result.exit_code == 0
2156+
body = holder["client"].calls[-1][2]
2157+
assert "delivery_mode" not in body
2158+
2159+
2160+
def test_message_to_notify_passed_as_body_list(monkeypatch):
2161+
holder: dict = {}
2162+
_patch_messages_client(
2163+
monkeypatch,
2164+
holder,
2165+
responses={
2166+
("POST", "/messages"): lambda: _FakeResp(201, {"id": "msg_z", "delivery_state": "queued"})
2167+
},
2168+
)
2169+
result = runner.invoke(
2170+
main,
2171+
["message-to", "agt_x", "--from", "y@z", "--body", "hi",
2172+
"--notify", "agt_a", "--notify", "agt_b"],
2173+
)
2174+
assert result.exit_code == 0, result.output
2175+
body = holder["client"].calls[-1][2]
2176+
assert body["notify"] == ["agt_a", "agt_b"]
2177+
2178+
2179+
def test_message_to_notify_omitted_when_unset(monkeypatch):
2180+
holder: dict = {}
2181+
_patch_messages_client(
2182+
monkeypatch,
2183+
holder,
2184+
responses={
2185+
("POST", "/messages"): lambda: _FakeResp(201, {"id": "msg_z", "delivery_state": "queued"})
2186+
},
2187+
)
2188+
result = runner.invoke(
2189+
main,
2190+
["message-to", "agt_x", "--from", "y@z", "--body", "hi"],
2191+
)
2192+
assert result.exit_code == 0
2193+
body = holder["client"].calls[-1][2]
2194+
assert "notify" not in body
2195+
2196+
20482197
def test_messages_send_priority_validated_by_click_intrange():
20492198
result = runner.invoke(
20502199
main,

0 commit comments

Comments
 (0)