statewire 0.8.2__tar.gz → 0.8.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {statewire-0.8.2 → statewire-0.8.4}/PKG-INFO +4 -2
  2. {statewire-0.8.2 → statewire-0.8.4}/pyproject.toml +3 -2
  3. {statewire-0.8.2 → statewire-0.8.4}/src/statewire/api.py +38 -17
  4. {statewire-0.8.2 → statewire-0.8.4}/src/statewire/client.py +24 -8
  5. statewire-0.8.4/src/statewire/langgraph.py +343 -0
  6. {statewire-0.8.2 → statewire-0.8.4}/src/statewire/ops.py +50 -14
  7. {statewire-0.8.2 → statewire-0.8.4}/src/statewire/state.py +246 -47
  8. statewire-0.8.4/tests/test_date_registry.py +238 -0
  9. statewire-0.8.4/tests/test_langgraph.py +420 -0
  10. {statewire-0.8.2 → statewire-0.8.4}/tests/test_meta.py +11 -3
  11. statewire-0.8.4/tests/test_ops.py +50 -0
  12. {statewire-0.8.2 → statewire-0.8.4}/tests/test_state_proxy.py +1 -2
  13. {statewire-0.8.2 → statewire-0.8.4}/tests/test_stream.py +28 -0
  14. statewire-0.8.2/src/statewire/langgraph.py +0 -175
  15. statewire-0.8.2/tests/test_langgraph.py +0 -142
  16. {statewire-0.8.2 → statewire-0.8.4}/.gitignore +0 -0
  17. {statewire-0.8.2 → statewire-0.8.4}/README.md +0 -0
  18. {statewire-0.8.2 → statewire-0.8.4}/examples/__init__.py +0 -0
  19. {statewire-0.8.2 → statewire-0.8.4}/examples/demo_app.py +0 -0
  20. {statewire-0.8.2 → statewire-0.8.4}/src/statewire/__init__.py +0 -0
  21. {statewire-0.8.2 → statewire-0.8.4}/src/statewire/metrics.py +0 -0
  22. {statewire-0.8.2 → statewire-0.8.4}/src/statewire/testing.py +0 -0
  23. {statewire-0.8.2 → statewire-0.8.4}/tests/client_helpers.py +0 -0
  24. {statewire-0.8.2 → statewire-0.8.4}/tests/statewire_helpers.py +0 -0
  25. {statewire-0.8.2 → statewire-0.8.4}/tests/test_authorize.py +0 -0
  26. {statewire-0.8.2 → statewire-0.8.4}/tests/test_client.py +0 -0
  27. {statewire-0.8.2 → statewire-0.8.4}/tests/test_client_ws.py +0 -0
  28. {statewire-0.8.2 → statewire-0.8.4}/tests/test_commands.py +0 -0
  29. {statewire-0.8.2 → statewire-0.8.4}/tests/test_context.py +0 -0
  30. {statewire-0.8.2 → statewire-0.8.4}/tests/test_frames.py +0 -0
  31. {statewire-0.8.2 → statewire-0.8.4}/tests/test_idle.py +0 -0
  32. {statewire-0.8.2 → statewire-0.8.4}/tests/test_lifespan.py +0 -0
  33. {statewire-0.8.2 → statewire-0.8.4}/tests/test_observer.py +0 -0
  34. {statewire-0.8.2 → statewire-0.8.4}/tests/test_route_auth.py +0 -0
  35. {statewire-0.8.2 → statewire-0.8.4}/tests/test_start_session.py +0 -0
  36. {statewire-0.8.2 → statewire-0.8.4}/tests/test_statewire_hostable.py +0 -0
  37. {statewire-0.8.2 → statewire-0.8.4}/tests/test_testing.py +0 -0
  38. {statewire-0.8.2 → statewire-0.8.4}/tests/test_writer_lease.py +0 -0
  39. {statewire-0.8.2 → statewire-0.8.4}/tests/test_ws.py +0 -0
@@ -1,15 +1,17 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: statewire
3
- Version: 0.8.2
3
+ Version: 0.8.4
4
4
  Summary: Replicate one JSON object over an SSE op stream + a command endpoint
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
7
7
  Requires-Python: <4.0,>=3.12
