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
23 changes: 22 additions & 1 deletion cueapi/resources/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def create(
slug: Optional[str] = None,
webhook_url: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
parent_agent_id: Optional[str] = None,
) -> dict:
"""Create an agent.

Expand All @@ -44,11 +45,25 @@ def create(
webhook_url: Push-delivery target. SSRF-validated. Omit for
poll-only.
metadata: Optional JSON metadata blob.
parent_agent_id: Optional ``agt_<12-alphanumeric>`` linking
this new agent to a BG parent (agent-id-split refactor
Layer 4, cueapi #823). NULL = BG agent (the default
shape — canonical entry point for a project's
coordination address). Supplying it makes this a Live
sibling. Substrate enforces: parent must be same-tenant
+ must NOT itself be a Live sibling (1-level hierarchy).
When supplied without an explicit ``slug``, server
auto-derives ``<parent_slug>-live`` (with collision-
suffix). Currently accepted by hosted cueapi; OSS
cueapi-core rejects with 422 until the Layer 4 OSS
port lands (graceful degradation; tracked on Backlog
row cmp2zi9tl001w04jxcxw3ank1).

Returns:
Dict matching the server's ``AgentResponse`` shape, including
``webhook_secret`` ONCE on this response if ``webhook_url``
was given.
was given. The response surfaces ``parent_agent_id`` —
NULL for BG agents, non-NULL for Live siblings.
"""
body: Dict[str, Any] = {"display_name": display_name}
if slug is not None:
Expand All @@ -57,6 +72,12 @@ def create(
body["webhook_url"] = webhook_url
if metadata is not None:
body["metadata"] = metadata
if parent_agent_id is not None:
# Per agent-id-split Layer 4 (cueapi #823). Default-omit so
# wire format matches pre-Layer-4 callers; server treats
# absent === NULL (BG agent — the default shape).
# Hosted accepts; cueapi-core OSS will 422 until precursors land.
body["parent_agent_id"] = parent_agent_id
return self._client._post("/v1/agents", json=body)

def list(
Expand Down
21 changes: 21 additions & 0 deletions cueapi/resources/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def send(
idempotency_key: Optional[str] = None,
send_at: Optional[Union[str, datetime]] = None,
auto_verify: bool = True,
live_fallback_mode: Optional[str] = None,
) -> dict:
"""Send a message.

Expand Down Expand Up @@ -87,6 +88,19 @@ def send(
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.
live_fallback_mode: Per-message override for the agent-id-
split refactor's Live-fallback semantic (cueapi #824,
Layer 4). ``"live_only"`` queues the message until the
target Live agent's session is actively heartbeating;
``"fallback_to_background"`` falls through to the
Live-sibling's BG parent (via ``parent_agent_id``) when
the Live session is silent. Default ``None`` omits the
field from the wire body (server default applies —
``"fallback_to_background"`` per spec lock 22:11Z
2026-05-12). The field is currently accepted by hosted
cueapi; OSS cueapi-core rejects with 422 until the
Layer 4 OSS port lands (graceful degradation; tracked
on Backlog row cmp2zi9tl001w04jxcxw3ank1).

Returns:
Dict matching the server's ``MessageResponse`` shape.
Expand Down Expand Up @@ -117,6 +131,13 @@ def send(
payload["send_at"] = (
send_at.isoformat() if isinstance(send_at, datetime) else send_at
)
if live_fallback_mode is not None:
# Per agent-id-split Layer 4 (cueapi #824). Default-omit when
# caller didn't specify so wire format matches pre-Layer-4
# callers; server applies its own default
# (``fallback_to_background`` per spec lock 22:11Z 2026-05-12).
# Hosted accepts; cueapi-core OSS will 422 until precursors land.
payload["live_fallback_mode"] = live_fallback_mode

headers: Dict[str, str] = {"X-Cueapi-From-Agent": from_agent}
if idempotency_key is not None:
Expand Down
66 changes: 66 additions & 0 deletions tests/test_agents_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,72 @@ def test_with_all_optionals(self):
},
)

def test_parent_agent_id_omitted_when_none(self):
"""Agent-id-split refactor Layer 4 (cueapi #823). Default None ⇒
field NOT on wire (preserves pre-Layer-4 shape — BG agent is the
canonical entry point)."""
mock_client = MagicMock()
mock_client._post.return_value = {
"id": "agt_x", "slug": "team-comm", "display_name": "Team Comm",
"status": "online",
}
r = AgentsResource(mock_client)

r.create(display_name="Team Comm")

call = mock_client._post.call_args
assert "parent_agent_id" not in call.kwargs["json"]

def test_parent_agent_id_passes_through(self):
"""Caller supplies parent_agent_id ⇒ flows into request body
verbatim. Substrate (hosted) interprets as a Live-sibling create
+ auto-derives ``<parent_slug>-live`` when slug omitted; cueapi-core
OSS 422s until precursors land (graceful degradation per spec)."""
mock_client = MagicMock()
mock_client._post.return_value = {
"id": "agt_live_x", "slug": "team-comm-live",
"display_name": "Team Comm Live", "status": "online",
"parent_agent_id": "agt_bg_parent",
}
r = AgentsResource(mock_client)

r.create(
display_name="Team Comm Live",
parent_agent_id="agt_bg_parent",
)

mock_client._post.assert_called_once_with(
"/v1/agents",
json={
"display_name": "Team Comm Live",
"parent_agent_id": "agt_bg_parent",
},
)

def test_parent_agent_id_combines_with_other_optionals(self):
"""Live-sibling create with explicit slug (Q5 labeled-session
convention) — parent_agent_id + slug + webhook combine cleanly
in the body."""
mock_client = MagicMock()
mock_client._post.return_value = {
"id": "agt_live_label", "slug": "team-comm-live-debug",
}
r = AgentsResource(mock_client)

r.create(
display_name="Team Comm Live (debug)",
slug="team-comm-live-debug",
webhook_url="https://x.example/live-hook",
parent_agent_id="agt_bg_parent",
)

call = mock_client._post.call_args
body = call.kwargs["json"]
assert body["parent_agent_id"] == "agt_bg_parent"
assert body["slug"] == "team-comm-live-debug"
assert body["webhook_url"] == "https://x.example/live-hook"
assert body["display_name"] == "Team Comm Live (debug)"


class TestList:
def test_defaults_omit_filters(self):
Expand Down
77 changes: 77 additions & 0 deletions tests/test_messages_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,83 @@ def test_send_without_send_at_omits_field(self):
assert "send_at" not in call.kwargs["json"]


class TestLiveFallbackMode:
"""Agent-id-split refactor Layer 4 (cueapi #824) — live_fallback_mode kwarg.

Per-message override for substrate's Live-fallback semantic. ``live_only``
queues until the target Live agent's session is actively heartbeating;
``fallback_to_background`` falls through to the Live-sibling's BG parent
when Live is silent. Default-omit when None so wire format matches
pre-Layer-4 callers; server applies its default
(``fallback_to_background`` per spec lock 22:11Z 2026-05-12).

Backlog row cmp2zi9tl001w04jxcxw3ank1 tracks the cueapi-core OSS port;
hosted accepts; cueapi-core 422 until precursors land (graceful
degradation).
"""

def test_live_fallback_mode_omitted_when_none(self):
"""Default None ⇒ field NOT on wire (preserves pre-Layer-4 shape)."""
from unittest.mock import MagicMock
from cueapi.resources.messages import MessagesResource

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 "live_fallback_mode" not in call.kwargs["json"]

def test_live_fallback_mode_live_only_passes_through(self):
"""``live_only`` flows verbatim into the request body."""
from unittest.mock import MagicMock
from cueapi.resources.messages import MessagesResource

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",
live_fallback_mode="live_only",
)

mock_client._post.assert_called_once_with(
"/v1/messages",
json={
"to": "recipient@y",
"body": "hi",
"live_fallback_mode": "live_only",
},
headers={"X-Cueapi-From-Agent": "sender@x", "X-CueAPI-Verify-Echo": "true"},
)

def test_live_fallback_mode_fallback_to_background_passes_through(self):
"""``fallback_to_background`` flows verbatim. Explicit-default value
is wired-out so callers can disambiguate "I explicitly want fallback"
from "I didn't specify"."""
from unittest.mock import MagicMock
from cueapi.resources.messages import MessagesResource

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",
live_fallback_mode="fallback_to_background",
)

call = mock_client._post.call_args
assert call.kwargs["json"]["live_fallback_mode"] == "fallback_to_background"


class TestAutoVerify:
"""Phase 2 of body-verify defense in depth (Mike directive 2026-05-11).

Expand Down
Loading