From f14174afef2ebb8ec58d00b323f18d601f068c08 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Sat, 11 Jul 2026 14:34:54 +0000 Subject: [PATCH 1/4] python-sdk: auto-paginate my_events and my_jobs --- ceki_sdk/contract.py | 99 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py index 6d13bba..19e7ada 100644 --- a/ceki_sdk/contract.py +++ b/ceki_sdk/contract.py @@ -204,15 +204,79 @@ def members(self, contract_id: int) -> Any: return self.call(_TOOL_MAP["members"], {"contract_id": int(contract_id)}) def tasks(self, contract_id: int) -> Any: - return self.call(_TOOL_MAP["tasks"], {"contract_id": int(contract_id)}) + """List contract events — auto-paginates ALL pages. + + Backend returns paginated ({data, current_page, last_page, total, + per_page}); the old single-call returned only page 1 (50 of N), + silently truncating. We follow last_page and merge data[]. + """ + cid = int(contract_id) + tool = _TOOL_MAP["tasks"] + out = self.call(tool, {"contract_id": cid}) + if not isinstance(out, dict) or not isinstance(out.get("data"), list): + return out + page = int(out.get("current_page", 1) or 1) + last = int(out.get("last_page", 1) or 1) + merged = list(out["data"]) + while page < last: + page += 1 + nxt = self.call(tool, {"contract_id": cid, "page": page}) + if not isinstance(nxt, dict) or not isinstance(nxt.get("data"), list): + break + merged.extend(nxt["data"]) + last = int(nxt.get("last_page", last) or last) # safety if backend shifts + # dedup by id — backend pagination occasionally overlaps pages + seen: set[Any] = set() + uniq: list[Any] = [] + for e in merged: + eid = e.get("id") if isinstance(e, dict) else None + if eid is None or eid not in seen: + if eid is not None: + seen.add(eid) + uniq.append(e) + out = dict(out) + out["data"] = uniq + out["current_page"] = 1 + out["last_page"] = 1 + out["per_page"] = len(uniq) + return out 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`). + Auto-paginates ALL pages. """ - return self.call(_TOOL_MAP["my-events"], {}) + tool = _TOOL_MAP["my-events"] + out = self.call(tool, {}) + if not isinstance(out, dict) or not isinstance(out.get("data"), list): + return out + page = int(out.get("current_page", 1) or 1) + last = int(out.get("last_page", 1) or 1) + merged = list(out["data"]) + while page < last: + page += 1 + nxt = self.call(tool, {"page": page}) + if not isinstance(nxt, dict) or not isinstance(nxt.get("data"), list): + break + merged.extend(nxt["data"]) + last = int(nxt.get("last_page", last) or last) # safety if backend shifts + # dedup by id — backend pagination occasionally overlaps pages + seen: set[Any] = set() + uniq: list[Any] = [] + for e in merged: + eid = e.get("id") if isinstance(e, dict) else None + if eid is None or eid not in seen: + if eid is not None: + seen.add(eid) + uniq.append(e) + out = dict(out) + out["data"] = uniq + out["current_page"] = 1 + out["last_page"] = 1 + out["per_page"] = len(uniq) + return out def call_human(self, event_id: int, kind: str, desc: str) -> Any: """Escalate to a human up the event→parent→contract→schedule chain. @@ -242,8 +306,37 @@ def my_jobs(self) -> Any: 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). + Auto-paginates ALL pages. """ - return self.call(_TOOL_MAP["my-jobs"], {}) + tool = _TOOL_MAP["my-jobs"] + out = self.call(tool, {}) + if not isinstance(out, dict) or not isinstance(out.get("data"), list): + return out + page = int(out.get("current_page", 1) or 1) + last = int(out.get("last_page", 1) or 1) + merged = list(out["data"]) + while page < last: + page += 1 + nxt = self.call(tool, {"page": page}) + if not isinstance(nxt, dict) or not isinstance(nxt.get("data"), list): + break + merged.extend(nxt["data"]) + last = int(nxt.get("last_page", last) or last) # safety if backend shifts + # dedup by id — backend pagination occasionally overlaps pages + seen: set[Any] = set() + uniq: list[Any] = [] + for e in merged: + eid = e.get("id") if isinstance(e, dict) else None + if eid is None or eid not in seen: + if eid is not None: + seen.add(eid) + uniq.append(e) + out = dict(out) + out["data"] = uniq + out["current_page"] = 1 + out["last_page"] = 1 + out["per_page"] = len(uniq) + return out def task(self, event_id: int) -> Any: return self.call(_TOOL_MAP["task"], {"event_id": int(event_id)}) From 26c9631c7867d9cc29201cc13274692078923c23 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Sat, 11 Jul 2026 14:35:17 +0000 Subject: [PATCH 2/4] bump: patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 18c9375..f87eec1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.35.0" +version = "2.35.1" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} From 31d1466fb17a0f25acc45eee7d918da47c6a6873 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Tue, 14 Jul 2026 11:43:45 +0000 Subject: [PATCH 3/4] Add description param to comment() + split label/desc by 1024 word boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _split_label_desc() helper: split long text (>1024) at word boundary - comment(): add description param, apply split before payload - propose(): apply split for consistency with long labels - progress(): inherits automatically via comment() wrapper - Bump version to 2.35.2 Acceptance: - comment --label "short" → label=short, desc=None - comment --label <2000 char> → label≈1024 (word boundary), description=tail - comment --desc <2000> (no label) → label=desc[:1024], description=tail - comment --label X --desc Y → label=X, desc=Y (both as-is) - propose --label/--desc — same split logic --- ceki_sdk/contract.py | 75 +++++++++++++++++++++++++++++++++++++++----- pyproject.toml | 2 +- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py index 19e7ada..50e41a4 100644 --- a/ceki_sdk/contract.py +++ b/ceki_sdk/contract.py @@ -50,6 +50,60 @@ def _clean(args: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in args.items() if v is not None} +def _word_boundary(text: str, limit: int) -> int: + """Find the last space position before limit for clean word break. + + Returns the index of the last space within `limit`, or `limit` itself + if no space is found (forces a hard cut). Used to avoid splitting + mid-word when truncating long text. + """ + i = text.rfind(" ", 0, limit) + return i if i > 0 else limit + + +def _split_label_desc( + label: str | None, description: str | None, limit: int = 1024 +) -> tuple[str | None, str | None]: + """Split long text between label and description at word boundary. + + Rules: + - Both present (label AND description): returned as-is. + - Only label: len<=limit → (label, None); >limit → cut at word boundary, + return (label[:cut], label[cut:].strip()). + - Only description (no label): len<=limit → (description, None); + >limit → cut at word boundary, return (desc[:cut], desc[cut:].strip()). + - Both None: (None, None). + + Args: + label: Primary text (events.label). + description: Secondary text (events.description). + limit: Max length for the first component before split (default 1024). + + Returns: + (label_out, description_out) where label_out is at most `limit` + characters (cut at word boundary when splitting). + """ + if label is not None and description is not None: + # Both explicitly provided – send as-is + return label, description + + if label is not None: + if len(label) <= limit: + return label, None + # Too long – split at word boundary + cut = _word_boundary(label, limit) + return label[:cut], label[cut:].strip() or None + + if description is not None: + if len(description) <= limit: + return description, None + # Too long – split at word boundary + cut = _word_boundary(description, limit) + return description[:cut], description[cut:].strip() or None + + return None, None + + def contract_ids_from_env() -> list[str]: raw = (os.getenv("CEKI_CONTRACT_IDS") or "").strip() if not raw: @@ -414,6 +468,7 @@ def comment( event_id: int, *, label: str | None = None, + description: str | None = None, type_id: int | None = None, status_id: int | None = None, start: str | None = None, @@ -426,15 +481,18 @@ def comment( ) -> 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. + The comment body lives primarily in `label` (events.label is + unbounded TEXT). `description` is now supported: when a long + label or description is provided (>1024 chars), the text is split + at a word boundary – the first part goes to `label`, the remainder + to `description`. This matches backend API behavior and keeps + the UI clean without visible duplication. """ + label_out, desc_out = _split_label_desc(label, description) args = _clean({ "event_id": int(event_id), - "label": label, + "label": label_out, + "description": desc_out, "type_id": type_id, "status_id": status_id, "start": start, @@ -463,11 +521,12 @@ def propose( benefitable: str | None = None, settings: dict[str, Any] | None = None, ) -> Any: + label_out, desc_out = _split_label_desc(label, description) args = _clean({ "event_id": int(event_id), "status_id": status_id, - "label": label, - "description": description, + "label": label_out, + "description": desc_out, "start": start, "end": end, "date": date, diff --git a/pyproject.toml b/pyproject.toml index f87eec1..3a80cbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.35.1" +version = "2.35.2" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} From b8b3bc6c210b5b23205ffbd5b21040568b64cc0a Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Tue, 14 Jul 2026 11:45:43 +0000 Subject: [PATCH 4/4] Update test for comment() description param support Replace test_comment_no_description_kwarg with test_comment_accepts_description_with_split: - Test that comment() accepts description parameter - Test that long text (>1024) is split at word boundary - Test that both short label and description are passed as-is - Test split behavior for long label and long description separately All 93 tests pass. --- tests/test_contract.py | 54 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/tests/test_contract.py b/tests/test_contract.py index 94c15af..ce7e0ed 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -215,17 +215,51 @@ def test_comment_long_multiline_body_in_label_no_description(): 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. +def test_comment_accepts_description_with_split(): + """comment() now accepts description and splits long text (>1024). + + Tests that: + - Both label and description are accepted. + - When label > 1024, it's split at word boundary. + - When description > 1024, it's split at word boundary. + - When both present and under limit, they're passed as-is. """ - c = ContractClient(endpoint="http://x/mcp/agent", token="t") - with pytest.raises(TypeError): - c.comment(99, label="x", description="y") # type: ignore[call-arg] + http, _ = _http_mock(_mcp_text({})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + + # Short label and description - passed as-is + c.comment(99, label="short label", description="short description") + args = _captured_body(http)["params"]["arguments"] + assert args["label"] == "short label" + assert args["description"] == "short description" + + # Long label (>1024) - split at word boundary + long_label = "a" * 1000 + " " + "b" * 100 + http, _ = _http_mock(_mcp_text({})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.comment(99, label=long_label) + args = _captured_body(http)["params"]["arguments"] + # Should be split - label ≈ 1024, description gets the rest + assert len(args["label"]) <= 1024 + assert args["description"] == "b" * 100 + + # Long description (>1024) - split at word boundary + long_desc = "c" * 1000 + " " + "d" * 100 + http, _ = _http_mock(_mcp_text({})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.comment(99, description=long_desc) + args = _captured_body(http)["params"]["arguments"] + # Should be split - label ≈ 1024, description gets the rest + assert len(args["label"]) <= 1024 + assert args["description"] == "d" * 100 + + # Both present and under limit - no split + http, _ = _http_mock(_mcp_text({})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.comment(99, label="label", description="desc") + args = _captured_body(http)["params"]["arguments"] + assert args["label"] == "label" + assert args["description"] == "desc" def test_propose_maps_tool():