Skip to content

Commit 935080d

Browse files
mikemolinetclaude
andcommitted
feat(cues): add send_at + exit_criteria + idempotency_key to fire()
Extends the existing CuesResource.fire() method with three kwargs covering recently-shipped server features: - send_at (str | datetime, cueapi #618) Per-fire scheduling — delay this fire to a specific timestamp instead of executing immediately. - exit_criteria (dict, cueapi #632) Per-fire termination conditions. Dict shape mirrors the API contract; SDK passes through verbatim. - idempotency_key (str, cueapi #683) Optional Idempotency-Key header. Same key + same body within 24h returns the existing execution (HTTP 200) instead of a fresh fire; same key + different body returns 409 idempotency_key_conflict. Sent as a request header, not a body field. datetime is auto-serialized to ISO 8601 via .isoformat(). Backwards compatible — all three kwargs default to None, omitted from the request body when unset. Existing fire(cue_id) and fire(cue_id, payload_override=..., merge_strategy=...) calls are unchanged. 6 new tests in test_cues.py::TestCueFire (no-args, payload-override, merge_strategy=replace, send_at, idempotency_key replay returns same exec, return-shape-is-dict-not-Cue). All run against staging via the existing client + cue fixtures. Source: drift audit handoff/cueapi-package-drift-2026-05-06; Backlog rows "Parity port: PR #618 → cueapi-python", "PR #632 → cueapi-python", implicit on idempotency from #683 (which dropped today; no separate backlog row yet). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b30d23b commit 935080d

2 files changed

Lines changed: 122 additions & 13 deletions

File tree

cueapi/resources/cues.py

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -225,29 +225,70 @@ def fire(
225225
*,
226226
payload_override: Optional[Dict[str, Any]] = None,
227227
merge_strategy: Optional[str] = None,
228+
send_at: Optional[Union[str, datetime]] = None,
229+
exit_criteria: Optional[Dict[str, Any]] = None,
230+
idempotency_key: Optional[str] = None,
228231
) -> Dict[str, Any]:
229-
"""Fire an existing cue immediately, optionally overriding its payload.
232+
"""Fire an existing cue, optionally overriding payload + scheduling.
230233
231-
For ad-hoc one-shot triggers and for using cues as a messaging channel
232-
between agents (carry message/instruction/task/reply_cue_id in
233-
payload_override).
234+
``POST /v1/cues/{cue_id}/fire``. Returns the created execution
235+
dict (not a Cue) — fire creates an execution row, not a new cue.
236+
237+
Useful for ad-hoc one-shot triggers and for using cues as a
238+
messaging channel between agents (carry message/instruction/task/
239+
reply_cue_id in ``payload_override``).
234240
235241
Args:
236242
cue_id: The cue ID to fire.
237-
payload_override: Override the cue's default payload for this fire
238-
only. Persisted on the resulting execution row, never on the
239-
cue itself.
240-
merge_strategy: How payload_override combines with the cue's stored
241-
payload. "merge" (server default) shallow-merges with override
242-
wins on key collisions. "replace" uses override as the final
243-
payload, ignoring cue.payload.
243+
payload_override: Override the cue's default payload for this
244+
fire only. Persisted on the resulting execution row, never
245+
on the cue itself.
246+
merge_strategy: How ``payload_override`` combines with the
247+
cue's stored payload. ``"merge"`` (server default) shallow-
248+
merges with override wins on key collisions. ``"replace"``
249+
uses override as the final payload, ignoring ``cue.payload``.
250+
send_at: Optional ISO 8601 timestamp (or ``datetime``) to
251+
delay this fire. If omitted, the execution is scheduled
252+
immediately. Per-fire scheduling landed in cueapi #618.
253+
exit_criteria: Optional per-fire termination conditions
254+
(cueapi #632). Dict shape mirrors the API contract;
255+
keys vary by criterion type.
256+
idempotency_key: Optional ``Idempotency-Key`` header
257+
(cueapi #683). Same key + same body within 24h returns
258+
the existing execution with HTTP 200 instead of creating
259+
a new fire; same key + different body returns 409
260+
``idempotency_key_conflict``.
244261
245262
Returns:
246-
The execution dict (id, scheduled_for, status, etc.).
263+
The execution dict (id, scheduled_for, status, triggered_by,
264+
etc.).
265+
266+
Examples:
267+
>>> exec = client.cues.fire("cue_abc123")
268+
>>> exec = client.cues.fire(
269+
... "cue_abc123",
270+
... payload_override={"task": "manual-trigger"},
271+
... send_at="2026-05-07T12:00:00Z",
272+
... idempotency_key="ci-run-456",
273+
... )
247274
"""
248275
body: Dict[str, Any] = {}
249276
if payload_override is not None:
250277
body["payload_override"] = payload_override
251278
if merge_strategy is not None:
252279
body["merge_strategy"] = merge_strategy
253-
return self._client._post(f"/v1/cues/{cue_id}/fire", json=body)
280+
if send_at is not None:
281+
body["send_at"] = (
282+
send_at.isoformat() if isinstance(send_at, datetime) else send_at
283+
)
284+
if exit_criteria is not None:
285+
body["exit_criteria"] = exit_criteria
286+
287+
headers = {}
288+
if idempotency_key is not None:
289+
headers["Idempotency-Key"] = idempotency_key
290+
291+
kwargs: Dict[str, Any] = {"json": body}
292+
if headers:
293+
kwargs["headers"] = headers
294+
return self._client._post(f"/v1/cues/{cue_id}/fire", **kwargs)

tests/test_cues.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,71 @@ def test_delete(self, client):
118118
client.cues.delete(cue.id)
119119
with pytest.raises(CueNotFoundError):
120120
client.cues.get(cue.id)
121+
122+
123+
class TestCueFire:
124+
"""Tests for the fire() method (manual trigger / per-fire override).
125+
126+
All tests run against a real cue (the ``cue`` fixture creates one for
127+
each test). Fire creates an execution row; we don't poll the
128+
execution status here — the worker/poller pipeline isn't exercised
129+
by the SDK suite. We just verify the fire response shape and HTTP
130+
success.
131+
"""
132+
133+
def test_fire_no_args(self, client, cue):
134+
"""Bare fire() — no payload override, no scheduling, immediate."""
135+
execution = client.cues.fire(cue.id)
136+
# Server returns the created execution dict
137+
assert "id" in execution
138+
assert execution["cue_id"] == cue.id
139+
# Triggered manually should set triggered_by accordingly
140+
assert execution.get("triggered_by") in ("manual_fire", "manual")
141+
# Default scheduling is immediate (or close to it)
142+
assert "scheduled_for" in execution
143+
144+
def test_fire_with_payload_override(self, client, cue):
145+
"""Fire with payload_override — execution carries the override."""
146+
execution = client.cues.fire(
147+
cue.id,
148+
payload_override={"task": "manual", "trigger": "test"},
149+
)
150+
assert "id" in execution
151+
# Default merge_strategy is server-side merge — we don't assert
152+
# on the merged result here (that's a server test); just verify
153+
# the call shape was accepted.
154+
155+
def test_fire_with_merge_strategy_replace(self, client, cue):
156+
"""Replace strategy — payload_override fully replaces stored payload."""
157+
execution = client.cues.fire(
158+
cue.id,
159+
payload_override={"action": "replace-test"},
160+
merge_strategy="replace",
161+
)
162+
assert "id" in execution
163+
164+
def test_fire_with_send_at(self, client, cue):
165+
"""send_at delays this fire to a specific timestamp (cueapi #618)."""
166+
future = "2030-01-01T12:00:00Z"
167+
execution = client.cues.fire(cue.id, send_at=future)
168+
assert "id" in execution
169+
# Server reflects the requested scheduled_for
170+
# (allow some tolerance — server may normalize the timestamp)
171+
assert "scheduled_for" in execution
172+
173+
def test_fire_with_idempotency_key(self, client, cue):
174+
"""Idempotency-Key replays the same fire (cueapi #683)."""
175+
import uuid
176+
177+
key = f"sdk-test-{uuid.uuid4().hex[:8]}"
178+
first = client.cues.fire(cue.id, idempotency_key=key)
179+
second = client.cues.fire(cue.id, idempotency_key=key)
180+
# Same key + same body → server returns the SAME execution
181+
assert first["id"] == second["id"]
182+
183+
def test_fire_returns_dict_not_cue(self, client, cue):
184+
"""Sanity: fire returns the execution dict (not a typed Cue)."""
185+
result = client.cues.fire(cue.id)
186+
# Not a Cue object — fire creates an execution, not a new cue
187+
assert not isinstance(result, Cue)
188+
assert isinstance(result, dict)

0 commit comments

Comments
 (0)