gateforge-sdk 0.2.4__tar.gz → 0.2.5__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 (42) hide show
  1. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/PKG-INFO +2 -2
  2. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/__init__.py +18 -1
  3. gateforge_sdk-0.2.5/gateforge/session_manager.py +344 -0
  4. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/pyproject.toml +2 -2
  5. gateforge_sdk-0.2.5/tests/test_phase2.py +528 -0
  6. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/.env.example +0 -0
  7. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/.gitignore +0 -0
  8. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/.pypirc.example +0 -0
  9. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/INSTALL.md +0 -0
  10. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/LICENSE +0 -0
  11. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/MANIFEST.in +0 -0
  12. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/PUBLISHING.md +0 -0
  13. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/PUBLISH_NOW.md +0 -0
  14. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/README.md +0 -0
  15. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/ab/__init__.py +0 -0
  16. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/ab/engine.py +0 -0
  17. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/client.py +0 -0
  18. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/config.py +0 -0
  19. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/context.py +0 -0
  20. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/features/__init__.py +0 -0
  21. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/guardrails/__init__.py +0 -0
  22. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/guardrails/engine.py +0 -0
  23. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/metrics.py +0 -0
  24. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/options.py +0 -0
  25. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/pii.py +0 -0
  26. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/pricing.py +0 -0
  27. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/prompt.py +0 -0
  28. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/providers/__init__.py +0 -0
  29. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/providers/anthropic.py +0 -0
  30. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/providers/gemini.py +0 -0
  31. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/providers/openai.py +0 -0
  32. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/response.py +0 -0
  33. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/tracing.py +0 -0
  34. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/wrappers/__init__.py +0 -0
  35. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/wrappers/anthropic.py +0 -0
  36. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/wrappers/gemini.py +0 -0
  37. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/gateforge/wrappers/openai.py +0 -0
  38. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/tests/__init__.py +0 -0
  39. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/tests/test_metrics.py +0 -0
  40. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/tests/test_phase1.py +0 -0
  41. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/tests/test_pii.py +0 -0
  42. {gateforge_sdk-0.2.4 → gateforge_sdk-0.2.5}/tests/test_providers.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gateforge-sdk
3
- Version: 0.2.4
4
- Summary: Privacy-first LLMOps SDK — Auto-init, tool/agent decorators, session tracing, nested conversation support
3
+ Version: 0.2.5
4
+ Summary: Privacy-first LLMOps SDK — Auto-init, tool/agent decorators, session management, nested conversation support
5
5
  Project-URL: Homepage, https://gateforge.dev
6
6
  Project-URL: Documentation, https://gateforge.dev/docs
7
7
  Project-URL: Repository, https://github.com/gateforge/gateforge-sdk
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.5"
4
4
 
5
5
  # Auto-install spaCy model if missing (required for PII detection)
6
6
  def _ensure_spacy_model():
@@ -26,6 +26,15 @@ from gateforge.response import GatforgeResponse
26
26
  from gateforge.tracing import configure_otel
27
27
  from gateforge.ab.engine import VariantAssignment
28
28
  from gateforge.guardrails.engine import GuardrailViolation, GuardrailBlocked
29
+ from gateforge.session_manager import (
30
+ SessionManager,
31
+ SessionState,
32
+ get_session_manager,
33
+ create_session,
34
+ get_session,
35
+ get_current_conversation_id,
36
+ get_current_trace_info,
37
+ )
29
38
 
30
39
  _client: GatforgeClient | None = None
31
40
 
