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
50 changes: 50 additions & 0 deletions cueapi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1528,13 +1528,30 @@ def agents() -> None:
@click.option("--slug", default=None, help="Per-user unique slug (optional; server derives from display-name when omitted)")
@click.option("--webhook-url", "webhook_url", default=None, help="Push-delivery target. SSRF-validated. Omit for poll-only.")
@click.option("--metadata", default=None, help="JSON metadata blob")
@click.option(
"--parent-agent-id",
"parent_agent_id",
default=None,
help=(
"Agent-id-split refactor (2026-05-12) — link this new agent as a "
"sibling of the given parent agent. Used when creating a Live "
"sibling for an existing BG agent: pass the BG agent's id here "
"and substrate stores the FK so router can fall back from Live "
"to BG via parent_agent_id when the Live session is silent "
"(see --live-fallback-mode on `messages send`). Omit for "
"standalone (parent) agents. Substrate enforces FK validity. "
"Design Dock: "
"https://trydock.ai/workspaces/agent-id-split-refactor-2026-05-12"
),
)
@click.pass_context
def agents_create(
ctx: click.Context,
display_name: str,
slug: Optional[str],
webhook_url: Optional[str],
metadata: Optional[str],
parent_agent_id: Optional[str],
) -> None:
"""Create an agent.

Expand All @@ -1552,6 +1569,10 @@ def agents_create(
body["metadata"] = json.loads(metadata)
except json.JSONDecodeError:
raise click.UsageError("--metadata must be valid JSON")
# Agent-id-split refactor (2026-05-12) — pass-through to substrate.
# Default-omit when None so wire format matches pre-refactor senders.
if parent_agent_id:
body["parent_agent_id"] = parent_agent_id
try:
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
resp = client.post("/agents", json=body)
Expand Down Expand Up @@ -2398,6 +2419,27 @@ def messages() -> None:
"`auto` (or omitted) — server treats absent === auto."
),
)
@click.option(
"--live-fallback-mode",
"live_fallback_mode",
default="fallback_to_background",
type=click.Choice(["live_only", "fallback_to_background"]),
show_default=True,
help=(
"Agent-id-split refactor (2026-05-12) — controls behavior when "
"the recipient is a Live-sibling agent and its Live session is "
"silent (no fresh heartbeat). "
"fallback_to_background = substrate looks up the BG sibling via "
"parent_agent_id and routes there (work gets done even if Live "
"is detached). "
"live_only = message queues against the Live agent until that "
"specific Live session attaches. "
"CLI omits the field on the wire when set to the substrate "
"default (fallback_to_background) so wire format matches pre-"
"refactor senders. Design Dock: "
"https://trydock.ai/workspaces/agent-id-split-refactor-2026-05-12"
),
)
@click.pass_context
def messages_send(
ctx: click.Context,
Expand All @@ -2418,6 +2460,7 @@ def messages_send(
send_at: Optional[str],
notify: tuple,
mode: str,
live_fallback_mode: str,
) -> None:
"""Send a message."""
resolved_body = _acquire_message_body(
Expand Down Expand Up @@ -2459,6 +2502,13 @@ def messages_send(
# identical to pre-Surface-6 senders. Server treats absent === auto.
if mode != "auto":
body["delivery_mode"] = mode
# Agent-id-split refactor (2026-05-12) — default-omit when value
# matches the substrate default (fallback_to_background). Wire format
# matches pre-refactor senders for the common path; only the
# opt-in live_only case sends the field. Same default-omit pattern
# as Surface 6 v2 mode + §17 notify.
if live_fallback_mode != "fallback_to_background":
body["live_fallback_mode"] = live_fallback_mode

headers: dict = {"X-Cueapi-From-Agent": from_agent}
if idempotency_key:
Expand Down
153 changes: 153 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,69 @@ def test_agents_create_invalid_metadata_json(monkeypatch):
assert "json" in result.output.lower()


# --- agent-id-split refactor (2026-05-12) — --parent-agent-id ---


def test_agents_create_parent_agent_id_default_omitted(monkeypatch):
# No --parent-agent-id → field omitted on the wire. Wire format
# matches pre-refactor senders; standalone (parent) agents look
# identical to today's create requests.
holder: dict = {}
_patch_client(
monkeypatch,
holder,
responses={
("POST", "/agents"): lambda: _FakeResp(
201,
{"id": "agt_x", "slug": "solo", "display_name": "Solo", "status": "online"},
)
},
)
result = runner.invoke(
main,
["agents", "create", "--display-name", "Solo"],
)
assert result.exit_code == 0, result.output
body = holder["client"].calls[-1][2]
assert "parent_agent_id" not in body


def test_agents_create_parent_agent_id_passed_through(monkeypatch):
# --parent-agent-id <agt_xxx> flows as body.parent_agent_id. Used
# when creating a Live sibling of an existing BG agent so substrate
# can fall back from Live to BG via the parent_agent_id FK.
holder: dict = {}
_patch_client(
monkeypatch,
holder,
responses={
("POST", "/agents"): lambda: _FakeResp(
201,
{
"id": "agt_livesib0001",
"slug": "linkedin-content-agent-live",
"display_name": "LinkedIn Content Agent (Live)",
"status": "online",
},
)
},
)
result = runner.invoke(
main,
[
"agents", "create",
"--display-name", "LinkedIn Content Agent (Live)",
"--slug", "linkedin-content-agent-live",
"--parent-agent-id", "agt_parentbg0001",
],
)
assert result.exit_code == 0, result.output
body = holder["client"].calls[-1][2]
assert body["parent_agent_id"] == "agt_parentbg0001"
assert body["display_name"] == "LinkedIn Content Agent (Live)"
assert body["slug"] == "linkedin-content-agent-live"


# --- list params ---


Expand Down Expand Up @@ -2448,6 +2511,96 @@ def test_messages_send_mode_auto_explicitly_omitted(monkeypatch):
assert "delivery_mode" not in body


# --- agent-id-split refactor (2026-05-12) — --live-fallback-mode ---


def test_messages_send_live_fallback_mode_default_omitted(monkeypatch):
# Default --live-fallback-mode=fallback_to_background matches the
# substrate default; CLI omits on the wire so pre-refactor senders
# observe an unchanged wire format. Same default-omit shape as --mode.
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", "x", "--to", "y", "--body", "hi"],
)
assert result.exit_code == 0
body = holder["client"].calls[-1][2]
assert "live_fallback_mode" not in body


def test_messages_send_live_fallback_mode_explicit_default_omitted(monkeypatch):
# Explicit --live-fallback-mode fallback_to_background also omits
# (explicit-default == default). Pin shape symmetric with --mode auto.
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", "x", "--to", "y", "--body", "hi",
"--live-fallback-mode", "fallback_to_background",
],
)
assert result.exit_code == 0
body = holder["client"].calls[-1][2]
assert "live_fallback_mode" not in body


def test_messages_send_live_fallback_mode_live_only_passed_through(monkeypatch):
# The opt-in case: --live-fallback-mode live_only flows as
# body.live_fallback_mode. Substrate routes the message strictly to
# the Live sibling; no parent-agent fallback.
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", "x", "--to", "y", "--body", "hi",
"--live-fallback-mode", "live_only",
],
)
assert result.exit_code == 0, result.output
body = holder["client"].calls[-1][2]
assert body["live_fallback_mode"] == "live_only"


def test_messages_send_live_fallback_mode_rejects_unknown(monkeypatch):
# Click.Choice rejects values outside the enum at CLI parse time;
# CLI exits with usage error (non-zero) before the HTTP call.
holder: dict = {}
_patch_messages_client(monkeypatch, holder)
result = runner.invoke(
main,
[
"messages", "send",
"--from", "x", "--to", "y", "--body", "hi",
"--live-fallback-mode", "bogus_value",
],
)
assert result.exit_code != 0
assert "bogus_value" in result.output or "Invalid value" in result.output


def test_message_to_notify_passed_as_body_list(monkeypatch):
holder: dict = {}
_patch_messages_client(
Expand Down