centive-sdk 2.2.0.dev6__tar.gz → 2.2.0.dev9__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 (50) hide show
  1. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/CHANGELOG.md +40 -0
  2. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/PKG-INFO +1 -1
  3. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/pyproject.toml +1 -1
  4. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/config.py +7 -0
  5. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/models/__init__.py +6 -0
  6. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/models/requests.py +45 -0
  7. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/models/responses.py +15 -0
  8. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/async_sessions.py +31 -7
  9. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/websocket_server.py +206 -5
  10. centive_sdk-2.2.0.dev9/tests/test_websocket_capabilities.py +80 -0
  11. centive_sdk-2.2.0.dev9/tests/test_websocket_kb_search.py +145 -0
  12. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/test_websocket_server.py +142 -37
  13. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/.gitignore +0 -0
  14. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/LICENSE +0 -0
  15. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/README.md +0 -0
  16. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/blackbox/README.md +0 -0
  17. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/blackbox/customer_app.py +0 -0
  18. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/blackbox/harness.py +0 -0
  19. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/blackbox/mock_centive_api.py +0 -0
  20. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/blackbox/test_blackbox.py +0 -0
  21. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/blackbox/test_blackbox_telemetry.py +0 -0
  22. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/examples/async_example.py +0 -0
  23. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/examples/fastapi_integration.py +0 -0
  24. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/examples/sync_example.py +0 -0
  25. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/examples/websocket_example.py +0 -0
  26. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/__init__.py +0 -0
  27. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/_http.py +0 -0
  28. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/_logging.py +0 -0
  29. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/_retry.py +0 -0
  30. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/async_client.py +0 -0
  31. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/client.py +0 -0
  32. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/exceptions.py +0 -0
  33. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/py.typed +0 -0
  34. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/__init__.py +0 -0
  35. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/async_telemetry.py +0 -0
  36. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/message_accumulator.py +0 -0
  37. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/sessions.py +0 -0
  38. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/telemetry_batcher.py +0 -0
  39. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/__init__.py +0 -0
  40. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/conftest.py +0 -0
  41. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/test_client_async.py +0 -0
  42. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/test_client_sync.py +0 -0
  43. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/test_config.py +0 -0
  44. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/test_error_mapping.py +0 -0
  45. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/test_logging_redaction.py +0 -0
  46. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/test_message_accumulator.py +0 -0
  47. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/test_retry_logic.py +0 -0
  48. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/test_telemetry_batcher.py +0 -0
  49. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/test_websocket_security.py +0 -0
  50. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev9}/tests/test_websocket_telemetry.py +0 -0
@@ -2,6 +2,37 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## Unreleased
6
+
7
+ ### Added
8
+
9
+ - **Grounded knowledge search for Aria's client tool.** A new websocket frame,
10
+ `type: "kb_search"` (`request_id`, `query`, optional `alternate_query`,
11
+ `user_messages`), is forwarded to Centive's `POST /anam/kb-search`, which
12
+ searches only this organization's knowledge folders with several phrasings
13
+ and returns an evidence packet. The server answers with
14
+ `type: "kb_search_response"` carrying the same `request_id` and either
15
+ `status: "success", packet` or `status: "error", error` (`INVALID_KB_SEARCH`,
16
+ `KB_SEARCH_UNAVAILABLE`). In token mode the frame is bound to the connection's
17
+ user like every other frame. A failed lookup is reported, never guessed.
18
+ - `AsyncSessions.kb_search(KbSearchRequest) -> KbSearchResponse` and
19
+ `ClientConfig.kb_search_path` (default `/anam/kb-search`).
20
+ - The `kb_search` frame's `session_id` and `user_id` are forwarded to Centive as
21
+ `session_id` / `external_user_id` on `KbSearchRequest`, so a question the
22
+ documentation could not answer is logged against the conversation in the
23
+ organization's knowledge-gap log.
24
+ - The browser's declared `capabilities` (e.g. `["kb_search"]`) on the `user_trigger`
25
+ frame are validated, remembered for the connection and forwarded on every
26
+ trigger-session call as `TriggerSessionRequest.capabilities`, so Centive attaches
27
+ Aria's client search tool only to sessions whose frontend can serve it.
28
+
29
+ ### Changed
30
+
31
+ - `AsyncSessions.kb_search` makes a single attempt (no retries). Centive spends up to
32
+ 8 s searching, the browser stops waiting at 12 s and Anam's tool times out at 15 s,
33
+ so a retried 503 could only arrive after everyone had given up. `_make_request`
34
+ accepts a per-call `max_retries` for this.
35
+
5
36
  ## v2.2.0
6
37
 
7
38
  Unreleased (branch `feature/user-page-tracking`, merged with the v2.1.0
@@ -40,6 +71,15 @@ Centive batcher and the host callback).
40
71
 
41
72
  ### Behavior
42
73
 
74
+ - **One trigger-session call per connection start.** The connect-time
75
+ automatic trigger now waits `session_trigger_grace_seconds` (default 0.5)
76
+ and stands down when the browser SDK has asked for a session itself in the
77
+ meantime, which it does the moment its socket opens, with the page it has
78
+ open. Before, both reached Centive within milliseconds: two Anam tokens per
79
+ connection and an opening prompt built from the page-less request. A request
80
+ that lands while another trigger for the same connection is in flight waits
81
+ for it and receives the same frame. Set the grace to 0 for the old behaviour.
82
+
43
83
  - The telemetry queue has its own circuit breaker, so a failing events
44
84
  endpoint cannot stop transcript saves (and vice versa).
45
85
  - The queue is bounded (`telemetry_max_queue_events`, default 5000); when full
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: centive-sdk
3
- Version: 2.2.0.dev6
3
+ Version: 2.2.0.dev9
4
4
  Summary: Python SDK for Centive backend integration
5
5
  Project-URL: Homepage, https://github.com/TheAgenticAI/centive-backend-sdk
6
6
  Project-URL: Repository, https://github.com/TheAgenticAI/centive-backend-sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "centive-sdk"
7
- version = "2.2.0.dev6"
7
+ version = "2.2.0.dev9"
8
8
  description = "Python SDK for Centive backend integration"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -39,6 +39,7 @@ class ClientConfig(BaseModel):
39
39
  save_messages_path: str = "/anam/save-messages"
40
40
  pause_status_path: str = "/anam/pause-status"
