flowscript-agents 0.2.8__tar.gz → 0.2.9__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 (63) hide show
  1. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/PKG-INFO +1 -1
  2. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/__init__.py +1 -1
  3. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/audit.py +5 -1
  4. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/cloud.py +94 -28
  5. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/pyproject.toml +1 -1
  6. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_cloud.py +137 -15
  7. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/.github/workflows/test.yml +0 -0
  8. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/.gitignore +0 -0
  9. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/AUDIT_TRAIL_DESIGN.md +0 -0
  10. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/README.md +0 -0
  11. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/docs/adapters.md +0 -0
  12. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/docs/api-reference.md +0 -0
  13. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/docs/audit-trail.md +0 -0
  14. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/docs/brand/logo-512.png +0 -0
  15. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/docs/brand/social-preview.png +0 -0
  16. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/docs/flowscript-demo.png +0 -0
  17. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/docs/lifecycle.md +0 -0
  18. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/examples/CLAUDE.md.example +0 -0
  19. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/examples/langgraph_live_test.py +0 -0
  20. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/examples/temporal_e2e_test.py +0 -0
  21. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/camel_ai.py +0 -0
  22. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/crewai.py +0 -0
  23. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/__init__.py +0 -0
  24. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/_utils.py +0 -0
  25. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/consolidate.py +0 -0
  26. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/extract.py +0 -0
  27. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/index.py +0 -0
  28. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/providers.py +0 -0
  29. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/search.py +0 -0
  30. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/explain.py +0 -0
  31. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/fixpoint.py +0 -0
  32. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/google_adk.py +0 -0
  33. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/haystack.py +0 -0
  34. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/langgraph.py +0 -0
  35. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/llamaindex.py +0 -0
  36. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/mcp.py +0 -0
  37. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/memory.py +0 -0
  38. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/openai_agents.py +0 -0
  39. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/pydantic_ai.py +0 -0
  40. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/query.py +0 -0
  41. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/smolagents.py +0 -0
  42. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/tool-integrity.json +0 -0
  43. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/types.py +0 -0
  44. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/flowscript_agents/unified.py +0 -0
  45. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/scripts/validate_dedup_threshold.py +0 -0
  46. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/conftest.py +0 -0
  47. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_audit.py +0 -0
  48. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_camel_ai.py +0 -0
  49. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_consolidation.py +0 -0
  50. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_crewai.py +0 -0
  51. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_embeddings.py +0 -0
  52. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_explain.py +0 -0
  53. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_fixpoint.py +0 -0
  54. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_google_adk.py +0 -0
  55. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_haystack.py +0 -0
  56. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_langgraph.py +0 -0
  57. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_llamaindex.py +0 -0
  58. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_mcp.py +0 -0
  59. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_memory.py +0 -0
  60. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_openai_agents.py +0 -0
  61. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_pydantic_ai.py +0 -0
  62. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_smolagents.py +0 -0
  63. {flowscript_agents-0.2.8 → flowscript_agents-0.2.9}/tests/test_temporal.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowscript-agents
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: Complete agent memory: reasoning queries + vector search + auto-extraction. Decision intelligence for LangGraph, CrewAI, Google ADK, OpenAI Agents SDK, Pydantic AI, smolagents, LlamaIndex, Haystack, and CAMEL-AI.
5
5
  Project-URL: Homepage, https://flowscript.org
6
6
  Project-URL: Repository, https://github.com/phillipclapham/flowscript-agents
