diff --git a/cueapi/cli.py b/cueapi/cli.py index 794f0be..6f55d2b 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -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() @@ -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, @@ -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} @@ -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: @@ -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, @@ -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. @@ -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: diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e8c37c..a196494 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 --- @@ -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. @@ -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,