8
8
  Requires-Dist: fastapi>=0.115
9
9
  Requires-Dist: httpx>=0.27
10
- Requires-Dist: pinned<0.5,>=0.4.0
10
+ Requires-Dist: pinned<0.8,>=0.7.0
11
11
  Provides-Extra: langgraph
12
12
  Requires-Dist: langchain-core>=0.3; extra == 'langgraph'
13
+ Provides-Extra: orjson
14
+ Requires-Dist: orjson>=3.9; extra == 'orjson'
13
15
  Description-Content-Type: text/markdown
14
16
 
15
17
  # statewire
@@ -1,18 +1,19 @@
1
1
  [project]
2
2
  name = "statewire"
3
- version = "0.8.2"
3
+ version = "0.8.4"
4
4
  description = "Replicate one JSON object over an SSE op stream + a command endpoint"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  requires-python = ">=3.12,<4.0"
8
8
  dependencies = [
9
- "pinned>=0.4.0,<0.5",
9
+ "pinned>=0.7.0,<0.8",
10
10
  "fastapi>=0.115",
11
11
  "httpx>=0.27",
12
12
  ]
13
13
 
14
14
  [project.optional-dependencies]
15
15
  langgraph = ["langchain-core>=0.3"]
16
+ orjson = ["orjson>=3.9"]
16
17
 
17
18
  [project.urls]
18
19
  Repository = "https://github.com/assistant-ui/harness-sdk"
@@ -130,7 +130,15 @@ from starlette.responses import JSONResponse, Response, StreamingResponse
130
130
  from starlette.websockets import WebSocketDisconnect
131
131
 
132
132
  from . import metrics, ops
133
- from .state import StateProxy, _decode_meta, _encoded, _frozen, plain
133
+ from .state import (
134
+ StateProxy,
135
+ _date_meta,
136
+ _decode_meta,
137
+ _frozen,
138
+ _frozen_encoded,
139
+ _track_dates,
140
+ plain,
141
+ )
134
142
 
135
143
  if sys.version_info < (3, 12):
136
144
  raise RuntimeError("statewire requires Python 3.12+ (eager task execution)")
@@ -258,7 +266,7 @@ def _parse_frame(frame: Any, *, first: bool) -> _Frame:
258
266
 
259
267
 
260
268
  def _payload_fields(value: Any) -> dict[str, Any]:
261
- payload, meta = _encoded(_frozen(value))
269
+ payload, meta = _frozen_encoded(value)
262
270
  return {"payload": payload, **({"meta": meta} if meta else {})}
263
271
 
264
272
 
@@ -266,10 +274,11 @@ def _ws_auth_rejected(exc: AuthRejected) -> AuthRejected:
266
274
  fin: dict[str, Any] = {"reason": "unauthorized", "message": exc.message}
267
275
  if exc.payload is not None:
268
276
  fin.update(_payload_fields(exc.payload))
277
+ # Pinned serializes this payload itself: hand it wire values, not datetimes.
269
278
  return AuthRejected(
270
279
  exc.message,
271
280
  status_code=exc.status_code,
272
- payload={"fin": fin},
281
+ payload={"fin": _load_json(ops.encode_envelope(fin))},
273
282
  )
274
283
 
275
284
 
@@ -670,6 +679,9 @@ class Statewire(PinnedAPI):
670
679
  def __init__(self, ctx: HostableContext) -> None:
671
680
  super().__init__(ctx)
672
681
  self._state: Any = _UNSET
682
+ self._state_version = 0
683
+ self._date_paths: set[tuple[str | int, ...]] = set()
684
+ self._snapshot_cache: tuple[int, bytes] | None = None
673
685
  self._pending_ops: list[ops.Op] = []
674
686
  self._dirty_acks: set[str] = set()
675
687
  self._pending_res: dict[str, list[dict[str, Any]]] = {}
@@ -747,14 +759,15 @@ class Statewire(PinnedAPI):
747
759
 
748
760
  @state.setter
749
761
  def state(self, value: Any) -> None:
