Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- `create_episode` (sync + async) accepts an optional `idempotency_key`. Re-ingesting an episode with the same key is a no-op server-side (the server returns the existing episode), so re-running a backfill or retrying a request no longer duplicates episodes. `create_episodes_batch` already forwards the key when present in each episode dict.

## 1.0.1 (2026-06-11)

Metadata-only refresh — no API or behavior changes. Republishes the package so the
Expand Down
14 changes: 14 additions & 0 deletions statewave/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,18 @@ def create_episode(
metadata: dict[str, Any] | None = None,
provenance: dict[str, Any] | None = None,
session_id: str | None = None,
idempotency_key: str | None = None,
) -> Episode:
"""Record a raw interaction episode.

Pass ``session_id`` to attribute the episode to a specific session —
the server's session-aware ranking uses it to surface active-session
content in context bundles. Omit it for one-off events; the server
treats absence as "no session pin" rather than auto-assigning.

Pass ``idempotency_key`` to make re-ingest a no-op: a later episode with
the same key (re-running a backfill, retrying a failed request) returns
the existing episode instead of inserting a duplicate.
"""
body: dict[str, Any] = {
"subject_id": subject_id,
Expand All @@ -211,6 +216,8 @@ def create_episode(
}
if session_id is not None:
body["session_id"] = session_id
if idempotency_key is not None:
body["idempotency_key"] = idempotency_key
return self._request("POST", "/v1/episodes", json=body, model=Episode)

def create_episodes_batch(
Expand Down Expand Up @@ -758,13 +765,18 @@ async def create_episode(
metadata: dict[str, Any] | None = None,
provenance: dict[str, Any] | None = None,
session_id: str | None = None,
idempotency_key: str | None = None,
) -> Episode:
"""Record a raw interaction episode.

Pass ``session_id`` to attribute the episode to a specific session —
the server's session-aware ranking uses it to surface active-session
content in context bundles. Omit it for one-off events; the server
treats absence as "no session pin" rather than auto-assigning.

Pass ``idempotency_key`` to make re-ingest a no-op: a later episode with
the same key (re-running a backfill, retrying a failed request) returns
the existing episode instead of inserting a duplicate.
"""
body: dict[str, Any] = {
"subject_id": subject_id,
Expand All @@ -776,6 +788,8 @@ async def create_episode(
}
if session_id is not None:
body["session_id"] = session_id
if idempotency_key is not None:
body["idempotency_key"] = idempotency_key
return await self._request("POST", "/v1/episodes", json=body, model=Episode)

async def create_episodes_batch(
Expand Down
33 changes: 33 additions & 0 deletions tests/test_episodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,36 @@ async def test_async_create_episode_forwards_session_id():

body = mock_req.call_args.kwargs["json"]
assert body["session_id"] == "sess-xyz-999"


# ---------------------------------------------------------------------------
# idempotency_key forwarding
# ---------------------------------------------------------------------------


def test_create_episode_forwards_idempotency_key():
"""idempotency_key is forwarded verbatim so re-ingest de-dups server-side."""
client = StatewaveClient(retry=NO_RETRY)
mock_req = MagicMock(return_value=_resp(200, _EPISODE_RESPONSE))
with patch.object(client._http, "request", mock_req):
client.create_episode(
subject_id="subj-1",
source="git",
type="git.commit",
payload={"text": "hi"},
idempotency_key="git:commit:abc",
)
body = mock_req.call_args.kwargs["json"]
assert body["idempotency_key"] == "git:commit:abc"


def test_create_episode_omits_idempotency_key_when_not_passed():
"""Wire shape stays unchanged when the caller doesn't pass a key."""
client = StatewaveClient(retry=NO_RETRY)
mock_req = MagicMock(return_value=_resp(200, _EPISODE_RESPONSE))
with patch.object(client._http, "request", mock_req):
client.create_episode(
subject_id="subj-1", source="chat", type="conversation", payload={"text": "hi"}
)
body = mock_req.call_args.kwargs["json"]
assert "idempotency_key" not in body