Skip to content

Commit f10548a

Browse files
committed
feat(cli): per-message send_at scheduling on messages send + message-to (#623)
Adds optional `--send-at` (ISO 8601 string) to `cueapi messages send` and `cueapi message-to` — ports the private-monorepo cueapi #623 server-side change that's already shipped on POST /v1/messages (MessageCreate.send_at in app/schemas/message.py). Server contract pinned: - NULL send_at (default) → send now - Future timestamp → message gates inbox-fetch + push delivery until send_at <= now() - Past timestamps → forgiving fallback (treated as send-now) - Same semantics as cue-fire send_at (PR #618, already shipped) Wire format: send_at flows in the BODY of POST /v1/messages, NOT a header. Mirrors the cue-fire send_at transport. Different from idempotency_key, which is a header. Verify-server-transport-per- endpoint pinned in tests so a refactor doesn't accidentally promote it to a header. Tests: - messages_send: 2 new (send_at_omitted_by_default, send_at_passed_in_body) - message_to: 2 new (send_at_passed_in_body, send_at_omitted_when_unset) - 171/171 pass total This unblocks the cueapi-action port (its messages-send shell branch needs the underlying CLI flag to surface --send-at to GitHub Actions callers). Parallels cueapi-python (already shipped) and cueapi-mcp (just shipped via PR #33) ports of the same private-monorepo PR #623.
1 parent 7da34a6 commit f10548a

2 files changed

Lines changed: 143 additions & 0 deletions

File tree

cueapi/cli.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1932,6 +1932,19 @@ def messages() -> None:
19321932
"different body returns HTTP 409 idempotency_key_conflict."
19331933
),
19341934
)
1935+
@click.option(
1936+
"--send-at",
1937+
"send_at",
1938+
default=None,
1939+
help=(
1940+
"Optional ISO 8601 timestamp to schedule this message for future delivery "
1941+
"(hosted PR #623, §13). Omitted = send now (server default). When set in the "
1942+
"future, the message sits in the recipient's inbox-query gate until "
1943+
"send-at <= now(); push-delivery dispatch is also gated. Past timestamps "
1944+
"are treated as send-now (forgiving fallback). Same semantics as cue-fire "
1945+
"--send-at (PR #618). Sent as a BODY field on POST /v1/messages."
1946+
),
1947+
)
19351948
@click.pass_context
19361949
def messages_send(
19371950
ctx: click.Context,
@@ -1945,6 +1958,7 @@ def messages_send(
19451958
reply_to_agent: Optional[str],
19461959
metadata: Optional[str],
19471960
idempotency_key: Optional[str],
1961+
send_at: Optional[str],
19481962
) -> None:
19491963
"""Send a message."""
19501964
body: dict = {"to": to, "body": body_text}
@@ -1965,6 +1979,11 @@ def messages_send(
19651979
body["metadata"] = json.loads(metadata)
19661980
except json.JSONDecodeError:
19671981
raise click.UsageError("--metadata must be valid JSON")
1982+
# send_at flows in the body (server contract: MessageCreate.send_at,
1983+
# app/schemas/message.py). Mirrors cue-fire send_at transport (also a
1984+
# body field). Different from idempotency_key, which is a header.
1985+
if send_at:
1986+
body["send_at"] = send_at
19681987

19691988
headers: dict = {"X-Cueapi-From-Agent": from_agent}
19701989
if idempotency_key:
@@ -2237,6 +2256,19 @@ def _resolve_recipient(client, recipient: str) -> str:
22372256
"24h returns the existing message with HTTP 200 instead of 201."
22382257
),
22392258
)
2259+
@click.option(
2260+
"--send-at",
2261+
"send_at",
2262+
default=None,
2263+
help=(
2264+
"Optional ISO 8601 timestamp to schedule this message for future delivery "
2265+
"(hosted PR #623, §13). Omitted = send now (server default). When set in the "
2266+
"future, the message sits in the recipient's inbox-query gate until "
2267+
"send-at <= now(); push-delivery dispatch is also gated. Past timestamps "
2268+
"are treated as send-now (forgiving fallback). Same semantics as cue-fire "
2269+
"--send-at (PR #618). Sent as a BODY field on POST /v1/messages."
2270+
),
2271+
)
22402272
@click.pass_context
22412273
def message_to(
22422274
ctx: click.Context,
@@ -2251,6 +2283,7 @@ def message_to(
22512283
metadata: Optional[str],
22522284
mode: str,
22532285
idempotency_key: Optional[str],
2286+
send_at: Optional[str],
22542287
) -> None:
22552288
"""Send a message to a recipient by name, slug, or agent ID.
22562289
@@ -2280,6 +2313,11 @@ def message_to(
22802313
# senders. `auto` is also redundant to send.
22812314
if mode != "auto":
22822315
body["delivery_mode"] = mode
2316+
# send_at flows in the body (server contract: MessageCreate.send_at).
2317+
# Mirrors cue-fire send_at transport. Different from idempotency_key,
2318+
# which is a header.
2319+
if send_at:
2320+
body["send_at"] = send_at
22832321

22842322
headers: dict = {"X-Cueapi-From-Agent": from_agent}
22852323
if idempotency_key:

tests/test_cli.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1969,6 +1969,61 @@ def test_messages_send_with_all_optionals(monkeypatch):
19691969
assert headers["Idempotency-Key"] == "idemp-key-1"
19701970

19711971

1972+
def test_messages_send_send_at_omitted_by_default(monkeypatch):
1973+
# Wire-format must match pre-#623 senders when --send-at is not passed.
1974+
# Server contract: NULL send_at === deliver immediately.
1975+
holder: dict = {}
1976+
_patch_messages_client(
1977+
monkeypatch,
1978+
holder,
1979+
responses={
1980+
("POST", "/messages"): lambda: _FakeResp(
1981+
201, {"id": "msg_x", "delivery_state": "queued"}
1982+
)
1983+
},
1984+
)
1985+
result = runner.invoke(
1986+
main,
1987+
["messages", "send", "--from", "sender@x", "--to", "recipient@y", "--body", "hello"],
1988+
)
1989+
assert result.exit_code == 0, result.output
1990+
body = holder["client"].calls[-1][2]
1991+
assert "send_at" not in body
1992+
1993+
1994+
def test_messages_send_send_at_passed_in_body(monkeypatch):
1995+
# send_at flows in the body (server contract: MessageCreate.send_at,
1996+
# app/schemas/message.py). Mirrors cue-fire send_at transport.
1997+
holder: dict = {}
1998+
_patch_messages_client(
1999+
monkeypatch,
2000+
holder,
2001+
responses={
2002+
("POST", "/messages"): lambda: _FakeResp(
2003+
201, {"id": "msg_x", "delivery_state": "queued"}
2004+
)
2005+
},
2006+
)
2007+
future = "2099-01-01T00:00:00Z"
2008+
result = runner.invoke(
2009+
main,
2010+
[
2011+
"messages", "send",
2012+
"--from", "sender@x",
2013+
"--to", "recipient@y",
2014+
"--body", "hello",
2015+
"--send-at", future,
2016+
],
2017+
)
2018+
assert result.exit_code == 0, result.output
2019+
body = holder["client"].calls[-1][2]
2020+
assert body["send_at"] == future
2021+
# Verify it's a body field, NOT a header (regression guard).
2022+
headers = holder["client"].calls[-1][3]
2023+
assert "Send-At" not in headers
2024+
assert "X-Cueapi-Send-At" not in headers
2025+
2026+
19722027
def test_messages_send_omits_expects_reply_when_unset(monkeypatch):
19732028
# Default false MUST NOT appear in the body — server's Pydantic default
19742029
# is false, and sending `expects_reply: false` explicitly creates noise.
@@ -2611,6 +2666,56 @@ def test_message_to_omits_expects_reply_when_unset(monkeypatch):
26112666
assert "expects_reply" not in body
26122667

26132668

2669+
def test_message_to_send_at_passed_in_body(monkeypatch):
2670+
# Same parity as `messages send` — send_at flows in body, not header.
2671+
holder: dict = {}
2672+
_patch_messages_client(
2673+
monkeypatch,
2674+
holder,
2675+
responses={
2676+
("POST", "/messages"): lambda: _FakeResp(
2677+
201, {"id": "msg_z", "delivery_state": "queued"}
2678+
)
2679+
},
2680+
)
2681+
future = "2099-01-01T00:00:00Z"
2682+
result = runner.invoke(
2683+
main,
2684+
[
2685+
"message-to", "agt_x",
2686+
"--from", "y@z",
2687+
"--body", "hi",
2688+
"--send-at", future,
2689+
],
2690+
)
2691+
assert result.exit_code == 0, result.output
2692+
body = holder["client"].calls[-1][2]
2693+
assert body["send_at"] == future
2694+
headers = holder["client"].calls[-1][3]
2695+
assert "Send-At" not in headers
2696+
assert "X-Cueapi-Send-At" not in headers
2697+
2698+
2699+
def test_message_to_send_at_omitted_when_unset(monkeypatch):
2700+
holder: dict = {}
2701+
_patch_messages_client(
2702+
monkeypatch,
2703+
holder,
2704+
responses={
2705+
("POST", "/messages"): lambda: _FakeResp(
2706+
201, {"id": "msg_z", "delivery_state": "queued"}
2707+
)
2708+
},
2709+
)
2710+
result = runner.invoke(
2711+
main,
2712+
["message-to", "agt_x", "--from", "y@z", "--body", "hi"],
2713+
)
2714+
assert result.exit_code == 0
2715+
body = holder["client"].calls[-1][2]
2716+
assert "send_at" not in body
2717+
2718+
26142719
def test_message_to_priority_validated_by_click_intrange():
26152720
result = runner.invoke(
26162721
main,

0 commit comments

Comments
 (0)