41
41
  events_path: str = "/anam/events"
42
+ kb_search_path: str = "/anam/kb-search"
42
43
  timeout_seconds: float = 10.0
43
44
  max_retries: int = 3
44
45
  initial_retry_delay: float = 0.5
@@ -83,6 +84,12 @@ class ClientConfig(BaseModel):
83
84
  max_session_id_chars: int = 200
84
85
  # Concurrent websocket connections accepted per user.
85
86
  max_connections_per_user: int = 5
87
+ # How long the connect-time session trigger waits for the browser SDK to
88
+ # ask for a session itself before firing. Browser SDKs since 1.0 ask the
89
+ # moment the socket opens, and their request is the one that carries the
90
+ # page the user has open; firing first meant two Centive calls and two Anam
91
+ # tokens per connection. 0 restores the old immediate trigger.
92
+ session_trigger_grace_seconds: float = 0.5
86
93
 
87
94
  # Page-telemetry frames (type="telemetry") from the FE SDK. These mirror the
88
95
  # aria-sdk producer's own caps (MAX_EVENTS_PER_FRAME, MAX_FRAME_BYTES,
@@ -1,4 +1,6 @@
1
1
  from .requests import (
2
+ KbSearchFrame,
3
+ KbSearchRequest,
2
4
  ProductEventBatchRequest,
3
5
  TelemetryEvent,
4
6
  TelemetryFrame,
@@ -7,6 +9,7 @@ from .requests import (
7
9
  TriggerSessionRequest,
8
10
  )
9
11
  from .responses import (
12
+ KbSearchResponse,
10
13
  PauseStatusResponse,
11
14
  ProductEventIngestResponse,
12
15
  ToolMappingResponse,
@@ -14,6 +17,9 @@ from .responses import (
14
17
  )
15
18
 
16
19
  __all__ = [
20
+ "KbSearchRequest",
21
+ "KbSearchFrame",
22
+ "KbSearchResponse",
17
23
  "ToolMappingRequest",
18
24
  "TriggerSessionRequest",
19
25
  "TelemetryEvent",
@@ -1,3 +1,4 @@
1
+ import re
1
2
  from typing import Any, Dict, List, Literal, Optional
2
3
 
3
4
  from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -28,6 +29,23 @@ class TriggerSessionRequest(BaseModel):
28
29
  current_page: Optional["TelemetryPage"] = Field(
29
30
  None, description="page the browser reports having open when it asks for a session"
30
31
  )
32
+ # What the browser SDK can serve in this session ("kb_search" for Aria's awaited
33
+ # knowledge-search tool). Centive attaches that tool only to sessions that
34
+ # declare it, so a customer on an older frontend never gets a tool nobody
35
+ # answers. Omitted when the browser declared nothing.
36
+ capabilities: Optional[List[str]] = Field(
37
+ None, max_length=16, description="capabilities the browser SDK declared for this session"
38
+ )
39
+
40
+ @field_validator("capabilities")
41
+ @classmethod
42
+ def _capability_tokens(cls, capabilities: Optional[List[str]]) -> Optional[List[str]]:
43
+ if capabilities is None:
44
+ return None
45
+ for capability in capabilities:
46
+ if not re.fullmatch(r"[a-z0-9_]{1,32}", capability):
47
+ raise ValueError("capabilities must be lowercase tokens of at most 32 characters")
48
+ return list(dict.fromkeys(capabilities))
31
49
 
32
50
 
33
51
  class SessionMessage(BaseModel):
@@ -96,6 +114,33 @@ class SessionEndEvent(BaseModel):
96
114
  user_id: str = Field(..., description="user ID")
97
115
 
98
116
 
117
+ class KbSearchRequest(BaseModel):
118
+ """request payload for POST /anam/kb-search: grounded evidence for Aria's client search tool."""
119
+
120
+ query: str = Field(..., min_length=1, max_length=4000, description="the user's sentence or the model's query")
121
+ alternate_query: Optional[str] = Field(None, max_length=4000, description="optional second phrasing")
122
+ user_messages: List[str] = Field(default_factory=list, max_length=10, description="the user's turns so far")
123
+ session_id: Optional[str] = Field(None, max_length=255, description="Anam session id, so unanswered questions tie to the conversation")
124
+ external_user_id: Optional[str] = Field(None, max_length=255, description="end-user id, for the knowledge-gap log")
125
+
126
+ @field_validator("user_messages")
127
+ @classmethod
128
+ def _bounded_user_messages(cls, messages: List[str]) -> List[str]:
129
+ for message in messages:
130
+ if len(message) > 4000:
131
+ raise ValueError("user message longer than 4000 characters")
132
+ return messages
133
+
134
+
135
+ class KbSearchFrame(KbSearchRequest):
136
+ """kb_search frame from FE SDK; answered with a kb_search_response frame carrying request_id."""
137
+
138
+ type: Literal["kb_search"] = "kb_search"
139
+ request_id: str = Field(..., min_length=1, max_length=128, description="client-chosen id echoed in the response")
140
+ session_id: Optional[str] = Field(None, description="Anam session id when known")
141
+ user_id: Optional[str] = Field(None, description="user id (checked against the connection identity)")
142
+
143
+
99
144
  # ---------------------------------------------------------------------------
100
145
  # Product events (page tracking). Contract: CentiveAI docs/product-events/CONTRACTS.md
101
146
  # ---------------------------------------------------------------------------
@@ -72,3 +72,18 @@ class ProductEventIngestResponse(BaseModel):
72
72
  # {"external_user_id", "content", "expires_at"}.
73
73
  context: list = Field(default_factory=list)
74
74
  raw: Optional[Dict[str, Any]] = None
75
+
76
+
77
+ class KbSearchResponse(BaseModel):
78
+ """evidence packet from POST /anam/kb-search (see CentiveAI kb_grounded_search_service)."""
79
+
80
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
81
+
82
+ status: str
83
+ document_passages: list = Field(default_factory=list)
84
+ user_messages: list = Field(default_factory=list)
85
+ answer_rules: str = ""
86
+ source_policy: str = ""
87
+ queries: list = Field(default_factory=list)
88
+ no_evidence_policy: Optional[str] = None
89
+ raw: Optional[Dict[str, Any]] = None
@@ -9,12 +9,14 @@ from .._retry import calculate_backoff, extract_retry_after, should_retry
9
9
  from ..config import ClientConfig
10
10
  from ..exceptions import AuthError, NetworkError, RateLimitError, ServerError, ValidationError
11
11
  from ..models.requests import (
12
+ KbSearchRequest,
12
13
  ProductEventBatchRequest,
13
14
  SaveMessagesRequest,
14
15
  ToolMappingRequest,
15
16
  TriggerSessionRequest,
16
17
  )
17
18
  from ..models.responses import (
19
+ KbSearchResponse,
18
20
  PauseStatusResponse,
19
21
  ProductEventIngestResponse,
20
22
  SaveMessagesResponse,
@@ -197,6 +199,23 @@ class AsyncSessions:
197
199
  raw=data,
198
200
  )
199
201
 
202
+ async def kb_search(self, input: KbSearchRequest) -> KbSearchResponse:
203
+ """Fetches a grounded evidence packet for Aria's client search tool.
204
+
205
+ Centive searches the organization's own knowledge folders with several
206
+ phrasings and validates provenance server-side. Raises the usual SDK
207
+ errors; the caller reports failure to the persona rather than guessing.
208
+ """
209
+ url = join_url(self._config.base_url, self._config.kb_search_path)
210
+ headers = build_headers(self._config.api_key)
211
+ # One attempt, no retries: the browser stops waiting for this answer after
212
+ # 12 s and Anam's tool times out at 15 s, while Centive already spends up
213
+ # to 8 s searching. A retried 503 would arrive after both have given up.
214
+ # Chain: Centive 8 s < this timeout (10 s, one attempt) < frontend 12 s < Anam 15 s.
215
+ response = await self._make_request(url, headers, input.model_dump(exclude_none=True), max_retries=0)
216
+ data = response.json()
217
+ return KbSearchResponse(**{key: value for key, value in data.items() if key != "raw"}, raw=data)
218
+
200
219
  async def send_events(self, input: ProductEventBatchRequest) -> ProductEventIngestResponse:
201
220
  """Sends a batch of product events (page views) to Centive.
202
221
 
@@ -394,12 +413,17 @@ class AsyncSessions:
394
413
  ) from last_exception
395
414
 
396
415
  async def _make_request(
397
- self, url: str, headers: dict, payload: dict
416
+ self, url: str, headers: dict, payload: dict, max_retries: Optional[int] = None
398
417
  ) -> httpx.Response:
399
- """Makes an HTTP POST request with retry logic and error handling."""
418
+ """Makes an HTTP POST request with retry logic and error handling.
419
+
420
+ ``max_retries`` overrides the configured retry count for calls whose
421
+ caller has its own deadline (``kb_search`` passes 0).
422
+ """
400
423
  last_exception = None
424
+ retries = self._config.max_retries if max_retries is None else max_retries
401
425
 
402
- for attempt in range(self._config.max_retries + 1):
426
+ for attempt in range(retries + 1):
403
427
  try:
404
428
  response = await self._client.post(
405
429
  url,
@@ -440,7 +464,7 @@ class AsyncSessions:
440
464
  elif response.status_code == 429:
441
465
  retry_after = extract_retry_after(response.headers)
442
466
 
443
- if should_retry(429, attempt, self._config.max_retries):
467
+ if should_retry(429, attempt, retries):
444
468
  delay = (
445
469
  retry_after
446
470
  if retry_after is not None
@@ -467,7 +491,7 @@ class AsyncSessions:
467
491
  )
468
492
 
469
493
  elif response.status_code >= 500:
470
- if should_retry(response.status_code, attempt, self._config.max_retries):
494
+ if should_retry(response.status_code, attempt, retries):
471
495
  delay = calculate_backoff(
472
496
  attempt, self._config.initial_retry_delay, self._config.max_retry_delay
473
497
  )
@@ -503,7 +527,7 @@ class AsyncSessions:
503
527
  except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError) as e:
504
528
  last_exception = e
505
529
 
506
- if attempt < self._config.max_retries:
530
+ if attempt < retries:
507
531
  delay = calculate_backoff(
508
532
  attempt, self._config.initial_retry_delay, self._config.max_retry_delay
509
533
  )
@@ -517,7 +541,7 @@ class AsyncSessions:
517
541
  continue
518
542
 
519
543
  raise NetworkError(
520
- f"Network error after {self._config.max_retries} retries: {str(last_exception)}"
544
+ f"Network error after {retries} retries: {str(last_exception)}"
521
545
  ) from last_exception
522
546
 
523
547
  def _extract_error_message(self, response: httpx.Response) -> str:
@@ -1,10 +1,12 @@
1
+ import asyncio
1
2
  import inspect
2
3
  import json
4
+ import re
3
5
  import secrets
4
6
  import time
5
7
  import warnings
6
8
  from collections import deque
7
- from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, Optional, Set, Tuple
9
+ from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, List, Optional, Set, Tuple
8
10
  from urllib.parse import parse_qs, urlparse
9
11
 
10
12
  import websockets
@@ -12,7 +14,10 @@ from websockets.server import WebSocketServerProtocol
12
14
 
13
15
  from .._logging import safe_log, transport_logger
14
16
  from ..config import ClientConfig
17
+ from ..exceptions import CentiveError
15
18
  from ..models.requests import (
19
+ KbSearchFrame,
20
+ KbSearchRequest,
16
21
  SaveMessagesRequest,
17
22
  SessionMessage,
18
23
  SessionMetadata,
@@ -82,6 +87,18 @@ class WebSocketServer:
82
87
  # current_page when the FE's user_trigger frame carries no page of its
83
88
  # own (older browser SDKs). Bounded by the number of live connections.
84
89
  self._connection_last_page: Dict[WebSocketServerProtocol, dict] = {}
90
+ # What each browser declared it can serve (e.g. "kb_search"), taken from its
91
+ # user_trigger frame and forwarded on every trigger-session call for the
92
+ # connection, so Centive attaches Aria's client tool only where it works.
93
+ self._connection_capabilities: Dict[WebSocketServerProtocol, List[str]] = {}
94
+ # One Centive trigger-session call per connection start, not two: the
95
+ # connect-time automatic trigger is held back for a grace period and
96
+ # stands down when the client asked itself (_auto_trigger_after_grace);
97
+ # a request that lands while another trigger for the same connection is
98
+ # in flight waits for it and receives the same frame.
99
+ self._connection_trigger_tasks: Dict[WebSocketServerProtocol, asyncio.Task] = {}
100
+ self._connection_triggered: set = set()
101
+ self._connection_trigger_inflight: Dict[WebSocketServerProtocol, asyncio.Future] = {}
85
102
  # Every session key touched by a connection, so a disconnect saves all of
86
103
  # them rather than only the most recent one.
87
104
  self._connection_sessions: Dict[WebSocketServerProtocol, Set[str]] = {}
@@ -569,7 +586,9 @@ class WebSocketServer:
569
586
  {"client": client_info, "user_id": user_id},
570
587
  )
571
588
 
572
- await self._trigger_and_emit(websocket, user_id, None, client_info)
589
+ self._connection_trigger_tasks[websocket] = asyncio.create_task(
590
+ self._auto_trigger_after_grace(websocket, user_id, client_info)
591
+ )
573
592
 
574
593
  try:
575
594
  async for message in websocket:
@@ -599,6 +618,12 @@ class WebSocketServer:
599
618
  self._clients.discard(websocket)
600
619
  self._connection_users.pop(websocket, None)
601
620
  self._connection_last_page.pop(websocket, None)
621
+ self._connection_capabilities.pop(websocket, None)
622
+ self._connection_triggered.discard(websocket)
623
+ self._connection_trigger_inflight.pop(websocket, None)
624
+ pending_trigger = self._connection_trigger_tasks.pop(websocket, None)
625
+ if pending_trigger is not None:
626
+ pending_trigger.cancel()
602
627
  self._telemetry_rate.forget(websocket)
603
628
  session_keys = self._connection_sessions.pop(websocket, set())
604
629
 
@@ -653,6 +678,10 @@ class WebSocketServer:
653
678
  await self._handle_session_end(websocket, data, client_info)
654
679
  return
655
680
 
681
+ if msg_type == "kb_search":
682
+ await self._handle_kb_search(websocket, data, client_info)
683
+ return
684
+
656
685
  if msg_type == "telemetry":
657
686
  # The raw message goes along: the frame byte cap is on what was
658
687
  # actually sent, and re-serializing `data` would not reproduce it.
@@ -668,6 +697,81 @@ class WebSocketServer:
668
697
  "message": "message received",
669
698
  }))
670
699
 
700
+ async def _handle_kb_search(
701
+ self,
702
+ websocket: WebSocketServerProtocol,
703
+ data: dict,
704
+ client_info: str,
705
+ ) -> None:
706
+ """serves Aria's awaited knowledge-search tool.
707
+
708
+ The FE SDK sends the user's sentence (prefetch) or the model's own query;
709
+ Centive answers with an evidence packet scoped to this organization. A
710
+ failed lookup is reported as an error frame so the persona says that
711
+ verification failed instead of answering from nothing.
712
+ """
713
+ request_id = data.get("request_id") if isinstance(data.get("request_id"), str) else None
714
+ try:
715
+ frame = KbSearchFrame.model_validate(data)
716
+ except Exception as e: # pydantic ValidationError or wrong shape
717
+ await websocket.send(json.dumps({
718
+ "type": "kb_search_response",
719
+ "request_id": request_id,
720
+ "status": "error",
721
+ "error": "INVALID_KB_SEARCH",
722
+ "message": f"invalid kb_search frame: {str(e)[:200]}",
723
+ }))
724
+ return
725
+
726
+ if self._config.ws_auth_mode != "open":
727
+ if await self._enforce_frame_identity(websocket, data, frame.session_id, client_info) is None:
728
+ return
729
+
730
+ request = KbSearchRequest(
731
+ query=frame.query,
732
+ alternate_query=frame.alternate_query,
733
+ user_messages=frame.user_messages,
734
+ session_id=frame.session_id,
735
+ external_user_id=frame.user_id,
736
+ )
737
+ started = time.monotonic()
738
+ try:
739
+ response = await self._sessions.kb_search(request)
740
+ except CentiveError as e:
741
+ safe_log(
742
+ self._logger,
743
+ "warning",
744
+ "kb_search failed",
745
+ {"client": client_info, "request_id": request_id, "error": str(e)[:200]},
746
+ )
747
+ await websocket.send(json.dumps({
748
+ "type": "kb_search_response",
749
+ "request_id": frame.request_id,
750
+ "status": "error",
751
+ "error": "KB_SEARCH_UNAVAILABLE",
752
+ "message": "knowledge search is unavailable",
753
+ }))
754
+ return
755
+
756
+ packet = response.raw if response.raw is not None else response.model_dump(exclude={"raw"})
757
+ safe_log(
758
+ self._logger,
759
+ "info",
760
+ "kb_search served",
761
+ {
762
+ "client": client_info,
763
+ "request_id": frame.request_id,
764
+ "passages": len(packet.get("document_passages", [])),
765
+ "seconds": round(time.monotonic() - started, 3),
766
+ },
767
+ )
768
+ await websocket.send(json.dumps({
769
+ "type": "kb_search_response",
770
+ "request_id": frame.request_id,
771
+ "status": "success",
772
+ "packet": packet,
773
+ }))
774
+
671
775
  async def _enforce_frame_identity(
672
776
  self,
673
777
  websocket: WebSocketServerProtocol,
@@ -1408,10 +1512,61 @@ class WebSocketServer:
1408
1512
  # batcher. The frame's own page wins; the last page_view seen on this
1409
1513
  # connection stands in for browser SDKs that predate the field.
1410
1514
  current_page = self._coerce_page(data.get("page")) or self._connection_last_page.get(websocket)
1515
+ declared = self._coerce_capabilities(data.get("capabilities"))
1516
+ if declared:
1517
+ self._connection_capabilities[websocket] = declared
1518
+ # The client asked itself, so the connect-time automatic trigger, if it
1519
+ # is still waiting out its grace period, has nothing left to do.
1520
+ self._connection_triggered.add(websocket)
1411
1521
  await self._trigger_and_emit(
1412
1522
  websocket, user_id, user_trigger, client_info, current_page=current_page
1413
1523
  )
1414
1524
 
1525
+ @staticmethod
1526
+ def _coerce_capabilities(value: object) -> Optional[List[str]]:
1527
+ """Bounded list of capability tokens from a frame, or None when nothing usable was declared."""
1528
+ if not isinstance(value, list):
1529
+ return None
1530
+ tokens: List[str] = []
1531
+ for item in value[:16]:
1532
+ if isinstance(item, str) and re.fullmatch(r"[a-z0-9_]{1,32}", item) and item not in tokens:
1533
+ tokens.append(item)
1534
+ return tokens or None
1535
+
1536
+ async def _auto_trigger_after_grace(
1537
+ self, websocket: WebSocketServerProtocol, user_id: str, client_info: str
1538
+ ) -> None:
1539
+ """The connect-time session trigger, held back briefly.
1540
+
1541
+ Browser SDKs since 1.0 ask for a token themselves the moment the socket
1542
+ opens, and that request is the one that carries the page the user has
1543
+ open. Firing the automatic trigger first (and before the message loop
1544
+ even started reading) meant two Centive calls and two Anam tokens per
1545
+ connection, with the opening prompt built from the page-less one. Now
1546
+ the automatic trigger waits config.session_trigger_grace_seconds and
1547
+ stands down when the client has asked in the meantime. A client that
1548
+ never asks (older SDKs, custom frontends) still gets its session, half
1549
+ a second later.
1550
+ """
1551
+ try:
1552
+ grace = max(0.0, float(self._config.session_trigger_grace_seconds or 0))
1553
+ if grace:
1554
+ await asyncio.sleep(grace)
1555
+ if websocket in self._connection_triggered or not self._is_live(websocket):
1556
+ safe_log(
1557
+ self._logger,
1558
+ "info",
1559
+ "automatic session trigger stood down",
1560
+ {"client": client_info, "user_id": user_id,
1561
+ "reason": "client asked" if websocket in self._connection_triggered else "connection gone"},
1562
+ )
1563
+ return
1564
+ await self._trigger_and_emit(websocket, user_id, None, client_info)
1565
+ except asyncio.CancelledError:
1566
+ return
1567
+ finally:
1568
+ self._connection_trigger_tasks.pop(websocket, None)
1569
+
1415
1570
  def _remember_page(self, websocket: WebSocketServerProtocol, events: Any) -> None:
1416
1571
  """Keeps the newest page_view's page for this connection (see _handle_user_trigger)."""
1417
1572
  if not isinstance(events, list):
@@ -1444,7 +1599,50 @@ class WebSocketServer:
1444
1599
  client_info: str,
1445
1600
  current_page: Optional[dict] = None,
1446
1601
  ) -> None:
1447
- """calls trigger_session API and emits response to FE."""
1602
+ """calls trigger_session API and emits response to FE.
1603
+
1604
+ Two triggers for the same connection at once (the automatic one mid
1605
+ call when the client's own request lands) share one Centive call: the
1606
+ second waits for the first and re-emits its frame.
1607
+ """
1608
+ inflight = self._connection_trigger_inflight.get(websocket)
1609
+ if inflight is not None:
1610
+ safe_log(
1611
+ self._logger,
1612
+ "info",
1613
+ "session trigger coalesced with one in flight",
1614
+ {"client": client_info, "user_id": user_id},
1615
+ )
1616
+ try:
1617
+ frame = await asyncio.shield(inflight)
1618
+ except Exception: # noqa: BLE001 - the first caller already reported it
1619
+ frame = None
1620
+ if frame is not None:
1621
+ try:
1622
+ await websocket.send(frame)
1623
+ except Exception: # noqa: BLE001
1624
+ pass
1625
+ return
1626
+
1627
+ done: asyncio.Future = asyncio.get_running_loop().create_future()
1628
+ self._connection_trigger_inflight[websocket] = done
1629
+ emitted: Optional[str] = None
1630
+ try:
1631
+ emitted = await self._trigger_once(websocket, user_id, user_trigger, client_info, current_page)
1632
+ finally:
1633
+ self._connection_trigger_inflight.pop(websocket, None)
1634
+ if not done.done():
1635
+ done.set_result(emitted)
1636
+
1637
+ async def _trigger_once(
1638
+ self,
1639
+ websocket: WebSocketServerProtocol,
1640
+ user_id: str,
1641
+ user_trigger: Optional[bool],
1642
+ client_info: str,
1643
+ current_page: Optional[dict],
1644
+ ) -> Optional[str]:
1645
+ """One trigger-session call. Returns the success frame it sent, if any."""
1448
1646
  safe_log(
1449
1647
  self._logger,
1450
1648
  "info",
@@ -1458,6 +1656,7 @@ class WebSocketServer:
1458
1656
  user_id=user_id,
1459
1657
  user_trigger=user_trigger,
1460
1658
  current_page=current_page,
1659
+ capabilities=self._connection_capabilities.get(websocket),
1461
1660
  )
1462
1661
 
1463
1662
  response = await self._sessions.trigger_session(request)
@@ -1481,7 +1680,7 @@ class WebSocketServer:
1481
1680
  "message": "failed to trigger session",
1482
1681
  "error": "UPSTREAM_TRIGGER_FAILED",
1483
1682
  }))