750
- value = _frozen(plain(value), lenient=self.strip_invalid_types)
751
- if not isinstance(value, dict):
762
+ stored, meta = _frozen_encoded(plain(value), lenient=self.strip_invalid_types)
763
+ if not isinstance(stored, dict):
752
764
  raise TypeError("state must be a dict")
753
- self._state = value
754
- wire, meta = _encoded(value)
755
- self._statewire_record(ops.replace_op([], wire, meta))
765
+ self._state = stored
766
+ self._statewire_record(ops.replace_op([], stored, meta))
756
767
 
757
768
  def _statewire_record(self, op: ops.Op) -> None:
769
+ self._state_version += 1
770
+ _track_dates(self._date_paths, self._state, op)
758
771
  self._pending_ops.append(op)
759
772
  self._schedule_flush()
760
773
 
@@ -837,8 +850,6 @@ class Statewire(PinnedAPI):
837
850
  for seq in (cmd_applied.get(key, []) if key is not None else [])
838
851
  ]
839
852
  envelope: dict[str, Any] = {}
840
- if entries:
841
- envelope["ops"] = entries
842
853
  if key is not None and key in res:
843
854
  envelope["res"] = res[key] # legacy: envelope res dual-emit
844
855
  if key is not None and key in acks:
@@ -854,10 +865,12 @@ class Statewire(PinnedAPI):
854
865
  envelope["cmd"] = applied_entries
855
866
  if idle_edge:
856
867
  envelope["idle"] = level
857
- elif applied_entries:
858
- msgs.append(ops.encode_envelope({"cmd": applied_entries}))
859
- if envelope:
860
- msgs.append(ops.encode_envelope(envelope))
868
+ msgs.append(ops.ops_envelope(entries, envelope))
869
+ else:
870
+ if applied_entries:
871
+ msgs.append(ops.encode_envelope({"cmd": applied_entries}))
872
+ if envelope:
873
+ msgs.append(ops.encode_envelope(envelope))
861
874
  verdicts = cmd_verdicts.get(key, []) if key is not None else []
862
875
  if verdicts:
863
876
  msgs.append(ops.encode_envelope({"cmd": verdicts}))
@@ -1019,7 +1032,6 @@ class Statewire(PinnedAPI):
1019
1032
  q: asyncio.Queue[Any] = asyncio.Queue(maxsize=self.stream_queue_size)
1020
1033
  self._subscribers[q] = client_id
1021
1034
  self._gc_clients()
1022
- wire_state, state_meta = _encoded(self._state)
1023
1035
  syn: dict[str, Any] = {
1024
1036
  "seq": client.admitted_through(),
1025
1037
  "ctxSeq": client.ctx_seq,
@@ -1029,8 +1041,17 @@ class Statewire(PinnedAPI):
1029
1041
  }
1030
1042
  if self._idle_wire:
1031
1043
  syn["idle"] = True
