From 229190cfe4c29e153c40894b81cee49478d3a245 Mon Sep 17 00:00:00 2001 From: Gk Date: Sat, 4 Apr 2026 13:15:18 -0700 Subject: [PATCH 01/13] chore: pipeline validation test --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b9eec10..e6ef832 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ + > **For AI agents:** This is the official Python SDK for CueAPI. Run `pip install cueapi-sdk`. Your API key is in `CUEAPI_API_KEY`. Create a client: `client = CueAPI(os.environ["CUEAPI_API_KEY"])`. Schedule a task: `client.cues.create(name="task-name", cron="0 9 * * *", callback="https://your-handler.com", payload={})`. For agents without a public URL: set `transport="worker"` and run `pip install cueapi-worker`, then define a handler with `@worker.handler("task-name")` returning `{"success": true}` or `{"success": false, "error": "reason"}`. List executions: `client.executions.list()`. Get one: `client.executions.get("exec_id")`. Do not retry on `AuthenticationError` or `CueLimitExceededError`. Wait `e.retry_after` seconds on `RateLimitError`. # cueapi-sdk From bc5b84c9547c07e5157cf45ec35e388e556ae71a Mon Sep 17 00:00:00 2001 From: Govind Kavaturi Date: Sun, 12 Apr 2026 12:59:06 -0700 Subject: [PATCH 02/13] security: add PEP 740 attestations to PyPI publish (#17) * security: add PEP 740 attestations to PyPI publish workflow - Pin pypa/gh-action-pypi-publish to SHA (v1.14.0) - Enable attestations: true for PEP 740 provenance - Add test job before publish (tests must pass before release) - Add explicit permissions: { contents: read } at workflow level - Bump version to 0.1.1 - Document attestation in README Co-Authored-By: Claude Opus 4.6 * fix: remove test job from publish workflow SDK tests are integration tests requiring staging API + service containers. They run on PRs via feature-to-main.yml. The publish workflow trusts that tests passed during the PR process. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Gk Co-authored-by: Claude Opus 4.6 --- .github/workflows/publish.yml | 14 ++++++++++---- README.md | 6 ++++++ pyproject.toml | 2 +- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2d867fd..d3ed6f4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,16 +5,20 @@ on: tags: - "v*" +permissions: + contents: read + jobs: publish: + name: Publish to PyPI runs-on: ubuntu-latest permissions: id-token: write + contents: read steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.7 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Set up Python - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.3.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -25,4 +29,6 @@ jobs: run: python -m build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 # v1.12.3 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + attestations: true diff --git a/README.md b/README.md index e6ef832..97ea027 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,12 @@ See [`/examples`](examples/) for working code: --- +## Releases + +Releases are published to PyPI with PEP 740 attestations via GitHub Actions Trusted Publishing. + +--- + ## License MIT. See [LICENSE](LICENSE). diff --git a/pyproject.toml b/pyproject.toml index 1a598ac..1412570 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "cueapi-sdk" -version = "0.1.0" +version = "0.1.1" description = "The official Python SDK for CueAPI — scheduling infrastructure for agents" readme = "README.md" license = { text = "MIT" } From 63a4a59c170b8ad52ba1ea2df9d160a504c27974 Mon Sep 17 00:00:00 2001 From: Govind Kavaturi Date: Sun, 12 Apr 2026 13:21:55 -0700 Subject: [PATCH 03/13] fix: add Bug Tracker URL to PyPI metadata (#18) The cueapi-sdk package on PyPI was missing the Bug Tracker project URL, meaning users had no link to report issues. Adds the correct URL pointing to https://github.com/cueapi/cueapi-python/issues. Also bumps version to 0.1.2 (metadata-only patch) and syncs __version__ in __init__.py. Co-authored-by: Gk Co-authored-by: Claude Opus 4.6 --- cueapi/__init__.py | 2 +- pyproject.toml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cueapi/__init__.py b/cueapi/__init__.py index fc882f3..3dc5817 100644 --- a/cueapi/__init__.py +++ b/cueapi/__init__.py @@ -14,7 +14,7 @@ from cueapi.resources.executions import ExecutionsResource from cueapi.webhook import verify_webhook -__version__ = "0.1.0" +__version__ = "0.1.2" __all__ = [ "CueAPI", diff --git a/pyproject.toml b/pyproject.toml index 1412570..89f258c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "cueapi-sdk" -version = "0.1.1" +version = "0.1.2" description = "The official Python SDK for CueAPI — scheduling infrastructure for agents" readme = "README.md" license = { text = "MIT" } @@ -32,6 +32,7 @@ dependencies = [ Homepage = "https://cueapi.ai" Documentation = "https://docs.cueapi.ai" Repository = "https://github.com/cueapi/cueapi-python" +"Bug Tracker" = "https://github.com/cueapi/cueapi-python/issues" [tool.pytest.ini_options] testpaths = ["tests"] From 156ed371a02cc7b61e4d92469b73e48da5181c09 Mon Sep 17 00:00:00 2001 From: Gk Date: Tue, 14 Apr 2026 22:48:53 -0700 Subject: [PATCH 04/13] chore(meta): fix license mismatch (Apache-2.0), expand PyPI metadata, bump to 0.1.3 Reconciles pyproject.toml license with LICENSE file content and repo description (Apache-2.0, not MIT). Adds Trove classifiers, expanded keyword list, maintainers block, and Changelog/Source Code/Status Page URLs. No code changes. --- pyproject.toml | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 89f258c..c5cce87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,24 +4,51 @@ build-backend = "hatchling.build" [project] name = "cueapi-sdk" -version = "0.1.2" -description = "The official Python SDK for CueAPI — scheduling infrastructure for agents" +version = "0.1.3" +description = "Official Python SDK for CueAPI — open-source execution accountability primitive for AI agents. Schedule agent work, require evidence-backed outcomes, and gate execution with write-once verification." readme = "README.md" -license = { text = "MIT" } +license = { text = "Apache-2.0" } requires-python = ">=3.9" -authors = [{ name = "CueAPI", email = "support@cueapi.ai" }] -keywords = ["cueapi", "scheduling", "ai-agents", "cron", "webhooks", "sdk"] +authors = [{ name = "Vector Apps", email = "support@cueapi.ai" }] +maintainers = [{ name = "Vector Apps", email = "support@cueapi.ai" }] +keywords = [ + "cueapi", + "ai-agents", + "agent-infrastructure", + "scheduling", + "cron", + "webhooks", + "sdk", + "llm", + "llm-observability", + "accountability", + "verification", + "outcome-reporting", + "claude", + "gpt", + "gemini", +] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "Intended Audience :: System Administrators", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Monitoring", + "Topic :: System :: Distributed Computing", + "Topic :: Internet :: WWW/HTTP", + "Typing :: Typed", ] dependencies = [ "httpx>=0.24.0", @@ -33,6 +60,9 @@ Homepage = "https://cueapi.ai" Documentation = "https://docs.cueapi.ai" Repository = "https://github.com/cueapi/cueapi-python" "Bug Tracker" = "https://github.com/cueapi/cueapi-python/issues" +Changelog = "https://github.com/cueapi/cueapi-python/blob/main/CHANGELOG.md" +"Source Code" = "https://github.com/cueapi/cueapi-python" +"Status Page" = "https://status.cueapi.ai" [tool.pytest.ini_options] testpaths = ["tests"] From ac957e269c3747635e22c83802f62602fb42e39a Mon Sep 17 00:00:00 2001 From: Govind Kavaturi Date: Sun, 19 Apr 2026 16:28:30 -0700 Subject: [PATCH 05/13] feat(executions): add result_ref kwarg to report_outcome (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server's /v1/executions/{id}/outcome endpoint already accepts a `result_ref` evidence field (see OutcomeRequest in cueapi/cueapi app/schemas/outcome.py). The SDK's report_outcome was missing it, forcing callers who want to attach a reference ID to fall back to either raw HTTP or using an unrelated field like external_id. Single-line addition in executions.py: new kwarg, forwarded to the POST body when provided, same shape as every other evidence field. Companion change in cueapi-worker 0.3.0 (monorepo PR in cueapi/cueapi) exposes the same field on its WorkerAPIClient so worker-transport handlers can surface result_ref via the new CUEAPI_OUTCOME_FILE pattern. No behavioral change for existing callers — kwarg is optional and defaults to None. Co-authored-by: Gk --- cueapi/resources/executions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cueapi/resources/executions.py b/cueapi/resources/executions.py index 4a4d017..713a28d 100644 --- a/cueapi/resources/executions.py +++ b/cueapi/resources/executions.py @@ -58,6 +58,7 @@ def report_outcome( metadata: Optional[dict] = None, external_id: Optional[str] = None, result_url: Optional[str] = None, + result_ref: Optional[str] = None, result_type: Optional[str] = None, summary: Optional[str] = None, artifacts: Optional[list] = None, @@ -74,6 +75,8 @@ def report_outcome( body["external_id"] = external_id if result_url is not None: body["result_url"] = result_url + if result_ref is not None: + body["result_ref"] = result_ref if result_type is not None: body["result_type"] = result_type if summary is not None: From 346fecaf5e78557a9f673f047b19980f70ef2489 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 4 May 2026 11:07:00 -0700 Subject: [PATCH 06/13] chore: seed parity-manifest.json (Layer 2 of parity discipline) (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-pass audit of cueapi-sdk coverage against the hosted CueAPI API. Catalogues which endpoints + model fields the SDK covers, and which have drifted out of sync. The manifest is the audit checklist. New PRs in the private monorepo that touch covered surfaces flag this manifest in their Parity Impact section (see private repo's `.github/pull_request_template.md`). Monthly full sweep updates `last_full_audit`. Notable drift surfaced: - 12 endpoints missing (cue.fire, execution.replay, worker registration + claim flow, usage, webhook-secret retrieval, plus the entire messaging primitive surface from Phase 12.1.5 — Identity / Messages / Inbox). - Cue model missing 8 fields shipped in private over the last few months: delivery, alerts, catch_up, verification, on_success_fire, require_payload_override, required_payload_keys, stats. CueDetailResponse shape (executions list + pagination) also missing. - Execution has no dedicated model class — SDK returns dicts. Should promote to a typed class along with the outcome / evidence / heartbeat / chain attribution fields. - Worker, Agent, Message classes missing entirely. Two PRs from 2026-05-03 (private repo #589 expose payload on GET executions; #590 require_payload_override enforcement) have explicit Backlog rows pending port to this SDK; both are priority=now once their source PRs merge. Co-authored-by: Claude Opus 4.7 (1M context) --- parity-manifest.json | 94 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 parity-manifest.json diff --git a/parity-manifest.json b/parity-manifest.json new file mode 100644 index 0000000..67751cc --- /dev/null +++ b/parity-manifest.json @@ -0,0 +1,94 @@ +{ + "manifest_version": 1, + "description": "Parity tracking for cueapi-sdk against the hosted CueAPI API. Enumerates which API surfaces this SDK covers and which it doesn't, so drift becomes visible at audit time. Per the 3-layer parity discipline (PR template + this manifest + Backlog rows for each port).", + "private_source_repo": "https://github.com/cueapi/cueapi", + "private_source_paths": [ + "app/routers/", + "app/schemas/" + ], + "last_full_audit": "2026-05-04", + "sdk_version_at_audit": "0.1.3", + "audit_methodology": "Walk every endpoint in private cueapi/app/routers/ and check coverage in cueapi/resources/. For each covered endpoint, walk the corresponding schemas/*.py to verify field-level coverage in cueapi/models/. Drift goes in `missing_endpoints` or `missing_fields` keyed by endpoint.", + "audit_cadence": "Monthly full sweep. Per-PR diffs handled via the .github/pull_request_template.md `Parity Impact` section in the private repo.", + + "endpoints_covered": { + "POST /v1/auth/register": {"sdk": "client.register (utility, not on a resource)"}, + "POST /v1/cues": {"sdk": "client.cues.create"}, + "GET /v1/cues": {"sdk": "client.cues.list"}, + "GET /v1/cues/{id}": {"sdk": "client.cues.get"}, + "PATCH /v1/cues/{id}": {"sdk": "client.cues.update"}, + "DELETE /v1/cues/{id}": {"sdk": "client.cues.delete"}, + "POST /v1/cues/{id} (pause)": {"sdk": "client.cues.pause"}, + "POST /v1/cues/{id} (resume)": {"sdk": "client.cues.resume"}, + "POST /v1/executions/{id}/outcome": {"sdk": "client.executions.report_outcome"}, + "GET /v1/executions": {"sdk": "client.executions.list"}, + "GET /v1/executions/{id}": {"sdk": "client.executions.get"}, + "POST /v1/executions/{id}/heartbeat": {"sdk": "client.executions.heartbeat"}, + "POST /v1/executions/{id}/verification-pending": {"sdk": "client.executions.mark_verification_pending"}, + "POST /v1/executions/{id}/verify": {"sdk": "client.executions.mark_verified"} + }, + + "endpoints_missing": { + "POST /v1/cues/{id}/fire": { + "blocker": "Real ergonomic gap. Team-comm convention requires payload_override per fire; SDK users currently fall back to raw httpx.", + "tracking": "Backlog row: priority=now after the audit ships. Should pair with the PR #590 require_payload_override port since cue-fire is the primary surface for that enforcement." + }, + "POST /v1/executions/{id}/replay": {"blocker": "Used for retry-from-failure flows; not in any current SDK release."}, + "GET /v1/executions/claimable": {"blocker": "Worker-pull endpoint; some SDK users want to write Python workers directly without using cueapi-worker."}, + "POST /v1/executions/{id}/claim": {"blocker": "Same as above — worker-pull surface missing."}, + "POST /v1/worker/heartbeat": {"blocker": "Worker registration endpoint; same justification."}, + "GET /v1/workers": {"blocker": "List workers + heartbeat status; useful for fleet visibility."}, + "GET /v1/usage": {"blocker": "Plan, cue count, execution usage, rate limit info. Currently SDK users hit /v1/auth/me only."}, + "POST /v1/billing/checkout": {"blocker": "Hosted-only — wrap if/when hosted users need programmatic checkout."}, + "POST /v1/billing/portal": {"blocker": "Hosted-only."}, + "POST /v1/auth/key/regenerate": {"blocker": "Risky destructive op; intentionally not surfaced. Re-evaluate."}, + "GET /v1/auth/webhook-secret": {"blocker": "Webhook-secret retrieval — SDK users running their own webhook servers want this."}, + "POST /v1/auth/webhook-secret/regenerate": {"blocker": "Destructive; re-evaluate."}, + "Messaging primitive (all of /v1/agents, /v1/messages, /v1/agents/{id}/inbox)": { + "blocker": "Phase 12.1.5 messaging primitive is on prod but not yet exposed in the SDK. Significant new surface — agent identity, send_message, inbox poll, idempotency-keyed sends, reply threading.", + "tracking": "Major SDK extension. Should land before push delivery (v1.5) goes wide." + } + }, + + "model_drift": { + "Cue": { + "sdk_class": "cueapi.models.Cue", + "covered_fields": [ + "id", "name", "description", "status", "transport", "schedule", + "callback", "payload", "retry", "next_run", "last_run", "run_count", + "fired_count", "on_failure", "warning", "created_at", "updated_at" + ], + "missing_fields": [ + "delivery", "alerts", "catch_up", "verification", "on_success_fire", + "require_payload_override", "required_payload_keys", "stats" + ], + "missing_response_shape": "CueDetailResponse (cue + executions[] + execution_total/limit/offset)" + }, + "Execution": { + "sdk_class": "no dedicated model; SDK returns dicts from executions.get/list", + "needs_dedicated_class": true, + "missing_fields": [ + "payload (PR #589, just shipped)", + "outcome", + "outcome_state", + "triggered_by", + "evidence_external_id, evidence_result_url, evidence_result_type, evidence_summary, evidence_validation_state, evidence_assertions", + "claimed_by_worker, claimed_at, last_heartbeat_at", + "chain_parent_id, chain_depth" + ] + }, + "Worker": { + "sdk_class": "missing entirely", + "missing_fields": ["worker_id", "user_id", "handlers", "last_heartbeat", "heartbeat_status (active/stale/dead)"] + }, + "Agent (messaging)": {"sdk_class": "missing entirely (Phase 12.1.5)"}, + "Message (messaging)": {"sdk_class": "missing entirely (Phase 12.1.5)"} + }, + + "ported_pr_history": [ + "PR #589 (expose payload on GET /v1/executions): NOT YET PORTED — Backlog row 'Parity port: PR #589 → cueapi-python SDK' priority=now.", + "PR #590 (require_payload_override + required_payload_keys + cue.fire enforcement): NOT YET PORTED — Backlog row 'Parity port: PR #590 → cueapi-python SDK' priority=now." + ], + + "notes": "First seeded 2026-05-04 as Layer 2 of parity discipline (PR template + this manifest + Backlog rows). Schema may evolve based on what auditors actually need. The `endpoints_missing` and `model_drift` sections are deliberately verbose — they are the audit checklist for catching up the SDK to the hosted API." +} From cc55599faf41db8cbd6ef27d16a1066abd68e522 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 4 May 2026 11:11:20 -0700 Subject: [PATCH 07/13] feat: expand Cue model with 8 missing fields (drift fix-up) (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the Cue portion of cueapi-python #24's `model_drift` manifest. The SDK's Cue Pydantic model was silently dropping 8 fields the server returns (Pydantic default extra="ignore"); callers reading e.g. the ``catch_up`` policy or ``stats`` blob via the SDK got nothing. Fields added (all Optional with defaults so legacy responses still parse): - delivery: Optional[DeliveryConfig] — timeout_seconds, outcome_deadline_seconds - alerts: Optional[AlertConfig] — extra="allow" forward-compat - catch_up: Optional[str] — run_once_if_missed / skip_missed / replay_all - verification: Optional[VerificationConfig] — mode + required_assertions; extra="allow" - on_success_fire: Optional[str] — cue ID for 1:1 chaining (Gap 1) - require_payload_override: bool = False — hosted PR #590; default False matches server - required_payload_keys: Optional[List[str]] — hosted PR #590 - stats: Optional[Dict[str, Any]] — CueDetailResponse-only blob (7d success rate etc.) Three new nested models: - DeliveryConfig: typed schema for the 2-phase delivery config - AlertConfig: forward-compat (extra="allow") since alert kinds evolve server-side - VerificationConfig: typed `mode` + `required_assertions` plus extra="allow" for forward-compat on assertion kinds Tests: 11 new (30 → 41 unit tests). Coverage: - Old response (without new fields) still parses cleanly — pinning backward compat - Each new field round-trips correctly with a realistic payload - AlertConfig forward-compat: unknown server-side keys land in model_extra without raising - VerificationConfig forward-compat: same - Full-response roundtrip with every field set - CueList parses correctly with new fields in each row No breaking change for SDK callers — fields are additive, all Optional/defaulted, server's prior shape still parses. Bump warranted at next minor (0.3.0) for the new accessor surface. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- cueapi/models/cue.py | 50 +++++++++++ tests/test_cue_model.py | 182 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 tests/test_cue_model.py diff --git a/cueapi/models/cue.py b/cueapi/models/cue.py index 0692d00..fb7d0fd 100644 --- a/cueapi/models/cue.py +++ b/cueapi/models/cue.py @@ -32,6 +32,38 @@ class OnFailure(BaseModel): pause: bool = False +class DeliveryConfig(BaseModel): + """Two-phase delivery configuration (Gap 5).""" + + timeout_seconds: int = 30 + outcome_deadline_seconds: int = 300 + + +class AlertConfig(BaseModel): + """Alert configuration (Gap 5). + + Surfaced as a passthrough dict via ``extra="allow"`` so callers see + every field the server returns even if the SDK hasn't been updated + for new alert kinds yet. Models that have grown additively benefit + from forward-compat. + """ + + model_config = {"extra": "allow"} + + +class VerificationConfig(BaseModel): + """Outcome verification policy. + + The ``mode`` field controls evidence requirements. The + ``required_assertions`` field (Gap 8) controls structural requirements + on the reported outcome. + """ + + mode: Optional[str] = None + required_assertions: Optional[List[str]] = None + model_config = {"extra": "allow"} + + class Cue(BaseModel): id: str name: str @@ -47,6 +79,24 @@ class Cue(BaseModel): run_count: int = 0 fired_count: int = 0 on_failure: Optional[OnFailure] = None + # Two-phase + alerts + catch-up + verification config (hosted Phase + # 18 / Gap 5 / Gap 8). All optional and forward-compat — server + # may grow these objects over time without breaking SDK callers. + delivery: Optional[DeliveryConfig] = None + alerts: Optional[AlertConfig] = None + catch_up: Optional[str] = None + verification: Optional[VerificationConfig] = None + # On-success chaining (Gap 1): cue ID to fire when an execution of + # this cue reaches a successful terminal state. Strictly 1:1. + on_success_fire: Optional[str] = None + # Per-cue payload_override enforcement on /fire (hosted PR #590). + # Default false (server's default) so old responses without these + # keys still parse cleanly. + require_payload_override: bool = False + required_payload_keys: Optional[List[str]] = None + # Cue-detail-response stats: 7d success rate, miss rate, totals. + # Returned only on GET /v1/cues/{id} detail; absent on list rows. + stats: Optional[Dict[str, Any]] = None warning: Optional[str] = None created_at: datetime updated_at: datetime diff --git a/tests/test_cue_model.py b/tests/test_cue_model.py new file mode 100644 index 0000000..695e68d --- /dev/null +++ b/tests/test_cue_model.py @@ -0,0 +1,182 @@ +"""Unit tests for the Cue Pydantic model — drift-against-hosted-API coverage. + +These tests validate that the Cue model deserializes the full server +response shape, not just the subset the SDK had before the +2026-05-04 fix-up. Run against synthesized payloads that mirror what +the hosted ``app/schemas/cue.py CueResponse`` returns. +""" + +from datetime import datetime, timezone + +from cueapi.models.cue import ( + AlertConfig, + Cue, + CueList, + DeliveryConfig, + VerificationConfig, +) + + +def _base_cue_payload() -> dict: + return { + "id": "cue_test123", + "name": "test-cue", + "status": "active", + "transport": "webhook", + "schedule": {"type": "recurring", "cron": "0 9 * * *", "timezone": "UTC"}, + "callback": {"url": "https://example.com/webhook", "method": "POST"}, + "payload": {}, + "retry": {"max_attempts": 3, "backoff_minutes": [1, 5, 15]}, + "next_run": None, + "last_run": None, + "run_count": 0, + "fired_count": 0, + "warning": None, + "created_at": "2026-05-04T17:00:00Z", + "updated_at": "2026-05-04T17:00:00Z", + } + + +class TestNewFields: + def test_old_response_still_parses(self): + # Older server responses without the new fields must still + # deserialize cleanly. Pinning so a future required-field + # addition doesn't break SDK callers reading legacy data. + cue = Cue.model_validate(_base_cue_payload()) + assert cue.delivery is None + assert cue.alerts is None + assert cue.catch_up is None + assert cue.verification is None + assert cue.on_success_fire is None + assert cue.require_payload_override is False + assert cue.required_payload_keys is None + assert cue.stats is None + + def test_delivery_config_parses(self): + payload = _base_cue_payload() + payload["delivery"] = {"timeout_seconds": 60, "outcome_deadline_seconds": 600} + cue = Cue.model_validate(payload) + assert isinstance(cue.delivery, DeliveryConfig) + assert cue.delivery.timeout_seconds == 60 + assert cue.delivery.outcome_deadline_seconds == 600 + + def test_alerts_config_forward_compat(self): + # AlertConfig has extra="allow" so server can grow the object + # without the SDK breaking. Pin the forward-compat behavior. + payload = _base_cue_payload() + payload["alerts"] = { + "channels": ["email", "slack"], + "future_field_we_dont_know_about_yet": "value", + } + cue = Cue.model_validate(payload) + assert isinstance(cue.alerts, AlertConfig) + assert cue.alerts.model_extra["channels"] == ["email", "slack"] + assert cue.alerts.model_extra["future_field_we_dont_know_about_yet"] == "value" + + def test_catch_up_passthrough(self): + for v in ("run_once_if_missed", "skip_missed", "replay_all"): + payload = _base_cue_payload() + payload["catch_up"] = v + cue = Cue.model_validate(payload) + assert cue.catch_up == v + + def test_verification_config_with_assertions(self): + payload = _base_cue_payload() + payload["verification"] = { + "mode": "evidence_required", + "required_assertions": ["external_id", "result_url"], + } + cue = Cue.model_validate(payload) + assert isinstance(cue.verification, VerificationConfig) + assert cue.verification.mode == "evidence_required" + assert cue.verification.required_assertions == ["external_id", "result_url"] + + def test_verification_config_forward_compat(self): + payload = _base_cue_payload() + payload["verification"] = { + "mode": "manual", + "future_assertion_subkey": {"nested": True}, + } + cue = Cue.model_validate(payload) + assert cue.verification.mode == "manual" + assert cue.verification.model_extra["future_assertion_subkey"] == {"nested": True} + + def test_on_success_fire(self): + payload = _base_cue_payload() + payload["on_success_fire"] = "cue_chained123" + cue = Cue.model_validate(payload) + assert cue.on_success_fire == "cue_chained123" + + def test_require_payload_override_explicitly_true(self): + payload = _base_cue_payload() + payload["require_payload_override"] = True + payload["required_payload_keys"] = ["task", "message"] + cue = Cue.model_validate(payload) + assert cue.require_payload_override is True + assert cue.required_payload_keys == ["task", "message"] + + def test_stats_blob(self): + # CueDetailResponse-only field. Pin that the SDK accepts the + # blob shape the server returns, opaquely (the keys evolve + # server-side and we don't want to lock them). + payload = _base_cue_payload() + payload["stats"] = { + "success_rate_7d": 0.94, + "miss_rate_7d": 0.02, + "total_executions_7d": 156, + } + cue = Cue.model_validate(payload) + assert cue.stats == { + "success_rate_7d": 0.94, + "miss_rate_7d": 0.02, + "total_executions_7d": 156, + } + + +class TestRoundTrip: + def test_full_response_roundtrip(self): + # Comprehensive: every new field set, ensure the model accepts + # the union shape and re-serializes to a dict that contains all + # the field names the server expects to see in a write-side + # request (when the SDK eventually grows builder-style helpers + # that send these fields back to the server). + payload = _base_cue_payload() + payload.update({ + "delivery": {"timeout_seconds": 90, "outcome_deadline_seconds": 900}, + "alerts": {"channels": ["email"]}, + "catch_up": "skip_missed", + "verification": { + "mode": "evidence_required", + "required_assertions": ["external_id"], + }, + "on_success_fire": "cue_next", + "require_payload_override": True, + "required_payload_keys": ["task"], + "stats": {"success_rate_7d": 1.0}, + }) + cue = Cue.model_validate(payload) + + # All fields present in dict roundtrip. + d = cue.model_dump() + assert d["delivery"]["timeout_seconds"] == 90 + assert d["catch_up"] == "skip_missed" + assert d["on_success_fire"] == "cue_next" + assert d["require_payload_override"] is True + assert d["required_payload_keys"] == ["task"] + + +class TestCueList: + def test_list_with_new_fields_in_each_cue(self): + list_payload = { + "cues": [ + {**_base_cue_payload(), "id": "cue_1", "require_payload_override": True}, + {**_base_cue_payload(), "id": "cue_2", "catch_up": "replay_all"}, + ], + "total": 2, + "limit": 50, + "offset": 0, + } + cl = CueList.model_validate(list_payload) + assert len(cl.cues) == 2 + assert cl.cues[0].require_payload_override is True + assert cl.cues[1].catch_up == "replay_all" From dc4908b62343c5af697496f563c750304a6278bc Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 4 May 2026 11:11:24 -0700 Subject: [PATCH 08/13] fix(executions): mark_verified actually sends valid+reason; add replay() (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes, both in ExecutionsResource: (1) **Bug fix — mark_verified silently dropped both kwargs.** The prior implementation accepted ``valid`` and ``reason`` as keyword args but always sent ``json={}``. The server treats absent body as ``valid=true``, so the default-arg path produced the right outcome by accident — but every caller passing ``valid=False`` or ``reason="..."`` got ``verified_success`` instead of their intent, silently. The fix builds the body explicitly: body = {"valid": valid} if reason is not None: body["reason"] = reason Pinned by 4 regression tests: - default-arg sends ``{"valid": True}`` (not ``{}``) - ``valid=False`` lands in body - ``reason="..."`` lands in body - ``reason=None`` is omitted (not serialized as null) (2) **New: ``ExecutionsResource.replay(execution_id)``** — POST /v1/executions/{id}/replay. Closes one of the ``endpoints_missing`` entries from cueapi-python #24's parity manifest. Server-side already shipped on prod; this is pure SDK catch-up. Returns the server's response dict unchanged (new execution_id, scheduled_for, status, triggered_by="replay", replayed_from). Tests: 5 new (12 → 17 total). All pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- cueapi/resources/executions.py | 49 +++++++++++++++++- tests/test_executions_resource.py | 84 ++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/cueapi/resources/executions.py b/cueapi/resources/executions.py index 713a28d..20fd7b2 100644 --- a/cueapi/resources/executions.py +++ b/cueapi/resources/executions.py @@ -128,6 +128,27 @@ def heartbeat(self, execution_id: str) -> dict: """Send heartbeat to extend claim lease.""" return self._client._post(f"/v1/executions/{execution_id}/heartbeat", json={}) + def replay(self, execution_id: str) -> dict: + """Replay a terminal execution. + + Creates a fresh execution against the same cue with the original + execution's ``payload_override`` carried forward. Server-side + constraint: only valid for terminal states (``success`` / + ``failed`` / ``missed`` / ``outcome_timeout``); 409 if the + execution is still in flight. + + Args: + execution_id: Execution UUID to replay. + + Returns: + Dict with ``execution_id`` (new), ``scheduled_for``, + ``status`` (``pending``), ``triggered_by`` (``replay``), + ``replayed_from`` (the original execution_id). + """ + return self._client._post( + f"/v1/executions/{execution_id}/replay", json={} + ) + def mark_verification_pending(self, execution_id: str) -> dict: """Mark execution outcome as pending verification.""" return self._client._post( @@ -141,5 +162,29 @@ def mark_verified( valid: bool = True, reason: Optional[str] = None, ) -> dict: - """Mark execution outcome as verified or verification failed.""" - return self._client._post(f"/v1/executions/{execution_id}/verify", json={}) + """Mark execution outcome as verified or verification failed. + + Args: + execution_id: Execution UUID. + valid: ``True`` (default) transitions to ``verified_success``; + ``False`` transitions to ``verification_failed``. + reason: Optional human-readable reason (max 500 chars). + Appended to the execution's ``evidence_summary``. Most + useful with ``valid=False`` to record why verification + failed. + + Returns: + Dict with the new ``outcome_state`` and timestamp fields. + """ + # Bug fix: the prior implementation accepted ``valid`` and + # ``reason`` kwargs but always sent ``json={}``. The server + # treated absent body as ``valid=true``, so callers passing + # ``valid=False`` or ``reason="..."`` got ``verified_success`` + # regardless of intent — silently dropping the kwargs. Pinned + # by the corresponding regression test. + body: Dict[str, Any] = {"valid": valid} + if reason is not None: + body["reason"] = reason + return self._client._post( + f"/v1/executions/{execution_id}/verify", json=body + ) diff --git a/tests/test_executions_resource.py b/tests/test_executions_resource.py index 9f6c7e1..cab3101 100644 --- a/tests/test_executions_resource.py +++ b/tests/test_executions_resource.py @@ -154,7 +154,14 @@ def test_mark_verification_pending(self): "/v1/executions/exec_123/verification-pending", json={}, ) - def test_mark_verified(self): + def test_mark_verified_default_sends_valid_true(self): + # Regression: prior implementation always sent ``json={}`` and + # silently dropped both kwargs. The server treated absent body + # as ``valid=true``, so the default-arg path produced the right + # outcome by accident — but ``valid=False`` and ``reason="..."`` + # callers got ``verified_success`` instead of their intent. + # Pinning that the default-arg path now sends ``{"valid": True}`` + # explicitly. mock_client = MagicMock() mock_client._post.return_value = {"outcome_state": "verified_success"} resource = ExecutionsResource(mock_client) @@ -162,5 +169,78 @@ def test_mark_verified(self): resource.mark_verified("exec_123") mock_client._post.assert_called_once_with( - "/v1/executions/exec_123/verify", json={}, + "/v1/executions/exec_123/verify", json={"valid": True}, ) + + def test_mark_verified_with_invalid_sends_false(self): + # The fix: ``valid=False`` MUST land in the body. Pre-fix this + # was silently dropped. + mock_client = MagicMock() + mock_client._post.return_value = {"outcome_state": "verification_failed"} + resource = ExecutionsResource(mock_client) + + resource.mark_verified("exec_123", valid=False) + + mock_client._post.assert_called_once_with( + "/v1/executions/exec_123/verify", json={"valid": False}, + ) + + def test_mark_verified_with_reason(self): + # The fix: ``reason`` MUST land in the body. Pre-fix this was + # silently dropped, so any caller passing a reason saw it + # disappear into the ether. + mock_client = MagicMock() + mock_client._post.return_value = {"outcome_state": "verification_failed"} + resource = ExecutionsResource(mock_client) + + resource.mark_verified("exec_123", valid=False, reason="evidence missing") + + mock_client._post.assert_called_once_with( + "/v1/executions/exec_123/verify", + json={"valid": False, "reason": "evidence missing"}, + ) + + def test_mark_verified_omits_reason_when_none(self): + # ``reason=None`` must NOT serialize as ``"reason": null``; it + # must be omitted entirely. Pinning the omit-when-default + # behavior. + mock_client = MagicMock() + mock_client._post.return_value = {"outcome_state": "verified_success"} + resource = ExecutionsResource(mock_client) + + resource.mark_verified("exec_123", valid=True, reason=None) + + sent_body = mock_client._post.call_args.kwargs["json"] + assert "reason" not in sent_body + + +class TestReplay: + def test_replay_posts_to_replay_endpoint(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "execution_id": "exec_new", + "scheduled_for": "2026-05-04T17:30:00Z", + "status": "pending", + "triggered_by": "replay", + "replayed_from": "exec_old", + } + resource = ExecutionsResource(mock_client) + + result = resource.replay("exec_old") + + mock_client._post.assert_called_once_with( + "/v1/executions/exec_old/replay", json={}, + ) + assert result["execution_id"] == "exec_new" + assert result["triggered_by"] == "replay" + + def test_replay_returns_server_dict_unchanged(self): + # SDK doesn't transform the response — caller gets the raw dict + # the server returned. Pin so a future refactor can't silently + # start munging fields. + mock_client = MagicMock() + mock_client._post.return_value = {"execution_id": "exec_x", "extra": "field"} + resource = ExecutionsResource(mock_client) + + result = resource.replay("exec_old") + assert result == {"execution_id": "exec_x", "extra": "field"} From 4b37d975d9829c0d565cc607805d7ec0649670f8 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 4 May 2026 11:12:06 -0700 Subject: [PATCH 09/13] feat: add WorkersResource + UsageResource (parity with /v1/workers + /v1/usage) (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 2 entries from cueapi-python #24's `endpoints_missing` parity manifest: - GET /v1/workers → client.workers.list() - DELETE /v1/workers/{id} → client.workers.delete(worker_id) - GET /v1/usage → client.usage.get() (`DELETE /v1/workers/{id}` wasn't in the manifest but is part of the same hosted surface — added for completeness.) New resource classes: - `cueapi/resources/workers.py`: WorkersResource — `.list()` + `.delete()` - `cueapi/resources/usage.py`: UsageResource — `.get()` Both registered on the CueAPI client and exported from cueapi.__init__. Skipped from manifest: POST /v1/worker/heartbeat (worker registration). The hosted endpoint is meant for cueapi-worker (which already wraps it correctly with heartbeat-loop semantics); direct SDK-driven registration is redundant. Documented in WorkersResource's class docstring. Tests: 5 new (12 → 17 unit tests). Mock-based, mirrors the existing ExecutionsResource test pattern. The 14 pre-existing staging-cred test_cues.py failures (`ValueError: api_key is required`) are unrelated to this PR — same flake captured in the Backlog row added when surveying cueapi-python earlier this session. No hosted-PR dependency. All 3 endpoints already shipped on prod. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- cueapi/__init__.py | 4 +++ cueapi/client.py | 4 +++ cueapi/resources/usage.py | 31 +++++++++++++++++++ cueapi/resources/workers.py | 51 +++++++++++++++++++++++++++++++ tests/test_usage_resource.py | 34 +++++++++++++++++++++ tests/test_workers_resource.py | 55 ++++++++++++++++++++++++++++++++++ 6 files changed, 179 insertions(+) create mode 100644 cueapi/resources/usage.py create mode 100644 cueapi/resources/workers.py create mode 100644 tests/test_usage_resource.py create mode 100644 tests/test_workers_resource.py diff --git a/cueapi/__init__.py b/cueapi/__init__.py index 3dc5817..86ca880 100644 --- a/cueapi/__init__.py +++ b/cueapi/__init__.py @@ -12,6 +12,8 @@ ) from cueapi.payload import CuePayload from cueapi.resources.executions import ExecutionsResource +from cueapi.resources.usage import UsageResource +from cueapi.resources.workers import WorkersResource from cueapi.webhook import verify_webhook __version__ = "0.1.2" @@ -20,6 +22,8 @@ "CueAPI", "CuePayload", "ExecutionsResource", + "UsageResource", + "WorkersResource", "verify_webhook", "CueAPIError", "AuthenticationError", diff --git a/cueapi/client.py b/cueapi/client.py index 696d0fd..0fe27b4 100644 --- a/cueapi/client.py +++ b/cueapi/client.py @@ -17,6 +17,8 @@ ) from cueapi.resources.cues import CuesResource from cueapi.resources.executions import ExecutionsResource +from cueapi.resources.usage import UsageResource +from cueapi.resources.workers import WorkersResource DEFAULT_BASE_URL = "https://api.cueapi.ai" DEFAULT_TIMEOUT = 30.0 @@ -69,6 +71,8 @@ def __init__( # Resources self.cues = CuesResource(self) self.executions = ExecutionsResource(self) + self.workers = WorkersResource(self) + self.usage = UsageResource(self) def close(self) -> None: """Close the underlying HTTP client.""" diff --git a/cueapi/resources/usage.py b/cueapi/resources/usage.py new file mode 100644 index 0000000..230db14 --- /dev/null +++ b/cueapi/resources/usage.py @@ -0,0 +1,31 @@ +"""Usage resource — plan + cue + execution usage stats.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cueapi.client import CueAPI + + +class UsageResource: + """Usage stats resource. + + Wraps ``GET /v1/usage`` for SDK callers who want plan + cue count + + execution count + rate-limit info without parsing the broader + ``/v1/auth/me`` response. + """ + + def __init__(self, client: "CueAPI") -> None: + self._client = client + + def get(self) -> dict: + """Get current usage stats. + + Returns: + Dict with ``plan`` (name + interval + period_end), + ``cues`` (active count + limit), + ``executions`` (used this period + limit + outcomes summary), + and ``rate_limit`` (requests/min limit). + """ + return self._client._get("/v1/usage") diff --git a/cueapi/resources/workers.py b/cueapi/resources/workers.py new file mode 100644 index 0000000..23e70f0 --- /dev/null +++ b/cueapi/resources/workers.py @@ -0,0 +1,51 @@ +"""Workers resource — fleet visibility for worker-transport users.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cueapi.client import CueAPI + + +class WorkersResource: + """Workers API resource. + + Mirrors the hosted ``/v1/workers`` surface — list registered workers + with heartbeat status, and delete decommissioned workers. Worker + registration itself happens via cueapi-worker (which sends heartbeats); + the SDK doesn't expose ``POST /v1/worker/heartbeat`` because direct + SDK-driven registration is redundant with that package. + """ + + def __init__(self, client: "CueAPI") -> None: + self._client = client + + def list(self) -> dict: + """List all registered workers with heartbeat status. + + Returns: + Dict with ``workers`` (list of worker dicts) and ``total``. + Each worker carries ``worker_id``, ``handlers``, + ``last_heartbeat``, ``heartbeat_status`` + (``online`` / ``stale`` / ``dead`` based on seconds since + last heartbeat), and ``seconds_since_heartbeat``. + """ + return self._client._get("/v1/workers") + + def delete(self, worker_id: str) -> None: + """Delete a registered worker. + + Removes the worker row; in-flight executions claimed by this + worker will be picked up by the stale-recovery loop. Useful for + cleaning up workers that have been decommissioned. + + Returns ``None`` on success (HTTP 204). Raises + ``CueNotFoundError`` if the worker doesn't exist. + + Args: + worker_id: The caller-defined worker_id used during the + worker's heartbeats. Same value is what appears in + ``list()`` responses. + """ + return self._client._delete(f"/v1/workers/{worker_id}") diff --git a/tests/test_usage_resource.py b/tests/test_usage_resource.py new file mode 100644 index 0000000..aa1be8b --- /dev/null +++ b/tests/test_usage_resource.py @@ -0,0 +1,34 @@ +"""Tests for UsageResource.""" + +from unittest.mock import MagicMock + +from cueapi.resources.usage import UsageResource + + +class TestGet: + def test_get_calls_get_usage(self): + mock_client = MagicMock() + mock_client._get.return_value = { + "plan": {"name": "pro", "interval": "monthly"}, + "cues": {"active": 12, "limit": 100}, + "executions": {"used": 543, "limit": 5000}, + "rate_limit": {"limit": 200}, + } + resource = UsageResource(mock_client) + + result = resource.get() + + mock_client._get.assert_called_once_with("/v1/usage") + assert result["plan"]["name"] == "pro" + assert result["cues"]["active"] == 12 + + def test_get_returns_server_dict_unchanged(self): + # Pin the no-transform behavior so a future refactor can't + # silently start coercing the response into a typed object + # without bumping the major version. + mock_client = MagicMock() + mock_client._get.return_value = {"unexpected_field": "value"} + resource = UsageResource(mock_client) + + result = resource.get() + assert result == {"unexpected_field": "value"} diff --git a/tests/test_workers_resource.py b/tests/test_workers_resource.py new file mode 100644 index 0000000..e9f96d3 --- /dev/null +++ b/tests/test_workers_resource.py @@ -0,0 +1,55 @@ +"""Tests for WorkersResource.""" + +from unittest.mock import MagicMock + +from cueapi.resources.workers import WorkersResource + + +class TestList: + def test_list_calls_get_workers(self): + mock_client = MagicMock() + mock_client._get.return_value = { + "workers": [ + { + "worker_id": "worker-1", + "handlers": ["task-a"], + "last_heartbeat": "2026-05-04T17:30:00Z", + "heartbeat_status": "online", + "seconds_since_heartbeat": 5, + } + ], + "total": 1, + } + resource = WorkersResource(mock_client) + + result = resource.list() + + mock_client._get.assert_called_once_with("/v1/workers") + assert result["total"] == 1 + assert result["workers"][0]["worker_id"] == "worker-1" + + def test_list_passes_no_params(self): + # Endpoint accepts no query params; SDK MUST NOT silently start + # passing params (would couple to a future server-side change). + # Pinning the bare-call shape. + mock_client = MagicMock() + mock_client._get.return_value = {"workers": [], "total": 0} + resource = WorkersResource(mock_client) + + resource.list() + + mock_client._get.assert_called_once_with("/v1/workers") + # No params kwarg. + assert "params" not in mock_client._get.call_args.kwargs + + +class TestDelete: + def test_delete_calls_delete_workers_id(self): + mock_client = MagicMock() + mock_client._delete.return_value = None # 204 -> None per client _request + resource = WorkersResource(mock_client) + + result = resource.delete("worker-1") + + mock_client._delete.assert_called_once_with("/v1/workers/worker-1") + assert result is None From 7be845b04b996d01d8978663d84e8a6bee29e4e2 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 4 May 2026 11:12:58 -0700 Subject: [PATCH 10/13] feat: add AgentsResource (messaging primitive identity surface) (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the entire `/v1/agents` surface from the messaging primitive (Phase 12.1.5). Closes the agents portion of the `Messaging primitive` endpoints_missing entry in cueapi-python #24's parity manifest. The companion `MessagesResource` (send/get/read/ack lifecycle) ships in a follow-up PR. New resource: - `cueapi/resources/agents.py`: AgentsResource - .create(display_name, slug=None, webhook_url=None, metadata=None) - .list(status=None, include_deleted=False, limit=50, offset=0) - .get(ref, include_deleted=False) - .update(ref, display_name=None, webhook_url=None, clear_webhook_url=False, status=None, metadata=None) - .delete(ref) - .webhook_secret_get(ref) - .webhook_secret_regenerate(ref) # sends X-Confirm-Destructive: true - .inbox(ref, state=None, limit=50, offset=0) - .sent(ref, limit=50, offset=0) Client extension: - `client._request` now accepts an optional `headers` kwarg, which extends (does not replace) the client's default Authorization + Content-Type + User-Agent headers. Used here for the destructive X-Confirm-Destructive guard; will also be used by the upcoming MessagesResource for X-Cueapi-From-Agent + Idempotency-Key. Design notes pinned by tests: - `--include-deleted` mirror: `include_deleted=True` sends `"true"`, `False` (default) omits. Same omit-when-default pattern as PR #26's `executions list --has-evidence`. - `clear_webhook_url=True` sends literal JSON `null` (key present, value None), NOT field omission. Server uses `model_fields_set` to disambiguate "omitted = no change" from "explicit null = clear", so the SDK MUST send the key with explicit None. Pinned by test_clear_webhook_url_sends_explicit_null. - `webhook_url` and `clear_webhook_url` mutex enforced with a clear ValueError before any HTTP call. - `webhook_secret_regenerate` sends X-Confirm-Destructive: true in the header. The server requires it; the SDK adds it automatically so callers don't have to know about the header. Pinned by test_regenerate_sends_destructive_header. Tests: 18 new across 9 test classes (12 → ~30 unit tests; total 46 passing across all unit-test files). No hosted-PR dependency. All 9 endpoints already shipped on prod. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- cueapi/__init__.py | 2 + cueapi/client.py | 16 ++- cueapi/resources/agents.py | 192 +++++++++++++++++++++++++++++ tests/test_agents_resource.py | 219 ++++++++++++++++++++++++++++++++++ 4 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 cueapi/resources/agents.py create mode 100644 tests/test_agents_resource.py diff --git a/cueapi/__init__.py b/cueapi/__init__.py index 86ca880..3be480c 100644 --- a/cueapi/__init__.py +++ b/cueapi/__init__.py @@ -11,6 +11,7 @@ RateLimitError, ) from cueapi.payload import CuePayload +from cueapi.resources.agents import AgentsResource from cueapi.resources.executions import ExecutionsResource from cueapi.resources.usage import UsageResource from cueapi.resources.workers import WorkersResource @@ -19,6 +20,7 @@ __version__ = "0.1.2" __all__ = [ + "AgentsResource", "CueAPI", "CuePayload", "ExecutionsResource", diff --git a/cueapi/client.py b/cueapi/client.py index 0fe27b4..ce925a9 100644 --- a/cueapi/client.py +++ b/cueapi/client.py @@ -15,6 +15,7 @@ InvalidScheduleError, RateLimitError, ) +from cueapi.resources.agents import AgentsResource from cueapi.resources.cues import CuesResource from cueapi.resources.executions import ExecutionsResource from cueapi.resources.usage import UsageResource @@ -73,6 +74,7 @@ def __init__( self.executions = ExecutionsResource(self) self.workers = WorkersResource(self) self.usage = UsageResource(self) + self.agents = AgentsResource(self) def close(self) -> None: """Close the underlying HTTP client.""" @@ -93,9 +95,19 @@ def _request( *, json: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, ) -> Any: - """Make an HTTP request and handle errors.""" - response = self._http.request(method, path, json=json, params=params) + """Make an HTTP request and handle errors. + + ``headers`` extends (does not replace) the client's default + ``Authorization`` + ``Content-Type`` + ``User-Agent`` headers. + Used by per-call header semantics: messaging primitive's + ``X-Cueapi-From-Agent`` + ``Idempotency-Key``, and the + destructive-operation guard ``X-Confirm-Destructive``. + """ + response = self._http.request( + method, path, json=json, params=params, headers=headers + ) return self._handle_response(response) def _handle_response(self, response: httpx.Response) -> Any: diff --git a/cueapi/resources/agents.py b/cueapi/resources/agents.py new file mode 100644 index 0000000..d7c8933 --- /dev/null +++ b/cueapi/resources/agents.py @@ -0,0 +1,192 @@ +"""Agents resource — messaging primitive identity surface (Phase 12.1.5).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional + +if TYPE_CHECKING: + from cueapi.client import CueAPI + + +class AgentsResource: + """Agents API resource. + + Wraps the ``/v1/agents`` surface from the messaging primitive + (Phase 12.1.5). Covers identity CRUD, webhook-secret rotation, and + the inbox/sent message lists keyed by agent ref. + + The send/get/read/ack message lifecycle lives on a sibling + ``client.messages`` resource — this class only handles identity. + """ + + def __init__(self, client: "CueAPI") -> None: + self._client = client + + def create( + self, + *, + display_name: str, + slug: Optional[str] = None, + webhook_url: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> dict: + """Create an agent. + + The ``webhook_secret`` field is populated in the 201 response + ONLY when ``webhook_url`` is supplied. Subsequent reads omit + the secret. Save it now or use ``webhook_secret_regenerate()`` + to mint a fresh one (which revokes the old one). + + Args: + display_name: Human-readable name (1-255 chars). + slug: Optional per-user unique slug. If omitted, the server + derives one from ``display_name``. + webhook_url: Push-delivery target. SSRF-validated. Omit for + poll-only. + metadata: Optional JSON metadata blob. + + Returns: + Dict matching the server's ``AgentResponse`` shape, including + ``webhook_secret`` ONCE on this response if ``webhook_url`` + was given. + """ + body: Dict[str, Any] = {"display_name": display_name} + if slug is not None: + body["slug"] = slug + if webhook_url is not None: + body["webhook_url"] = webhook_url + if metadata is not None: + body["metadata"] = metadata + return self._client._post("/v1/agents", json=body) + + def list( + self, + *, + status: Optional[str] = None, + include_deleted: bool = False, + limit: int = 50, + offset: int = 0, + ) -> dict: + """List your agents. + + Args: + status: Optional filter — ``online`` / ``offline`` / ``away``. + include_deleted: Whether to include soft-deleted agents. + Defaults to False; only sent on the wire when True + (omit-when-default keeps URLs clean and matches the + server's ``include_deleted=false`` default). + limit: Page size (default 50, max 100). + offset: Pagination offset. + """ + params: Dict[str, Any] = {"limit": limit, "offset": offset} + if status is not None: + params["status"] = status + if include_deleted: + params["include_deleted"] = "true" + return self._client._get("/v1/agents", params=params) + + def get( + self, + ref: str, + *, + include_deleted: bool = False, + ) -> dict: + """Get an agent by opaque ID or slug-form (``agent@user``).""" + params: Dict[str, Any] = {} + if include_deleted: + params["include_deleted"] = "true" + return self._client._get(f"/v1/agents/{ref}", params=params) + + def update( + self, + ref: str, + *, + display_name: Optional[str] = None, + webhook_url: Optional[str] = None, + clear_webhook_url: bool = False, + status: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> dict: + """Update an agent (PATCH semantics). + + ``webhook_url`` and ``clear_webhook_url`` are mutually exclusive. + Pass ``clear_webhook_url=True`` to send literal JSON ``null`` and + revert the agent to poll-only — the server uses + ``model_fields_set`` to disambiguate "field omitted = no change" + from "field explicitly null = clear", so the SDK MUST send the + key with explicit None rather than omit. + """ + if webhook_url is not None and clear_webhook_url: + raise ValueError( + "webhook_url and clear_webhook_url are mutually exclusive" + ) + body: Dict[str, Any] = {} + if display_name is not None: + body["display_name"] = display_name + if webhook_url is not None: + body["webhook_url"] = webhook_url + elif clear_webhook_url: + body["webhook_url"] = None + if status is not None: + body["status"] = status + if metadata is not None: + body["metadata"] = metadata + return self._client._patch(f"/v1/agents/{ref}", json=body) + + def delete(self, ref: str) -> None: + """Soft-delete an agent. Returns ``None`` on success (204).""" + return self._client._delete(f"/v1/agents/{ref}") + + def webhook_secret_get(self, ref: str) -> dict: + """Reveal the agent's current webhook signing secret. + + 404 path commonly means the agent has no ``webhook_url`` set + (poll-only agents have no webhook secret). + """ + return self._client._get(f"/v1/agents/{ref}/webhook-secret") + + def webhook_secret_regenerate(self, ref: str) -> dict: + """Mint a fresh webhook secret. Old secret revoked immediately. + + Sends ``X-Confirm-Destructive: true`` header, which the server + requires for this destructive op. Returns the new secret one-time + in the response — save it now. + """ + return self._client._post( + f"/v1/agents/{ref}/webhook-secret/regenerate", + json={}, + headers={"X-Confirm-Destructive": "true"}, + ) + + def inbox( + self, + ref: str, + *, + state: Optional[str] = None, + limit: int = 50, + offset: int = 0, + ) -> dict: + """Poll the agent's inbox (incoming messages). + + Args: + ref: Agent opaque ID or slug-form. + state: Optional filter (e.g. ``queued`` / ``delivered`` / + ``read`` / ``acked`` / ``failed``). + limit: Page size (default 50). + offset: Pagination offset. + """ + params: Dict[str, Any] = {"limit": limit, "offset": offset} + if state is not None: + params["state"] = state + return self._client._get(f"/v1/agents/{ref}/inbox", params=params) + + def sent( + self, + ref: str, + *, + limit: int = 50, + offset: int = 0, + ) -> dict: + """List messages sent by this agent.""" + params: Dict[str, Any] = {"limit": limit, "offset": offset} + return self._client._get(f"/v1/agents/{ref}/sent", params=params) diff --git a/tests/test_agents_resource.py b/tests/test_agents_resource.py new file mode 100644 index 0000000..58c6de1 --- /dev/null +++ b/tests/test_agents_resource.py @@ -0,0 +1,219 @@ +"""Tests for AgentsResource.""" + +import pytest +from unittest.mock import MagicMock + +from cueapi.resources.agents import AgentsResource + + +class TestCreate: + def test_minimal_only_display_name(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "agt_x", "slug": "team-comm", "display_name": "Team Comm", + "status": "online", + } + r = AgentsResource(mock_client) + + r.create(display_name="Team Comm") + + mock_client._post.assert_called_once_with( + "/v1/agents", + json={"display_name": "Team Comm"}, + ) + + def test_with_all_optionals(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "agt_x", "slug": "team-comm", "display_name": "Team Comm", + "status": "online", "webhook_url": "https://x.example", + "webhook_secret": "wsec_secretvalue", + } + r = AgentsResource(mock_client) + + r.create( + display_name="Team Comm", + slug="team-comm", + webhook_url="https://x.example/webhook", + metadata={"team": "platform"}, + ) + + mock_client._post.assert_called_once_with( + "/v1/agents", + json={ + "display_name": "Team Comm", + "slug": "team-comm", + "webhook_url": "https://x.example/webhook", + "metadata": {"team": "platform"}, + }, + ) + + +class TestList: + def test_defaults_omit_filters(self): + mock_client = MagicMock() + mock_client._get.return_value = {"agents": [], "total": 0} + r = AgentsResource(mock_client) + + r.list() + + params = mock_client._get.call_args.kwargs["params"] + assert params["limit"] == 50 + assert params["offset"] == 0 + assert "status" not in params + assert "include_deleted" not in params + + def test_include_deleted_only_sent_when_true(self): + # Same omit-when-default pattern as `executions list --has-evidence` + # in the CLI. Pinned so a refactor can't silently start sending + # `include_deleted=false` (which is no-op server-side but adds noise). + mock_client = MagicMock() + mock_client._get.return_value = {"agents": [], "total": 0} + r = AgentsResource(mock_client) + + r.list(include_deleted=True) + assert mock_client._get.call_args.kwargs["params"]["include_deleted"] == "true" + + # Reset, run with default — must omit. + mock_client.reset_mock() + r.list() + assert "include_deleted" not in mock_client._get.call_args.kwargs["params"] + + def test_status_filter_passed(self): + mock_client = MagicMock() + mock_client._get.return_value = {"agents": [], "total": 0} + r = AgentsResource(mock_client) + + r.list(status="online") + + assert mock_client._get.call_args.kwargs["params"]["status"] == "online" + + +class TestGet: + def test_get_basic(self): + mock_client = MagicMock() + mock_client._get.return_value = {"id": "agt_x"} + r = AgentsResource(mock_client) + + r.get("agt_x") + + mock_client._get.assert_called_once_with("/v1/agents/agt_x", params={}) + + def test_get_with_include_deleted(self): + mock_client = MagicMock() + mock_client._get.return_value = {"id": "agt_x"} + r = AgentsResource(mock_client) + + r.get("agt_x", include_deleted=True) + + mock_client._get.assert_called_once_with( + "/v1/agents/agt_x", params={"include_deleted": "true"} + ) + + +class TestUpdate: + def test_partial_body(self): + mock_client = MagicMock() + mock_client._patch.return_value = {"id": "agt_x"} + r = AgentsResource(mock_client) + + r.update("agt_x", status="away") + + mock_client._patch.assert_called_once_with( + "/v1/agents/agt_x", json={"status": "away"} + ) + + def test_clear_webhook_url_sends_explicit_null(self): + # Mirror of cueapi-cli #28's --clear-webhook-url pin. Server uses + # model_fields_set to disambiguate "field omitted = no change" + # vs "field explicitly null = clear", so the SDK MUST send None + # (literal JSON null) rather than omit the key. + mock_client = MagicMock() + mock_client._patch.return_value = {"id": "agt_x"} + r = AgentsResource(mock_client) + + r.update("agt_x", clear_webhook_url=True) + + sent_body = mock_client._patch.call_args.kwargs["json"] + assert "webhook_url" in sent_body + assert sent_body["webhook_url"] is None + + def test_webhook_url_and_clear_mutually_exclusive(self): + mock_client = MagicMock() + r = AgentsResource(mock_client) + + with pytest.raises(ValueError, match="mutually exclusive"): + r.update("agt_x", webhook_url="https://x.example", clear_webhook_url=True) + + +class TestDelete: + def test_delete(self): + mock_client = MagicMock() + mock_client._delete.return_value = None + r = AgentsResource(mock_client) + + result = r.delete("agt_x") + + mock_client._delete.assert_called_once_with("/v1/agents/agt_x") + assert result is None + + +class TestWebhookSecret: + def test_get(self): + mock_client = MagicMock() + mock_client._get.return_value = {"webhook_secret": "wsec_revealed"} + r = AgentsResource(mock_client) + + r.webhook_secret_get("agt_x") + + mock_client._get.assert_called_once_with("/v1/agents/agt_x/webhook-secret") + + def test_regenerate_sends_destructive_header(self): + # Server requires X-Confirm-Destructive: true for this op. Pin + # the header so a refactor can't drop it (which would 400). + mock_client = MagicMock() + mock_client._post.return_value = {"webhook_secret": "wsec_new"} + r = AgentsResource(mock_client) + + r.webhook_secret_regenerate("agt_x") + + mock_client._post.assert_called_once_with( + "/v1/agents/agt_x/webhook-secret/regenerate", + json={}, + headers={"X-Confirm-Destructive": "true"}, + ) + + +class TestInbox: + def test_inbox_basic(self): + mock_client = MagicMock() + mock_client._get.return_value = {"messages": [], "total": 0} + r = AgentsResource(mock_client) + + r.inbox("agt_x") + + params = mock_client._get.call_args.kwargs["params"] + assert params == {"limit": 50, "offset": 0} + + def test_inbox_with_state_filter(self): + mock_client = MagicMock() + mock_client._get.return_value = {"messages": [], "total": 0} + r = AgentsResource(mock_client) + + r.inbox("agt_x", state="queued") + + assert mock_client._get.call_args.kwargs["params"]["state"] == "queued" + + +class TestSent: + def test_sent_basic(self): + mock_client = MagicMock() + mock_client._get.return_value = {"messages": [], "total": 0} + r = AgentsResource(mock_client) + + r.sent("agt_x") + + mock_client._get.assert_called_once_with( + "/v1/agents/agt_x/sent", + params={"limit": 50, "offset": 0}, + ) From ff28c3003ebf3749ab754257e0e8ba126d0c7498 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 4 May 2026 11:13:38 -0700 Subject: [PATCH 11/13] feat: add MessagesResource (messaging primitive lifecycle) (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the `/v1/messages` surface (Phase 12.1.5). Closes the messages portion of the `Messaging primitive` `endpoints_missing` entry in cueapi-python #24's parity manifest. New resource: - `cueapi/resources/messages.py`: MessagesResource - .send(from_agent, to, body, subject=, reply_to=, priority=, expects_reply=, reply_to_agent=, metadata=, idempotency_key=) - .get(msg_id) - .mark_read(msg_id) # idempotent on already-read - .ack(msg_id) # terminal Client extension: - Same `_request(headers=...)` extension as PR #27 (AgentsResource). Independent commit on this branch since the two resources can land in either order; minor merge conflict on client.py is auto-resolvable (both PRs add the same kwarg in the same way). Design notes pinned by tests: - `from_agent` goes via `X-Cueapi-From-Agent` HEADER, NOT in body. The server's MessageCreate schema is extra="forbid" — putting `from` in the body would 400, but we want this caught at unit-test time. Pinned by test_minimal_body_and_from_header. - `expects_reply=False` (default) NOT sent in body. Server default is False; sending `expects_reply: false` is no-op + adds noise. Pinned by test_omits_expects_reply_when_default. - `idempotency_key` >255 chars raises ValueError client-side BEFORE any HTTP call. Matches server's hard limit. Pinned that no HTTP request is made when the validation fails. - `idempotency_key=None` omits the header entirely (no `Idempotency-Key: None` leakage). Pinned. Tests: 9 new (12 → 21 in this resource family; 38 total across all unit-test files). Server-side dedup-hit (200 response) and priority-downgrade signals (`X-CueAPI-Priority-Downgraded` header) are surfaced through the underlying httpx response — the SDK's `_handle_response` returns the data dict on 2xx, so callers see status_code 200 vs 201 only via the underlying client. A future enhancement could expose these signals explicitly via a richer return type; documented for follow-up. No hosted-PR dependency. All 4 endpoints already shipped on prod. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- cueapi/__init__.py | 2 + cueapi/client.py | 2 + cueapi/resources/messages.py | 120 +++++++++++++++++++++++++++ tests/test_messages_resource.py | 139 ++++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 cueapi/resources/messages.py create mode 100644 tests/test_messages_resource.py diff --git a/cueapi/__init__.py b/cueapi/__init__.py index 3be480c..b5b7bb3 100644 --- a/cueapi/__init__.py +++ b/cueapi/__init__.py @@ -13,6 +13,7 @@ from cueapi.payload import CuePayload from cueapi.resources.agents import AgentsResource from cueapi.resources.executions import ExecutionsResource +from cueapi.resources.messages import MessagesResource from cueapi.resources.usage import UsageResource from cueapi.resources.workers import WorkersResource from cueapi.webhook import verify_webhook @@ -24,6 +25,7 @@ "CueAPI", "CuePayload", "ExecutionsResource", + "MessagesResource", "UsageResource", "WorkersResource", "verify_webhook", diff --git a/cueapi/client.py b/cueapi/client.py index ce925a9..0fa1113 100644 --- a/cueapi/client.py +++ b/cueapi/client.py @@ -18,6 +18,7 @@ from cueapi.resources.agents import AgentsResource from cueapi.resources.cues import CuesResource from cueapi.resources.executions import ExecutionsResource +from cueapi.resources.messages import MessagesResource from cueapi.resources.usage import UsageResource from cueapi.resources.workers import WorkersResource @@ -75,6 +76,7 @@ def __init__( self.workers = WorkersResource(self) self.usage = UsageResource(self) self.agents = AgentsResource(self) + self.messages = MessagesResource(self) def close(self) -> None: """Close the underlying HTTP client.""" diff --git a/cueapi/resources/messages.py b/cueapi/resources/messages.py new file mode 100644 index 0000000..a7ea2e8 --- /dev/null +++ b/cueapi/resources/messages.py @@ -0,0 +1,120 @@ +"""Messages resource — messaging primitive lifecycle (Phase 12.1.5).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional + +if TYPE_CHECKING: + from cueapi.client import CueAPI + + +class MessagesResource: + """Messages API resource. + + Wraps the ``/v1/messages`` surface from the messaging primitive + (Phase 12.1.5). Covers send + per-message lifecycle (get / read / + ack). The agents identity surface lives on the sibling + ``client.agents`` resource — this class only handles messages. + """ + + def __init__(self, client: "CueAPI") -> None: + self._client = client + + def send( + self, + *, + from_agent: str, + to: str, + body: str, + subject: Optional[str] = None, + reply_to: Optional[str] = None, + priority: Optional[int] = None, + expects_reply: bool = False, + reply_to_agent: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + idempotency_key: Optional[str] = None, + ) -> dict: + """Send a message. + + ``from_agent`` is sent as the ``X-Cueapi-From-Agent`` header, + NOT in the body. The server reads it from the header to + authenticate the sender against the calling key. Don't try to + pass it in the body — the server's ``MessageCreate`` schema is + ``extra="forbid"`` and will 400. + + ``idempotency_key`` is sent as the ``Idempotency-Key`` header. + Same key + same body within 24h returns the existing message + with HTTP 200 instead of 201. Same key + different body + returns HTTP 409 ``idempotency_key_conflict``. + + Args: + from_agent: Sender agent — opaque agent_id or slug-form + (``agent@user``). MUST be owned by the calling key. + to: Recipient — opaque agent_id or slug-form. + body: Message body (1-32768 chars). + subject: Optional subject line (max 255 chars). + reply_to: Previous message ID this is replying to + (``msg_<12 alphanumeric>``). thread_id inherits. + priority: 1-5 (server default 3). Receiver-pair limits may + downgrade priority>3 to 3; the server signals this via + the ``X-CueAPI-Priority-Downgraded: true`` response + header. Callers wanting to detect downgrade need to + inspect the response shape via the underlying + httpx.Response — not exposed in the SDK return value. + expects_reply: Mark this message as expecting a reply. + Default False; only sent when True. + reply_to_agent: Decoupled reply target. Defaults to + ``from`` (sender). Use when reply should route to a + different agent. + metadata: Optional JSON metadata blob. + idempotency_key: Optional ``Idempotency-Key`` header + (≤255 chars). + + Returns: + Dict matching the server's ``MessageResponse`` shape. + + Raises: + ValueError: If ``idempotency_key`` exceeds 255 chars + (matches the server's hard limit). + """ + if idempotency_key is not None and len(idempotency_key) > 255: + raise ValueError("idempotency_key must be ≤255 characters") + + payload: Dict[str, Any] = {"to": to, "body": body} + if subject is not None: + payload["subject"] = subject + if reply_to is not None: + payload["reply_to"] = reply_to + if priority is not None: + payload["priority"] = priority + # Boolean flag — only send when True. Server default is False; + # sending `false` is no-op + adds payload noise. Pinned in tests. + if expects_reply: + payload["expects_reply"] = True + if reply_to_agent is not None: + payload["reply_to_agent"] = reply_to_agent + if metadata is not None: + payload["metadata"] = metadata + + headers: Dict[str, str] = {"X-Cueapi-From-Agent": from_agent} + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key + + return self._client._post("/v1/messages", json=payload, headers=headers) + + def get(self, msg_id: str) -> dict: + """Get a single message by ID.""" + return self._client._get(f"/v1/messages/{msg_id}") + + def mark_read(self, msg_id: str) -> dict: + """Mark a message as read. + + Idempotent — calling on already-``read`` returns 200 unchanged. + Returns 409 (raised as ``CueAPIError``) if the message is in a + terminal state (``acked`` / ``expired``). + """ + return self._client._post(f"/v1/messages/{msg_id}/read", json={}) + + def ack(self, msg_id: str) -> dict: + """Acknowledge a message — terminal state, no further transitions.""" + return self._client._post(f"/v1/messages/{msg_id}/ack", json={}) diff --git a/tests/test_messages_resource.py b/tests/test_messages_resource.py new file mode 100644 index 0000000..d5c5ede --- /dev/null +++ b/tests/test_messages_resource.py @@ -0,0 +1,139 @@ +"""Tests for MessagesResource.""" + +import pytest +from unittest.mock import MagicMock + +from cueapi.resources.messages import MessagesResource + + +class TestSend: + def test_minimal_body_and_from_header(self): + # Pin: --from goes in X-Cueapi-From-Agent HEADER, NOT in body. + # The server's MessageCreate is extra="forbid" and would 400 on + # `{"from": "..."}` in the body, but we want this caught at unit + # test time, not silently at integration. + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "msg_x", "delivery_state": "queued", "thread_id": "thr_x", + } + r = MessagesResource(mock_client) + + r.send(from_agent="sender@x", to="recipient@y", body="hi") + + mock_client._post.assert_called_once_with( + "/v1/messages", + json={"to": "recipient@y", "body": "hi"}, + headers={"X-Cueapi-From-Agent": "sender@x"}, + ) + + def test_with_all_optionals(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send( + from_agent="sender@x", + to="recipient@y", + body="hi", + subject="re: hello", + reply_to="msg_abcdef123456", + priority=5, + expects_reply=True, + reply_to_agent="alt@x", + metadata={"k": "v"}, + idempotency_key="idemp-key-1", + ) + + call = mock_client._post.call_args + assert call.args == ("/v1/messages",) + assert call.kwargs["json"] == { + "to": "recipient@y", + "body": "hi", + "subject": "re: hello", + "reply_to": "msg_abcdef123456", + "priority": 5, + "expects_reply": True, + "reply_to_agent": "alt@x", + "metadata": {"k": "v"}, + } + assert call.kwargs["headers"] == { + "X-Cueapi-From-Agent": "sender@x", + "Idempotency-Key": "idemp-key-1", + } + + def test_omits_expects_reply_when_default(self): + # Pin: default False MUST NOT appear in body. Server's Pydantic + # default is False; sending `expects_reply: false` is no-op + adds + # noise. Refactor that always-sends would slip past the typed + # server schema but be caught here. + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x"} + r = MessagesResource(mock_client) + + r.send(from_agent="x", to="y", body="hi") + + body = mock_client._post.call_args.kwargs["json"] + assert "expects_reply" not in body + + def test_idempotency_key_too_long_raises_client_side(self): + mock_client = MagicMock() + r = MessagesResource(mock_client) + + with pytest.raises(ValueError, match="255"): + r.send( + from_agent="x", to="y", body="hi", + idempotency_key="x" * 256, + ) + # Crucially: must NOT have hit the wire. + mock_client._post.assert_not_called() + + def test_omits_idempotency_key_header_when_unset(self): + # Headers should ONLY contain X-Cueapi-From-Agent when no + # idempotency_key is passed. Pin so a refactor can't silently + # start adding `Idempotency-Key: None` (httpx would coerce). + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x"} + r = MessagesResource(mock_client) + + r.send(from_agent="x", to="y", body="hi") + + headers = mock_client._post.call_args.kwargs["headers"] + assert headers == {"X-Cueapi-From-Agent": "x"} + assert "Idempotency-Key" not in headers + + +class TestGet: + def test_get(self): + mock_client = MagicMock() + mock_client._get.return_value = {"id": "msg_x"} + r = MessagesResource(mock_client) + + r.get("msg_x") + + mock_client._get.assert_called_once_with("/v1/messages/msg_x") + + +class TestMarkRead: + def test_mark_read(self): + mock_client = MagicMock() + mock_client._post.return_value = {"delivery_state": "read"} + r = MessagesResource(mock_client) + + r.mark_read("msg_x") + + mock_client._post.assert_called_once_with( + "/v1/messages/msg_x/read", json={}, + ) + + +class TestAck: + def test_ack(self): + mock_client = MagicMock() + mock_client._post.return_value = {"delivery_state": "acked"} + r = MessagesResource(mock_client) + + r.ack("msg_x") + + mock_client._post.assert_called_once_with( + "/v1/messages/msg_x/ack", json={}, + ) From ffecf24a837ae666c3c2b5f2c45cdfa4e4dc0e35 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 4 May 2026 11:14:25 -0700 Subject: [PATCH 12/13] feat: add fire + list_claimable + claim + claim_next (#23) Brings the Python SDK toward parity with the @cueapi/mcp 0.3.0 + 0.4.0 surface. Adds four new methods covering the SEND side (fire) and the RECEIVE side (claimable list, claim, claim-next). Heartbeat signature fix is held for a follow-up commit pending technical review. CuesResource: - fire(cue_id, payload_override=None, merge_strategy=None) POST /v1/cues/{id}/fire. For ad-hoc one-shot triggers and for using cues as a messaging channel between agents (carry message, instruction, task, reply_cue_id in payload_override). ExecutionsResource: - list_claimable(task=None, agent=None) GET /v1/executions/claimable?task=&agent= Filters server-side via query params (NOT client-side). Required for single-purpose workers; client-side filter after fetch hits the LIMIT 50 starvation bug fixed in the 2026-04-25 prod incident. - claim(execution_id, worker_id=...) POST /v1/executions/{id}/claim Atomic; returns 409 if already claimed. - claim_next(worker_id=..., task=None) POST /v1/executions/claim (no task) OR list+claim chain (with task). Server's claim endpoint does not accept a task filter today; with task the SDK fans out (list_claimable filtered, pick oldest, claim by ID). Tiny race window between list and claim is bounded by the atomic claim returning 409, in which case the caller retries. Version: 0.1.3 -> 0.2.0. Aligned cueapi/__init__.py (had drifted to 0.1.2) with pyproject.toml at the same time. Tests: 42 unit tests pass (was 30; +12 net). Mirrors the existing MagicMock pattern in tests/test_executions_resource.py. Pending follow-up: - ExecutionsResource.heartbeat(execution_id) currently sends an empty body and does not include worker_id via the X-Worker-Id request header that the server reads from. Worker-id is what the server uses to enforce ownership on heartbeat (returns 403 on mismatch); without it the race-protection check is silently bypassed. A signature change to add worker_id is held pending technical review of the deprecation cadence (additive kwarg-only with default-warn-on-omit vs hard signature change in a major bump). Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 14 +++ README.md | 2 +- cueapi/__init__.py | 2 +- cueapi/resources/cues.py | 33 +++++++ cueapi/resources/executions.py | 90 +++++++++++++++++++ pyproject.toml | 2 +- tests/test_cues_resource.py | 57 ++++++++++++ tests/test_executions_resource.py | 140 ++++++++++++++++++++++++++++++ 8 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 tests/test_cues_resource.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1420d84..f2d1d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to cueapi-sdk will be documented here. +## [0.2.0] - 2026-05-01 + +### Added +- `client.cues.fire(cue_id, payload_override=None, merge_strategy=None)` for ad-hoc one-shot triggers and for using cues as a messaging channel between agents. Wraps `POST /v1/cues/{id}/fire`. +- `client.executions.list_claimable(task=None, agent=None)` for unclaimed worker-transport executions ready for processing. Filters server-side via task / agent query params; required for single-purpose workers. +- `client.executions.claim(execution_id, worker_id=...)` for atomic claim of a specific execution. Returns 409 if already claimed. +- `client.executions.claim_next(worker_id=..., task=None)` for the next available execution. Without task the server picks the oldest pending; with task the SDK fans out internally (list_claimable filtered, pick oldest, claim by ID) since the server's claim endpoint does not accept a task filter today. + +### Changed +- `__version__` in `cueapi/__init__.py` had drifted to 0.1.2 while `pyproject.toml` was at 0.1.3. Both now aligned at 0.2.0. + +### Pending follow-up +- `client.executions.heartbeat(execution_id)` currently sends an empty body and does NOT include `worker_id` via the `X-Worker-Id` request header that the server reads from. Worker-id is what the server uses to enforce ownership on the heartbeat (returns 403 on mismatch); without it the race-protection check is silently bypassed. A signature change to add `worker_id` is held pending technical review of the deprecation cadence. + ## [0.1.0] - 2025-03-28 ### Added diff --git a/README.md b/README.md index 97ea027..f07d756 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -> **For AI agents:** This is the official Python SDK for CueAPI. Run `pip install cueapi-sdk`. Your API key is in `CUEAPI_API_KEY`. Create a client: `client = CueAPI(os.environ["CUEAPI_API_KEY"])`. Schedule a task: `client.cues.create(name="task-name", cron="0 9 * * *", callback="https://your-handler.com", payload={})`. For agents without a public URL: set `transport="worker"` and run `pip install cueapi-worker`, then define a handler with `@worker.handler("task-name")` returning `{"success": true}` or `{"success": false, "error": "reason"}`. List executions: `client.executions.list()`. Get one: `client.executions.get("exec_id")`. Do not retry on `AuthenticationError` or `CueLimitExceededError`. Wait `e.retry_after` seconds on `RateLimitError`. +> **For AI agents:** This is the official Python SDK for CueAPI. Run `pip install cueapi-sdk`. Your API key is in `CUEAPI_API_KEY`. Create a client: `client = CueAPI(os.environ["CUEAPI_API_KEY"])`. Schedule a task: `client.cues.create(name="task-name", cron="0 9 * * *", callback="https://your-handler.com", payload={})`. For agents without a public URL: set `transport="worker"` and run `pip install cueapi-worker`, then define a handler with `@worker.handler("task-name")` returning `{"success": true}` or `{"success": false, "error": "reason"}`. Fire an existing cue immediately: `client.cues.fire("cue_id", payload_override={"task": "x"})`. List unclaimed worker executions: `client.executions.list_claimable(task="task-name")`. Claim one: `client.executions.claim("exec_id", worker_id="my-worker")`. List executions: `client.executions.list()`. Get one: `client.executions.get("exec_id")`. Do not retry on `AuthenticationError` or `CueLimitExceededError`. Wait `e.retry_after` seconds on `RateLimitError`. # cueapi-sdk diff --git a/cueapi/__init__.py b/cueapi/__init__.py index b5b7bb3..5d60b4d 100644 --- a/cueapi/__init__.py +++ b/cueapi/__init__.py @@ -18,7 +18,7 @@ from cueapi.resources.workers import WorkersResource from cueapi.webhook import verify_webhook -__version__ = "0.1.2" +__version__ = "0.2.0" __all__ = [ "AgentsResource", diff --git a/cueapi/resources/cues.py b/cueapi/resources/cues.py index 7a0e114..5ad2c00 100644 --- a/cueapi/resources/cues.py +++ b/cueapi/resources/cues.py @@ -218,3 +218,36 @@ def resume(self, cue_id: str) -> Cue: The updated Cue object. """ return self.update(cue_id, status="active") + + def fire( + self, + cue_id: str, + *, + payload_override: Optional[Dict[str, Any]] = None, + merge_strategy: Optional[str] = None, + ) -> Dict[str, Any]: + """Fire an existing cue immediately, optionally overriding its payload. + + For ad-hoc one-shot triggers and for using cues as a messaging channel + between agents (carry message/instruction/task/reply_cue_id in + payload_override). + + Args: + cue_id: The cue ID to fire. + payload_override: Override the cue's default payload for this fire + only. Persisted on the resulting execution row, never on the + cue itself. + merge_strategy: How payload_override combines with the cue's stored + payload. "merge" (server default) shallow-merges with override + wins on key collisions. "replace" uses override as the final + payload, ignoring cue.payload. + + Returns: + The execution dict (id, scheduled_for, status, etc.). + """ + body: Dict[str, Any] = {} + if payload_override is not None: + body["payload_override"] = payload_override + if merge_strategy is not None: + body["merge_strategy"] = merge_strategy + return self._client._post(f"/v1/cues/{cue_id}/fire", json=body) diff --git a/cueapi/resources/executions.py b/cueapi/resources/executions.py index 20fd7b2..960c1ea 100644 --- a/cueapi/resources/executions.py +++ b/cueapi/resources/executions.py @@ -124,6 +124,96 @@ def get(self, execution_id: str) -> dict: """Get a single execution.""" return self._client._get(f"/v1/executions/{execution_id}") + def list_claimable( + self, + *, + task: Optional[str] = None, + agent: Optional[str] = None, + ) -> dict: + """List unclaimed worker-transport executions ready for processing. + + Filters server-side via task / agent query params (NOT client-side). + Required for single-purpose workers; without a filter, sibling tasks + ahead in the LIMIT 50 window starve your handler. + + Returns: + Dict with "executions" list, each item carrying execution_id, + cue_id, cue_name, task, scheduled_for, payload, attempt. + """ + params: Dict[str, Any] = {} + if task is not None: + params["task"] = task + if agent is not None: + params["agent"] = agent + return self._client._get("/v1/executions/claimable", params=params) + + def claim(self, execution_id: str, *, worker_id: str) -> dict: + """Atomically claim a specific worker-transport execution. + + Conditional UPDATE WHERE status IN ('pending', 'retry_ready'); returns + 409 if already claimed or not eligible. Response includes lease_seconds + (default 900s = 15 min); send heartbeat well before that to extend. + + Args: + execution_id: Execution UUID. + worker_id: Stable identifier for this worker. Caller-defined, not + session/process-scoped. Same value must be used across + claim, heartbeat, and outcome calls so the server can enforce + ownership. + + Returns: + Dict with claimed (bool), execution_id, lease_seconds. + """ + return self._client._post( + f"/v1/executions/{execution_id}/claim", + json={"worker_id": worker_id}, + ) + + def claim_next( + self, + *, + worker_id: str, + task: Optional[str] = None, + ) -> dict: + """Claim the next available worker-transport execution. + + Without task, the server picks the oldest pending across any of your + worker cues. With task, this method internally fans out (list_claimable + filtered, pick oldest, claim by ID) since the server's claim endpoint + does not accept a task filter today. Tiny race window between list and + claim is bounded by the atomic claim returning 409, in which case the + caller retries. + + Args: + worker_id: Stable caller-defined identifier (see claim()). + task: Optional task filter. + + Returns: + Dict with claimed (bool), execution_id, lease_seconds. When + task is set and no executions are claimable for that task, + returns {"claimed": False, "reason": "no_executions_for_task", + "task": }. + """ + if task is not None: + listing = self._client._get( + "/v1/executions/claimable", params={"task": task} + ) + execs = listing.get("executions") or [] + if not execs: + return { + "claimed": False, + "reason": "no_executions_for_task", + "task": task, + } + next_id = execs[0].get("execution_id") + return self._client._post( + f"/v1/executions/{next_id}/claim", + json={"worker_id": worker_id}, + ) + return self._client._post( + "/v1/executions/claim", json={"worker_id": worker_id} + ) + def heartbeat(self, execution_id: str) -> dict: """Send heartbeat to extend claim lease.""" return self._client._post(f"/v1/executions/{execution_id}/heartbeat", json={}) diff --git a/pyproject.toml b/pyproject.toml index c5cce87..81757fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "cueapi-sdk" -version = "0.1.3" +version = "0.2.0" description = "Official Python SDK for CueAPI — open-source execution accountability primitive for AI agents. Schedule agent work, require evidence-backed outcomes, and gate execution with write-once verification." readme = "README.md" license = { text = "Apache-2.0" } diff --git a/tests/test_cues_resource.py b/tests/test_cues_resource.py new file mode 100644 index 0000000..fa7f6ec --- /dev/null +++ b/tests/test_cues_resource.py @@ -0,0 +1,57 @@ +"""Unit tests for CuesResource methods that don't fit the staging-integration pattern.""" + +from unittest.mock import MagicMock + +from cueapi.resources.cues import CuesResource + + +class TestFire: + def test_fire_no_payload_override(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_test", "status": "queued"} + resource = CuesResource(mock_client) + + result = resource.fire("cue_abc123") + + mock_client._post.assert_called_once_with("/v1/cues/cue_abc123/fire", json={}) + assert result["id"] == "exec_test" + + def test_fire_with_payload_override_only(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_test"} + resource = CuesResource(mock_client) + + payload = {"task": "downstream", "scope": "single-row"} + resource.fire("cue_abc123", payload_override=payload) + + mock_client._post.assert_called_once_with( + "/v1/cues/cue_abc123/fire", + json={"payload_override": payload}, + ) + + def test_fire_with_payload_override_and_merge_strategy(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_test"} + resource = CuesResource(mock_client) + + payload = {"run_id": "ad-hoc-001"} + resource.fire("cue_abc123", payload_override=payload, merge_strategy="replace") + + mock_client._post.assert_called_once_with( + "/v1/cues/cue_abc123/fire", + json={"payload_override": payload, "merge_strategy": "replace"}, + ) + + def test_fire_omits_merge_strategy_when_not_passed(self): + # When the caller omits merge_strategy, the wrapper must NOT send a + # client-side default. The server's Pydantic default of "merge" + # applies. This pins the contract so a future refactor can't silently + # start sending a strategy that overrides the server's choice. + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_test"} + resource = CuesResource(mock_client) + + resource.fire("cue_abc123", payload_override={"k": "v"}) + + sent_body = mock_client._post.call_args.kwargs["json"] + assert "merge_strategy" not in sent_body diff --git a/tests/test_executions_resource.py b/tests/test_executions_resource.py index cab3101..7f6880f 100644 --- a/tests/test_executions_resource.py +++ b/tests/test_executions_resource.py @@ -244,3 +244,143 @@ def test_replay_returns_server_dict_unchanged(self): result = resource.replay("exec_old") assert result == {"execution_id": "exec_x", "extra": "field"} + + +class TestListClaimable: + # Filtering MUST be server-side via query params, not client-side after + # fetch. Client-side filter hits the LIMIT 50 starvation bug fixed in the + # 2026-04-25 prod incident (see cueapi-core app/routers/executions.py + # docstring at line 122-131). + + def test_list_claimable_no_filters_sends_no_params(self): + mock_client = MagicMock() + mock_client._get.return_value = {"executions": []} + resource = ExecutionsResource(mock_client) + + resource.list_claimable() + + mock_client._get.assert_called_once_with( + "/v1/executions/claimable", params={}, + ) + + def test_list_claimable_passes_task_as_query_param(self): + mock_client = MagicMock() + mock_client._get.return_value = {"executions": []} + resource = ExecutionsResource(mock_client) + + resource.list_claimable(task="cowork-workspace") + + mock_client._get.assert_called_once_with( + "/v1/executions/claimable", + params={"task": "cowork-workspace"}, + ) + + def test_list_claimable_passes_agent_as_query_param(self): + mock_client = MagicMock() + mock_client._get.return_value = {"executions": []} + resource = ExecutionsResource(mock_client) + + resource.list_claimable(agent="writer-bot") + + mock_client._get.assert_called_once_with( + "/v1/executions/claimable", + params={"agent": "writer-bot"}, + ) + + def test_list_claimable_passes_both_task_and_agent(self): + mock_client = MagicMock() + mock_client._get.return_value = {"executions": []} + resource = ExecutionsResource(mock_client) + + resource.list_claimable(task="t", agent="a") + + mock_client._get.assert_called_once_with( + "/v1/executions/claimable", + params={"task": "t", "agent": "a"}, + ) + + +class TestClaim: + def test_claim_posts_to_specific_execution_with_worker_id_in_body(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "claimed": True, + "execution_id": "exec_abc123", + "lease_seconds": 900, + } + resource = ExecutionsResource(mock_client) + + result = resource.claim("exec_abc123", worker_id="cowork-workspace") + + mock_client._post.assert_called_once_with( + "/v1/executions/exec_abc123/claim", + json={"worker_id": "cowork-workspace"}, + ) + assert result["claimed"] is True + + +class TestClaimNext: + # Two branches: with task and without. Without task is a single POST. + # With task is a fan-out (list_claimable filtered, pick first, claim by ID) + # because the server's POST /v1/executions/claim does not accept a task + # filter today. + + def test_claim_next_without_task_sends_single_post(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "claimed": True, + "execution_id": "exec_test", + "lease_seconds": 900, + } + resource = ExecutionsResource(mock_client) + + resource.claim_next(worker_id="cowork-workspace") + + mock_client._post.assert_called_once_with( + "/v1/executions/claim", + json={"worker_id": "cowork-workspace"}, + ) + + def test_claim_next_with_task_fans_out_to_list_then_claim(self): + mock_client = MagicMock() + mock_client._get.return_value = { + "executions": [ + {"execution_id": "exec_first"}, + {"execution_id": "exec_second"}, + ], + } + mock_client._post.return_value = { + "claimed": True, + "execution_id": "exec_first", + "lease_seconds": 900, + } + resource = ExecutionsResource(mock_client) + + result = resource.claim_next( + worker_id="cowork-workspace", task="cowork-workspace" + ) + + mock_client._get.assert_called_once_with( + "/v1/executions/claimable", params={"task": "cowork-workspace"}, + ) + mock_client._post.assert_called_once_with( + "/v1/executions/exec_first/claim", + json={"worker_id": "cowork-workspace"}, + ) + assert result["claimed"] is True + + def test_claim_next_with_task_and_empty_list_returns_no_claim(self): + mock_client = MagicMock() + mock_client._get.return_value = {"executions": []} + resource = ExecutionsResource(mock_client) + + result = resource.claim_next( + worker_id="cowork-workspace", task="no-such-task" + ) + + mock_client._post.assert_not_called() + assert result == { + "claimed": False, + "reason": "no_executions_for_task", + "task": "no-such-task", + } From a2b532562e43b37c8c38c0324d8d546d58b4fbe6 Mon Sep 17 00:00:00 2001 From: Govind Kavaturi Date: Mon, 4 May 2026 11:15:53 -0700 Subject: [PATCH 13/13] test(executions): cover all report_outcome kwargs (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change, test_report_with_evidence exercised only three of the ten optional evidence kwargs on report_outcome (external_id, result_url, result_type). The remaining seven — result_ref, summary, artifacts, metadata, result, error — were supported by the SDK but had no test confirming they serialize correctly into the POST body. Most urgent of those is result_ref, which shipped in 0.1.4 via PR #20 with no coverage at all. A future refactor dropping the kwarg or breaking its serialization would pass CI silently. Adds six tests: - test_report_with_result_ref - test_report_with_summary - test_report_with_artifacts - test_report_with_metadata - test_report_with_all_evidence_fields — every optional kwarg in one call; guards against accidental field drops - test_report_omits_none_kwargs — ensures unspecified kwargs do NOT appear in the body (important because server distinguishes "not provided" from "explicitly null" for evidence merge semantics) Tests: 18/18 pass locally (was 12/12). Co-authored-by: Gk --- tests/test_executions_resource.py | 116 ++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/tests/test_executions_resource.py b/tests/test_executions_resource.py index 7f6880f..f95bb9a 100644 --- a/tests/test_executions_resource.py +++ b/tests/test_executions_resource.py @@ -50,6 +50,122 @@ def test_report_with_evidence(self): assert body["result_type"] == "tweet" assert body["success"] is True + def test_report_with_result_ref(self): + # result_ref was added in 0.1.4 (PR #20); confirm it reaches the body. + mock_client = MagicMock() + resource = ExecutionsResource(mock_client) + + resource.report_outcome( + "exec_123", + success=True, + result_ref="batch-id:7842", + ) + + body = mock_client._post.call_args.kwargs["json"] + assert body["result_ref"] == "batch-id:7842" + assert body["success"] is True + + def test_report_with_summary(self): + mock_client = MagicMock() + resource = ExecutionsResource(mock_client) + + resource.report_outcome( + "exec_123", + success=True, + summary="Generated 47 qualified leads", + ) + + body = mock_client._post.call_args.kwargs["json"] + assert body["summary"] == "Generated 47 qualified leads" + + def test_report_with_artifacts(self): + mock_client = MagicMock() + resource = ExecutionsResource(mock_client) + + artifacts = [ + {"name": "leads.csv", "url": "https://storage/leads.csv"}, + {"name": "report.pdf", "url": "https://storage/report.pdf"}, + ] + resource.report_outcome( + "exec_123", + success=True, + artifacts=artifacts, + ) + + body = mock_client._post.call_args.kwargs["json"] + assert body["artifacts"] == artifacts + + def test_report_with_metadata(self): + mock_client = MagicMock() + resource = ExecutionsResource(mock_client) + + resource.report_outcome( + "exec_123", + success=True, + metadata={"agent": "lead-finder-v3", "duration_ms": 1420}, + ) + + body = mock_client._post.call_args.kwargs["json"] + assert body["metadata"] == {"agent": "lead-finder-v3", "duration_ms": 1420} + + def test_report_with_all_evidence_fields(self): + # Single request that exercises every optional kwarg the API + # currently accepts. Guards against a future refactor silently + # dropping one of them. + mock_client = MagicMock() + resource = ExecutionsResource(mock_client) + + resource.report_outcome( + "exec_123", + success=True, + result="ok", + metadata={"foo": "bar"}, + external_id="ext-1", + result_url="https://example.com/1", + result_ref="ref-1", + result_type="report", + summary="done", + artifacts=[{"name": "a.json", "url": "https://example.com/a"}], + ) + + body = mock_client._post.call_args.kwargs["json"] + assert body == { + "success": True, + "result": "ok", + "metadata": {"foo": "bar"}, + "external_id": "ext-1", + "result_url": "https://example.com/1", + "result_ref": "ref-1", + "result_type": "report", + "summary": "done", + "artifacts": [{"name": "a.json", "url": "https://example.com/a"}], + } + + def test_report_omits_none_kwargs(self): + # Optional kwargs left at their None default must NOT appear + # in the POST body. Important because the server distinguishes + # "field not provided" from "field explicitly set to null" + # for evidence merging semantics. + mock_client = MagicMock() + resource = ExecutionsResource(mock_client) + + resource.report_outcome("exec_123", success=True) + + body = mock_client._post.call_args.kwargs["json"] + assert body == {"success": True} + for key in ( + "result", + "error", + "metadata", + "external_id", + "result_url", + "result_ref", + "result_type", + "summary", + "artifacts", + ): + assert key not in body + class TestContextManager: def test_clean_exit_reports_success(self):