@@ -426,6 +435,14 @@ __all__ = [
426
435
  "agent",
427
436
  "record_tool_call",
428
437
  "record_tool_result",
438
+ # Session management (Phase 2)
439
+ "SessionManager",
440
+ "SessionState",
441
+ "get_session_manager",
442
+ "create_session",
443
+ "get_session",
444
+ "get_current_conversation_id",
445
+ "get_current_trace_info",
429
446
  # OTel configuration
430
447
  "configure_otel",
431
448
  # Standalone PII helpers
@@ -0,0 +1,344 @@
1
+ """
2
+ Phase 2: Session Management & Enhanced Trace Context
3
+
4
+ Provides:
5
+ - SessionManager for persistent session state
6
+ - Enhanced metadata support (user_id, tags, custom fields)
7
+ - Session persistence (save/load from dict)
8
+ - Conversation helpers
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from typing import Optional, Dict, Any, List
17
+ from datetime import datetime
18
+
19
+
20
+ @dataclass
21
+ class SessionState:
22
+ """
23
+ Represents the state of a conversation session.
24
+
25
+ Attributes:
26
+ conversation_id: Unique identifier for the conversation
27
+ user_id: Optional user identifier
28
+ created_at: Unix timestamp when session was created
29
+ updated_at: Unix timestamp of last activity
30
+ message_count: Number of messages/turns in this conversation
31
+ tags: Optional tags for categorization
32
+ metadata: Custom metadata dictionary
33
+ """
34
+ conversation_id: str
35
+ user_id: Optional[str] = None
36
+ created_at: float = field(default_factory=time.time)
37
+ updated_at: float = field(default_factory=time.time)
38
+ message_count: int = 0
39
+ tags: List[str] = field(default_factory=list)
40
+ metadata: Dict[str, Any] = field(default_factory=dict)
41
+
42
+ def to_dict(self) -> Dict[str, Any]:
43
+ """Serialize session state to dictionary."""
44
+ return {
45
+ "conversation_id": self.conversation_id,
46
+ "user_id": self.user_id,
47
+ "created_at": self.created_at,
48
+ "updated_at": self.updated_at,
49
+ "message_count": self.message_count,
50
+ "tags": self.tags,
51
+ "metadata": self.metadata,
52
+ }
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: Dict[str, Any]) -> SessionState:
56
+ """Deserialize session state from dictionary."""
57
+ return cls(
58
+ conversation_id=data["conversation_id"],
59
+ user_id=data.get("user_id"),
60
+ created_at=data.get("created_at", time.time()),
61
+ updated_at=data.get("updated_at", time.time()),
62
+ message_count=data.get("message_count", 0),
63
+ tags=data.get("tags", []),
64
+ metadata=data.get("metadata", {}),
65
+ )
66
+
67
+ def touch(self) -> None:
68
+ """Update the updated_at timestamp and increment message count."""
69
+ self.updated_at = time.time()
70
+ self.message_count += 1
71
+
72
+ def add_tag(self, tag: str) -> None:
73
+ """Add a tag to the session."""
74
+ if tag not in self.tags:
75
+ self.tags.append(tag)
76
+
77
+ def set_metadata(self, key: str, value: Any) -> None:
78
+ """Set a metadata field."""
79
+ self.metadata[key] = value
80
+
81
+
82
+ class SessionManager:
83
+ """
84
+ Manages conversation sessions with persistence support.
85
+
86
+ Usage::
87
+ # Create manager
88
+ manager = SessionManager()
89
+
90
+ # Create new session
91
+ session = manager.create_session(user_id="user-123")
92
+ print(session.conversation_id)
93
+
94
+ # Get existing session
95
+ session = manager.get_session("conv-123")
96
+
97
+ # Update session activity
98
+ manager.touch("conv-123")
99
+
100
+ # Serialize all sessions
101
+ data = manager.to_dict()
102
+
103
+ # Load from serialized data
104
+ manager2 = SessionManager.from_dict(data)
105
+ """
106
+
107
+ def __init__(self):
108
+ self._sessions: Dict[str, SessionState] = {}
109
+
110
+ def create_session(
111
+ self,
112
+ user_id: Optional[str] = None,
113
+ conversation_id: Optional[str] = None,
114
+ tags: Optional[List[str]] = None,
115
+ metadata: Optional[Dict[str, Any]] = None,
116
+ ) -> SessionState:
117
+ """
118
+ Create a new session.
119
+
120
+ Args:
121
+ user_id: Optional user identifier
122
+ conversation_id: Optional explicit conversation_id (auto-generated if not provided)
123
+ tags: Optional list of tags
124
+ metadata: Optional custom metadata
125
+
126
+ Returns:
127
+ New SessionState object
128
+ """
129
+ cid = conversation_id or str(uuid.uuid4())
130
+
131
+ session = SessionState(
132
+ conversation_id=cid,
133
+ user_id=user_id,
134
+ tags=tags or [],
135
+ metadata=metadata or {},
136
+ )
137
+
138
+ self._sessions[cid] = session
139
+ return session
140
+
141
+ def get_session(self, conversation_id: str) -> Optional[SessionState]:
142
+ """
143
+ Get an existing session by ID.
144
+
145
+ Args:
146
+ conversation_id: The conversation ID to look up
147
+
148
+ Returns:
149
+ SessionState if found, None otherwise
150
+ """
151
+ return self._sessions.get(conversation_id)
152
+
153
+ def get_or_create_session(
154
+ self,
155
+ conversation_id: str,
156
+ user_id: Optional[str] = None,
157
+ ) -> SessionState:
158
+ """
159
+ Get existing session or create new one if not exists.
160
+
161
+ Args:
162
+ conversation_id: The conversation ID
163
+ user_id: Optional user_id for new session
164
+
165
+ Returns:
166
+ Existing or new SessionState
167
+ """
168
+ if conversation_id in self._sessions:
169
+ return self._sessions[conversation_id]
170
+
171
+ return self.create_session(
172
+ conversation_id=conversation_id,
173
+ user_id=user_id,
174
+ )
175
+
176
+ def touch(self, conversation_id: str) -> None:
177
+ """
178
+ Update session activity timestamp and message count.
179
+
180
+ Args:
181
+ conversation_id: The conversation ID to touch
182
+ """
183
+ session = self._sessions.get(conversation_id)
184
+ if session:
185
+ session.touch()
186
+
187
+ def delete_session(self, conversation_id: str) -> bool:
188
+ """
189
+ Delete a session.
190
+
191
+ Args:
192
+ conversation_id: The conversation ID to delete
193
+
194
+ Returns:
195
+ True if deleted, False if not found
196
+ """
197
+ if conversation_id in self._sessions:
198
+ del self._sessions[conversation_id]
199
+ return True
200
+ return False
201
+
202
+ def list_sessions(
203
+ self,
204
+ user_id: Optional[str] = None,
205
+ tags: Optional[List[str]] = None,
206
+ limit: Optional[int] = None,
207
+ ) -> List[SessionState]:
208
+ """
209
+ List sessions with optional filters.
210
+
211
+ Args:
212
+ user_id: Filter by user_id
213
+ tags: Filter by tags (must have all specified tags)
214
+ limit: Maximum number of sessions to return
215
+
216
+ Returns:
217
+ List of matching SessionState objects
218
+ """
219
+ sessions = list(self._sessions.values())
220
+
221
+ if user_id:
222
+ sessions = [s for s in sessions if s.user_id == user_id]
223
+
224
+ if tags:
225
+ sessions = [s for s in sessions if all(tag in s.tags for tag in tags)]
226
+
227
+ # Sort by updated_at (most recent first)
228
+ sessions.sort(key=lambda s: s.updated_at, reverse=True)
229
+
230
+ if limit:
231
+ sessions = sessions[:limit]
232
+
233
+ return sessions
234
+
235
+ def to_dict(self) -> Dict[str, Any]:
236
+ """Serialize all sessions to dictionary."""
237
+ return {
238
+ "sessions": {
239
+ cid: session.to_dict()
240
+ for cid, session in self._sessions.items()
241
+ }
242
+ }
243
+
244
+ @classmethod
245
+ def from_dict(cls, data: Dict[str, Any]) -> SessionManager:
246
+ """Deserialize SessionManager from dictionary."""
247
+ manager = cls()
248
+
249
+ sessions_data = data.get("sessions", {})
250
+ for cid, session_data in sessions_data.items():
251
+ manager._sessions[cid] = SessionState.from_dict(session_data)
252
+
253
+ return manager
254
+
255
+ def __len__(self) -> int:
256
+ """Return number of active sessions."""
257
+ return len(self._sessions)
258
+
259
+ def __contains__(self, conversation_id: str) -> bool:
260
+ """Check if conversation_id exists."""
261
+ return conversation_id in self._sessions
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Global session manager (optional convenience)
266
+ # ---------------------------------------------------------------------------
267
+
268
+ _default_manager: Optional[SessionManager] = None
269
+
270
+
271
+ def get_session_manager() -> SessionManager:
272
+ """Get or create the default session manager."""
273
+ global _default_manager
274
+ if _default_manager is None:
275
+ _default_manager = SessionManager()
276
+ return _default_manager
277
+
278
+
279
+ def create_session(
280
+ user_id: Optional[str] = None,
281
+ conversation_id: Optional[str] = None,
282
+ tags: Optional[List[str]] = None,
283
+ ) -> SessionState:
284
+ """
285
+ Create a new session using the default manager.
286
+
287
+ Usage::
288
+ session = gateforge.create_session(user_id="user-123")
289
+ print(session.conversation_id)
290
+ """
291
+ return get_session_manager().create_session(
292
+ user_id=user_id,
293
+ conversation_id=conversation_id,
294
+ tags=tags,
295
+ )
296
+
297
+
298
+ def get_session(conversation_id: str) -> Optional[SessionState]:
299
+ """
300
+ Get an existing session using the default manager.
301
+
302
+ Usage::
303
+ session = gateforge.get_session("conv-123")
304
+ if session:
305
+ print(f"Messages: {session.message_count}")
306
+ """
307
+ return get_session_manager().get_session(conversation_id)
308
+
309
+
310
+ def get_current_conversation_id() -> Optional[str]:
311
+ """
312
+ Get the conversation_id from the active trace context.
313
+
314
+ Usage::
315
+ cid = gateforge.get_current_conversation_id()
316
+ if cid:
317
+ print(f"Current conversation: {cid}")
318
+ """
319
+ from gateforge.context import get_active_trace
320
+ trace = get_active_trace()
321
+ return trace.conversation_id if trace else None
322
+
323
+
324
+ def get_current_trace_info() -> Optional[Dict[str, Any]]:
325
+ """
326
+ Get information about the current trace context.
327
+
328
+ Returns:
329
+ Dictionary with conversation_id, trace_id, step, or None if no active trace
330
+
331
+ Usage::
332
+ info = gateforge.get_current_trace_info()
333
+ if info:
334
+ print(f"Conversation: {info['conversation_id']}, Step: {info['step']}")
335
+ """
336
+ from gateforge.context import get_active_trace
337
+ trace = get_active_trace()
338
+ if trace:
339
+ return {
340
+ "conversation_id": trace.conversation_id,
341
+ "trace_id": trace.trace_id,
342
+ "step": trace.step,
343
+ }
344
+ return None
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gateforge-sdk"
7
- version = "0.2.4"
8
- description = "Privacy-first LLMOps SDK — Auto-init, tool/agent decorators, session tracing, nested conversation support"
7
+ version = "0.2.5"
8
+ description = "Privacy-first LLMOps SDK — Auto-init, tool/agent decorators, session management, nested conversation support"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.10"
@@ -0,0 +1,528 @@
1
+ """
2
+ Tests for Phase 2 features:
3
+ - SessionManager
4
+ - SessionState
5
+ - Enhanced trace context helpers
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import pytest
11
+ import time
12
+
13
+ # Add gateforge to path
14
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
15
+
16
+
17
+ class TestSessionState:
18
+ """Tests for SessionState dataclass."""
19
+
20
+ def test_create_session_state(self):
21
+ """Should create SessionState with required fields."""
22
+ from gateforge.session_manager import SessionState
23
+
24
+ session = SessionState(conversation_id="conv-123")
25
+
26
+ assert session.conversation_id == "conv-123"
27
+ assert session.user_id is None
28
+ assert session.message_count == 0
29
+ assert session.tags == []
30
+ assert session.metadata == {}
31
+
32
+ def test_create_session_state_with_user(self):
33
+ """Should create SessionState with user_id."""
34
+ from gateforge.session_manager import SessionState
35
+
36
+ session = SessionState(
37
+ conversation_id="conv-123",
38
+ user_id="user-456",
39
+ tags=["test", "phase2"],
40
+ )
41
+
42
+ assert session.user_id == "user-456"
43
+ assert session.tags == ["test", "phase2"]
44
+
45
+ def test_session_to_dict(self):
46
+ """Should serialize SessionState to dictionary."""
47
+ from gateforge.session_manager import SessionState
48
+
49
+ session = SessionState(
50
+ conversation_id="conv-123",
51
+ user_id="user-456",
52
+ message_count=5,
53
+ tags=["tag1"],
54
+ metadata={"key": "value"},
55
+ )
56
+
57
+ data = session.to_dict()
58
+
59
+ assert data["conversation_id"] == "conv-123"
60
+ assert data["user_id"] == "user-456"
61
+ assert data["message_count"] == 5
62
+ assert data["tags"] == ["tag1"]
63
+ assert data["metadata"]["key"] == "value"
64
+
65
+ def test_session_from_dict(self):
66
+ """Should deserialize SessionState from dictionary."""
67
+ from gateforge.session_manager import SessionState
68
+
69
+ data = {
70
+ "conversation_id": "conv-789",
71
+ "user_id": "user-999",
72
+ "message_count": 10,
73
+ "tags": ["a", "b"],
74
+ "metadata": {"x": 1},
75
+ }
76
+
77
+ session = SessionState.from_dict(data)
78
+
79
+ assert session.conversation_id == "conv-789"
80
+ assert session.user_id == "user-999"
81
+ assert session.message_count == 10
82
+ assert session.tags == ["a", "b"]
83
+
84
+ def test_session_touch(self):
85
+ """Should update timestamp and increment message count."""
86
+ from gateforge.session_manager import SessionState
87
+
88
+ session = SessionState(conversation_id="conv-123", message_count=0)
89
+ old_updated = session.updated_at
90
+
91
+ time.sleep(0.01) # Small delay to ensure timestamp changes
92
+ session.touch()
93
+
94
+ assert session.message_count == 1
95
+ assert session.updated_at > old_updated
96
+
97
+ def test_session_add_tag(self):
98
+ """Should add tag to session."""
99
+ from gateforge.session_manager import SessionState
100
+
101
+ session = SessionState(conversation_id="conv-123")
102
+ session.add_tag("weather")
103
+ session.add_tag("agent")
104
+
105
+ assert "weather" in session.tags
106
+ assert "agent" in session.tags
107
+ assert len(session.tags) == 2
108
+
109
+ def test_session_add_duplicate_tag(self):
110
+ """Should not add duplicate tags."""
111
+ from gateforge.session_manager import SessionState
112
+
113
+ session = SessionState(conversation_id="conv-123")
114
+ session.add_tag("test")
115
+ session.add_tag("test") # Duplicate
116
+
117
+ assert session.tags == ["test"]
118
+ assert len(session.tags) == 1
119
+
120
+ def test_session_set_metadata(self):
121
+ """Should set metadata field."""
122
+ from gateforge.session_manager import SessionState
123
+
124
+ session = SessionState(conversation_id="conv-123")
125
+ session.set_metadata("model", "gemini-2.5-flash")
126
+ session.set_metadata("temperature", 0.7)
127
+
128
+ assert session.metadata["model"] == "gemini-2.5-flash"
129
+ assert session.metadata["temperature"] == 0.7
130
+
131
+
132
+ class TestSessionManager:
133
+ """Tests for SessionManager."""
134
+
135
+ def test_create_session(self):
136
+ """Should create new session."""
137
+ from gateforge.session_manager import SessionManager
138
+
139
+ manager = SessionManager()
140
+ session = manager.create_session(user_id="user-123")
141
+
142
+ assert session.conversation_id is not None
143
+ assert session.user_id == "user-123"
144
+ assert len(manager) == 1
145
+
146
+ def test_create_session_with_explicit_id(self):
147
+ """Should create session with explicit conversation_id."""
148
+ from gateforge.session_manager import SessionManager
149
+
150
+ manager = SessionManager()
151
+ session = manager.create_session(
152
+ conversation_id="my-conv-123",
153
+ user_id="user-456",
154
+ )
155
+
156
+ assert session.conversation_id == "my-conv-123"
157
+ assert session.user_id == "user-456"
158
+
159
+ def test_get_session(self):
160
+ """Should retrieve existing session."""
161
+ from gateforge.session_manager import SessionManager
162
+
163
+ manager = SessionManager()
164
+ session = manager.create_session(user_id="user-789")
165
+
166
+ retrieved = manager.get_session(session.conversation_id)
167
+
168
+ assert retrieved is not None
169
+ assert retrieved.user_id == "user-789"
170
+
171
+ def test_get_session_not_found(self):
172
+ """Should return None for non-existent session."""
173
+ from gateforge.session_manager import SessionManager
174
+
175
+ manager = SessionManager()
176
+ retrieved = manager.get_session("non-existent")
177
+
178
+ assert retrieved is None
179
+
180
+ def test_get_or_create_session_existing(self):
181
+ """Should return existing session."""
182
+ from gateforge.session_manager import SessionManager
183
+
184
+ manager = SessionManager()
185
+ session1 = manager.create_session(
186
+ conversation_id="conv-123",
187
+ user_id="user-original",
188
+ )
189
+
190
+ session2 = manager.get_or_create_session(
191
+ conversation_id="conv-123",
192
+ user_id="user-new", # Should be ignored
193
+ )
194
+
195
+ assert session2.user_id == "user-original"
196
+ assert session1 is session2
197
+
198
+ def test_get_or_create_session_new(self):
199
+ """Should create new session if not exists."""
200
+ from gateforge.session_manager import SessionManager
201
+
202
+ manager = SessionManager()
203
+
204
+ session = manager.get_or_create_session(
205
+ conversation_id="new-conv",
206
+ user_id="user-new",
207
+ )
208
+
209
+ assert session.conversation_id == "new-conv"
210
+ assert session.user_id == "user-new"
211
+
212
+ def test_touch_session(self):
213
+ """Should update session activity."""
214
+ from gateforge.session_manager import SessionManager
215
+
216
+ manager = SessionManager()
217
+ session = manager.create_session(conversation_id="conv-123")
218
+
219
+ manager.touch("conv-123")
220
+
221
+ assert session.message_count == 1
222
+
223
+ def test_delete_session(self):
224
+ """Should delete session."""
225
+ from gateforge.session_manager import SessionManager
226
+
227
+ manager = SessionManager()
228
+ session = manager.create_session(conversation_id="conv-123")
229
+
230
+ result = manager.delete_session("conv-123")
231
+
232
+ assert result is True
233
+ assert len(manager) == 0
234
+ assert manager.get_session("conv-123") is None
235
+
236
+ def test_delete_nonexistent_session(self):
237
+ """Should return False for non-existent session."""
238
+ from gateforge.session_manager import SessionManager
239
+
240
+ manager = SessionManager()
241
+ result = manager.delete_session("non-existent")
242
+
243
+ assert result is False
244
+
245
+ def test_list_sessions_all(self):
246
+ """Should list all sessions."""
247
+ from gateforge.session_manager import SessionManager
248
+
249
+ manager = SessionManager()
250
+ manager.create_session(conversation_id="conv-1")
251
+ manager.create_session(conversation_id="conv-2")
252
+ manager.create_session(conversation_id="conv-3")
253
+
254
+ sessions = manager.list_sessions()
255
+
256
+ assert len(sessions) == 3
257
+
258
+ def test_list_sessions_by_user(self):
259
+ """Should filter sessions by user_id."""
260
+ from gateforge.session_manager import SessionManager
261
+
262
+ manager = SessionManager()
263
+ manager.create_session(conversation_id="conv-1", user_id="user-a")
264
+ manager.create_session(conversation_id="conv-2", user_id="user-b")
265
+ manager.create_session(conversation_id="conv-3", user_id="user-a")
266
+
267
+ sessions = manager.list_sessions(user_id="user-a")
268
+
269
+ assert len(sessions) == 2
270
+ assert all(s.user_id == "user-a" for s in sessions)
271
+
272
+ def test_list_sessions_by_tags(self):
273
+ """Should filter sessions by tags."""
274
+ from gateforge.session_manager import SessionManager
275
+
276
+ manager = SessionManager()
277
+ manager.create_session(conversation_id="conv-1", tags=["weather"])
278
+ manager.create_session(conversation_id="conv-2", tags=["search"])
279
+ manager.create_session(conversation_id="conv-3", tags=["weather", "agent"])
280
+
281
+ sessions = manager.list_sessions(tags=["weather"])
282
+
283
+ assert len(sessions) == 2
284
+
285
+ def test_list_sessions_limit(self):
286
+ """Should limit number of sessions returned."""
287
+ from gateforge.session_manager import SessionManager
288
+
289
+ manager = SessionManager()
290
+ for i in range(10):
291
+ manager.create_session(conversation_id=f"conv-{i}")
292
+
293
+ sessions = manager.list_sessions(limit=3)
294
+
295
+ assert len(sessions) == 3
296
+
297
+ def test_list_sessions_sorted_by_updated(self):
298
+ """Should return sessions sorted by updated_at (most recent first)."""
299
+ from gateforge.session_manager import SessionManager
300
+ import time
301
+
302
+ manager = SessionManager()
303
+ manager.create_session(conversation_id="conv-1")
304
+ time.sleep(0.01)
305
+ manager.create_session(conversation_id="conv-2")
306
+ time.sleep(0.01)
307
+ manager.create_session(conversation_id="conv-3")
308
+
309
+ sessions = manager.list_sessions()
310
+
311
+ assert sessions[0].conversation_id == "conv-3"
312
+ assert sessions[1].conversation_id == "conv-2"
313
+ assert sessions[2].conversation_id == "conv-1"
314
+
315
+ def test_serialize_to_dict(self):
316
+ """Should serialize all sessions to dictionary."""
317
+ from gateforge.session_manager import SessionManager
318
+
319
+ manager = SessionManager()
320
+ manager.create_session(
321
+ conversation_id="conv-123",
322
+ user_id="user-456",
323
+ tags=["test"],
324
+ )
325
+
326
+ data = manager.to_dict()
327
+
328
+ assert "sessions" in data
329
+ assert "conv-123" in data["sessions"]
330
+ assert data["sessions"]["conv-123"]["user_id"] == "user-456"
331
+
332
+ def test_deserialize_from_dict(self):
333
+ """Should deserialize SessionManager from dictionary."""
334
+ from gateforge.session_manager import SessionManager
335
+
336
+ data = {
337
+ "sessions": {
338
+ "conv-123": {
339
+ "conversation_id": "conv-123",
340
+ "user_id": "user-456",
341
+ "message_count": 5,
342
+ "tags": ["a", "b"],
343
+ "metadata": {},
344
+ }
345
+ }
346
+ }
347
+
348
+ manager = SessionManager.from_dict(data)
349
+
350
+ assert len(manager) == 1
351
+ session = manager.get_session("conv-123")
352
+ assert session.user_id == "user-456"
353
+ assert session.message_count == 5
354
+
355
+ def test_contains(self):
356
+ """Should support 'in' operator."""
357
+ from gateforge.session_manager import SessionManager
358
+
359
+ manager = SessionManager()
360
+ manager.create_session(conversation_id="conv-123")
361
+
362
+ assert "conv-123" in manager
363
+ assert "conv-456" not in manager
364
+
365
+ def test_len(self):
366
+ """Should support len()."""
367
+ from gateforge.session_manager import SessionManager
368
+
369
+ manager = SessionManager()
370
+ manager.create_session(conversation_id="conv-1")
371
+ manager.create_session(conversation_id="conv-2")
372
+
373
+ assert len(manager) == 2
374
+
375
+
376
+ class TestGlobalHelpers:
377
+ """Tests for global helper functions."""
378
+
379
+ def test_create_session_global(self):
380
+ """Should create session using default manager."""
381
+ from gateforge.session_manager import create_session, get_session_manager
382
+
383
+ # Clear default manager
384
+ import gateforge.session_manager as sm
385
+ sm._default_manager = None
386
+
387
+ session = create_session(user_id="test-user")
388
+
389
+ assert session.user_id == "test-user"
390
+ assert len(get_session_manager()) == 1
391
+
392
+ def test_get_session_global(self):
393
+ """Should get session using default manager."""
394
+ from gateforge.session_manager import create_session, get_session
395
+
396
+ # Clear default manager
397
+ import gateforge.session_manager as sm
398
+ sm._default_manager = None
399
+
400
+ session = create_session(conversation_id="test-conv")
401
+ retrieved = get_session("test-conv")
402
+
403
+ assert retrieved is session
404
+
405
+ def test_get_current_conversation_id_with_trace(self):
406
+ """Should return conversation_id from active trace."""
407
+ from gateforge.session_manager import get_current_conversation_id
408
+ from gateforge.context import trace
409
+
410
+ with trace(conversation_id="test-conv-123"):
411
+ cid = get_current_conversation_id()
412
+ assert cid == "test-conv-123"
413
+
414
+ def test_get_current_conversation_id_no_trace(self):
415
+ """Should return None when no active trace."""
416
+ from gateforge.session_manager import get_current_conversation_id
417
+
418
+ cid = get_current_conversation_id()
419
+ assert cid is None
420
+
421
+ def test_get_current_trace_info(self):
422
+ """Should return trace info dictionary."""
423
+ from gateforge.session_manager import get_current_trace_info
424
+ from gateforge.context import trace
425
+
426
+ with trace(conversation_id="test-conv") as t:
427
+ # Advance step
428
+ t.next_step()
429
+
430
+ info = get_current_trace_info()
431
+
432
+ assert info is not None
433
+ assert info["conversation_id"] == "test-conv"
434
+ assert info["trace_id"] is not None
435
+ assert info["step"] == 1
436
+
437
+ def test_get_current_trace_info_no_trace(self):
438
+ """Should return None when no active trace."""
439
+ from gateforge.session_manager import get_current_trace_info
440
+
441
+ info = get_current_trace_info()
442
+ assert info is None
443
+
444
+
445
+ class TestIntegration:
446
+ """Integration tests for Phase 2 features."""
447
+
448
+ def test_session_manager_with_trace(self):
449
+ """Should work with trace context manager."""
450
+ from gateforge.session_manager import SessionManager
451
+ from gateforge.context import trace
452
+
453
+ manager = SessionManager()
454
+ session = manager.create_session(conversation_id="conv-123")
455
+
456
+ with trace(conversation_id=session.conversation_id) as t:
457
+ # Simulate tool call
458
+ t.next_step()
459
+ t.next_step()
460
+
461
+ manager.touch(session.conversation_id)
462
+
463
+ assert session.message_count == 1
464
+ assert t.step == 2
465
+
466
+ def test_multiple_concurrent_sessions(self):
467
+ """Should handle multiple concurrent sessions."""
468
+ from gateforge.session_manager import SessionManager
469
+ from gateforge.context import trace, get_active_trace
470
+
471
+ manager = SessionManager()
472
+
473
+ # Create two sessions
474
+ session1 = manager.create_session(conversation_id="conv-a")
475
+ session2 = manager.create_session(conversation_id="conv-b")
476
+
477
+ # Use first session
478
+ with trace(conversation_id=session1.conversation_id):
479
+ assert get_active_trace().conversation_id == "conv-a"
480
+
481
+ # Use second session
482
+ with trace(conversation_id=session2.conversation_id):
483
+ assert get_active_trace().conversation_id == "conv-b"
484
+
485
+ # Outside trace, no active session
486
+ assert get_active_trace() is None
487
+
488
+ def test_session_persistence_roundtrip(self):
489
+ """Should serialize and deserialize correctly."""
490
+ from gateforge.session_manager import SessionManager
491
+
492
+ # Create manager with sessions
493
+ manager1 = SessionManager()
494
+ manager1.create_session(
495
+ conversation_id="conv-1",
496
+ user_id="user-a",
497
+ tags=["test"],
498
+ metadata={"key": "value1"},
499
+ )
500
+ manager1.create_session(
501
+ conversation_id="conv-2",
502
+ user_id="user-b",
503
+ tags=["prod"],
504
+ metadata={"key": "value2"},
505
+ )
506
+
507
+ # Serialize
508
+ data = manager1.to_dict()
509
+
510
+ # Deserialize
511
+ manager2 = SessionManager.from_dict(data)
512
+
513
+ # Verify
514
+ assert len(manager2) == 2
515
+
516
+ s1 = manager2.get_session("conv-1")
517
+ assert s1.user_id == "user-a"
518
+ assert s1.tags == ["test"]
519
+ assert s1.metadata["key"] == "value1"
520
+
521
+ s2 = manager2.get_session("conv-2")
522
+ assert s2.user_id == "user-b"
523
+ assert s2.tags == ["prod"]
524
+ assert s2.metadata["key"] == "value2"
525
+
526
+
527
+ if __name__ == '__main__':
528
+ pytest.main([__file__, '-v'])
File without changes
File without changes
File without changes
File without changes
File without changes