From bb24040c89f37f185c01f2f38068c21a27c0e2db Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Wed, 6 May 2026 17:37:47 -0700 Subject: [PATCH] feat(messages): add send_at per-message scheduling (cueapi #623 parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the optional ``send_at`` kwarg to ``MessagesResource.send()``, covering server PR #623 (POST /v1/messages send_at). Caller can now schedule per-message delivery instead of always-immediate. Accepts ``str`` (ISO 8601) or ``datetime`` — auto-serialized via ``.isoformat()`` (same convention as ``cues.create(at=...)`` and the new ``cues.fire(send_at=...)`` from PR #33). Body field, not header (matches the server contract). Backwards compatible — defaults to None, omitted from the request body when unset; existing call sites unchanged. 3 new mock-based tests in test_messages_resource.py::TestSendAt (matches existing test style in this file: assert on the request body shape rather than against staging): - send_at as ISO 8601 string flows verbatim - send_at as datetime auto-isoformats with tz - send_at unset omits field from body entirely Source: drift audit handoff/cueapi-package-drift-2026-05-06; Backlog row "Parity port: PR #623 (POST /v1/messages send_at) → cueapi-python" (p1, CTO-SEC-DRIFT-AUDIT-AUTHORIZE 2026-05-06). Co-Authored-By: Claude Opus 4.7 (1M context) --- cueapi/resources/messages.py | 13 ++++++- tests/test_messages_resource.py | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/cueapi/resources/messages.py b/cueapi/resources/messages.py index a7ea2e8..4d565f3 100644 --- a/cueapi/resources/messages.py +++ b/cueapi/resources/messages.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, Optional +from datetime import datetime +from typing import TYPE_CHECKING, Any, Dict, Optional, Union if TYPE_CHECKING: from cueapi.client import CueAPI @@ -33,6 +34,7 @@ def send( reply_to_agent: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, idempotency_key: Optional[str] = None, + send_at: Optional[Union[str, datetime]] = None, ) -> dict: """Send a message. @@ -69,6 +71,11 @@ def send( metadata: Optional JSON metadata blob. idempotency_key: Optional ``Idempotency-Key`` header (≤255 chars). + send_at: Optional ISO 8601 timestamp (or ``datetime``) to + delay this message's delivery. If omitted, the message + is delivered immediately. Per-message scheduling landed + in cueapi #623 — server stores ``send_at`` on the + message row and the worker picks it up when due. Returns: Dict matching the server's ``MessageResponse`` shape. @@ -95,6 +102,10 @@ def send( payload["reply_to_agent"] = reply_to_agent if metadata is not None: payload["metadata"] = metadata + if send_at is not None: + payload["send_at"] = ( + send_at.isoformat() if isinstance(send_at, datetime) else send_at + ) headers: Dict[str, str] = {"X-Cueapi-From-Agent": from_agent} if idempotency_key is not None: diff --git a/tests/test_messages_resource.py b/tests/test_messages_resource.py index d5c5ede..02f54c7 100644 --- a/tests/test_messages_resource.py +++ b/tests/test_messages_resource.py @@ -137,3 +137,64 @@ def test_ack(self): mock_client._post.assert_called_once_with( "/v1/messages/msg_x/ack", json={}, ) + + +class TestSendAt: + """Per-message scheduling via send_at (cueapi #623 parity port). + + Mock-based following the existing pattern in this file. Asserts on + the request body shape — that's the SDK contract; what the server + does with it (delay then deliver) is exercised by the server suite. + """ + + def test_send_with_send_at_iso_string(self): + """send_at as ISO 8601 string flows into the request body verbatim.""" + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send( + from_agent="sender@x", + to="recipient@y", + body="hi", + send_at="2030-01-01T12:00:00Z", + ) + + mock_client._post.assert_called_once_with( + "/v1/messages", + json={ + "to": "recipient@y", + "body": "hi", + "send_at": "2030-01-01T12:00:00Z", + }, + headers={"X-Cueapi-From-Agent": "sender@x"}, + ) + + def test_send_with_send_at_datetime_auto_isoformats(self): + """send_at as datetime auto-serializes via .isoformat().""" + from datetime import datetime, timezone + + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send( + from_agent="sender@x", + to="recipient@y", + body="hi", + send_at=datetime(2030, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + + call = mock_client._post.call_args + assert call.kwargs["json"]["send_at"] == "2030-01-01T12:00:00+00:00" + + def test_send_without_send_at_omits_field(self): + """send_at unset → field NOT present in request body.""" + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send(from_agent="sender@x", to="recipient@y", body="hi") + + call = mock_client._post.call_args + assert "send_at" not in call.kwargs["json"]