@@ -45,7 +45,7 @@ from .memory import (
45
45
  from .unified import UnifiedMemory
46
46
  from .explain import explain
47
47
 
48
- __version__ = "0.2.8"
48
+ __version__ = "0.2.9"
49
49
  __all__ = [
50
50
  "explain",
51
51
  "AuditConfig",
@@ -19,6 +19,7 @@ File layout:
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
+ import copy
22
23
  import gzip
23
24
  import atexit
24
25
  import hashlib
@@ -407,7 +408,10 @@ class AuditWriter:
407
408
  # Fire on_event callback (failure must never block audit, but log to stderr)
408
409
  if self._config.on_event:
409
410
  if self._config.on_event_async:
410
- self._get_executor().submit(self._fire_on_event, entry)
411
+ # Deep copy to prevent caller mutation between write() return and
412
+ # async callback execution from producing different canonical JSON
413
+ # (which would break hash chain verification in CloudClient).
414
+ self._get_executor().submit(self._fire_on_event, copy.deepcopy(entry))
411
415
  else:
412
416
  self._fire_on_event(entry)
413
417
 
@@ -40,7 +40,7 @@ import threading
40
40
  import urllib.error
41
41
  import urllib.request
42
42
  from dataclasses import dataclass, field
43
- from typing import Any, Optional
43
+ from typing import Any, Callable, Optional
44
44
 
45
45
 
46
46
  # =============================================================================
@@ -49,8 +49,16 @@ from typing import Any, Optional
49
49
 
50
50
  DEFAULT_CLOUD_URL = "https://api.flowscript.org"
51
51
  DEFAULT_BATCH_SIZE = 100
52
+ DEFAULT_MAX_BUFFER_SIZE = 10000
52
53
  DEFAULT_FLUSH_INTERVAL = 5.0 # seconds (for future auto-flush)
53
- USER_AGENT = "flowscript-agents-python/0.2.8"
54
+ def _get_user_agent() -> str:
55
+ try:
56
+ from flowscript_agents import __version__
57
+ return f"flowscript-agents-python/{__version__}"
58
+ except ImportError:
59
+ return "flowscript-agents-python/unknown"
60
+
61
+ USER_AGENT = _get_user_agent()
54
62
 
55
63
 
56
64
  # =============================================================================
@@ -101,6 +109,12 @@ class CloudClient:
101
109
  env var, then https://api.flowscript.org.
102
110
  batch_size: Flush automatically when buffer reaches this size.
103
111
  Default 100.
112
+ max_buffer_size: Maximum events to buffer before dropping new events.
113
+ Prevents unbounded memory growth when Cloud is unreachable.
114
+ Dropped events are still in the local audit trail (AuditWriter
115
+ writes to disk before calling on_event). Use the backfill
116
+ endpoint to recover after Cloud connectivity is restored.
117
+ Default 10000.
104
118
  timeout: HTTP request timeout in seconds. Default 30.
105
119
  on_witness: Optional callback invoked with each CloudWitness after
106
120
  successful flush. Use for logging or monitoring.
@@ -114,9 +128,10 @@ class CloudClient:
114
128
  namespace: Optional[str] = None,
115
129
  endpoint: Optional[str] = None,
116
130
  batch_size: int = DEFAULT_BATCH_SIZE,
131
+ max_buffer_size: int = DEFAULT_MAX_BUFFER_SIZE,
117
132
  timeout: int = 30,
118
- on_witness: Optional[callable] = None,
119
- on_error: Optional[callable] = None,
133
+ on_witness: Optional[Callable] = None,
134
+ on_error: Optional[Callable] = None,
120
135
  ) -> None:
121
136
  self._api_key = api_key or os.environ.get("FLOWSCRIPT_API_KEY", "")
122
137
  self._namespace = namespace or os.environ.get("FLOWSCRIPT_NAMESPACE", "")
@@ -124,6 +139,7 @@ class CloudClient:
124
139
  endpoint or os.environ.get("FLOWSCRIPT_CLOUD_URL", DEFAULT_CLOUD_URL)
125
140
  ).rstrip("/")
126
141
  self._batch_size = batch_size
142
+ self._max_buffer_size = max_buffer_size
127
143
  self._timeout = timeout
128
144
  self._on_witness = on_witness
129
145
  self._on_error = on_error
@@ -131,6 +147,7 @@ class CloudClient:
131
147
  self._lock = threading.Lock()
132
148
  self._total_sent = 0
133
149
  self._total_accepted = 0
