diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index 4a5fe32..bb815bd 100644 --- a/ceki_sdk/__init__.py +++ b/ceki_sdk/__init__.py @@ -21,7 +21,7 @@ from ._profile import BrowserProfile from .humanize import HumanProfile -__version__ = "2.23.0" +__version__ = "2.31.0" __all__ = [ "connect", "ConnectOptions", diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index aa72519..990a314 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -420,6 +420,58 @@ def _contract_client(): return ContractClient() +def _parse_participant(spec: str) -> dict[str, Any]: + """Parse 'agent:5:reviewer' / 'user:7:qa' / 'agent:5:role:42'. + + Returns {participable_id: int, participable_type: 'agent'|'user', role_id: int} + — the element shape EventController users[] validation expects + (back/2542 renamed the array key from `participants` to `users`; + element shape unchanged). CLI flag `--participant` keeps its + human-facing name; only the wire key changed. + """ + from .contract import ROLE_QA, ROLE_REVIEWER + + if not spec or not isinstance(spec, str): + raise ValueError(f"--participant must be a non-empty string, got: {spec!r}") + parts = spec.split(":") + if len(parts) < 3: + raise ValueError( + f"--participant must be 'type:id:role' (e.g. agent:5:reviewer), got: {spec!r}" + ) + ptype, pid, role, *rest = parts + if ptype not in ("agent", "user"): + raise ValueError(f"--participant type must be 'agent' or 'user', got: {ptype!r}") + try: + value = int(pid) + except ValueError as e: + raise ValueError(f"--participant id must be int, got: {pid!r}") from e + + role_map = {"reviewer": ROLE_REVIEWER, "qa": ROLE_QA} + if role in role_map: + role_id = role_map[role] + elif role == "role": + if not rest: + raise ValueError( + f"--participant 'role:NUMBER' needs a number, got: {spec!r}" + ) + try: + role_id = int(rest[0]) + except ValueError as e: + raise ValueError( + f"--participant role id must be int, got: {rest[0]!r}" + ) from e + else: + raise ValueError( + f"--participant unknown role {role!r}; expected 'reviewer', 'qa', " + f"or 'role:NUMBER'" + ) + return { + "participable_id": value, + "type": ptype, + "role_id": role_id, + } + + def _contract_dump(value: Any) -> None: if isinstance(value, str): sys.stdout.write(value) @@ -448,6 +500,8 @@ def _cmd_contract(args: argparse.Namespace) -> int: for cid in ids: print(f"--- contract {cid} ---") _contract_dump(cli.tasks(int(cid))) + elif action == "my-events": + _contract_dump(cli.my_events()) elif action == "my-jobs": _contract_dump(cli.my_jobs()) elif action == "task": @@ -464,6 +518,14 @@ def _cmd_contract(args: argparse.Namespace) -> int: _err("contract id required (positional or CEKI_CONTRACT_IDS)", "args") return 1 data_obj = json.loads(args.data) if args.data else None + try: + extra_parts = [ + _parse_participant(spec) + for spec in (getattr(args, "participant", None) or []) + ] + except ValueError as e: + _err(str(e), "args") + return 1 _contract_dump(cli.create( cid, label=args.label, @@ -480,11 +542,20 @@ def _cmd_contract(args: argparse.Namespace) -> int: description=args.desc, data=data_obj, benefitable=args.benefitable, + reviewer=args.reviewer, + qa=args.qa, + participants=extra_parts or None, )) elif action == "comment": + # A comment's body lives in `label` (events.label is + # unbounded TEXT). `--label` wins when both are given; + # otherwise `--desc` is the body. `description` is never + # sent on a comment — the UI would render it on top of + # `label` and duplicate the body. + body = args.label if args.label is not None else args.desc _contract_dump(cli.comment( args.eid, - label=args.label, + label=body, type_id=args.type, status_id=args.status, start=args.start, @@ -493,7 +564,6 @@ def _cmd_contract(args: argparse.Namespace) -> int: duration=args.duration, amount=args.amount, currency=args.currency, - description=args.desc, benefitable=args.benefitable, )) elif action == "propose": @@ -510,6 +580,12 @@ def _cmd_contract(args: argparse.Namespace) -> int: currency=args.currency, benefitable=args.benefitable, )) + elif action == "progress": + _contract_dump(cli.progress( + args.eid, + status=args.status, + desc=args.desc, + )) elif action == "vote": ids = [int(s) for s in str(args.ids).split(",") if s.strip()] vote = str(args.vote).lower() in ("true", "1", "yes") @@ -748,7 +824,21 @@ def build_parser() -> argparse.ArgumentParser: p_ct = csub.add_parser("tasks", help="List contract events (default: CEKI_CONTRACT_IDS)") p_ct.add_argument("cid", type=int, nargs="?", help="Contract ID") - csub.add_parser("my-jobs", help="List events assigned to me") + csub.add_parser( + "my-events", + help=( + "List contract events assigned to me (get-my-events). " + "The 'plate' feed. Wire tool renamed from get-my-jobs." + ), + ) + csub.add_parser( + "my-jobs", + help=( + "List hire schedules I posted, type 3 (get-my-jobs). " + "The listings feed. Wire tool reused after the backend swap " + "(formerly get-hire-jobs); for contract events use 'my-events'." + ), + ) p_ctask = csub.add_parser("task", help="Get event") p_ctask.add_argument("eid", type=int, help="Event ID") @@ -779,6 +869,18 @@ def build_parser() -> argparse.ArgumentParser: p_cc.add_argument("--amount", type=int) p_cc.add_argument("--currency") p_cc.add_argument("--benefitable", help="agent:8 or user:61") + p_cc.add_argument("--reviewer", help="agent:8 or user:61 (role_id 5 shortcut)") + p_cc.add_argument("--qa", help="agent:8 or user:61 (role_id 6 shortcut)") + p_cc.add_argument( + "--participant", + action="append", + default=[], + dest="participant", + help=( + "Repeatable. agent:N:reviewer | user:N:qa | agent:N:role:NUMBER. " + "Stacks on top of --reviewer/--qa." + ), + ) p_cc.add_argument("--desc") p_cc.add_argument("--data", help="Extra JSON object passed through as `data`") @@ -809,6 +911,14 @@ def build_parser() -> argparse.ArgumentParser: p_cp.add_argument("--currency") p_cp.add_argument("--benefitable") + p_cpr = csub.add_parser( + "progress", + help="Status correction + progress comment (description is not touched)", + ) + p_cpr.add_argument("eid", type=int) + p_cpr.add_argument("--status", type=int) + p_cpr.add_argument("--desc", required=True) + p_cv = csub.add_parser("vote", help="Vote on correction(s)") p_cv.add_argument("eid", type=int) p_cv.add_argument("--ids", required=True, help="Comma-separated correction IDs") diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py index c3bdde5..8965998 100644 --- a/ceki_sdk/contract.py +++ b/ceki_sdk/contract.py @@ -11,6 +11,10 @@ from ._config import default_api_url +# Contract role IDs (back/2542 users[] payload — renamed from participants[]). +ROLE_REVIEWER = 5 +ROLE_QA = 6 + def _benefitable(value: str | None) -> dict[str, Any] | None: if not value: @@ -22,6 +26,26 @@ def _benefitable(value: str | None) -> dict[str, Any] | None: return {"type": btype, "value": int(bid)} +def _participant(value: str | None, role_id: int) -> dict[str, Any] | None: + """Parse 'agent:8' / 'user:61' into {participable_id, type, role_id}. + + Wire shape declared by the create-contract-event MCP tool schema: + `participable_id` + `type` (short token: 'agent' or 'user') + + `role_id`. The MCP tool drops any field it does not know about, so + sending `participable_type` (FQCN) silently loses the type and the + backend membership lookup defaults to user → misleading 422 + "Participant must be a member of the contract". Send `type`. + """ + base = _benefitable(value) + if base is None: + return None + return { + "participable_id": base["value"], + "type": base["type"], + "role_id": role_id, + } + + def _clean(args: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in args.items() if v is not None} @@ -59,10 +83,17 @@ def _resolve_token() -> str: return os.getenv("CEKI_AGENT_TOKEN") or os.getenv("CEKI_API_KEY") or "" +# Wire names swapped on the backend: +# get-my-jobs (formerly contract tasks) → get-my-events +# get-hire-jobs (formerly posted hire jobs) → get-my-jobs +# The two sugar keys reflect the new, non-cross-contaminated semantics: +# "my-events" = contract events assigned to me (the plate feed) +# "my-jobs" = hire schedules I posted (type 3) (the listings feed) _TOOL_MAP = { "list": "get-my-contracts", "members": "get-contract-members", "tasks": "get-contract-events", + "my-events": "get-my-events", "my-jobs": "get-my-jobs", "task": "get-event", "children": "get-event-children", @@ -175,7 +206,21 @@ def members(self, contract_id: int) -> Any: def tasks(self, contract_id: int) -> Any: return self.call(_TOOL_MAP["tasks"], {"contract_id": int(contract_id)}) + def my_events(self) -> Any: + """Contract events assigned to me — the agent's plate feed. + + Calls `get-my-events` (formerly `get-my-jobs`; backend renamed + the wire tool when the listings feed reclaimed `get-my-jobs`). + """ + return self.call(_TOOL_MAP["my-events"], {}) + def my_jobs(self) -> Any: + """Hire schedules I posted (type 3) — the listings feed. + + Calls `get-my-jobs` (the wire name was reused for this semantic + after the backend swap; previously this method returned contract + events — use `my_events()` for that now). + """ return self.call(_TOOL_MAP["my-jobs"], {}) def task(self, event_id: int) -> Any: @@ -206,7 +251,24 @@ def create( description: str | None = None, data: dict[str, Any] | None = None, benefitable: str | None = None, + reviewer: str | None = None, + qa: str | None = None, + participants: list[dict[str, Any]] | None = None, ) -> Any: + # back/2542: reviewer/qa now live inside users[] (renamed from + # participants[]). Element shape unchanged. The `participants` + # kwarg name is kept as a stable Python API for callers, but on + # the wire it is emitted under the `users` key. + users: list[dict[str, Any]] = [] + rev = _participant(reviewer, ROLE_REVIEWER) + if rev is not None: + users.append(rev) + qa_p = _participant(qa, ROLE_QA) + if qa_p is not None: + users.append(qa_p) + if participants: + users.extend(participants) + args = _clean({ "contract_id": int(contract_id), "label": label, @@ -223,6 +285,7 @@ def create( "description": description, "data": data, "benefitable": _benefitable(benefitable), + "users": users if users else None, }) return self.call(_TOOL_MAP["create"], args) @@ -239,9 +302,16 @@ def comment( duration: int | None = None, amount: int | None = None, currency: str | None = None, - description: str | None = None, benefitable: str | None = None, ) -> Any: + """Post a comment event. + + The comment body lives entirely in `label` (events.label is + unbounded TEXT). `description` is deliberately NOT exposed: the + web UI renders both `label` and `description` on a comment, and + the human-typed path only writes to `label`. Passing both would + produce a visible duplicate in the renderer. + """ args = _clean({ "event_id": int(event_id), "label": label, @@ -253,7 +323,6 @@ def comment( "duration": duration, "amount": amount, "currency": currency, - "description": description, "benefitable": _benefitable(benefitable), }) return self.call(_TOOL_MAP["comment"], args) @@ -288,6 +357,30 @@ def propose( }) return self.call(_TOOL_MAP["propose"], args) + def progress( + self, + event_id: int, + *, + status: int | None = None, + desc: str, + ) -> dict[str, Any]: + """Status correction (optional) + progress comment in one shot. + + The event's own description is NOT touched. `--desc` becomes the + body of a child comment-event, not a label/description overwrite + on the parent event. Use this for Hand/QA/Reviewer progress + reports — `propose --desc` would clobber the parent spec. + """ + status_result: Any = None + if status is not None: + status_result = self.propose(event_id, status_id=int(status)) + # events.label is unbounded TEXT — the full body lives there, and + # `description` is never set on a comment (the UI renders both, + # which would duplicate the body for SDK-posted comments). + label = desc if (desc or "").strip() else "progress" + comment_result = self.comment(event_id, label=label) + return {"status_correction": status_result, "comment": comment_result} + def vote(self, event_id: int, ids: list[int], vote: bool) -> Any: return self.call(_TOOL_MAP["vote"], { "event_id": int(event_id), diff --git a/pyproject.toml b/pyproject.toml index 8b4794f..6ae778d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.26.0" +version = "2.31.0" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_contract.py b/tests/test_contract.py index 961df8b..868891c 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -188,6 +188,44 @@ def test_comment_strips_undefined(): assert "amount" not in args assert "currency" not in args assert "benefitable" not in args + # task 2936 — `description` must never appear on a comment payload. + assert "description" not in args + + +def test_comment_long_multiline_body_in_label_no_description(): + """Regression guard for task 2936 (duplicate-comment bug). + + A comment's body lives ENTIRELY in `label` (events.label is + unbounded TEXT). `description` is not part of the comment() API and + must never end up on the wire — the web renderer would show both + `label` and `description`, duplicating the body. + """ + http, _ = _http_mock(_mcp_text({"id": 99})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + body = ( + "long body across\nmultiple lines exceeding 60 characters in " + "total length here for sure" + ) + c.comment(99, label=body) + args = _captured_body(http)["params"]["arguments"] + # Full body, untruncated, multi-line preserved. + assert args["label"] == body + assert len(body) > 60 # sanity — the body really is over 60 chars + # No description key at all on the wire. + assert "description" not in args + + +def test_comment_no_description_kwarg(): + """The comment() public API must not accept `description=`. + + Dropping this kwarg is the API-level breaking change for 2.30.0: + a comment's body lives in `label`. Anyone still passing + `description=` gets a TypeError at call time, which is the loud + failure we want. + """ + c = ContractClient(endpoint="http://x/mcp/agent", token="t") + with pytest.raises(TypeError): + c.comment(99, label="x", description="y") # type: ignore[call-arg] def test_propose_maps_tool(): @@ -217,6 +255,57 @@ def test_history_tool_name(): assert body["params"]["arguments"] == {"event_id": 42} +def test_my_events_uses_get_my_events_tool(): + """Pin the new wire name for the contract-task plate feed. + + Backend swapped: the wire tool that returns contract events + assigned to me is now `get-my-events` (formerly `get-my-jobs`). + The Python method is `ContractClient.my_events()`. + """ + http, _ = _http_mock(_mcp_text([])) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.my_events() + body = _captured_body(http) + assert body["params"]["name"] == "get-my-events" + assert body["params"]["arguments"] == {} + + +def test_my_jobs_uses_get_my_jobs_tool_for_hire_schedules(): + """Pin the reused wire name for the hire-schedules listings feed. + + Backend swapped: `get-my-jobs` (the wire tool) now returns the + hire schedules I posted (type 3) — what was previously served by + `get-hire-jobs`. The Python method `ContractClient.my_jobs()` + targets this new semantic; for contract events use `my_events()`. + """ + http, _ = _http_mock(_mcp_text([])) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.my_jobs() + body = _captured_body(http) + assert body["params"]["name"] == "get-my-jobs" + assert body["params"]["arguments"] == {} + + +def test_no_hire_jobs_wire_name_anywhere(): + """The old `get-hire-jobs` wire name must be gone from the SDK map.""" + from ceki_sdk.contract import _TOOL_MAP + assert "get-hire-jobs" not in _TOOL_MAP.values() + # And the new entries are present under the right sugar keys. + assert _TOOL_MAP["my-events"] == "get-my-events" + assert _TOOL_MAP["my-jobs"] == "get-my-jobs" + + +def test_parser_contract_my_events(): + a = build_parser().parse_args(["contract", "my-events"]) + assert a.command == "contract" and a.contract_action == "my-events" + + +def test_parser_contract_my_jobs_still_present(): + """`contract my-jobs` is still a valid CLI action (now hire schedules).""" + a = build_parser().parse_args(["contract", "my-jobs"]) + assert a.command == "contract" and a.contract_action == "my-jobs" + + # ── polling ─────────────────────────────────────────────────────── @@ -379,3 +468,389 @@ def test_parser_propose_start_end_date(): "--start", "s", "--end", "e", "--date", "d", ]) assert a.start == "s" and a.end == "e" and a.date == "d" + + +# ── users[] payload on create (task 2494, back/2542 — renamed from +# participants[]) ──────────────────────────────────────────────── + + +def test_create_reviewer_folds_into_users(): + http, _ = _http_mock(_mcp_text({"id": 1})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create(14, label="L", reviewer="agent:9") + args = _captured_body(http)["params"]["arguments"] + assert args["users"] == [ + {"participable_id": 9, "type": "agent", "role_id": 5} + ] + assert "reviewer" not in args + assert "qa" not in args + assert "participants" not in args + + +def test_create_qa_folds_into_users(): + http, _ = _http_mock(_mcp_text({"id": 1})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create(14, label="L", qa="user:42") + args = _captured_body(http)["params"]["arguments"] + assert args["users"] == [ + {"participable_id": 42, "type": "user", "role_id": 6} + ] + assert "reviewer" not in args + assert "qa" not in args + assert "participants" not in args + + +def test_create_reviewer_and_qa_both_in_users(): + http, _ = _http_mock(_mcp_text({"id": 1})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create(14, label="L", reviewer="agent:9", qa="agent:12") + args = _captured_body(http)["params"]["arguments"] + assert "reviewer" not in args + assert "qa" not in args + assert "participants" not in args + by_role = {p["role_id"]: p for p in args["users"]} + assert by_role[5] == { + "participable_id": 9, "type": "agent", "role_id": 5, + } + assert by_role[6] == { + "participable_id": 12, "type": "agent", "role_id": 6, + } + assert len(args["users"]) == 2 + + +def test_create_users_uses_participable_id_keys(): + """Regression guard for the 422 element-shape bug. + + EventController users[] validation (back/2542; previously named + participants[]) requires `participable_id` + `participable_type` + + `role_id` keys on each element. The earlier shape + {value, type, role_id} was rejected with HTTP 422 + (`users.0.participable_id field is required`). This test pins the + correct shape so the bug can't sneak back. + """ + http, _ = _http_mock(_mcp_text({"id": 1})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create(14, label="L", reviewer="agent:9", qa="agent:12") + parts = _captured_body(http)["params"]["arguments"]["users"] + assert len(parts) == 2 + for p in parts: + # correct keys present + assert "participable_id" in p + assert "type" in p + assert "role_id" in p + # forbidden legacy keys absent + assert "value" not in p + assert "participable_type" not in p + by_role = {p["role_id"]: p for p in parts} + assert by_role[5]["participable_id"] == 9 + assert by_role[5]["type"] == "agent" + assert by_role[6]["participable_id"] == 12 + assert by_role[6]["type"] == "agent" + + +def test_create_uses_users_field_not_participants(): + """Regression guard pinning the wire-key rename. + + back/2542 renamed the role-attachment array on the wire from + `participants` to `users`. This test asserts the emitted payload + carries `users` and NOT `participants`, so we don't slip back. + """ + http, _ = _http_mock(_mcp_text({"id": 1})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create(14, label="L", reviewer="agent:9", qa="user:42") + args = _captured_body(http)["params"]["arguments"] + assert "users" in args + assert "participants" not in args + + +def test_create_no_reviewer_no_qa_omits_users(): + http, _ = _http_mock(_mcp_text({"id": 1})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create(14, label="L", benefitable="agent:8") + args = _captured_body(http)["params"]["arguments"] + assert "users" not in args + assert "participants" not in args + assert "reviewer" not in args + assert "qa" not in args + assert args["benefitable"] == {"type": "agent", "value": 8} + + +def test_create_benefitable_and_billable_stay_top_level(): + http, _ = _http_mock(_mcp_text({"id": 1})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create(14, label="L", benefitable="agent:8", reviewer="agent:9") + args = _captured_body(http)["params"]["arguments"] + # benefitable is a different parser — stays {type, value}. + assert args["benefitable"] == {"type": "agent", "value": 8} + assert args["users"] == [ + {"participable_id": 9, "type": "agent", "role_id": 5} + ] + assert "participants" not in args + + +def test_parser_create_reviewer_and_qa(): + a = build_parser().parse_args([ + "contract", "create", "14", "--label", "X", + "--benefitable", "agent:8", + "--reviewer", "agent:9", + "--qa", "agent:12", + ]) + assert a.benefitable == "agent:8" + assert a.reviewer == "agent:9" + assert a.qa == "agent:12" + + +def test_parser_create_participant_repeated(): + a = build_parser().parse_args([ + "contract", "create", "14", "--label", "X", + "--participant", "agent:5:reviewer", + "--participant", "user:7:qa", + ]) + assert a.participant == ["agent:5:reviewer", "user:7:qa"] + + +def test_parse_participant_reviewer_shortcut(): + from ceki_sdk.cli import _parse_participant + assert _parse_participant("agent:5:reviewer") == { + "participable_id": 5, "type": "agent", "role_id": 5, + } + + +def test_parse_participant_qa_shortcut(): + from ceki_sdk.cli import _parse_participant + assert _parse_participant("user:7:qa") == { + "participable_id": 7, "type": "user", "role_id": 6, + } + + +def test_parse_participant_numeric_role(): + from ceki_sdk.cli import _parse_participant + assert _parse_participant("agent:5:role:42") == { + "participable_id": 5, "type": "agent", "role_id": 42, + } + + +def test_parse_participant_unknown_role_raises(): + from ceki_sdk.cli import _parse_participant + with pytest.raises(ValueError, match="unknown role"): + _parse_participant("agent:5:bogus") + + +def test_parse_participant_bad_type_raises(): + from ceki_sdk.cli import _parse_participant + with pytest.raises(ValueError, match="type"): + _parse_participant("robot:5:reviewer") + + +# ── progress (status correction + comment in one shot) ─────────── + + +def test_progress_calls_propose_then_comment(monkeypatch): + """progress(eid, status=222, desc='r') → propose(status_id=222) then comment(label='r'). + + The full body goes into `label`. `description` is never set on a + comment — the UI renders `label` AND `description`, so doing both + would visibly duplicate the body. + """ + c = ContractClient(endpoint="http://x/mcp/agent", token="t") + calls: list[tuple[str, tuple, dict]] = [] + + def fake_propose(self, event_id, **kw): + calls.append(("propose", (event_id,), kw)) + return {"applied": True, "id": 1} + + def fake_comment(self, event_id, **kw): + calls.append(("comment", (event_id,), kw)) + return {"id": 2} + + monkeypatch.setattr(ContractClient, "propose", fake_propose) + monkeypatch.setattr(ContractClient, "comment", fake_comment) + + result = c.progress(99, status=222, desc="r") + + assert [name for name, _, _ in calls] == ["propose", "comment"] + assert calls[0][1] == (99,) + assert calls[0][2] == {"status_id": 222} + assert calls[1][1] == (99,) + assert calls[1][2] == {"label": "r"} + assert "description" not in calls[1][2] + assert result == { + "status_correction": {"applied": True, "id": 1}, + "comment": {"id": 2}, + } + + +def test_progress_without_status_only_comments(monkeypatch): + """progress(eid, desc=...) without status → ONLY comment, propose never called.""" + c = ContractClient(endpoint="http://x/mcp/agent", token="t") + propose_calls: list = [] + comment_calls: list = [] + + def fake_propose(self, event_id, **kw): + propose_calls.append((event_id, kw)) + return {"applied": True} + + def fake_comment(self, event_id, **kw): + comment_calls.append((event_id, kw)) + return {"id": 7} + + monkeypatch.setattr(ContractClient, "propose", fake_propose) + monkeypatch.setattr(ContractClient, "comment", fake_comment) + + result = c.progress(99, desc="just an update") + + assert propose_calls == [] + assert comment_calls == [(99, {"label": "just an update"})] + assert "description" not in comment_calls[0][1] + assert result == {"status_correction": None, "comment": {"id": 7}} + + +def test_progress_never_passes_desc_to_propose(monkeypatch): + """Regression guard: --desc must NEVER reach propose (would overwrite spec).""" + c = ContractClient(endpoint="http://x/mcp/agent", token="t") + propose_kwargs: dict = {} + + def fake_propose(self, event_id, **kw): + propose_kwargs.update(kw) + return {"applied": True} + + def fake_comment(self, event_id, **kw): + return {"id": 1} + + monkeypatch.setattr(ContractClient, "propose", fake_propose) + monkeypatch.setattr(ContractClient, "comment", fake_comment) + + c.progress(99, status=222, desc="this is a progress report, NOT a spec") + + assert "status_id" in propose_kwargs + assert "desc" not in propose_kwargs + assert "description" not in propose_kwargs + assert "label" not in propose_kwargs + + +def test_progress_puts_full_desc_in_label_no_description(monkeypatch): + """Regression guard for the duplicate-comment bug (task 2936). + + progress() used to send `label = desc.splitlines()[0][:60]` AND the + full text as `description`. The UI renders both fields, so the + truncated prefix appeared on top of the full body — visible + duplication. + + The fix: full body goes into `label` (events.label is unbounded + TEXT), and `description` is never set on a comment. + """ + c = ContractClient(endpoint="http://x/mcp/agent", token="t") + comment_kwargs: dict = {} + + def fake_propose(self, event_id, **kw): + return {"applied": True} + + def fake_comment(self, event_id, **kw): + comment_kwargs.update(kw) + return {"id": 1} + + monkeypatch.setattr(ContractClient, "propose", fake_propose) + monkeypatch.setattr(ContractClient, "comment", fake_comment) + + long_desc = "Long report\nwith multiple\nlines that go well past sixty characters total" + c.progress(99, desc=long_desc) + + # Full body in label, untruncated, multi-line preserved. + assert comment_kwargs["label"] == long_desc + # `description` MUST NOT be passed — UI duplication guard. + assert "description" not in comment_kwargs + + +def test_progress_wire_payload_label_only(monkeypatch): + """End-to-end wire shape: progress() comment arguments contain + `label` with the full body and NO `description` key. + """ + http, _ = _http_mock(_mcp_text({"id": 5})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.progress(99, status=222, desc="Long report\nwith multiple\nlines") + + # Two POSTs: propose, then comment. Comment is the last one. + last_body = http.post.call_args.kwargs["json"] + assert last_body["params"]["name"] == "comment" + args = last_body["params"]["arguments"] + assert args["label"] == "Long report\nwith multiple\nlines" + assert "description" not in args + + +def test_parser_progress_full(): + a = build_parser().parse_args([ + "contract", "progress", "99", "--status", "222", "--desc", "did stuff", + ]) + assert a.contract_action == "progress" + assert a.eid == 99 + assert a.status == 222 + assert a.desc == "did stuff" + + +def test_parser_progress_no_status(): + a = build_parser().parse_args([ + "contract", "progress", "99", "--desc", "just a note", + ]) + assert a.contract_action == "progress" + assert a.eid == 99 + assert a.status is None + assert a.desc == "just a note" + + +def test_parser_progress_missing_desc_fails(): + with pytest.raises(SystemExit): + build_parser().parse_args(["contract", "progress", "99"]) + + +def test_cli_dispatch_progress(monkeypatch, capsys): + """End-to-end: `ceki contract progress 99 --status 222 --desc x` calls client.progress.""" + from ceki_sdk import cli as cli_module + + captured: dict = {} + + class FakeClient: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def progress(self, eid, *, status, desc): + captured["eid"] = eid + captured["status"] = status + captured["desc"] = desc + return {"status_correction": {"ok": 1}, "comment": {"ok": 2}} + + monkeypatch.setattr(cli_module, "_contract_client", lambda: FakeClient()) + + parser = cli_module.build_parser() + args = parser.parse_args(["contract", "progress", "99", "--status", "222", "--desc", "x"]) + rc = cli_module._cmd_contract(args) + + assert rc == 0 + assert captured == {"eid": 99, "status": 222, "desc": "x"} + + +def test_create_reviewer_plus_participant_stacks(): + """--reviewer agent:9 + --participant agent:5:reviewer → two role_id=5 entries. + + The `participants` kwarg is the stable Python API for callers + (CLI feeds it from --participant); on the wire both feed into + the `users` array (back/2542 rename). + """ + http, _ = _http_mock(_mcp_text({"id": 1})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create( + 14, label="L", + reviewer="agent:9", + participants=[ + {"participable_id": 5, "type": "agent", "role_id": 5} + ], + ) + args = _captured_body(http)["params"]["arguments"] + parts = args["users"] + assert "participants" not in args + assert len(parts) == 2 + assert all(p["role_id"] == 5 for p in parts) + values = sorted(p["participable_id"] for p in parts) + assert values == [5, 9]