Skip to content

Commit e7899b6

Browse files
committed
feat: add MessagesResource (messaging primitive lifecycle)
Wraps the `/v1/messages` surface (Phase 12.1.5). Closes the messages portion of the `Messaging primitive` `endpoints_missing` entry in cueapi-python #24's parity manifest. New resource: - `cueapi/resources/messages.py`: MessagesResource - .send(from_agent, to, body, subject=, reply_to=, priority=, expects_reply=, reply_to_agent=, metadata=, idempotency_key=) - .get(msg_id) - .mark_read(msg_id) # idempotent on already-read - .ack(msg_id) # terminal Client extension: - Same `_request(headers=...)` extension as PR #27 (AgentsResource). Independent commit on this branch since the two resources can land in either order; minor merge conflict on client.py is auto-resolvable (both PRs add the same kwarg in the same way). Design notes pinned by tests: - `from_agent` goes via `X-Cueapi-From-Agent` HEADER, NOT in body. The server's MessageCreate schema is extra="forbid" — putting `from` in the body would 400, but we want this caught at unit-test time. Pinned by test_minimal_body_and_from_header. - `expects_reply=False` (default) NOT sent in body. Server default is False; sending `expects_reply: false` is no-op + adds noise. Pinned by test_omits_expects_reply_when_default. - `idempotency_key` >255 chars raises ValueError client-side BEFORE any HTTP call. Matches server's hard limit. Pinned that no HTTP request is made when the validation fails. - `idempotency_key=None` omits the header entirely (no `Idempotency-Key: None` leakage). Pinned. Tests: 9 new (12 → 21 in this resource family; 38 total across all unit-test files). Server-side dedup-hit (200 response) and priority-downgrade signals (`X-CueAPI-Priority-Downgraded` header) are surfaced through the underlying httpx response — the SDK's `_handle_response` returns the data dict on 2xx, so callers see status_code 200 vs 201 only via the underlying client. A future enhancement could expose these signals explicitly via a richer return type; documented for follow-up. No hosted-PR dependency. All 4 endpoints already shipped on prod. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent ac957e2 commit e7899b6

4 files changed

Lines changed: 275 additions & 2 deletions

File tree

cueapi/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313
from cueapi.payload import CuePayload
1414
from cueapi.resources.executions import ExecutionsResource
15+
from cueapi.resources.messages import MessagesResource
1516
from cueapi.webhook import verify_webhook
1617

1718
__version__ = "0.1.2"
@@ -20,6 +21,7 @@
2021
"CueAPI",
2122
"CuePayload",
2223
"ExecutionsResource",
24+
"MessagesResource",
2325
"verify_webhook",
2426
"CueAPIError",
2527
"AuthenticationError",

cueapi/client.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
)
1818
from cueapi.resources.cues import CuesResource
1919
from cueapi.resources.executions import ExecutionsResource
20+
from cueapi.resources.messages import MessagesResource
2021

