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
44 changes: 43 additions & 1 deletion cueapi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1662,7 +1662,11 @@ def agents_webhook_secret_regenerate(ctx: click.Context, ref: str, yes: bool) ->
return
try:
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
resp = client.post(f"/agents/{ref}/webhook-secret/regenerate", json={})
resp = client.post(
f"/agents/{ref}/webhook-secret/regenerate",
json={},
headers={"X-Confirm-Destructive": "true"},
)
if resp.status_code == 200:
data = resp.json()
click.echo()
Expand Down Expand Up @@ -1928,6 +1932,19 @@ def messages() -> None:
"different body returns HTTP 409 idempotency_key_conflict."
),
)
@click.option(
"--send-at",
"send_at",
default=None,
help=(
"Optional ISO 8601 timestamp to schedule this message for future delivery "
"(hosted PR #623, §13). Omitted = send now (server default). When set in the "
"future, the message sits in the recipient's inbox-query gate until "
"send-at <= now(); push-delivery dispatch is also gated. Past timestamps "
"are treated as send-now (forgiving fallback). Same semantics as cue-fire "
"--send-at (PR #618). Sent as a BODY field on POST /v1/messages."
),
)
@click.pass_context
def messages_send(
ctx: click.Context,
Expand All @@ -1941,6 +1958,7 @@ def messages_send(
reply_to_agent: Optional[str],
metadata: Optional[str],
idempotency_key: Optional[str],
send_at: Optional[str],
) -> None:
"""Send a message."""
body: dict = {"to": to, "body": body_text}
Expand All @@ -1961,6 +1979,11 @@ def messages_send(
body["metadata"] = json.loads(metadata)
except json.JSONDecodeError:
raise click.UsageError("--metadata must be valid JSON")
# send_at flows in the body (server contract: MessageCreate.send_at,
# app/schemas/message.py). Mirrors cue-fire send_at transport (also a
# body field). Different from idempotency_key, which is a header.
if send_at:
body["send_at"] = send_at

headers: dict = {"X-Cueapi-From-Agent": from_agent}
if idempotency_key:
Expand Down Expand Up @@ -2233,6 +2256,19 @@ def _resolve_recipient(client, recipient: str) -> str:
"24h returns the existing message with HTTP 200 instead of 201."
),
)
@click.option(
"--send-at",
"send_at",
default=None,
help=(
"Optional ISO 8601 timestamp to schedule this message for future delivery "
"(hosted PR #623, §13). Omitted = send now (server default). When set in the "
"future, the message sits in the recipient's inbox-query gate until "
"send-at <= now(); push-delivery dispatch is also gated. Past timestamps "
"are treated as send-now (forgiving fallback). Same semantics as cue-fire "
"--send-at (PR #618). Sent as a BODY field on POST /v1/messages."
),
)
@click.pass_context
def message_to(
ctx: click.Context,
Expand All @@ -2247,6 +2283,7 @@ def message_to(
metadata: Optional[str],
mode: str,
idempotency_key: Optional[str],
send_at: Optional[str],
) -> None:
"""Send a message to a recipient by name, slug, or agent ID.

Expand Down Expand Up @@ -2276,6 +2313,11 @@ def message_to(
# senders. `auto` is also redundant to send.
if mode != "auto":
body["delivery_mode"] = mode
# send_at flows in the body (server contract: MessageCreate.send_at).
# Mirrors cue-fire send_at transport. Different from idempotency_key,
# which is a header.
if send_at:
body["send_at"] = send_at

headers: dict = {"X-Cueapi-From-Agent": from_agent}
if idempotency_key:
Expand Down
133 changes: 133 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,34 @@ def test_agents_webhook_secret_regenerate_with_yes(monkeypatch):
assert "save now" in result.output.lower() or "shown once" in result.output.lower()


def test_agents_webhook_secret_regenerate_sends_destructive_header(monkeypatch):
"""Pin: `cueapi agents webhook-secret regenerate` MUST send
`X-Confirm-Destructive: true` header — server requires it (Bug
cmp03hy9o, surfaced 2026-05-10 from Phase 2 messaging smoke).
Mirrors the same header pin on `cueapi key webhook-secret regenerate`.
"""
holder: dict = {}
_patch_ws_client(
monkeypatch,
holder,
responses={
("POST", "/agents/agt_x/webhook-secret/regenerate"): lambda: _FakeResp(
200, {"webhook_secret": "wsec_new"}
)
},
)
result = runner.invoke(
main,
["agents", "webhook-secret", "regenerate", "agt_x", "--yes"],
)
assert result.exit_code == 0, result.output
method, path, body, headers = holder["client"].calls[-1]
assert method == "POST"
assert path == "/agents/agt_x/webhook-secret/regenerate"
assert headers == {"X-Confirm-Destructive": "true"}
assert "wsec_new" in result.output


# --- inbox / sent ---


Expand Down Expand Up @@ -1941,6 +1969,61 @@ def test_messages_send_with_all_optionals(monkeypatch):
assert headers["Idempotency-Key"] == "idemp-key-1"


def test_messages_send_send_at_omitted_by_default(monkeypatch):
# Wire-format must match pre-#623 senders when --send-at is not passed.
# Server contract: NULL send_at === deliver immediately.
holder: dict = {}
_patch_messages_client(
monkeypatch,
holder,
responses={
("POST", "/messages"): lambda: _FakeResp(
201, {"id": "msg_x", "delivery_state": "queued"}
)
},
)
result = runner.invoke(
main,
["messages", "send", "--from", "sender@x", "--to", "recipient@y", "--body", "hello"],
)
assert result.exit_code == 0, result.output
body = holder["client"].calls[-1][2]
assert "send_at" not in body


def test_messages_send_send_at_passed_in_body(monkeypatch):
# send_at flows in the body (server contract: MessageCreate.send_at,
# app/schemas/message.py). Mirrors cue-fire send_at transport.
holder: dict = {}
_patch_messages_client(
monkeypatch,
holder,
responses={
("POST", "/messages"): lambda: _FakeResp(
201, {"id": "msg_x", "delivery_state": "queued"}
)
},
)
future = "2099-01-01T00:00:00Z"
result = runner.invoke(
main,
[
"messages", "send",
"--from", "sender@x",
"--to", "recipient@y",
"--body", "hello",
"--send-at", future,
],
)
assert result.exit_code == 0, result.output
body = holder["client"].calls[-1][2]
assert body["send_at"] == future
# Verify it's a body field, NOT a header (regression guard).
headers = holder["client"].calls[-1][3]
assert "Send-At" not in headers
assert "X-Cueapi-Send-At" not in headers


def test_messages_send_omits_expects_reply_when_unset(monkeypatch):
# Default false MUST NOT appear in the body — server's Pydantic default
# is false, and sending `expects_reply: false` explicitly creates noise.
Expand Down Expand Up @@ -2583,6 +2666,56 @@ def test_message_to_omits_expects_reply_when_unset(monkeypatch):
assert "expects_reply" not in body


def test_message_to_send_at_passed_in_body(monkeypatch):
# Same parity as `messages send` — send_at flows in body, not header.
holder: dict = {}
_patch_messages_client(
monkeypatch,
holder,
responses={
("POST", "/messages"): lambda: _FakeResp(
201, {"id": "msg_z", "delivery_state": "queued"}
)
},
)
future = "2099-01-01T00:00:00Z"
result = runner.invoke(
main,
[
"message-to", "agt_x",
"--from", "y@z",
"--body", "hi",
"--send-at", future,
],
)
assert result.exit_code == 0, result.output
body = holder["client"].calls[-1][2]
assert body["send_at"] == future
headers = holder["client"].calls[-1][3]
assert "Send-At" not in headers
assert "X-Cueapi-Send-At" not in headers


def test_message_to_send_at_omitted_when_unset(monkeypatch):
holder: dict = {}
_patch_messages_client(
monkeypatch,
holder,
responses={
("POST", "/messages"): lambda: _FakeResp(
201, {"id": "msg_z", "delivery_state": "queued"}
)
},
)
result = runner.invoke(
main,
["message-to", "agt_x", "--from", "y@z", "--body", "hi"],
)
assert result.exit_code == 0
body = holder["client"].calls[-1][2]
assert "send_at" not in body


def test_message_to_priority_validated_by_click_intrange():
result = runner.invoke(
main,
Expand Down