1484
- return
1683
+ return None
1485
1684
 
1486
1685
  response_data = {
1487
1686
  "status": response.status,
@@ -1490,7 +1689,8 @@ class WebSocketServer:
1490
1689
  "time_taken": response.time_taken,
1491
1690
  }
1492
1691
 
1493
- await websocket.send(json.dumps(response_data))
1692
+ frame = json.dumps(response_data)
1693
+ await websocket.send(frame)
1494
1694
 
1495
1695
  safe_log(
1496
1696
  self._logger,
@@ -1503,6 +1703,7 @@ class WebSocketServer:
1503
1703
  "has_session_data": response.session_data is not None,
1504
1704
  },
1505
1705
  )
1706
+ return frame
1506
1707
 
1507
1708
  except Exception as e:
1508
1709
  # Generic on the wire, detailed in the server's own logs: upstream
@@ -0,0 +1,80 @@
1
+ """The browser's declared capabilities ride the user_trigger frame and are forwarded on every
2
+ trigger-session call for that connection; malformed tokens are dropped, nothing declared means the
3
+ field is absent so Centive keeps Anam's built-in tools."""
4
+
5
+ import asyncio
6
+ import json
7
+
8
+ import httpx
9
+ import pytest
10
+ import respx
11
+ import websockets
12
+
13
+ from centive_sdk import AsyncCentiveClient
14
+
15
+ WS_PORT = 18791
16
+
17
+
18
+ @pytest.fixture
19
+ def trigger_session_response():
20
+ return {
21
+ "status": "success",
22
+ "message": "Session created successfully",
23
+ "session_data": {"session_id": "sess_123", "token": "tok_abc"},
24
+ "time_taken": 0.1,
25
+ }
26
+
27
+
28
+ async def _connect(client, base_url, trigger_session_response, pause_status_not_paused_response):
29
+ respx.get(f"{base_url}/anam/pause-status/user_123").mock(return_value=httpx.Response(200, json=pause_status_not_paused_response))
30
+ route = respx.post(f"{base_url}/anam/trigger-session").mock(return_value=httpx.Response(200, json=trigger_session_response))
31
+ token = await client.initialize_websocket(user_id="user_123", port=WS_PORT)
32
+ ws = await websockets.connect(f"ws://localhost:{WS_PORT}/ws?token={token}")
33
+ first = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
34
+ assert first["status"] == "success" # connect-time session frame
35
+ return ws, route
36
+
37
+
38
+ async def _trigger(ws, frame):
39
+ await ws.send(json.dumps(frame))
40
+ for _ in range(5):
41
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
42
+ if data.get("status") == "success" and "session_data" in data:
43
+ return data
44
+ raise AssertionError("no session frame received")
45
+
46
+
47
+ def _last_body(route):
48
+ return json.loads(route.calls[-1].request.content)
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ @respx.mock
53
+ async def test_declared_capabilities_are_forwarded_and_remembered(base_url, api_key, trigger_session_response, pause_status_not_paused_response):
54
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
55
+ ws, route = await _connect(client, base_url, trigger_session_response, pause_status_not_paused_response)
56
+ try:
57
+ assert "capabilities" not in _last_body(route) # the automatic connect-time trigger declared nothing
58
+ await _trigger(ws, {"user_trigger": True, "user_id": "user_123", "capabilities": ["kb_search", "Bad Token!", 42, "kb_search"]})
59
+ assert _last_body(route)["capabilities"] == ["kb_search"]
60
+ # A later trigger on the same connection (a token refresh) keeps the declaration.
61
+ await _trigger(ws, {"user_trigger": True, "user_id": "user_123"})
62
+ assert _last_body(route)["capabilities"] == ["kb_search"]
63
+ finally:
64
+ await ws.close()
65
+ await client.aclose()
66
+
67
+
68
+ @pytest.mark.asyncio
69
+ @respx.mock
70
+ async def test_nothing_usable_declared_means_no_capabilities_field(base_url, api_key, trigger_session_response, pause_status_not_paused_response):
71
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
72
+ ws, route = await _connect(client, base_url, trigger_session_response, pause_status_not_paused_response)
73
+ try:
74
+ await _trigger(ws, {"user_trigger": True, "user_id": "user_123", "capabilities": ["Not A Token"]})
75
+ assert "capabilities" not in _last_body(route)
76
+ await _trigger(ws, {"user_trigger": True, "user_id": "user_123", "capabilities": "kb_search"}) # wrong shape
77
+ assert "capabilities" not in _last_body(route)
78
+ finally:
79
+ await ws.close()
80
+ await client.aclose()
@@ -0,0 +1,145 @@
1
+ """kb_search frames: served from Centive's /anam/kb-search, scoped to the connection's user,
2
+ and reported as an error frame (never a guess) when the lookup fails."""
3
+
4
+ import asyncio
5
+ import json
6
+
7
+ import httpx
8
+ import pytest
9
+ import respx
10
+ import websockets
11
+
12
+ from centive_sdk import AsyncCentiveClient
13
+
14
+ WS_PORT = 18790
15
+
16
+
17
+ @pytest.fixture
18
+ def trigger_session_response():
19
+ return {
20
+ "status": "success",
21
+ "message": "Session created successfully",
22
+ "session_data": {"session_id": "sess_123", "token": "tok_abc"},
23
+ "time_taken": 0.1,
24
+ }
25
+
26
+
27
+ def ws_url(token):
28
+ return f"ws://localhost:{WS_PORT}/ws?token={token}"
29
+
30
+
31
+ PACKET = {
32
+ "status": "sources_found",
33
+ "document_passages": [{"document_id": "d1", "chunk_index": 0, "folder_id": "f", "filename": "faqs.md",
34
+ "content": "Invite them from Team, Invite Member.", "score": 0.7, "sha256": "h"}],
35
+ "user_messages": [{"turn_index": 0, "text": "how do I invite", "origin": "user_message", "verified_by_document": False}],
36
+ "answer_rules": "rules", "source_policy": "policy", "queries": ["how do I invite", "invite"],
37
+ }
38
+
39
+
40
+ async def _connect(client, base_url, trigger_session_response, pause_status_not_paused_response):
41
+ respx.get(f"{base_url}/anam/pause-status/user_123").mock(return_value=httpx.Response(200, json=pause_status_not_paused_response))
42
+ respx.post(f"{base_url}/anam/trigger-session").mock(return_value=httpx.Response(200, json=trigger_session_response))
43
+ token = await client.initialize_websocket(user_id="user_123", port=WS_PORT)
44
+ ws = await websockets.connect(ws_url(token))
45
+ first = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
46
+ assert first["status"] == "success" # connect-time session frame
47
+ return ws
48
+
49
+
50
+ async def _recv_type(ws, frame_type):
51
+ for _ in range(5):
52
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
53
+ if data.get("type") == frame_type:
54
+ return data
55
+ raise AssertionError(f"no {frame_type} frame received")
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ @respx.mock
60
+ async def test_kb_search_frame_is_served_from_centive(base_url, api_key, trigger_session_response, pause_status_not_paused_response):
61
+ route = respx.post(f"{base_url}/anam/kb-search").mock(return_value=httpx.Response(200, json=PACKET))
62
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
63
+ ws = await _connect(client, base_url, trigger_session_response, pause_status_not_paused_response)
64
+ try:
65
+ await ws.send(json.dumps({"type": "kb_search", "request_id": "r1", "user_id": "user_123", "session_id": "sess_123",
66
+ "query": "how do I invite", "alternate_query": "invite member", "user_messages": ["how do I invite"]}))
67
+ frame = await _recv_type(ws, "kb_search_response")
68
+ assert frame["status"] == "success" and frame["request_id"] == "r1"
69
+ assert frame["packet"]["document_passages"][0]["document_id"] == "d1"
70
+ sent = json.loads(route.calls[0].request.content)
71
+ assert sent == {"query": "how do I invite", "alternate_query": "invite member", "user_messages": ["how do I invite"],
72
+ "session_id": "sess_123", "external_user_id": "user_123"}
73
+ assert route.calls[0].request.headers["X-API-Key"] == api_key
74
+ finally:
75
+ await ws.close()
76
+ await client.aclose()
77
+
78
+
79
+ @pytest.mark.asyncio
80
+ @respx.mock
81
+ async def test_kb_search_failure_is_an_error_frame_after_a_single_attempt(base_url, api_key, trigger_session_response, pause_status_not_paused_response):
82
+ """A 503 from Centive is not retried: the browser stops waiting at 12 s and Anam at 15 s,
83
+ so the error frame must arrive at once rather than after three back-offs."""
84
+ route = respx.post(f"{base_url}/anam/kb-search").mock(return_value=httpx.Response(503, json={"detail": "Knowledge search is unavailable"}))
85
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url) # default retry policy (3) must not apply here
86
+ ws = await _connect(client, base_url, trigger_session_response, pause_status_not_paused_response)
87
+ try:
88
+ await ws.send(json.dumps({"type": "kb_search", "request_id": "r2", "user_id": "user_123", "query": "anything"}))
89
+ frame = await asyncio.wait_for(_recv_type(ws, "kb_search_response"), timeout=3.0)
90
+ assert frame == {"type": "kb_search_response", "request_id": "r2", "status": "error",
91
+ "error": "KB_SEARCH_UNAVAILABLE", "message": "knowledge search is unavailable"}
92
+ assert route.call_count == 1
93
+ finally:
94
+ await ws.close()
95
+ await client.aclose()
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ @respx.mock
100
+ async def test_kb_search_rejects_bad_frames_and_foreign_identity(base_url, api_key, trigger_session_response, pause_status_not_paused_response):
101
+ route = respx.post(f"{base_url}/anam/kb-search").mock(return_value=httpx.Response(200, json=PACKET))
102
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
103
+ ws = await _connect(client, base_url, trigger_session_response, pause_status_not_paused_response)
104
+ try:
105
+ await ws.send(json.dumps({"type": "kb_search", "request_id": "r3", "query": ""}))
106
+ frame = await _recv_type(ws, "kb_search_response")
107
+ assert frame["status"] == "error" and frame["error"] == "INVALID_KB_SEARCH" and frame["request_id"] == "r3"
108
+
109
+ await ws.send(json.dumps({"type": "kb_search", "request_id": "r4", "user_id": "someone_else", "query": "ok"}))
110
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
111
+ assert data["status"] == "error" and data["error"] == "IDENTITY_MISMATCH"
112
+ assert route.call_count == 0
113
+ finally:
114
+ await ws.close()
115
+ await client.aclose()
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ @respx.mock
120
+ async def test_long_chat_message_and_followup_pass_through_without_truncation(
121
+ base_url, api_key, trigger_session_response, pause_status_not_paused_response,
122
+ ):
123
+ route = respx.post(f"{base_url}/anam/kb-search").mock(return_value=httpx.Response(200, json=PACKET))
124
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
125
+ ws = await _connect(client, base_url, trigger_session_response, pause_status_not_paused_response)
126
+ question = "q" * 4000
127
+ history = [question]
128
+ try:
129
+ for index, query in enumerate([question, "download invoice"]):
130
+ if index:
131
+ history.append(query)
132
+ await ws.send(json.dumps({
133
+ "type": "kb_search", "request_id": f"long-{index}", "user_id": "user_123",
134
+ "query": query, "user_messages": history,
135
+ }))
136
+ frame = await _recv_type(ws, "kb_search_response")
137
+ assert frame["status"] == "success"
138
+ sent = json.loads(route.calls[index].request.content)
139
+ assert sent["query"] == query and sent["user_messages"] == history
140
+ await ws.send(json.dumps({"type": "kb_search", "request_id": "too-long", "query": "q" * 4001}))
141
+ assert (await _recv_type(ws, "kb_search_response"))["error"] == "INVALID_KB_SEARCH"
142
+ assert route.call_count == 2
143
+ finally:
144
+ await ws.close()
145
+ await client.aclose()
@@ -127,31 +127,134 @@ async def test_user_trigger_forwards_the_browsers_page(base_url, api_key, ws_por
127
127
  client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
128
128
  token = await client.initialize_websocket(user_id="user_123", port=ws_port)
129
129
 
130
- async with websockets.connect(ws_url(ws_port, token)) as ws:
131
- await ws.recv() # auto-trigger on connect: no page known yet
132
- assert "current_page" not in json.loads(route.calls.last.request.content)
130
+ try:
131
+ async with websockets.connect(ws_url(ws_port, token)) as ws:
132
+ await ws.recv() # auto-trigger on connect: no page known yet
133
+ assert "current_page" not in json.loads(route.calls.last.request.content)
134
+
135
+ await ws.send(json.dumps({
136
+ "user_trigger": True,
137
+ "page": {"path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices",
138
+ "referrer_path": "/home", "junk": "ignored"},
139
+ }))
140
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
141
+ assert data["status"] == "success"
142
+
143
+ body = json.loads(route.calls.last.request.content)
144
+ assert body["external_user_id"] == "user_123"
145
+ assert body["current_page"] == {
146
+ "path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices", "referrer_path": "/home",
147
+ }
148
+
149
+ # A malformed hint costs nothing: the session is still triggered, without it.
150
+ await ws.send(json.dumps({"user_trigger": True, "page": {"pattern": 7}}))
151
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
152
+ assert data["status"] == "success"
153
+ assert "current_page" not in json.loads(route.calls.last.request.content)
154
+
155
+ finally:
156
+ await client.aclose()
133
157
 
134
- await ws.send(json.dumps({
135
- "user_trigger": True,
136
- "page": {"path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices",
137
- "referrer_path": "/home", "junk": "ignored"},
138
- }))
139
- data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
140
- assert data["status"] == "success"
141
158
 
142
- body = json.loads(route.calls.last.request.content)
143
- assert body["external_user_id"] == "user_123"
144
- assert body["current_page"] == {
145
- "path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices", "referrer_path": "/home",
146
- }
159
+ @pytest.mark.asyncio
160
+ @respx.mock
161
+ async def test_a_client_that_asks_for_its_session_replaces_the_automatic_trigger(
162
+ base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response
163
+ ):
164
+ """One Centive call per connection start, carrying the page.
147
165
 
148
- # A malformed hint costs nothing: the session is still triggered, without it.
149
- await ws.send(json.dumps({"user_trigger": True, "page": {"pattern": 7}}))
150
- data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
151
- assert data["status"] == "success"
166
+ Seen on dev: the connect-time automatic trigger and the browser SDK's own
167
+ request (sent the moment the socket opens) both reached Centive within
168
+ milliseconds, two Anam tokens per connection, and the opening prompt was
169
+ built from the page-less one.
170
+ """
171
+ respx.get(f"{base_url}/anam/pause-status/user_123").mock(
172
+ return_value=httpx.Response(200, json=pause_status_not_paused_response)
173
+ )
174
+ route = respx.post(f"{base_url}/anam/trigger-session").mock(
175
+ return_value=httpx.Response(200, json=trigger_session_response)
176
+ )
177
+
178
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
179
+ token = await client.initialize_websocket(user_id="user_123", port=ws_port)
180
+
181
+ try:
182
+ async with websockets.connect(ws_url(ws_port, token)) as ws:
183
+ await ws.send(json.dumps({"user_trigger": True, "page": {"path": "/dashboard", "pattern": "/dashboard"}}))
184
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
185
+ assert data["status"] == "success"
186
+
187
+ # Past the grace period nothing else arrives: the automatic trigger stood down.
188
+ with pytest.raises(asyncio.TimeoutError):
189
+ await asyncio.wait_for(ws.recv(), timeout=client.config.session_trigger_grace_seconds + 0.7)
190
+
191
+ assert route.call_count == 1
192
+ assert json.loads(route.calls.last.request.content)["current_page"]["path"] == "/dashboard"
193
+ finally:
194
+ await client.aclose()
195
+
196
+
197
+ @pytest.mark.asyncio
198
+ @respx.mock
199
+ async def test_a_silent_client_still_gets_the_automatic_trigger(
200
+ base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response
201
+ ):
202
+ """Older browser SDKs and custom frontends never ask; they get their session
203
+ after the grace period, from exactly one call."""
204
+ respx.get(f"{base_url}/anam/pause-status/user_123").mock(
205
+ return_value=httpx.Response(200, json=pause_status_not_paused_response)
206
+ )
207
+ route = respx.post(f"{base_url}/anam/trigger-session").mock(
208
+ return_value=httpx.Response(200, json=trigger_session_response)
209
+ )
210
+
211
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
212
+ token = await client.initialize_websocket(user_id="user_123", port=ws_port)
213
+
214
+ try:
215
+ async with websockets.connect(ws_url(ws_port, token)) as ws:
216
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
217
+ assert data["status"] == "success"
218
+ assert data["session_data"]["token"] == trigger_session_response["session_data"]["token"]
219
+
220
+ assert route.call_count == 1
152
221
  assert "current_page" not in json.loads(route.calls.last.request.content)
222
+ finally:
223
+ await client.aclose()
153
224
 
154
- await client.aclose()
225
+
226
+ @pytest.mark.asyncio
227
+ @respx.mock
228
+ async def test_concurrent_triggers_on_one_connection_share_a_call(
229
+ base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response
230
+ ):
231
+ """A request that lands while another trigger for the connection is in
232
+ flight waits for it and receives the same frame; Centive is called once."""
233
+ respx.get(f"{base_url}/anam/pause-status/user_123").mock(
234
+ return_value=httpx.Response(200, json=pause_status_not_paused_response)
235
+ )
236
+
237
+ async def slow(request):
238
+ await asyncio.sleep(0.4)
239
+ return httpx.Response(200, json=trigger_session_response)
240
+
241
+ route = respx.post(f"{base_url}/anam/trigger-session").mock(side_effect=slow)
242
+
243
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
244
+ client.config.session_trigger_grace_seconds = 0 # automatic trigger fires at once
245
+ token = await client.initialize_websocket(user_id="user_123", port=ws_port)
246
+
247
+ try:
248
+ async with websockets.connect(ws_url(ws_port, token)) as ws:
249
+ await ws.send(json.dumps({"user_trigger": True})) # lands while the automatic call is in flight
250
+ first = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
251
+ second = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
252
+ assert first["status"] == second["status"] == "success"
253
+ assert first["session_data"] == second["session_data"]
254
+
255
+ assert route.call_count == 1
256
+ finally:
257
+ await client.aclose()
155
258
 
156
259
 
157
260
  @pytest.mark.asyncio
@@ -174,23 +277,25 @@ async def test_user_trigger_falls_back_to_the_last_page_view_seen(
174
277
  client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
175
278
  token = await client.initialize_websocket(user_id="user_123", port=ws_port)
176
279
 
177
- async with websockets.connect(ws_url(ws_port, token)) as ws:
178
- await ws.recv()
179
- frame = dict(telemetry_event_data)
180
- await ws.send(json.dumps(frame))
181
- ack = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
182
- assert ack["type"] == "telemetry_ack"
183
-
184
- await ws.send(json.dumps({"user_trigger": True}))
185
- data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
186
- assert data["status"] == "success"
187
-
188
- body = json.loads(route.calls.last.request.content)
189
- latest_view = [e for e in frame["events"] if e["event_type"] == "page_view"][-1]["page"]
190
- assert body["current_page"]["path"] == latest_view["path"]
191
- assert body["current_page"]["pattern"] == latest_view["pattern"]
192
-
193
- await client.aclose()
280
+ try:
281
+ async with websockets.connect(ws_url(ws_port, token)) as ws:
282
+ await ws.recv()
283
+ frame = dict(telemetry_event_data)
284
+ await ws.send(json.dumps(frame))
285
+ ack = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
286
+ assert ack["type"] == "telemetry_ack"
287
+
288
+ await ws.send(json.dumps({"user_trigger": True}))
289
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
290
+ assert data["status"] == "success"
291
+
292
+ body = json.loads(route.calls.last.request.content)
293
+ latest_view = [e for e in frame["events"] if e["event_type"] == "page_view"][-1]["page"]
294
+ assert body["current_page"]["path"] == latest_view["path"]
295
+ assert body["current_page"]["pattern"] == latest_view["pattern"]
296
+
297
+ finally:
298
+ await client.aclose()
194
299
 
195
300
 
196
301
  @pytest.mark.asyncio