2122
DEFAULT_BASE_URL = "https://api.cueapi.ai"
2223
DEFAULT_TIMEOUT = 30.0
@@ -69,6 +70,7 @@ def __init__(
6970
# Resources
7071
self.cues = CuesResource(self)
7172
self.executions = ExecutionsResource(self)
73+
self.messages = MessagesResource(self)
7274

7375
def close(self) -> None:
7476
"""Close the underlying HTTP client."""
@@ -89,9 +91,19 @@ def _request(
8991
*,
9092
json: Optional[Dict[str, Any]] = None,
9193
params: Optional[Dict[str, Any]] = None,
94+
headers: Optional[Dict[str, str]] = None,
9295
) -> Any:
93-
"""Make an HTTP request and handle errors."""
94-
response = self._http.request(method, path, json=json, params=params)
96+
"""Make an HTTP request and handle errors.
97+
98+
``headers`` extends (does not replace) the client's default
99+
``Authorization`` + ``Content-Type`` + ``User-Agent`` headers.
100+
Used by per-call header semantics: messaging primitive's
101+
``X-Cueapi-From-Agent`` + ``Idempotency-Key``, and the
102+
destructive-operation guard ``X-Confirm-Destructive``.
103+
"""
104+
response = self._http.request(
105+
method, path, json=json, params=params, headers=headers
106+
)
95107
return self._handle_response(response)
96108

97109
def _handle_response(self, response: httpx.Response) -> Any:

cueapi/resources/messages.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Messages resource — messaging primitive lifecycle (Phase 12.1.5)."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, Any, Dict, Optional
6+
7+
if TYPE_CHECKING:
8+
from cueapi.client import CueAPI
9+
10+
11+
class MessagesResource:
12+
"""Messages API resource.
13+
14+
Wraps the ``/v1/messages`` surface from the messaging primitive
15+
(Phase 12.1.5). Covers send + per-message lifecycle (get / read /
16+
ack). The agents identity surface lives on the sibling
17+
``client.agents`` resource — this class only handles messages.
18+
"""
19+
20+
def __init__(self, client: "CueAPI") -> None:
21+
self._client = client
22+
23+
def send(
24+
self,
25+
*,
26+
from_agent: str,
27+
to: str,
28+
body: str,
29+
subject: Optional[str] = None,
30+
reply_to: Optional[str] = None,
31+
priority: Optional[int] = None,
32+
expects_reply: bool = False,
33+
reply_to_agent: Optional[str] = None,
34+
metadata: Optional[Dict[str, Any]] = None,
35+
idempotency_key: Optional[str] = None,
36+
) -> dict:
37+
"""Send a message.
38+
39+
``from_agent`` is sent as the ``X-Cueapi-From-Agent`` header,
40+
NOT in the body. The server reads it from the header to
41+
authenticate the sender against the calling key. Don't try to
42+
pass it in the body — the server's ``MessageCreate`` schema is
43+
``extra="forbid"`` and will 400.
44+
45+
``idempotency_key`` is sent as the ``Idempotency-Key`` header.
46+
Same key + same body within 24h returns the existing message
47+
with HTTP 200 instead of 201. Same key + different body
48+
returns HTTP 409 ``idempotency_key_conflict``.
49+
50+
Args:
51+
from_agent: Sender agent — opaque agent_id or slug-form
52+
(``agent@user``). MUST be owned by the calling key.
53+
to: Recipient — opaque agent_id or slug-form.
54+
body: Message body (1-32768 chars).
55+
subject: Optional subject line (max 255 chars).
56+
reply_to: Previous message ID this is replying to
57+
(``msg_<12 alphanumeric>``). thread_id inherits.
58+
priority: 1-5 (server default 3). Receiver-pair limits may
59+
downgrade priority>3 to 3; the server signals this via
60+
the ``X-CueAPI-Priority-Downgraded: true`` response
61+
header. Callers wanting to detect downgrade need to
62+
inspect the response shape via the underlying
63+
httpx.Response — not exposed in the SDK return value.
64+
expects_reply: Mark this message as expecting a reply.
65+
Default False; only sent when True.
66+
reply_to_agent: Decoupled reply target. Defaults to
67+
``from`` (sender). Use when reply should route to a
68+
different agent.
69+
metadata: Optional JSON metadata blob.
70+
idempotency_key: Optional ``Idempotency-Key`` header
71+
(≤255 chars).
72+
73+
Returns:
74+
Dict matching the server's ``MessageResponse`` shape.
75+
76+
Raises:
77+
ValueError: If ``idempotency_key`` exceeds 255 chars
78+
(matches the server's hard limit).
79+
"""
80+
if idempotency_key is not None and len(idempotency_key) > 255:
81+
raise ValueError("idempotency_key must be ≤255 characters")
82+
83+
payload: Dict[str, Any] = {"to": to, "body": body}
84+
if subject is not None:
85+
payload["subject"] = subject
86+
if reply_to is not None:
87+
payload["reply_to"] = reply_to
88+
if priority is not None:
89+
payload["priority"] = priority
90+
# Boolean flag — only send when True. Server default is False;
91+
# sending `false` is no-op + adds payload noise. Pinned in tests.
92+
if expects_reply:
93+
payload["expects_reply"] = True
94+
if reply_to_agent is not None:
95+
payload["reply_to_agent"] = reply_to_agent
96+
if metadata is not None:
97+
payload["metadata"] = metadata
98+
99+
headers: Dict[str, str] = {"X-Cueapi-From-Agent": from_agent}
100+
if idempotency_key is not None:
101+
headers["Idempotency-Key"] = idempotency_key
102+
103+
return self._client._post("/v1/messages", json=payload, headers=headers)
104+
105+
def get(self, msg_id: str) -> dict:
106+
"""Get a single message by ID."""
107+
return self._client._get(f"/v1/messages/{msg_id}")
108+
109+
def mark_read(self, msg_id: str) -> dict:
110+
"""Mark a message as read.
111+
112+
Idempotent — calling on already-``read`` returns 200 unchanged.
113+
Returns 409 (raised as ``CueAPIError``) if the message is in a
114+
terminal state (``acked`` / ``expired``).
115+
"""
116+
return self._client._post(f"/v1/messages/{msg_id}/read", json={})
117+
118+
def ack(self, msg_id: str) -> dict:
119+
"""Acknowledge a message — terminal state, no further transitions."""
120+
return self._client._post(f"/v1/messages/{msg_id}/ack", json={})

tests/test_messages_resource.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Tests for MessagesResource."""
2+
3+
import pytest
4+
from unittest.mock import MagicMock
5+
6+
from cueapi.resources.messages import MessagesResource
7+
8+
9+
class TestSend:
10+
def test_minimal_body_and_from_header(self):
11+
# Pin: --from goes in X-Cueapi-From-Agent HEADER, NOT in body.
12+
# The server's MessageCreate is extra="forbid" and would 400 on
13+
# `{"from": "..."}` in the body, but we want this caught at unit
14+
# test time, not silently at integration.
15+
mock_client = MagicMock()
16+
mock_client._post.return_value = {
17+
"id": "msg_x", "delivery_state": "queued", "thread_id": "thr_x",
18+
}
19+
r = MessagesResource(mock_client)
20+
21+
r.send(from_agent="sender@x", to="recipient@y", body="hi")
22+
23+
mock_client._post.assert_called_once_with(
24+
"/v1/messages",
25+
json={"to": "recipient@y", "body": "hi"},
26+
headers={"X-Cueapi-From-Agent": "sender@x"},
27+
)
28+
29+
def test_with_all_optionals(self):
30+
mock_client = MagicMock()
31+
mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"}
32+
r = MessagesResource(mock_client)
33+
34+
r.send(
35+
from_agent="sender@x",
36+
to="recipient@y",
37+
body="hi",
38+
subject="re: hello",
39+
reply_to="msg_abcdef123456",
40+
priority=5,
41+
expects_reply=True,
42+
reply_to_agent="alt@x",
43+
metadata={"k": "v"},
44+
idempotency_key="idemp-key-1",
45+
)
46+
47+
call = mock_client._post.call_args
48+
assert call.args == ("/v1/messages",)
49+
assert call.kwargs["json"] == {
50+
"to": "recipient@y",
51+
"body": "hi",
52+
"subject": "re: hello",
53+
"reply_to": "msg_abcdef123456",
54+
"priority": 5,
55+
"expects_reply": True,
56+
"reply_to_agent": "alt@x",
57+
"metadata": {"k": "v"},
58+
}
59+
assert call.kwargs["headers"] == {
60+
"X-Cueapi-From-Agent": "sender@x",
61+
"Idempotency-Key": "idemp-key-1",
62+
}
63+
64+
def test_omits_expects_reply_when_default(self):
65+
# Pin: default False MUST NOT appear in body. Server's Pydantic
66+
# default is False; sending `expects_reply: false` is no-op + adds
67+
# noise. Refactor that always-sends would slip past the typed
68+
# server schema but be caught here.
69+
mock_client = MagicMock()
70+
mock_client._post.return_value = {"id": "msg_x"}
71+
r = MessagesResource(mock_client)
72+
73+
r.send(from_agent="x", to="y", body="hi")
74+
75+
body = mock_client._post.call_args.kwargs["json"]
76+
assert "expects_reply" not in body
77+
78+
def test_idempotency_key_too_long_raises_client_side(self):
79+
mock_client = MagicMock()
80+
r = MessagesResource(mock_client)
81+
82+
with pytest.raises(ValueError, match="255"):
83+
r.send(
84+
from_agent="x", to="y", body="hi",
85+
idempotency_key="x" * 256,
86+
)
87+
# Crucially: must NOT have hit the wire.
88+
mock_client._post.assert_not_called()
89+
90+
def test_omits_idempotency_key_header_when_unset(self):
91+
# Headers should ONLY contain X-Cueapi-From-Agent when no
92+
# idempotency_key is passed. Pin so a refactor can't silently
93+
# start adding `Idempotency-Key: None` (httpx would coerce).
94+
mock_client = MagicMock()
95+
mock_client._post.return_value = {"id": "msg_x"}
96+
r = MessagesResource(mock_client)
97+
98+
r.send(from_agent="x", to="y", body="hi")
99+
100+
headers = mock_client._post.call_args.kwargs["headers"]
101+
assert headers == {"X-Cueapi-From-Agent": "x"}
102+
assert "Idempotency-Key" not in headers
103+
104+
105+
class TestGet:
106+
def test_get(self):
107+
mock_client = MagicMock()
108+
mock_client._get.return_value = {"id": "msg_x"}
109+
r = MessagesResource(mock_client)
110+
111+
r.get("msg_x")
112+
113+
mock_client._get.assert_called_once_with("/v1/messages/msg_x")
114+
115+
116+
class TestMarkRead:
117+
def test_mark_read(self):
118+
mock_client = MagicMock()
119+
mock_client._post.return_value = {"delivery_state": "read"}
120+
r = MessagesResource(mock_client)
121+
122+
r.mark_read("msg_x")
123+
124+
mock_client._post.assert_called_once_with(
125+
"/v1/messages/msg_x/read", json={},
126+
)
127+
128+
129+
class TestAck:
130+
def test_ack(self):
131+
mock_client = MagicMock()
132+
mock_client._post.return_value = {"delivery_state": "acked"}
133+
r = MessagesResource(mock_client)
134+
135+
r.ack("msg_x")
136+
137+
mock_client._post.assert_called_once_with(
138+
"/v1/messages/msg_x/ack", json={},
139+
)

0 commit comments

Comments
 (0)