150
+ self._total_dropped = 0
134
151
  self._last_witness: Optional[CloudWitness] = None
135
152
 
136
153
  if not self._api_key:
@@ -159,6 +176,16 @@ class CloudClient:
159
176
  """Total events accepted by Cloud (excludes replays)."""
160
177
  return self._total_accepted
161
178
 
179
+ @property
180
+ def total_dropped(self) -> int:
181
+ """Total events dropped due to buffer overflow.
182
+
183
+ Dropped events are NOT lost — they exist in the local audit trail
184
+ (AuditWriter writes to disk before calling on_event). Use the
185
+ backfill endpoint to sync them to Cloud after connectivity is restored.
186
+ """
187
+ return self._total_dropped
188
+
162
189
  @property
163
190
  def last_witness(self) -> Optional[CloudWitness]:
164
191
  """Most recent witness attestation received."""
@@ -175,6 +202,10 @@ class CloudClient:
175
202
  The entry dict is re-serialized to canonical JSON (matching the
176
203
  AuditWriter's serialization) for hash-chain-compatible transmission.
177
204
 
205
+ If the buffer has reached max_buffer_size, the event is dropped with
206
+ a warning to stderr. Dropped events are NOT lost — they exist in the
207
+ local audit trail. Use the backfill endpoint to recover.
208
+
178
209
  Args:
179
210
  entry: Audit event dict as produced by AuditWriter.write()
180
211
  """
@@ -182,10 +213,28 @@ class CloudClient:
182
213
  # Python json.dumps with sort_keys=True is deterministic for the same dict.
183
214
  json_line = json.dumps(entry, sort_keys=True, separators=(",", ":"))
184
215
 
216
+ batch_to_send = None
185
217
  with self._lock:
218
+ if len(self._buffer) >= self._max_buffer_size:
219
+ self._total_dropped += 1
220
+ if self._total_dropped == 1 or self._total_dropped % 1000 == 0:
221
+ print(
222
+ f"CloudClient: buffer full ({self._max_buffer_size} events). "
223
+ f"Dropping Cloud sync ({self._total_dropped} dropped total). "
224
+ f"Local audit trail is unaffected. Use backfill to recover.",
225
+ file=sys.stderr,
226
+ )
227
+ return
228
+
186
229
  self._buffer.append(json_line)
187
230
  if len(self._buffer) >= self._batch_size:
188
- self._flush_locked()
231
+ batch_to_send = list(self._buffer)
232
+ self._buffer.clear()
233
+
234
+ # Auto-flush outside the lock — same pattern as flush() to avoid
235
+ # blocking other queue_event() callers during HTTP I/O.
236
+ if batch_to_send is not None:
237
+ self._send_batch(batch_to_send)
189
238
 
190
239
  def flush(self) -> Optional[CloudFlushResult]:
191
240
  """Send all buffered events to Cloud.
@@ -195,18 +244,22 @@ class CloudClient:
195
244
 
196
245
  Safe to call from any thread, including atexit handlers.
197
246
  """
247
+ # Take events from buffer under lock, then release lock for HTTP I/O.
248
+ # This allows other threads to queue_event() while we're sending.
198
249
  with self._lock:
199
- return self._flush_locked()
250
+ if not self._buffer:
251
+ return None
252
+ events = list(self._buffer)
253
+ self._buffer.clear()
200
254
 
201
- def _flush_locked(self) -> Optional[CloudFlushResult]:
202
- """Internal flush — must be called with self._lock held."""
203
- if not self._buffer:
204
- return None
255
+ return self._send_batch(events)
205
256
 
206
- events = list(self._buffer)
207
- self._buffer.clear()
257
+ def _send_batch(self, events: list[str]) -> CloudFlushResult:
258
+ """Send a batch of canonical JSON event strings to Cloud.
208
259
 
209
- # Build request body
260
+ Handles response parsing, witness tracking, error handling, and retry
261
+ (putting events back in buffer on 5xx/network errors).
262
+ """
210
263
  body = json.dumps({
211
264
  "namespace": self._namespace,
212
265
  "events": events,
@@ -231,18 +284,27 @@ class CloudClient:
231
284
  accepted = resp_body.get("accepted", 0)
232
285
  self._total_accepted += accepted
233
286
 
234
- # Parse witness
287
+ # Parse witness — defensive against malformed responses.
288
+ # Events are already accepted; witness parse failure should not
289
+ # cause data loss or propagate errors.
235
290
  witness_data = resp_body.get("witness")
236
291
  witness = None
237
292
  if witness_data:
238
- witness = CloudWitness(
239
- id=witness_data["id"],
240
- chain_head_seq=witness_data["chain_head_seq"],
241
- chain_head_hash=witness_data["chain_head_hash"],
242
- witnessed_at=witness_data["witnessed_at"],
243
- )
244
- self._last_witness = witness
245
- if self._on_witness:
293
+ try:
294
+ witness = CloudWitness(
295
+ id=witness_data["id"],
296
+ chain_head_seq=witness_data["chain_head_seq"],
297
+ chain_head_hash=witness_data["chain_head_hash"],
298
+ witnessed_at=witness_data["witnessed_at"],
299
+ )
300
+ self._last_witness = witness
301
+ except (KeyError, TypeError) as e:
302
+ print(
303
+ f"CloudClient: malformed witness response: {e}",
304
+ file=sys.stderr,
305
+ )
306
+
307
+ if witness and self._on_witness:
246
308
  try:
247
309
  self._on_witness(witness)
248
310
  except Exception as e:
@@ -274,14 +336,16 @@ class CloudClient:
274
336
  # that needs human attention. On 4xx, also don't retry (client error).
275
337
  # On 5xx, put events back in buffer for retry.
276
338
  if e.code >= 500:
277
- self._buffer = events + self._buffer # prepend for order preservation
339
+ with self._lock:
340
+ self._buffer = events + self._buffer
278
341
 
279
342
  self._handle_error(result)
280
343
  return result
281
344
 
282
345
  except (urllib.error.URLError, OSError, TimeoutError) as e:
283
346
  # Network error — put events back for retry
284
- self._buffer = events + self._buffer
347
+ with self._lock:
348
+ self._buffer = events + self._buffer
285
349
  result = CloudFlushResult(
286
350
  accepted=0,
287
351
  error=f"Network error: {e}",
@@ -311,10 +375,12 @@ class CloudClient:
311
375
 
312
376
  Useful for backfill or one-shot uploads.
313
377
  """
314
- for entry in entries:
315
- json_line = json.dumps(entry, sort_keys=True, separators=(",", ":"))
316
- with self._lock:
317
- self._buffer.append(json_line)
378
+ json_lines = [
379
+ json.dumps(entry, sort_keys=True, separators=(",", ":"))
380
+ for entry in entries
381
+ ]
382
+ with self._lock:
383
+ self._buffer.extend(json_lines)
318
384
  return self.flush() or CloudFlushResult(accepted=0)
319
385
 
320
386
  def health(self) -> dict[str, Any]:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "flowscript-agents"
7
- version = "0.2.8"
7
+ version = "0.2.9"
8
8
  description = "Complete agent memory: reasoning queries + vector search + auto-extraction. Decision intelligence for LangGraph, CrewAI, Google ADK, OpenAI Agents SDK, Pydantic AI, smolagents, LlamaIndex, Haystack, and CAMEL-AI."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -107,21 +107,19 @@ class TestCloudClientBuffering:
107
107
  client = CloudClient(api_key="fsk_test", namespace="test/agent", batch_size=3)
108
108
  entry = {"v": 1, "seq": 0, "event": "test", "data": {}, "timestamp": "T", "prev_hash": "sha256:GENESIS", "session_id": None, "adapter": None}
109
109
 
110
- # Mock the HTTP call to avoid network
111
- with patch.object(client, "_flush_locked", wraps=client._flush_locked) as mock_flush:
112
- # Patch urlopen to return a mock response
113
- mock_resp = MagicMock()
114
- mock_resp.read.return_value = json.dumps({"accepted": 3, "witness": None}).encode()
115
- mock_resp.status = 200
116
- mock_resp.__enter__ = lambda s: s
117
- mock_resp.__exit__ = MagicMock(return_value=False)
118
-
119
- with patch("urllib.request.urlopen", return_value=mock_resp):
120
- client.queue_event(entry)
121
- client.queue_event(entry)
122
- assert client.buffered_count == 2
123
- client.queue_event(entry) # This should trigger auto-flush
124
- assert client.buffered_count == 0
110
+ # Patch urlopen to return a mock response
111
+ mock_resp = MagicMock()
112
+ mock_resp.read.return_value = json.dumps({"accepted": 3, "witness": None}).encode()
113
+ mock_resp.status = 200
114
+ mock_resp.__enter__ = lambda s: s
115
+ mock_resp.__exit__ = MagicMock(return_value=False)
116
+
117
+ with patch("urllib.request.urlopen", return_value=mock_resp):
118
+ client.queue_event(entry)
119
+ client.queue_event(entry)
120
+ assert client.buffered_count == 2
121
+ client.queue_event(entry) # This should trigger auto-flush
122
+ assert client.buffered_count == 0
125
123
 
126
124
  def test_thread_safety(self):
127
125
  """Verify concurrent queue_event calls don't corrupt buffer."""
@@ -141,6 +139,64 @@ class TestCloudClientBuffering:
141
139
  assert client.buffered_count == 1000
142
140
 
143
141
 
142
+ class TestCloudClientBufferOverflow:
143
+ """Test buffer overflow protection."""
144
+
145
+ def test_drops_events_at_max_buffer_size(self):
146
+ """Events beyond max_buffer_size are dropped (not lost — on disk)."""
147
+ client = CloudClient(api_key="fsk_test", namespace="test/agent", batch_size=10000, max_buffer_size=5)
148
+ entry = {"v": 1, "seq": 0, "event": "test", "data": {}, "timestamp": "T", "prev_hash": "sha256:GENESIS", "session_id": None, "adapter": None}
149
+
150
+ for _ in range(10):
151
+ client.queue_event(entry)
152
+
153
+ assert client.buffered_count == 5
154
+ assert client.total_dropped == 5
155
+
156
+ def test_drop_warning_rate_limited(self, capsys):
157
+ """Warning prints on first drop and every 1000th, not every drop."""
158
+ client = CloudClient(api_key="fsk_test", namespace="test/agent", batch_size=10000, max_buffer_size=1)
159
+ entry = {"v": 1, "seq": 0, "event": "test", "data": {}, "timestamp": "T", "prev_hash": "sha256:GENESIS", "session_id": None, "adapter": None}
160
+
161
+ # Fill buffer
162
+ client.queue_event(entry)
163
+ # Drop 3 more
164
+ for _ in range(3):
165
+ client.queue_event(entry)
166
+
167
+ captured = capsys.readouterr()
168
+ # Should have exactly 1 warning (first drop), not 3
169
+ assert captured.err.count("buffer full") == 1
170
+ assert client.total_dropped == 3
171
+
172
+ def test_buffer_accepts_again_after_flush(self):
173
+ """After flushing, buffer accepts new events even if drops occurred."""
174
+ client = CloudClient(api_key="fsk_test", namespace="test/agent", batch_size=10000, max_buffer_size=2)
175
+ entry = {"v": 1, "seq": 0, "event": "test", "data": {}, "timestamp": "T", "prev_hash": "sha256:GENESIS", "session_id": None, "adapter": None}
176
+
177
+ # Fill and overflow
178
+ for _ in range(5):
179
+ client.queue_event(entry)
180
+ assert client.buffered_count == 2
181
+ assert client.total_dropped == 3
182
+
183
+ # Flush (mock HTTP)
184
+ mock_resp = MagicMock()
185
+ mock_resp.read.return_value = json.dumps({"accepted": 2, "witness": None}).encode()
186
+ mock_resp.status = 200
187
+ mock_resp.__enter__ = lambda s: s
188
+ mock_resp.__exit__ = MagicMock(return_value=False)
189
+
190
+ with patch("urllib.request.urlopen", return_value=mock_resp):
191
+ client.flush()
192
+
193
+ assert client.buffered_count == 0
194
+
195
+ # Should accept again
196
+ client.queue_event(entry)
197
+ assert client.buffered_count == 1
198
+
199
+
144
200
  class TestCloudClientErrorHandling:
145
201
  """Test error handling and retry behavior."""
146
202
 
@@ -192,6 +248,72 @@ class TestCloudClientErrorHandling:
192
248
  assert client.buffered_count == 1 # Put back
193
249
 
194
250
 
251
+ class TestCloudClientMalformedResponse:
252
+ """Test handling of malformed server responses."""
253
+
254
+ def test_malformed_witness_does_not_lose_events(self):
255
+ """If witness response is malformed, events are still counted as accepted."""
256
+ client = CloudClient(api_key="fsk_test", namespace="test/agent")
257
+ entry = {"v": 1, "seq": 0, "event": "test", "data": {}, "timestamp": "T", "prev_hash": "sha256:GENESIS", "session_id": None, "adapter": None}
258
+ client.queue_event(entry)
259
+
260
+ mock_resp = MagicMock()
261
+ mock_resp.read.return_value = json.dumps({
262
+ "accepted": 1,
263
+ "witness": {"garbage": True}, # missing required fields
264
+ }).encode()
265
+ mock_resp.status = 200
266
+ mock_resp.__enter__ = lambda s: s
267
+ mock_resp.__exit__ = MagicMock(return_value=False)
268
+
269
+ with patch("urllib.request.urlopen", return_value=mock_resp):
270
+ result = client.flush()
271
+
272
+ assert result.accepted == 1
273
+ assert client.total_accepted == 1
274
+ assert client.last_witness is None # Not updated with garbage
275
+ assert client.buffered_count == 0 # Events NOT put back
276
+
277
+ def test_on_error_callback_fires_on_failure(self):
278
+ """Verify on_error callback is invoked on flush failure."""
279
+ errors = []
280
+ client = CloudClient(
281
+ api_key="fsk_test",
282
+ namespace="test/agent",
283
+ on_error=lambda r: errors.append(r),
284
+ )
285
+ entry = {"v": 1, "seq": 0, "event": "test", "data": {}, "timestamp": "T", "prev_hash": "sha256:GENESIS", "session_id": None, "adapter": None}
286
+ client.queue_event(entry)
287
+
288
+ import urllib.error
289
+ error = urllib.error.HTTPError("http://test", 409, "Conflict", {}, None)
290
+ with patch("urllib.request.urlopen", side_effect=error):
291
+ client.flush()
292
+
293
+ assert len(errors) == 1
294
+ assert errors[0].status_code == 409
295
+
296
+ def test_on_error_exception_swallowed(self):
297
+ """Exceptions in on_error callback must not propagate."""
298
+ def bad_callback(result):
299
+ raise RuntimeError("callback boom")
300
+
301
+ client = CloudClient(
302
+ api_key="fsk_test",
303
+ namespace="test/agent",
304
+ on_error=bad_callback,
305
+ )
306
+ entry = {"v": 1, "seq": 0, "event": "test", "data": {}, "timestamp": "T", "prev_hash": "sha256:GENESIS", "session_id": None, "adapter": None}
307
+ client.queue_event(entry)
308
+
309
+ import urllib.error
310
+ error = urllib.error.HTTPError("http://test", 400, "Bad Request", {}, None)
311
+ with patch("urllib.request.urlopen", side_effect=error):
312
+ # Should not raise despite bad callback
313
+ result = client.flush()
314
+ assert result.error is not None
315
+
316
+
195
317
  class TestAuditWriterIntegration:
196
318
  """Test CloudClient integration with AuditWriter via on_event callback."""
197
319