From 2bc9704cf7e8c8a3cbb78f260f37d9adc7a61dc6 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Tue, 12 May 2026 17:24:56 -0700 Subject: [PATCH] feat(messages,agents): --live-fallback-mode + --parent-agent-id (agent-id-split refactor 2026-05-12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive port of the agent-id-split substrate refactor — two new flags on existing subcommands, no breaking changes. `messages send`: - `--live-fallback-mode ` - Default `fallback_to_background` (matches substrate default per spec Q2 lean — "work should get done") - CLI omits the field on the wire when value matches the substrate default → wire format unchanged for pre-refactor senders. Same default-omit pattern as `--mode auto` (Surface 6 v2). `agents create`: - `--parent-agent-id ` - Optional FK to a parent (BG-sibling) agent. Substrate uses it for the Live → BG fallback path when a Live-sibling agent's session is silent. - Default-omits on the wire when None → standalone agents create with unchanged wire format. 6 new tests in tests/test_cli.py: - live_fallback_mode default omitted - live_fallback_mode explicit-default omitted (symmetric with --mode auto) - live_fallback_mode=live_only passed through as body field - live_fallback_mode rejects unknown values (Click.Choice validation) - parent_agent_id default omitted - parent_agent_id passed through as body field All 6 pass locally; pre-existing Pyright `_FakeResp` redeclaration warnings unchanged (pattern across the test file). Substrate spec: - live_fallback_mode: Optional[Literal['live_only','fallback_to_background']] = 'fallback_to_background' - parent_agent_id: Optional[String] (FK to agents.id) Design Dock: https://trydock.ai/workspaces/agent-id-split-refactor-2026-05-12 Backlog: cmp2zi9tl001w04jxcxw3ank1 Part 1 of 3 in the Layer 4 SDK additive port: - PR 1 (this): cueapi-cli - PR 2 (next): cueapi-mcp tool schemas - PR 3 (next): cueapi-action flag forwarding Co-Authored-By: Claude Opus 4.7 (1M context) --- cueapi/cli.py | 50 +++++++++++++++ tests/test_cli.py | 153 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) diff --git a/cueapi/cli.py b/cueapi/cli.py index 9ba8bdc..84d40a5 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -1528,6 +1528,22 @@ 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, @@ -1535,6 +1551,7 @@ def agents_create( slug: Optional[str], webhook_url: Optional[str], metadata: Optional[str], + parent_agent_id: Optional[str], ) -> None: """Create an agent. @@ -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) @@ -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, @@ -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( @@ -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: diff --git a/tests/test_cli.py b/tests/test_cli.py index d446bb1..5cb437c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 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 --- @@ -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(