From e8dbdfb0df3b47ea0c792ed7bd578a827f27ee68 Mon Sep 17 00:00:00 2001 From: iWedmak Date: Wed, 24 Jun 2026 12:46:49 +0000 Subject: [PATCH 1/8] feat(cli,contract): --reviewer and --qa flags on `ceki contract create` Backend create-contract-event now accepts explicit reviewer/qa objects ({type, value}); without them the event stays a clean Hand. Expose the same surface on the CLI + SDK: matching shape to --benefitable (agent:N / user:N), only forwarded into the payload when supplied. Tests: regression cases for the contract client (reviewer-only, qa-only, both, neither) plus a CLI parser dispatch case wiring all three through. --- ceki_sdk/cli.py | 4 ++++ ceki_sdk/contract.py | 4 ++++ tests/test_contract.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index aa72519..33a7abc 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -480,6 +480,8 @@ def _cmd_contract(args: argparse.Namespace) -> int: description=args.desc, data=data_obj, benefitable=args.benefitable, + reviewer=args.reviewer, + qa=args.qa, )) elif action == "comment": _contract_dump(cli.comment( @@ -779,6 +781,8 @@ 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") + p_cc.add_argument("--qa", help="agent:8 or user:61") p_cc.add_argument("--desc") p_cc.add_argument("--data", help="Extra JSON object passed through as `data`") diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py index c3bdde5..f30121b 100644 --- a/ceki_sdk/contract.py +++ b/ceki_sdk/contract.py @@ -206,6 +206,8 @@ def create( description: str | None = None, data: dict[str, Any] | None = None, benefitable: str | None = None, + reviewer: str | None = None, + qa: str | None = None, ) -> Any: args = _clean({ "contract_id": int(contract_id), @@ -223,6 +225,8 @@ def create( "description": description, "data": data, "benefitable": _benefitable(benefitable), + "reviewer": _benefitable(reviewer), + "qa": _benefitable(qa), }) return self.call(_TOOL_MAP["create"], args) diff --git a/tests/test_contract.py b/tests/test_contract.py index 961df8b..20d233f 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -379,3 +379,45 @@ 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" + + +# ── reviewer / qa on create (task 2465) ─────────────────────────── + + +def test_create_reviewer_only(): + 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["reviewer"] == {"type": "agent", "value": 9} + assert "qa" not in args + + +def test_create_qa_only(): + 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["qa"] == {"type": "user", "value": 42} + assert "reviewer" not in args + + +def test_create_reviewer_and_qa(): + 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 args["reviewer"] == {"type": "agent", "value": 9} + assert args["qa"] == {"type": "agent", "value": 12} + + +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" From fa0c6b89a3275d22247769309f5d04456055dd80 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Thu, 25 Jun 2026 16:58:50 +0000 Subject: [PATCH 2/8] feat(contract): switch reviewer/qa attachment to participants[] payload Backend moved role pivots into a single participants array of {value, type, role_id} items (reviewer -> role 5, qa -> role 6). Drop the old top-level reviewer/qa keys, keep --reviewer / --qa on the CLI as the human-facing shortcut, and add a repeatable --participant for arbitrary roles. Version bumped to 2.27.0 - wire format is breaking. --- ceki_sdk/__init__.py | 2 +- ceki_sdk/cli.py | 67 ++++++++++++++++++++++++++- ceki_sdk/contract.py | 27 ++++++++++- pyproject.toml | 2 +- tests/test_contract.py | 101 +++++++++++++++++++++++++++++++++++++---- 5 files changed, 185 insertions(+), 14 deletions(-) diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index 4a5fe32..2eb9c7c 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.27.0" __all__ = [ "connect", "ConnectOptions", diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index 33a7abc..930f032 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -420,6 +420,50 @@ 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 {value: int, type: 'agent'|'user', role_id: int}. + """ + 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 {"value": value, "type": ptype, "role_id": role_id} + + def _contract_dump(value: Any) -> None: if isinstance(value, str): sys.stdout.write(value) @@ -464,6 +508,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, @@ -482,6 +534,7 @@ def _cmd_contract(args: argparse.Namespace) -> int: benefitable=args.benefitable, reviewer=args.reviewer, qa=args.qa, + participants=extra_parts or None, )) elif action == "comment": _contract_dump(cli.comment( @@ -781,8 +834,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") - p_cc.add_argument("--qa", 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`") diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py index f30121b..32d9a93 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/2485 participants[] payload). +ROLE_REVIEWER = 5 +ROLE_QA = 6 + def _benefitable(value: str | None) -> dict[str, Any] | None: if not value: @@ -22,6 +26,14 @@ 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 {value, type, role_id}.""" + base = _benefitable(value) + if base is None: + return None + return {"value": 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} @@ -208,7 +220,19 @@ def create( benefitable: str | None = None, reviewer: str | None = None, qa: str | None = None, + participants: list[dict[str, Any]] | None = None, ) -> Any: + # back/2485: reviewer/qa now live inside participants[]. + all_participants: list[dict[str, Any]] = [] + rev = _participant(reviewer, ROLE_REVIEWER) + if rev is not None: + all_participants.append(rev) + qa_p = _participant(qa, ROLE_QA) + if qa_p is not None: + all_participants.append(qa_p) + if participants: + all_participants.extend(participants) + args = _clean({ "contract_id": int(contract_id), "label": label, @@ -225,8 +249,7 @@ def create( "description": description, "data": data, "benefitable": _benefitable(benefitable), - "reviewer": _benefitable(reviewer), - "qa": _benefitable(qa), + "participants": all_participants if all_participants else None, }) return self.call(_TOOL_MAP["create"], args) diff --git a/pyproject.toml b/pyproject.toml index 8b4794f..6415d87 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.27.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 20d233f..0ab4c44 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -381,34 +381,60 @@ def test_parser_propose_start_end_date(): assert a.start == "s" and a.end == "e" and a.date == "d" -# ── reviewer / qa on create (task 2465) ─────────────────────────── +# ── participants[] payload on create (task 2494, back/2485) ─────── -def test_create_reviewer_only(): +def test_create_reviewer_folds_into_participants(): 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["reviewer"] == {"type": "agent", "value": 9} + assert args["participants"] == [{"value": 9, "type": "agent", "role_id": 5}] + assert "reviewer" not in args assert "qa" not in args -def test_create_qa_only(): +def test_create_qa_folds_into_participants(): 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["qa"] == {"type": "user", "value": 42} + assert args["participants"] == [{"value": 42, "type": "user", "role_id": 6}] assert "reviewer" not in args + assert "qa" not in args -def test_create_reviewer_and_qa(): +def test_create_reviewer_and_qa_both_in_participants(): 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 args["reviewer"] == {"type": "agent", "value": 9} - assert args["qa"] == {"type": "agent", "value": 12} + assert "reviewer" not in args + assert "qa" not in args + by_role = {p["role_id"]: p for p in args["participants"]} + assert by_role[5] == {"value": 9, "type": "agent", "role_id": 5} + assert by_role[6] == {"value": 12, "type": "agent", "role_id": 6} + assert len(args["participants"]) == 2 + + +def test_create_no_reviewer_no_qa_omits_participants(): + 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 "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"] + assert args["benefitable"] == {"type": "agent", "value": 8} + assert args["participants"] == [{"value": 9, "type": "agent", "role_id": 5}] def test_parser_create_reviewer_and_qa(): @@ -421,3 +447,62 @@ def test_parser_create_reviewer_and_qa(): 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") == { + "value": 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") == { + "value": 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") == { + "value": 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") + + +def test_create_reviewer_plus_participant_stacks(): + """--reviewer agent:9 + --participant agent:5:reviewer → two role_id=5 entries.""" + 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=[{"value": 5, "type": "agent", "role_id": 5}], + ) + args = _captured_body(http)["params"]["arguments"] + parts = args["participants"] + assert len(parts) == 2 + assert all(p["role_id"] == 5 for p in parts) + values = sorted(p["value"] for p in parts) + assert values == [5, 9] From 47ac6f2c75626bcdd2d4d7835b00f5c710dfc798 Mon Sep 17 00:00:00 2001 From: iWedmak Date: Thu, 25 Jun 2026 17:04:52 +0000 Subject: [PATCH 3/8] feat(contract): new `ceki contract progress` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `propose --desc` was being used as a progress report, but the backend treats --desc as the event description — agents were silently wiping the spec. Add a dedicated `progress` command that wraps a status correction (optional --status) and a comment in one call, leaving the event description untouched. `propose` semantics are unchanged. SKILL.md updated to recommend `progress` for status+report; `propose` stays for pure field corrections. Version bumped to 2.28.0. --- ceki_sdk/__init__.py | 2 +- ceki_sdk/cli.py | 14 +++++ ceki_sdk/contract.py | 20 +++++++ pyproject.toml | 2 +- tests/test_contract.py | 133 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 2 deletions(-) diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index 2eb9c7c..eb31e2f 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.27.0" +__version__ = "2.28.0" __all__ = [ "connect", "ConnectOptions", diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index 930f032..60dc15d 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -565,6 +565,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") @@ -876,6 +882,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 32d9a93..62e5451 100644 --- a/ceki_sdk/contract.py +++ b/ceki_sdk/contract.py @@ -315,6 +315,26 @@ 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)) + comment_result = self.comment(event_id, description=desc) + 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 6415d87..d9031cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.27.0" +version = "2.28.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 0ab4c44..df1dbee 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -491,6 +491,139 @@ def test_parse_participant_bad_type_raises(): _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(desc='r').""" + 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] == {"description": "r"} + 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, {"description": "just an update"})] + 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_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.""" http, _ = _http_mock(_mcp_text({"id": 1})) From f9cece82993450c15a0371f1b5be2b7e0beb6e4d Mon Sep 17 00:00:00 2001 From: iWedmak Date: Thu, 25 Jun 2026 17:06:20 +0000 Subject: [PATCH 4/8] fix(contract): progress() derives a label for the comment Backend rejects comment events without a `label` ("The label field is required"). The bare `progress` call hit this on first dogfood. Derive a short label from the first line of --desc (capped at 60 chars); fall back to "progress" if desc is empty. The full --desc still goes into `description` untouched. --- ceki_sdk/contract.py | 5 ++++- tests/test_contract.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py index 62e5451..e5434c4 100644 --- a/ceki_sdk/contract.py +++ b/ceki_sdk/contract.py @@ -332,7 +332,10 @@ def progress( status_result: Any = None if status is not None: status_result = self.propose(event_id, status_id=int(status)) - comment_result = self.comment(event_id, description=desc) + # Backend requires `label` on comment events — derive a short one + # from the desc (server-side validation rejects label-less comments). + label = (desc or "").strip().splitlines()[0][:60] or "progress" + comment_result = self.comment(event_id, label=label, description=desc) return {"status_correction": status_result, "comment": comment_result} def vote(self, event_id: int, ids: list[int], vote: bool) -> Any: diff --git a/tests/test_contract.py b/tests/test_contract.py index df1dbee..58add5d 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -516,7 +516,7 @@ def fake_comment(self, event_id, **kw): assert calls[0][1] == (99,) assert calls[0][2] == {"status_id": 222} assert calls[1][1] == (99,) - assert calls[1][2] == {"description": "r"} + assert calls[1][2] == {"label": "r", "description": "r"} assert result == { "status_correction": {"applied": True, "id": 1}, "comment": {"id": 2}, @@ -543,7 +543,7 @@ def fake_comment(self, event_id, **kw): result = c.progress(99, desc="just an update") assert propose_calls == [] - assert comment_calls == [(99, {"description": "just an update"})] + assert comment_calls == [(99, {"label": "just an update", "description": "just an update"})] assert result == {"status_correction": None, "comment": {"id": 7}} @@ -570,6 +570,30 @@ def fake_comment(self, event_id, **kw): assert "label" not in propose_kwargs +def test_progress_label_derived_from_desc(monkeypatch): + """Backend requires label on comments — progress should derive one from desc.""" + 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 = "x" * 200 + "\nsecond line" + c.progress(99, desc=long_desc) + + assert "label" in comment_kwargs + assert len(comment_kwargs["label"]) <= 60 + assert comment_kwargs["label"] == "x" * 60 + assert comment_kwargs["description"] == long_desc + + def test_parser_progress_full(): a = build_parser().parse_args([ "contract", "progress", "99", "--status", "222", "--desc", "did stuff", From 0fbc63b4e26de89651e0901c01c3b5f2605cb47c Mon Sep 17 00:00:00 2001 From: iWedmak Date: Thu, 25 Jun 2026 17:14:38 +0000 Subject: [PATCH 5/8] fix(contract): participants[] use participable_id/participable_type keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend rejects {value, type, role_id} with 422 — the wire shape per EventController validation rules is {participable_id, participable_type, role_id}. role_id mapping (reviewer=5, qa=6) is unchanged. Unit tests previously asserted the wrong shape; regression guard test_create_participants_uses_participable_id_keys() now pins the correct keys. Patch bump to 2.28.1. --- ceki_sdk/__init__.py | 2 +- ceki_sdk/cli.py | 9 +++++-- ceki_sdk/contract.py | 13 ++++++++-- pyproject.toml | 2 +- tests/test_contract.py | 58 ++++++++++++++++++++++++++++++++++-------- 5 files changed, 68 insertions(+), 16 deletions(-) diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index eb31e2f..5d92967 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.28.0" +__version__ = "2.28.1" __all__ = [ "connect", "ConnectOptions", diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index 60dc15d..d22f5bf 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -423,7 +423,8 @@ def _contract_client(): def _parse_participant(spec: str) -> dict[str, Any]: """Parse 'agent:5:reviewer' / 'user:7:qa' / 'agent:5:role:42'. - Returns {value: int, type: 'agent'|'user', role_id: int}. + Returns {participable_id: int, participable_type: 'agent'|'user', role_id: int} + — the wire shape EventController participants[] validation expects. """ from .contract import ROLE_QA, ROLE_REVIEWER @@ -461,7 +462,11 @@ def _parse_participant(spec: str) -> dict[str, Any]: f"--participant unknown role {role!r}; expected 'reviewer', 'qa', " f"or 'role:NUMBER'" ) - return {"value": value, "type": ptype, "role_id": role_id} + return { + "participable_id": value, + "participable_type": ptype, + "role_id": role_id, + } def _contract_dump(value: Any) -> None: diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py index e5434c4..1e52013 100644 --- a/ceki_sdk/contract.py +++ b/ceki_sdk/contract.py @@ -27,11 +27,20 @@ def _benefitable(value: str | None) -> dict[str, Any] | None: def _participant(value: str | None, role_id: int) -> dict[str, Any] | None: - """Parse 'agent:8' / 'user:61' into {value, type, role_id}.""" + """Parse 'agent:8' / 'user:61' into {participable_id, participable_type, role_id}. + + Wire shape required by EventController participants[] validation rules: + `participable_id` + `participable_type` + `role_id`. Anything else + (e.g. {value, type, role_id}) is rejected with HTTP 422. + """ base = _benefitable(value) if base is None: return None - return {"value": base["value"], "type": base["type"], "role_id": role_id} + return { + "participable_id": base["value"], + "participable_type": base["type"], + "role_id": role_id, + } def _clean(args: dict[str, Any]) -> dict[str, Any]: diff --git a/pyproject.toml b/pyproject.toml index d9031cb..117f39c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.28.0" +version = "2.28.1" 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 58add5d..03526ad 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -389,7 +389,9 @@ def test_create_reviewer_folds_into_participants(): 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["participants"] == [{"value": 9, "type": "agent", "role_id": 5}] + assert args["participants"] == [ + {"participable_id": 9, "participable_type": "agent", "role_id": 5} + ] assert "reviewer" not in args assert "qa" not in args @@ -399,7 +401,9 @@ def test_create_qa_folds_into_participants(): 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["participants"] == [{"value": 42, "type": "user", "role_id": 6}] + assert args["participants"] == [ + {"participable_id": 42, "participable_type": "user", "role_id": 6} + ] assert "reviewer" not in args assert "qa" not in args @@ -412,11 +416,40 @@ def test_create_reviewer_and_qa_both_in_participants(): assert "reviewer" not in args assert "qa" not in args by_role = {p["role_id"]: p for p in args["participants"]} - assert by_role[5] == {"value": 9, "type": "agent", "role_id": 5} - assert by_role[6] == {"value": 12, "type": "agent", "role_id": 6} + assert by_role[5] == {"participable_id": 9, "participable_type": "agent", "role_id": 5} + assert by_role[6] == {"participable_id": 12, "participable_type": "agent", "role_id": 6} assert len(args["participants"]) == 2 +def test_create_participants_uses_participable_id_keys(): + """Regression guard for the 422 wire-format bug. + + EventController participants[] validation requires `participable_id` + + `participable_type` keys. The earlier shape {value, type, role_id} + was rejected with HTTP 422 + (`participants.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"]["participants"] + assert len(parts) == 2 + for p in parts: + # correct keys present + assert "participable_id" in p + assert "participable_type" in p + assert "role_id" in p + # forbidden legacy keys absent + assert "value" not in p + assert "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]["participable_type"] == "agent" + assert by_role[6]["participable_id"] == 12 + assert by_role[6]["participable_type"] == "agent" + + def test_create_no_reviewer_no_qa_omits_participants(): http, _ = _http_mock(_mcp_text({"id": 1})) c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") @@ -433,8 +466,11 @@ def test_create_benefitable_and_billable_stay_top_level(): 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["participants"] == [{"value": 9, "type": "agent", "role_id": 5}] + assert args["participants"] == [ + {"participable_id": 9, "participable_type": "agent", "role_id": 5} + ] def test_parser_create_reviewer_and_qa(): @@ -461,21 +497,21 @@ def test_parser_create_participant_repeated(): def test_parse_participant_reviewer_shortcut(): from ceki_sdk.cli import _parse_participant assert _parse_participant("agent:5:reviewer") == { - "value": 5, "type": "agent", "role_id": 5, + "participable_id": 5, "participable_type": "agent", "role_id": 5, } def test_parse_participant_qa_shortcut(): from ceki_sdk.cli import _parse_participant assert _parse_participant("user:7:qa") == { - "value": 7, "type": "user", "role_id": 6, + "participable_id": 7, "participable_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") == { - "value": 5, "type": "agent", "role_id": 42, + "participable_id": 5, "participable_type": "agent", "role_id": 42, } @@ -655,11 +691,13 @@ def test_create_reviewer_plus_participant_stacks(): c.create( 14, label="L", reviewer="agent:9", - participants=[{"value": 5, "type": "agent", "role_id": 5}], + participants=[ + {"participable_id": 5, "participable_type": "agent", "role_id": 5} + ], ) args = _captured_body(http)["params"]["arguments"] parts = args["participants"] assert len(parts) == 2 assert all(p["role_id"] == 5 for p in parts) - values = sorted(p["value"] for p in parts) + values = sorted(p["participable_id"] for p in parts) assert values == [5, 9] From bf0d5411c898d6daab0de5ae493c2537a8a366e5 Mon Sep 17 00:00:00 2001 From: iWedmak Date: Thu, 25 Jun 2026 17:36:44 +0000 Subject: [PATCH 6/8] =?UTF-8?q?feat(contract):=20rename=20payload=20key=20?= =?UTF-8?q?participants=20=E2=86=92=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend renamed the role-attachment array field from `participants` to `users` (sync with back/2542). Element shape stays {participable_id, participable_type, role_id}. CLI flag --participant keeps its name — it is the human-facing word; only the wire key moves. Regression guard test_create_uses_users_field_not_participants pins the field name. Minor bump to 2.29.0. --- ceki_sdk/__init__.py | 2 +- ceki_sdk/cli.py | 5 +++- ceki_sdk/contract.py | 25 +++++++++------- pyproject.toml | 2 +- tests/test_contract.py | 68 +++++++++++++++++++++++++++++------------- 5 files changed, 69 insertions(+), 33 deletions(-) diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index 5d92967..7cde729 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.28.1" +__version__ = "2.29.0" __all__ = [ "connect", "ConnectOptions", diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index d22f5bf..ee8eaf7 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -424,7 +424,10 @@ 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 wire shape EventController participants[] validation expects. + — 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 diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py index 1e52013..5a53291 100644 --- a/ceki_sdk/contract.py +++ b/ceki_sdk/contract.py @@ -11,7 +11,7 @@ from ._config import default_api_url -# Contract role IDs (back/2485 participants[] payload). +# Contract role IDs (back/2542 users[] payload — renamed from participants[]). ROLE_REVIEWER = 5 ROLE_QA = 6 @@ -29,9 +29,11 @@ def _benefitable(value: str | None) -> dict[str, Any] | None: def _participant(value: str | None, role_id: int) -> dict[str, Any] | None: """Parse 'agent:8' / 'user:61' into {participable_id, participable_type, role_id}. - Wire shape required by EventController participants[] validation rules: - `participable_id` + `participable_type` + `role_id`. Anything else - (e.g. {value, type, role_id}) is rejected with HTTP 422. + Wire shape required by EventController users[] validation rules + (back/2542 renamed the array key from `participants` to `users`; + element shape unchanged): `participable_id` + `participable_type` + + `role_id`. Anything else (e.g. {value, type, role_id}) is rejected + with HTTP 422. """ base = _benefitable(value) if base is None: @@ -231,16 +233,19 @@ def create( qa: str | None = None, participants: list[dict[str, Any]] | None = None, ) -> Any: - # back/2485: reviewer/qa now live inside participants[]. - all_participants: list[dict[str, 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: - all_participants.append(rev) + users.append(rev) qa_p = _participant(qa, ROLE_QA) if qa_p is not None: - all_participants.append(qa_p) + users.append(qa_p) if participants: - all_participants.extend(participants) + users.extend(participants) args = _clean({ "contract_id": int(contract_id), @@ -258,7 +263,7 @@ def create( "description": description, "data": data, "benefitable": _benefitable(benefitable), - "participants": all_participants if all_participants else None, + "users": users if users else None, }) return self.call(_TOOL_MAP["create"], args) diff --git a/pyproject.toml b/pyproject.toml index 117f39c..e33cdc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.28.1" +version = "2.29.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 03526ad..ed6a7f2 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -381,59 +381,64 @@ def test_parser_propose_start_end_date(): assert a.start == "s" and a.end == "e" and a.date == "d" -# ── participants[] payload on create (task 2494, back/2485) ─────── +# ── users[] payload on create (task 2494, back/2542 — renamed from +# participants[]) ──────────────────────────────────────────────── -def test_create_reviewer_folds_into_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["participants"] == [ + assert args["users"] == [ {"participable_id": 9, "participable_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_participants(): +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["participants"] == [ + assert args["users"] == [ {"participable_id": 42, "participable_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_participants(): +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 - by_role = {p["role_id"]: p for p in args["participants"]} + assert "participants" not in args + by_role = {p["role_id"]: p for p in args["users"]} assert by_role[5] == {"participable_id": 9, "participable_type": "agent", "role_id": 5} assert by_role[6] == {"participable_id": 12, "participable_type": "agent", "role_id": 6} - assert len(args["participants"]) == 2 + assert len(args["users"]) == 2 -def test_create_participants_uses_participable_id_keys(): - """Regression guard for the 422 wire-format bug. +def test_create_users_uses_participable_id_keys(): + """Regression guard for the 422 element-shape bug. - EventController participants[] validation requires `participable_id` + - `participable_type` keys. The earlier shape {value, type, role_id} - was rejected with HTTP 422 - (`participants.0.participable_id field is required`). This test pins - the correct shape so the bug can't sneak back. + 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"]["participants"] + parts = _captured_body(http)["params"]["arguments"]["users"] assert len(parts) == 2 for p in parts: # correct keys present @@ -450,11 +455,27 @@ def test_create_participants_uses_participable_id_keys(): assert by_role[6]["participable_type"] == "agent" -def test_create_no_reviewer_no_qa_omits_participants(): +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 @@ -468,9 +489,10 @@ def test_create_benefitable_and_billable_stay_top_level(): args = _captured_body(http)["params"]["arguments"] # benefitable is a different parser — stays {type, value}. assert args["benefitable"] == {"type": "agent", "value": 8} - assert args["participants"] == [ + assert args["users"] == [ {"participable_id": 9, "participable_type": "agent", "role_id": 5} ] + assert "participants" not in args def test_parser_create_reviewer_and_qa(): @@ -685,7 +707,12 @@ def progress(self, eid, *, status, desc): def test_create_reviewer_plus_participant_stacks(): - """--reviewer agent:9 + --participant agent:5:reviewer → two role_id=5 entries.""" + """--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( @@ -696,7 +723,8 @@ def test_create_reviewer_plus_participant_stacks(): ], ) args = _captured_body(http)["params"]["arguments"] - parts = args["participants"] + 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) From 47acf8bd78b992977aa99f4ab01b03b45bb436e6 Mon Sep 17 00:00:00 2001 From: iWedmak Date: Thu, 25 Jun 2026 18:23:21 +0000 Subject: [PATCH 7/8] fix(contract): emit users[] participable_type as FQCN EventController validator accepts the short tokens `agent` / `user`, but the downstream membership lookup compares the string against the contract members' `participable_type` column which stores the fully-qualified class name. Sending the short token bypasses validation yet trips the misleading 422 "Participant must be a member of the contract". Send `App\\Models\\Agent` / `App\\Models\\User` instead. Tests follow. Patch bump to 2.29.1. --- ceki_sdk/__init__.py | 2 +- ceki_sdk/cli.py | 3 ++- ceki_sdk/contract.py | 15 ++++++++++++--- pyproject.toml | 2 +- tests/test_contract.py | 26 +++++++++++++++----------- 5 files changed, 31 insertions(+), 17 deletions(-) diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index 7cde729..c707776 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.29.0" +__version__ = "2.29.1" __all__ = [ "connect", "ConnectOptions", diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index ee8eaf7..da85d7f 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -465,9 +465,10 @@ def _parse_participant(spec: str) -> dict[str, Any]: f"--participant unknown role {role!r}; expected 'reviewer', 'qa', " f"or 'role:NUMBER'" ) + from .contract import _PARTICIPABLE_FQCN return { "participable_id": value, - "participable_type": ptype, + "participable_type": _PARTICIPABLE_FQCN[ptype], "role_id": role_id, } diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py index 5a53291..78e0cad 100644 --- a/ceki_sdk/contract.py +++ b/ceki_sdk/contract.py @@ -26,21 +26,30 @@ def _benefitable(value: str | None) -> dict[str, Any] | None: return {"type": btype, "value": int(bid)} +_PARTICIPABLE_FQCN = { + "agent": "App\\Models\\Agent", + "user": "App\\Models\\User", +} + + def _participant(value: str | None, role_id: int) -> dict[str, Any] | None: """Parse 'agent:8' / 'user:61' into {participable_id, participable_type, role_id}. Wire shape required by EventController users[] validation rules (back/2542 renamed the array key from `participants` to `users`; element shape unchanged): `participable_id` + `participable_type` + - `role_id`. Anything else (e.g. {value, type, role_id}) is rejected - with HTTP 422. + `role_id`. The validator accepts the short tokens `agent` / `user`, + but the membership lookup compares the string to the contract + members' `participable_type` column, which stores the fully-qualified + class name. Sending the short token leads to the misleading 422 + "Participant must be a member of the contract"; we send the FQCN. """ base = _benefitable(value) if base is None: return None return { "participable_id": base["value"], - "participable_type": base["type"], + "participable_type": _PARTICIPABLE_FQCN[base["type"]], "role_id": role_id, } diff --git a/pyproject.toml b/pyproject.toml index e33cdc3..6b68869 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.29.0" +version = "2.29.1" 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 ed6a7f2..95a65b1 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -391,7 +391,7 @@ def test_create_reviewer_folds_into_users(): c.create(14, label="L", reviewer="agent:9") args = _captured_body(http)["params"]["arguments"] assert args["users"] == [ - {"participable_id": 9, "participable_type": "agent", "role_id": 5} + {"participable_id": 9, "participable_type": "App\\Models\\Agent", "role_id": 5} ] assert "reviewer" not in args assert "qa" not in args @@ -404,7 +404,7 @@ def test_create_qa_folds_into_users(): c.create(14, label="L", qa="user:42") args = _captured_body(http)["params"]["arguments"] assert args["users"] == [ - {"participable_id": 42, "participable_type": "user", "role_id": 6} + {"participable_id": 42, "participable_type": "App\\Models\\User", "role_id": 6} ] assert "reviewer" not in args assert "qa" not in args @@ -420,8 +420,12 @@ def test_create_reviewer_and_qa_both_in_users(): 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, "participable_type": "agent", "role_id": 5} - assert by_role[6] == {"participable_id": 12, "participable_type": "agent", "role_id": 6} + assert by_role[5] == { + "participable_id": 9, "participable_type": "App\\Models\\Agent", "role_id": 5, + } + assert by_role[6] == { + "participable_id": 12, "participable_type": "App\\Models\\Agent", "role_id": 6, + } assert len(args["users"]) == 2 @@ -450,9 +454,9 @@ def test_create_users_uses_participable_id_keys(): assert "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]["participable_type"] == "agent" + assert by_role[5]["participable_type"] == "App\\Models\\Agent" assert by_role[6]["participable_id"] == 12 - assert by_role[6]["participable_type"] == "agent" + assert by_role[6]["participable_type"] == "App\\Models\\Agent" def test_create_uses_users_field_not_participants(): @@ -490,7 +494,7 @@ def test_create_benefitable_and_billable_stay_top_level(): # benefitable is a different parser — stays {type, value}. assert args["benefitable"] == {"type": "agent", "value": 8} assert args["users"] == [ - {"participable_id": 9, "participable_type": "agent", "role_id": 5} + {"participable_id": 9, "participable_type": "App\\Models\\Agent", "role_id": 5} ] assert "participants" not in args @@ -519,21 +523,21 @@ def test_parser_create_participant_repeated(): def test_parse_participant_reviewer_shortcut(): from ceki_sdk.cli import _parse_participant assert _parse_participant("agent:5:reviewer") == { - "participable_id": 5, "participable_type": "agent", "role_id": 5, + "participable_id": 5, "participable_type": "App\\Models\\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, "participable_type": "user", "role_id": 6, + "participable_id": 7, "participable_type": "App\\Models\\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, "participable_type": "agent", "role_id": 42, + "participable_id": 5, "participable_type": "App\\Models\\Agent", "role_id": 42, } @@ -719,7 +723,7 @@ def test_create_reviewer_plus_participant_stacks(): 14, label="L", reviewer="agent:9", participants=[ - {"participable_id": 5, "participable_type": "agent", "role_id": 5} + {"participable_id": 5, "participable_type": "App\\Models\\Agent", "role_id": 5} ], ) args = _captured_body(http)["params"]["arguments"] From 0de3d37960c792f25592b45926c5c1163ee92d23 Mon Sep 17 00:00:00 2001 From: iWedmak Date: Thu, 25 Jun 2026 18:33:40 +0000 Subject: [PATCH 8/8] fix(contract): users[] element key is `type`, not `participable_type` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create-contract-event MCP tool schema declares the element as {participable_id, type, role_id} — `type` is the short token 'agent' / 'user'. Sending `participable_type` (FQCN) is silently dropped by the MCP layer, which collapses the type to user and trips the misleading 422 "Participant must be a member of the contract". Live smoke (contract 43, --reviewer agent:10 --qa agent:12) now returns event.id=2587 with the two role pivots materialized server-side (participants role_id 5 + 6). Patch bump to 2.29.2. --- ceki_sdk/__init__.py | 2 +- ceki_sdk/cli.py | 3 +-- ceki_sdk/contract.py | 26 +++++++++----------------- pyproject.toml | 2 +- tests/test_contract.py | 26 +++++++++++++------------- 5 files changed, 25 insertions(+), 34 deletions(-) diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index c707776..72cde4b 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.29.1" +__version__ = "2.29.2" __all__ = [ "connect", "ConnectOptions", diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index da85d7f..8d2bc80 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -465,10 +465,9 @@ def _parse_participant(spec: str) -> dict[str, Any]: f"--participant unknown role {role!r}; expected 'reviewer', 'qa', " f"or 'role:NUMBER'" ) - from .contract import _PARTICIPABLE_FQCN return { "participable_id": value, - "participable_type": _PARTICIPABLE_FQCN[ptype], + "type": ptype, "role_id": role_id, } diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py index 78e0cad..812ded1 100644 --- a/ceki_sdk/contract.py +++ b/ceki_sdk/contract.py @@ -26,30 +26,22 @@ def _benefitable(value: str | None) -> dict[str, Any] | None: return {"type": btype, "value": int(bid)} -_PARTICIPABLE_FQCN = { - "agent": "App\\Models\\Agent", - "user": "App\\Models\\User", -} - - def _participant(value: str | None, role_id: int) -> dict[str, Any] | None: - """Parse 'agent:8' / 'user:61' into {participable_id, participable_type, role_id}. - - Wire shape required by EventController users[] validation rules - (back/2542 renamed the array key from `participants` to `users`; - element shape unchanged): `participable_id` + `participable_type` + - `role_id`. The validator accepts the short tokens `agent` / `user`, - but the membership lookup compares the string to the contract - members' `participable_type` column, which stores the fully-qualified - class name. Sending the short token leads to the misleading 422 - "Participant must be a member of the contract"; we send the FQCN. + """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"], - "participable_type": _PARTICIPABLE_FQCN[base["type"]], + "type": base["type"], "role_id": role_id, } diff --git a/pyproject.toml b/pyproject.toml index 6b68869..6c90cc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.29.1" +version = "2.29.2" 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 95a65b1..6b03db4 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -391,7 +391,7 @@ def test_create_reviewer_folds_into_users(): c.create(14, label="L", reviewer="agent:9") args = _captured_body(http)["params"]["arguments"] assert args["users"] == [ - {"participable_id": 9, "participable_type": "App\\Models\\Agent", "role_id": 5} + {"participable_id": 9, "type": "agent", "role_id": 5} ] assert "reviewer" not in args assert "qa" not in args @@ -404,7 +404,7 @@ def test_create_qa_folds_into_users(): c.create(14, label="L", qa="user:42") args = _captured_body(http)["params"]["arguments"] assert args["users"] == [ - {"participable_id": 42, "participable_type": "App\\Models\\User", "role_id": 6} + {"participable_id": 42, "type": "user", "role_id": 6} ] assert "reviewer" not in args assert "qa" not in args @@ -421,10 +421,10 @@ def test_create_reviewer_and_qa_both_in_users(): assert "participants" not in args by_role = {p["role_id"]: p for p in args["users"]} assert by_role[5] == { - "participable_id": 9, "participable_type": "App\\Models\\Agent", "role_id": 5, + "participable_id": 9, "type": "agent", "role_id": 5, } assert by_role[6] == { - "participable_id": 12, "participable_type": "App\\Models\\Agent", "role_id": 6, + "participable_id": 12, "type": "agent", "role_id": 6, } assert len(args["users"]) == 2 @@ -447,16 +447,16 @@ def test_create_users_uses_participable_id_keys(): for p in parts: # correct keys present assert "participable_id" in p - assert "participable_type" in p + assert "type" in p assert "role_id" in p # forbidden legacy keys absent assert "value" not in p - assert "type" 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]["participable_type"] == "App\\Models\\Agent" + assert by_role[5]["type"] == "agent" assert by_role[6]["participable_id"] == 12 - assert by_role[6]["participable_type"] == "App\\Models\\Agent" + assert by_role[6]["type"] == "agent" def test_create_uses_users_field_not_participants(): @@ -494,7 +494,7 @@ def test_create_benefitable_and_billable_stay_top_level(): # benefitable is a different parser — stays {type, value}. assert args["benefitable"] == {"type": "agent", "value": 8} assert args["users"] == [ - {"participable_id": 9, "participable_type": "App\\Models\\Agent", "role_id": 5} + {"participable_id": 9, "type": "agent", "role_id": 5} ] assert "participants" not in args @@ -523,21 +523,21 @@ def test_parser_create_participant_repeated(): def test_parse_participant_reviewer_shortcut(): from ceki_sdk.cli import _parse_participant assert _parse_participant("agent:5:reviewer") == { - "participable_id": 5, "participable_type": "App\\Models\\Agent", "role_id": 5, + "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, "participable_type": "App\\Models\\User", "role_id": 6, + "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, "participable_type": "App\\Models\\Agent", "role_id": 42, + "participable_id": 5, "type": "agent", "role_id": 42, } @@ -723,7 +723,7 @@ def test_create_reviewer_plus_participant_stacks(): 14, label="L", reviewer="agent:9", participants=[ - {"participable_id": 5, "participable_type": "App\\Models\\Agent", "role_id": 5} + {"participable_id": 5, "type": "agent", "role_id": 5} ], ) args = _captured_body(http)["params"]["arguments"]