1032
- snapshot = ops.encode_envelope(
1033
- {"ops": ops.snapshot_event(wire_state, state_meta), "syn": syn}
1044
+ if (
1045
+ self._snapshot_cache is None
1046
+ or self._snapshot_cache[0] != self._state_version
1047
+ ):
1048
+ op = ops.replace_op(
1049
+ [], self._state, _date_meta(self._state, self._date_paths)
1050
+ )
1051
+ self._snapshot_cache = (self._state_version, ops.ops_envelope([op])[:-1])
1052
+ # splice the cached b'{"ops":[...]' fragment with the per-attach syn
1053
+ snapshot = (
1054
+ self._snapshot_cache[1] + b"," + ops.encode_envelope({"syn": syn})[1:]
1034
1055
  )
1035
1056
  return q, snapshot
1036
1057
 
@@ -25,7 +25,7 @@ from urllib.parse import quote, urlencode
25
25
 
26
26
  import httpx
27
27
 
28
- from .state import _iso
28
+ from .ops import _iso
29
29
 
30
30
  logger = logging.getLogger("statewire.client")
31
31
 
@@ -50,14 +50,20 @@ def _describe(failure: dict[str, Any]) -> str:
50
50
  match fate:
51
51
  case "rejected":
52
52
  code = failure.get("code")
53
- detail = (f" ({code})" if code else "") + (f": {message}" if message else "")
53
+ detail = (f" ({code})" if code else "") + (
54
+ f": {message}" if message else ""
55
+ )
54
56
  return f"statewire: command rejected{detail}"
55
57
  case "aborted":
56
58
  return f"statewire: command aborted ({cause})"
57
59
  case "lost":
58
60
  return f"statewire: command lost ({cause})"
59
61
  case "failed":
60
- return f"statewire: command failed: {message}" if message else "statewire: command failed"
62
+ return (
63
+ f"statewire: command failed: {message}"
64
+ if message
65
+ else "statewire: command failed"
66
+ )
61
67
  case "crashed":
62
68
  return "statewire: command crashed"
63
69
  case "expired":
@@ -103,7 +109,9 @@ def _revive_value(value: Any, meta: Any) -> Any:
103
109
  parent, key = node, seg
104
110
  if isinstance(seg, str) and isinstance(node, dict) and seg in node:
105
111
  node = node[seg]
106
- elif isinstance(seg, int) and isinstance(node, list) and 0 <= seg < len(node):
112
+ elif (
113
+ isinstance(seg, int) and isinstance(node, list) and 0 <= seg < len(node)
114
+ ):
107
115
  node = node[seg]
108
116
  else:
109
117
  raise StatewireProtocolError(f"meta path {path} does not resolve")
@@ -326,7 +334,9 @@ class SseTransport:
326
334
  except httpx.HTTPError as exc:
327
335
  raise TransportError(str(exc) or "network") from exc
328
336
 
329
- async def _envelopes(self, response: httpx.Response) -> AsyncIterator[dict[str, Any]]:
337
+ async def _envelopes(
338
+ self, response: httpx.Response
339
+ ) -> AsyncIterator[dict[str, Any]]:
330
340
  buffer = ""
331
341
  try:
332
342
  async for chunk in response.aiter_text():
@@ -444,7 +454,9 @@ class WsTransport:
444
454
  async def _frames(self, sock: Any) -> AsyncIterator[dict[str, Any]]:
445
455
  while True:
446
456
  try:
447
- raw = await asyncio.wait_for(sock.recv(), self._timing.heartbeat_timeout)
457
+ raw = await asyncio.wait_for(
458
+ sock.recv(), self._timing.heartbeat_timeout
459
+ )
448
460
  except TimeoutError as exc:
449
461
  raise TransportError("timeout") from exc
450
462
  except asyncio.CancelledError:
@@ -1105,11 +1117,15 @@ class StatewireClient:
1105
1117
  if outcome == "idle":
1106
1118
  self._attempt = 0
1107
1119
  self._set_connection({"status": "idle"})
1108
- while self._running and not self._has_work() and not self._reconnect_now:
1120
+ while (
1121
+ self._running and not self._has_work() and not self._reconnect_now
1122
+ ):
1109
1123
  await self._wait_wake()
1110
1124
  continue
1111
1125
  self._attempt += 1
1112
- delay = 0.0 if self._reconnect_now else _backoff(self._timing, self._attempt)
1126
+ delay = (
1127
+ 0.0 if self._reconnect_now else _backoff(self._timing, self._attempt)
1128
+ )
1113
1129
  if self._session_known or self._reconnect_now:
1114
1130
  reconnecting: dict[str, Any] = {
1115
1131
  "status": "reconnecting",
@@ -0,0 +1,343 @@
1
+ import weakref
2
+ from typing import Any, NamedTuple
3
+
4
+ from langchain_core.messages.ai import AIMessageChunk, add_ai_message_chunks
5
+
6
+ from .state import plain
7
+
8
+
9
+ def _message_matches(existing_message: Any, message_dict: dict[str, Any]) -> bool:
10
+ existing_message = plain(existing_message)
11
+ if not isinstance(existing_message, dict):
12
+ return False
13
+
14
+ message_id = message_dict.get("id")
15
+ if message_id is not None and existing_message.get("id") == message_id:
16
+ return True
17
+
18
+ tool_call_id = message_dict.get("tool_call_id")
19
+ return (
20
+ tool_call_id is not None
21
+ and existing_message.get("tool_call_id") == tool_call_id
22
+ )
23
+
24
+
25
+ def _find_existing_message_index(
26
+ messages: Any, message_dict: dict[str, Any]
27
+ ) -> int | None:
28
+ for i, existing_message in enumerate(messages):
29
+ if _message_matches(existing_message, message_dict):
30
+ return i
31
+ return None
32
+
33
+
34
+ def _is_provisional_id(message_id: Any) -> bool:
35
+ return message_id is None or (
36
+ isinstance(message_id, str) and message_id.startswith("lc_")
37
+ )
38
+
39
+
40
+ def _open_chunk_tail_index(messages: Any, message_dict: dict[str, Any]) -> int | None:
41
+ """Providers churn chunk ids mid-generation (lc_run-* vs provider ids);
42
+ an open chunk accumulation at the tail is the same generation."""
43
+ if not len(messages):
44
+ return None
45
+ index = len(messages) - 1
46
+ tail = plain(messages[index])
47
+ if not isinstance(tail, dict) or tail.get("type") != "ai":
48
+ return None
49
+ if "chunk_position" not in tail or tail["chunk_position"] == "last":
50
+ return None
51
+ if _is_provisional_id(tail.get("id")) or _is_provisional_id(message_dict.get("id")):
52
+ return index
53
+ return None
54
+
55
+
56
+ def _can_patch_value(current_value: Any, next_value: Any) -> bool:
57
+ # Keep these guards in sync with _patch_child so the later mutation cannot
58
+ # partially apply before discovering an unsupported deletion/truncation.
59
+ current_value = plain(current_value)
60
+
61
+ if current_value == next_value:
62
+ return True
63
+
64
+ if isinstance(current_value, dict) and isinstance(next_value, dict):
65
+ current_keys = set(current_value.keys())
66
+ next_keys = set(next_value.keys())
67
+ return current_keys.issubset(next_keys) and all(
68
+ _can_patch_value(current_value[key], next_value[key])
69
+ for key in current_keys
70
+ )
71
+
72
+ if isinstance(current_value, list) and isinstance(next_value, list):
73
+ return len(current_value) <= len(next_value) and all(
74
+ _can_patch_value(item, next_value[index])
75
+ for index, item in enumerate(current_value)
76
+ )
77
+
78
+ return True
79
+
80
+
81
+ def _patch_child(parent: Any, key: str | int, next_value: Any) -> None:
82
+ """Patch through StateProxy containers so writes emit granular ops."""
83
+ current_value = plain(parent[key])
84
+
85
+ if current_value == next_value:
86
+ return
87
+
88
+ if isinstance(current_value, dict) and isinstance(next_value, dict):
89
+ current_keys = set(current_value.keys())
90
+ next_keys = set(next_value.keys())
91
+ if not current_keys.issubset(next_keys):
92
+ raise ValueError("Cannot represent deleted dictionary keys as ops")
93
+
94
+ target = parent[key]
95
+ for child_key in next_keys:
96
+ if child_key not in current_value:
97
+ target[child_key] = next_value[child_key]
98
+ else:
99
+ _patch_child(target, child_key, next_value[child_key])
100
+ return
101
+
102
+ if isinstance(current_value, list) and isinstance(next_value, list):
103
+ if len(next_value) < len(current_value):
104
+ raise ValueError("Cannot represent list truncation as ops")
105
+
106
+ target = parent[key]
107
+ for index, item in enumerate(next_value):
108
+ if index >= len(current_value):
109
+ target.append(item)
110
+ else:
111
+ _patch_child(target, index, item)
112
+ return
113
+
114
+ parent[key] = next_value
115
+
116
+
117
+ _CHUNK_FIELDS = frozenset(AIMessageChunk(content="").model_dump())
118
+
119
+
120
+ class _OpenChunk(NamedTuple):
121
+ index: int
122
+ chunk: AIMessageChunk
123
+ stored: dict[str, Any]
124
+ churn_id: str | None
125
+
126
+
127
+ _open_chunks: weakref.WeakKeyDictionary[Any, _OpenChunk] = weakref.WeakKeyDictionary()
128
+
129
+
130
+ def _fast_chunk_merge(
131
+ state: Any, entry: _OpenChunk, message: Any, message_dict: dict[str, Any]
132
+ ) -> AIMessageChunk | None:
133
+ """Merge a streamed chunk into the retained open accumulation in O(chunk);
134
+ None on any mismatch, which falls back to the full scan-and-patch path."""
135
+ messages = plain(state["messages"])
136
+ if entry.index >= len(messages) or messages[entry.index] is not entry.stored:
137
+ return None
138
+ if message_dict.get("chunk_position") == "last":
139
+ return None
140
+ if not _CHUNK_FIELDS.issuperset(message_dict):
141
+ return None
142
+ incoming_id = message_dict.get("id")
143
+ if incoming_id != entry.stored.get("id"):
144
+ # Only an id the transcript scan is known to miss may churn into the tail.
145
+ if incoming_id is not None and incoming_id != entry.churn_id:
146
+ return None
147
+ if entry.index != len(messages) - 1:
148
+ return None
149
+ if not (
150
+ _is_provisional_id(entry.stored.get("id"))
151
+ or _is_provisional_id(incoming_id)
152
+ ):
153
+ return None
154
+
155
+ merged = add_ai_message_chunks(entry.chunk, message)
156
+
157
+ changed: dict[str, Any] = {}
158
+ if message_dict["content"] != "":
159
+ changed["content"] = merged.content
160
+ if message_dict["additional_kwargs"]:
161
+ changed["additional_kwargs"] = merged.additional_kwargs
162
+ if message_dict["response_metadata"]:
163
+ changed["response_metadata"] = merged.response_metadata
164
+ if message_dict["tool_call_chunks"]:
165
+ changed["tool_call_chunks"] = merged.tool_call_chunks
166
+ changed["tool_calls"] = merged.tool_calls
167
+ changed["invalid_tool_calls"] = merged.invalid_tool_calls
168
+ if message_dict["usage_metadata"] is not None:
169
+ changed["usage_metadata"] = merged.usage_metadata
170
+ for key in ("id", "name", "chunk_position"):
171
+ value = getattr(merged, key)
172
+ if entry.stored.get(key) != value:
173
+ changed[key] = value
174
+
175
+ if not all(
176
+ _can_patch_value(entry.stored[key], value) for key, value in changed.items()
177
+ ):
178
+ return None
179
+
180
+ target = state["messages"][entry.index]
181
+ for key, value in changed.items():
182
+ _patch_child(target, key, value)
183
+ return merged
184
+
185
+
186
+ def _retain_open_chunk(
187
+ state: Any,
188
+ host: Any,
189
+ message_dict: dict[str, Any],
190
+ index: int,
191
+ merged: AIMessageChunk | None,
192
+ churn_id: str | None,
193
+ ) -> None:
194
+ messages = plain(state["messages"])
195
+ if index >= len(messages):
196
+ return
197
+ stored = messages[index]
198
+ chunk = (
199
+ merged
200
+ if merged is not None
201
+ else AIMessageChunk(**{**message_dict, "type": "AIMessageChunk"})
202
+ )
203
+ if (
204
+ not isinstance(stored, dict)
205
+ or stored.get("id") != chunk.id
206
+ or stored.get("chunk_position", "last") == "last"
207
+ ):
208
+ return
209
+ _open_chunks[host] = _OpenChunk(index, chunk, stored, churn_id)
210
+
211
+
212
+ def _patch_message(messages: Any, index: int, next_message: dict[str, Any]) -> None:
213
+ current_message = plain(messages[index])
214
+ if not isinstance(current_message, dict) or not _can_patch_value(
215
+ current_message, next_message
216
+ ):
217
+ messages[index] = next_message
218
+ return
219
+
220
+ _patch_child(messages, index, next_message)
221
+
222
+
223
+ def _dump_message(message: Any) -> dict[str, Any]:
224
+ message_dict = message if isinstance(message, dict) else message.model_dump()
225
+ if message_dict.get("type") == "AIMessageChunk":
226
+ message_dict = {**message_dict, "type": "ai"}
227
+ return message_dict
228
+
229
+
230
+ def _patch_message_fields(
231
+ target: Any, current: dict[str, Any], next_message: dict[str, Any]
232
+ ) -> None:
233
+ for key in [key for key in current if key not in next_message]:
234
+ del target[key]
235
+ for key, value in next_message.items():
236
+ if key not in current or current[key] != value:
237
+ target[key] = value
238
+
239
+
240
+ def replace_messages(state: Any, messages: list[Any]) -> None:
241
+ """Rebaseline ``state["messages"]`` to ``messages``, emitting granular ops."""
242
+ dumped = [_dump_message(message) for message in messages]
243
+
244
+ host = getattr(state, "_host", None)
245
+ if host is not None:
246
+ _open_chunks.pop(host, None)
247
+
248
+ if "messages" not in state:
249
+ state["messages"] = dumped
250
+ return
251
+
252
+ target = state["messages"]
253
+ stored = plain(target)
254
+ stored_len = len(stored)
255
+
256
+ matched = 0
257
+ while (
258
+ matched < stored_len
259
+ and matched < len(dumped)
260
+ and isinstance(stored[matched], dict)
261
+ and stored[matched].get("id") == dumped[matched].get("id")
262
+ ):
263
+ _patch_message_fields(target[matched], stored[matched], dumped[matched])
264
+ matched += 1
265
+
266
+ for index in range(matched, min(stored_len, len(dumped))):
267
+ target[index] = dumped[index]
268
+ for index in range(stored_len, len(dumped)):
269
+ target.append(dumped[index])
270
+ for index in range(stored_len - 1, len(dumped) - 1, -1):
271
+ del target[index]
272
+
273
+
274
+ def append_langgraph_event(
275
+ state: Any, _namespace: str, type: str, payload: Any
276
+ ) -> None:
277
+ """Fold a LangGraph stream event ('messages' or 'updates') into the state."""
278
+ if type == "messages":
279
+ if "messages" not in state:
280
+ state["messages"] = []
281
+
282
+ message = payload[0]
283
+ message_dict = message.model_dump()
284
+
285
+ is_ai_message_chunk = message_dict.get("type") == "AIMessageChunk"
286
+ if is_ai_message_chunk:
287
+ message_dict["type"] = "ai"
288
+
289
+ host = getattr(state, "_host", None)
290
+ entry = _open_chunks.get(host) if host is not None else None
291
+ if entry is not None and is_ai_message_chunk:
292
+ merged = _fast_chunk_merge(state, entry, message, message_dict)
293
+ if merged is not None:
294
+ _open_chunks[host] = entry._replace(chunk=merged)
295
+ return
296
+ if host is not None:
297
+ _open_chunks.pop(host, None)
298
+
299
+ merged = None
300
+ scan_index = _find_existing_message_index(state["messages"], message_dict)
301
+ existing_message_index = scan_index
302
+ if existing_message_index is None and is_ai_message_chunk:
303
+ existing_message_index = _open_chunk_tail_index(
304
+ state["messages"], message_dict
305
+ )
306
+
307
+ if existing_message_index is not None:
308
+ if is_ai_message_chunk:
309
+ existing_message = plain(state["messages"][existing_message_index])
310
+ merged = add_ai_message_chunks(
311
+ AIMessageChunk(**{**existing_message, "type": "AIMessageChunk"}),
312
+ AIMessageChunk(**{**message_dict, "type": "AIMessageChunk"}),
313
+ )
314
+ new_message_dict = merged.model_dump()
315
+ new_message_dict["type"] = "ai"
316
+ _patch_message(
317
+ state["messages"],
318
+ existing_message_index,
319
+ new_message_dict,
320
+ )
321
+ else:
322
+ state["messages"][existing_message_index] = message_dict
323
+ else:
324
+ state["messages"].append(message_dict)
325
+
326
+ if host is not None and is_ai_message_chunk:
327
+ index = (
328
+ existing_message_index
329
+ if existing_message_index is not None
330
+ else len(plain(state["messages"])) - 1
331
+ )
332
+ churn_id = message_dict.get("id") if scan_index is None else None
333
+ _retain_open_chunk(state, host, message_dict, index, merged, churn_id)
334
+
335
+ elif type == "updates":
336
+ for _node_name, channels in payload.items():
337
+ if not isinstance(channels, dict):
338
+ continue
339
+ for channel_name, channel_value in channels.items():
340
+ if channel_name == "messages":
341
+ continue
342
+
343
+ state[channel_name] = channel_value
@@ -125,29 +125,44 @@ silence; 30s of receive silence means the line is dead.
125
125
  """
126
126
 
127
127
  import json
128
- from typing import Any
128
+ from datetime import datetime, timezone
129
+ from typing import Any, NamedTuple
130
+
131
+ try:
132
+ import orjson
133
+ except ImportError:
134
+ orjson = None
129
135
 
130
- Op = dict[str, Any]
131
136
  Path = list[str | int]
132
137
  Meta = list[dict[str, Any]] | None
133
138
 
134
139
 
135
- def replace_op(path: Path, value: Any, meta: Meta = None) -> Op:
136
- op = {"op": "replace", "path": path, "value": value}
140
+ class Op(NamedTuple):
141
+ """A recorded state change: kind/path/meta as data for the host's date
142
+ registry, plus its wire bytes, encoded at record time."""
143
+
144
+ kind: str
145
+ path: Path
146
+ meta: Meta
147
+ data: bytes
148
+
149
+
150
+ def _op(kind: str, path: Path, body: dict[str, Any], meta: Meta) -> Op:
137
151
  if meta:
138
- op["meta"] = meta
139
- return op
152
+ body["meta"] = meta
153
+ return Op(kind, path, meta or None, encode_envelope(body))
154
+
155
+
156
+ def replace_op(path: Path, value: Any, meta: Meta = None) -> Op:
157
+ return _op("replace", path, {"op": "replace", "path": path, "value": value}, meta)
140
158
 
141
159
 
142
160
  def add_op(path: Path, value: Any, meta: Meta = None) -> Op:
143
- op = {"op": "add", "path": path, "value": value}
144
- if meta:
145
- op["meta"] = meta
146
- return op
161
+ return _op("add", path, {"op": "add", "path": path, "value": value}, meta)
147
162
 
148
163
 
149
164
  def remove_op(path: Path) -> Op:
150
- return {"op": "remove", "path": path}
165
+ return _op("remove", path, {"op": "remove", "path": path}, None)
151
166
 
152
167
 
153
168
  PROTOCOL_VERSION = 1
@@ -155,12 +170,33 @@ WS_SUBPROTOCOL = "statewire.v1"
155
170
  VERSION_HEADER = "Statewire-Version"
156
171
 
157
172
 
158
- def snapshot_event(state: dict[str, Any], meta: Meta = None) -> list[Op]:
159
- return [replace_op([], state, meta)]
173
+ def _iso(value: datetime) -> str:
174
+ return value.astimezone(timezone.utc).isoformat(timespec="milliseconds")[:-6] + "Z"
175
+
176
+
177
+ def _iso_default(value: Any) -> str:
178
+ if not isinstance(value, datetime):
179
+ raise TypeError(f"{type(value).__name__} is not wire-encodable")
180
+ return _iso(value)
160
181
 
161
182
 
162
183
  def encode_envelope(envelope: dict[str, Any]) -> bytes:
163
- return json.dumps(envelope, separators=(",", ":"), allow_nan=False).encode()
184
+ if orjson is not None:
185
+ # orjson nullifies non-finite floats instead of raising; _frozen
186
+ # rejects them before any envelope value is built.
187
+ return orjson.dumps(
188
+ envelope, default=_iso_default, option=orjson.OPT_PASSTHROUGH_DATETIME
189
+ )
190
+ return json.dumps(
191
+ envelope, separators=(",", ":"), allow_nan=False, default=_iso_default
192
+ ).encode()
193
+
194
+
195
+ def ops_envelope(entries: list[Op], rest: dict[str, Any] | None = None) -> bytes:
196
+ head = b'{"ops":[' + b",".join(op.data for op in entries) + b"]"
197
+ if not rest:
198
+ return head + b"}"
199
+ return head + b"," + encode_envelope(rest)[1:]
164
200
 
165
201
 
166
202
  def sse_frame(data: bytes) -> bytes: