feat(session): add session domain models and contracts
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / security (push) Waiting to run
CI / quality (push) Waiting to run
CI / unit_tests (push) Waiting to run
CI / integration_tests (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / build (push) Waiting to run
CI / docker (push) Blocked by required conditions
CI / lint (pull_request) Successful in 15s
CI / typecheck (pull_request) Successful in 27s
CI / security (pull_request) Successful in 21s
CI / quality (pull_request) Successful in 15s
CI / integration_tests (pull_request) Successful in 4m14s
CI / build (pull_request) Successful in 15s
CI / unit_tests (pull_request) Successful in 8m46s
CI / coverage (pull_request) Successful in 6m43s
CI / docker (pull_request) Successful in 38s
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / security (push) Waiting to run
CI / quality (push) Waiting to run
CI / unit_tests (push) Waiting to run
CI / integration_tests (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / build (push) Waiting to run
CI / docker (push) Blocked by required conditions
CI / lint (pull_request) Successful in 15s
CI / typecheck (pull_request) Successful in 27s
CI / security (pull_request) Successful in 21s
CI / quality (pull_request) Successful in 15s
CI / integration_tests (pull_request) Successful in 4m14s
CI / build (pull_request) Successful in 15s
CI / unit_tests (pull_request) Successful in 8m46s
CI / coverage (pull_request) Successful in 6m43s
CI / docker (pull_request) Successful in 38s
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""ASV benchmarks for Session domain model validation and serialization.
|
||||
|
||||
Measures the performance of:
|
||||
- Session model construction (Pydantic validation)
|
||||
- SessionMessage construction
|
||||
- Session.append_message() auto-sequencing
|
||||
- Session.as_cli_dict() serialization
|
||||
- Session.as_export_dict() serialization with checksum
|
||||
- SessionTokenUsage construction
|
||||
- Session.get_messages() with pagination
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cleveragents.domain.models.core.session import (
|
||||
MessageRole,
|
||||
Session,
|
||||
SessionMessage,
|
||||
SessionTokenUsage,
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from cleveragents.domain.models.core.session import (
|
||||
MessageRole,
|
||||
Session,
|
||||
SessionMessage,
|
||||
SessionTokenUsage,
|
||||
)
|
||||
|
||||
from ulid import ULID
|
||||
|
||||
|
||||
def _make_session(message_count: int = 0) -> Session:
|
||||
"""Create a Session with optional messages for benchmarking."""
|
||||
session = Session(session_id=str(ULID()))
|
||||
for i in range(message_count):
|
||||
session.append_message(MessageRole.USER, f"Benchmark message {i}")
|
||||
return session
|
||||
|
||||
|
||||
def _make_message() -> SessionMessage:
|
||||
"""Create a fully-populated SessionMessage for benchmarking."""
|
||||
return SessionMessage(
|
||||
message_id=str(ULID()),
|
||||
role=MessageRole.USER,
|
||||
content="Benchmark message content",
|
||||
sequence=0,
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
|
||||
|
||||
class SessionValidationSuite:
|
||||
"""Benchmark Session model construction (Pydantic validation)."""
|
||||
|
||||
def time_session_construction(self) -> None:
|
||||
"""Benchmark creating a minimal Session object."""
|
||||
Session(session_id=str(ULID()))
|
||||
|
||||
def time_session_with_actor(self) -> None:
|
||||
"""Benchmark creating a Session with actor_name."""
|
||||
Session(session_id=str(ULID()), actor_name="local/orchestrator")
|
||||
|
||||
def time_message_construction(self) -> None:
|
||||
"""Benchmark creating a SessionMessage."""
|
||||
_make_message()
|
||||
|
||||
def time_tool_message_construction(self) -> None:
|
||||
"""Benchmark creating a tool SessionMessage with tool_call_id."""
|
||||
SessionMessage(
|
||||
message_id=str(ULID()),
|
||||
role=MessageRole.TOOL,
|
||||
content="Tool result",
|
||||
sequence=0,
|
||||
tool_call_id="call_123",
|
||||
)
|
||||
|
||||
def time_token_usage_construction(self) -> None:
|
||||
"""Benchmark SessionTokenUsage construction."""
|
||||
SessionTokenUsage(
|
||||
input_tokens=1000,
|
||||
output_tokens=500,
|
||||
estimated_cost=0.05,
|
||||
)
|
||||
|
||||
|
||||
class SessionAppendSuite:
|
||||
"""Benchmark Session.append_message() auto-sequencing."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create a session for append benchmarks."""
|
||||
self.session = _make_session()
|
||||
|
||||
def time_append_single_message(self) -> None:
|
||||
"""Benchmark appending a single message."""
|
||||
self.session.append_message(MessageRole.USER, "Benchmark message")
|
||||
|
||||
def time_append_10_messages(self) -> None:
|
||||
"""Benchmark appending 10 messages sequentially."""
|
||||
session = _make_session()
|
||||
for i in range(10):
|
||||
session.append_message(MessageRole.USER, f"Message {i}")
|
||||
|
||||
|
||||
class SessionSerializationSuite:
|
||||
"""Benchmark Session serialization methods."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create a session with messages for serialization benchmarks."""
|
||||
self.session = _make_session(message_count=10)
|
||||
self.session.link_plan(str(ULID()))
|
||||
self.large_session = _make_session(message_count=100)
|
||||
|
||||
def time_as_cli_dict(self) -> None:
|
||||
"""Benchmark Session.as_cli_dict() ordered dict generation."""
|
||||
self.session.as_cli_dict()
|
||||
|
||||
def time_as_export_dict(self) -> None:
|
||||
"""Benchmark Session.as_export_dict() with checksum generation."""
|
||||
self.session.as_export_dict()
|
||||
|
||||
def time_as_export_dict_large(self) -> None:
|
||||
"""Benchmark export dict for a large session (100 messages)."""
|
||||
self.large_session.as_export_dict()
|
||||
|
||||
def time_model_dump(self) -> None:
|
||||
"""Benchmark Pydantic model_dump() serialization."""
|
||||
self.session.model_dump()
|
||||
|
||||
def time_model_dump_json(self) -> None:
|
||||
"""Benchmark Pydantic model_dump_json() JSON serialization."""
|
||||
self.session.model_dump_json()
|
||||
|
||||
|
||||
class SessionPaginationSuite:
|
||||
"""Benchmark Session.get_messages() with pagination."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create a session with many messages for pagination benchmarks."""
|
||||
self.session = _make_session(message_count=100)
|
||||
|
||||
def time_get_all_messages(self) -> None:
|
||||
"""Benchmark getting all messages (no limit)."""
|
||||
self.session.get_messages()
|
||||
|
||||
def time_get_first_10(self) -> None:
|
||||
"""Benchmark getting first 10 messages."""
|
||||
self.session.get_messages(limit=10)
|
||||
|
||||
def time_get_with_offset(self) -> None:
|
||||
"""Benchmark getting messages with offset."""
|
||||
self.session.get_messages(limit=10, offset=50)
|
||||
@@ -0,0 +1,105 @@
|
||||
# Session Domain Model
|
||||
|
||||
A `Session` is a persistent conversation thread tied to an orchestrator actor.
|
||||
It maintains message history across plans and serves as the user's
|
||||
natural-language interface.
|
||||
|
||||
## MessageRole
|
||||
|
||||
| Value | Description |
|
||||
|-------------|--------------------------------|
|
||||
| `user` | Human input |
|
||||
| `assistant` | AI/agent response |
|
||||
| `system` | System-level instructions |
|
||||
| `tool` | Tool invocation result |
|
||||
|
||||
## SessionMessage
|
||||
|
||||
Each message within a session:
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|----------------|-------------------|-----------------|------------------------------------------|
|
||||
| `message_id` | `str` | (required) | Unique ULID identifier |
|
||||
| `role` | `MessageRole` | (required) | Role of the message sender |
|
||||
| `content` | `str` | (required) | Message content (min 1 char, no whitespace-only) |
|
||||
| `sequence` | `int` | (required) | Ordering index within the session (>= 0) |
|
||||
| `timestamp` | `datetime` | `datetime.now()`| When the message was created |
|
||||
| `metadata` | `dict[str, Any]` | `{}` | Optional metadata |
|
||||
| `tool_call_id` | `str \| None` | `None` | Required when role is `tool` |
|
||||
|
||||
**Constraints**:
|
||||
- `content` must not be whitespace-only
|
||||
- `tool_call_id` is required when `role` is `tool`
|
||||
|
||||
## SessionTokenUsage
|
||||
|
||||
Accumulated token usage for a session:
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|------------------|---------|---------|--------------------------------|
|
||||
| `input_tokens` | `int` | `0` | Total input tokens consumed |
|
||||
| `output_tokens` | `int` | `0` | Total output tokens generated |
|
||||
| `estimated_cost` | `float` | `0.0` | Estimated total cost (USD) |
|
||||
|
||||
All fields must be >= 0.
|
||||
|
||||
## Session Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|--------------------|---------------------------|--------------------------|----------------------------------------|
|
||||
| `session_id` | `str` | (required) | Unique ULID identifier |
|
||||
| `actor_name` | `str \| None` | `None` | Namespaced actor reference |
|
||||
| `namespace` | `str` | `"local"` | Namespace for ownership |
|
||||
| `messages` | `list[SessionMessage]` | `[]` | Ordered messages |
|
||||
| `linked_plan_ids` | `list[str]` | `[]` | ULID references to linked plans |
|
||||
| `automation_level` | `str \| None` | `None` | Session-level automation override |
|
||||
| `token_usage` | `SessionTokenUsage` | `SessionTokenUsage()` | Accumulated token usage |
|
||||
| `created_at` | `datetime` | `datetime.now()` | Creation timestamp |
|
||||
| `updated_at` | `datetime` | `datetime.now()` | Last modification timestamp |
|
||||
| `metadata` | `dict[str, Any]` | `{}` | Arbitrary session metadata |
|
||||
|
||||
**Constraints**:
|
||||
- `session_id` must match ULID format (`^[0-9A-HJKMNP-TV-Z]{26}$`)
|
||||
- `actor_name` if provided must match `namespace/name` pattern
|
||||
- `messages` must be ordered by `sequence`
|
||||
|
||||
## Properties
|
||||
|
||||
- `session.message_count` -- Number of messages
|
||||
- `session.last_message` -- Most recent message (or None)
|
||||
- `session.is_empty` -- True if no messages
|
||||
|
||||
## Methods
|
||||
|
||||
- `session.append_message(role, content, metadata=None, tool_call_id=None)` -- Append a message with auto-generated ULID and sequence
|
||||
- `session.get_messages(limit=None, offset=0)` -- Paginated message retrieval
|
||||
- `session.link_plan(plan_id)` -- Link a plan (deduplicated)
|
||||
- `session.as_cli_dict()` -- Stable ordered dict for CLI rendering
|
||||
- `session.as_export_dict()` -- JSON-serializable dict with checksum
|
||||
|
||||
## CLI Dict Output
|
||||
|
||||
The `as_cli_dict()` output matches `agents session show`:
|
||||
|
||||
- `session_id`, `actor_name`, `namespace`, `message_count`
|
||||
- `created_at`, `updated_at`, `automation_level`
|
||||
- `recent_messages` (last 5), `linked_plan_ids`
|
||||
- `token_usage` (input/output/cost), `metadata`
|
||||
|
||||
## Export Dict
|
||||
|
||||
The `as_export_dict()` output includes:
|
||||
|
||||
- `schema_version`, `session_id`, `actor_name`, `namespace`
|
||||
- `messages` (full history), `linked_plan_ids`
|
||||
- `automation_level`, `token_usage`, `metadata`
|
||||
- `created_at`, `updated_at`, `checksum` (SHA-256)
|
||||
|
||||
## Error Types
|
||||
|
||||
| Error | Base | Description |
|
||||
|------------------------|-------------------------|------------------------------------|
|
||||
| `SessionServiceError` | `Exception` | Base error for session operations |
|
||||
| `SessionNotFoundError` | `SessionServiceError` | Session ID not found |
|
||||
| `SessionExportError` | `SessionServiceError` | Export failures |
|
||||
| `SessionImportError` | `SessionServiceError` | Import failures (schema, corrupt) |
|
||||
@@ -0,0 +1,125 @@
|
||||
# Session Service Contract
|
||||
|
||||
The `SessionService` is the abstract interface for session management in
|
||||
CleverAgents. Implementations handle persistence, ULID generation, and
|
||||
lifecycle management for `Session` objects.
|
||||
|
||||
## Interface
|
||||
|
||||
```python
|
||||
class SessionService(ABC):
|
||||
def create(self, actor_name: str | None = None) -> Session: ...
|
||||
def get(self, session_id: str) -> Session: ...
|
||||
def list(self) -> list[Session]: ...
|
||||
def delete(self, session_id: str) -> None: ...
|
||||
def append_message(
|
||||
self,
|
||||
session_id: str,
|
||||
role: MessageRole,
|
||||
content: str,
|
||||
metadata: dict | None = None,
|
||||
) -> SessionMessage: ...
|
||||
def export_session(self, session_id: str) -> dict: ...
|
||||
def import_session(self, data: dict) -> Session: ...
|
||||
def update_token_usage(
|
||||
self,
|
||||
session_id: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
cost: float,
|
||||
) -> None: ...
|
||||
```
|
||||
|
||||
## Methods
|
||||
|
||||
### create
|
||||
|
||||
Create a new session with a fresh ULID. Optionally bind to an actor.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-------------|----------------|---------|-------------------------------|
|
||||
| `actor_name` | `str \| None` | `None` | Namespaced actor to bind |
|
||||
|
||||
**Returns**: `Session`
|
||||
|
||||
### get
|
||||
|
||||
Retrieve a session by its ULID.
|
||||
|
||||
**Raises**: `SessionNotFoundError` if not found.
|
||||
|
||||
### list
|
||||
|
||||
Return all sessions ordered by creation time.
|
||||
|
||||
### delete
|
||||
|
||||
Remove a session by its ULID.
|
||||
|
||||
**Raises**: `SessionNotFoundError` if not found.
|
||||
|
||||
### append_message
|
||||
|
||||
Add a message to a session. The implementation generates a ULID for the
|
||||
message and sets the sequence number.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-------------|-------------------|---------|------------------------------|
|
||||
| `session_id` | `str` | -- | Target session ULID |
|
||||
| `role` | `MessageRole` | -- | Message role |
|
||||
| `content` | `str` | -- | Message content |
|
||||
| `metadata` | `dict \| None` | `None` | Optional metadata |
|
||||
|
||||
**Returns**: `SessionMessage`
|
||||
**Raises**: `SessionNotFoundError` if session not found.
|
||||
|
||||
### export_session
|
||||
|
||||
Export a session as a JSON-serializable dict including a SHA-256 checksum
|
||||
for integrity verification.
|
||||
|
||||
**Raises**: `SessionNotFoundError`, `SessionExportError`
|
||||
|
||||
### import_session
|
||||
|
||||
Import a session from an export dict. Validates schema version and
|
||||
checksum integrity.
|
||||
|
||||
**Raises**: `SessionImportError` on schema mismatch or corrupt data.
|
||||
|
||||
### update_token_usage
|
||||
|
||||
Increment token usage counters for a session.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|----------------|---------|--------------------------------|
|
||||
| `session_id` | `str` | Target session ULID |
|
||||
| `input_tokens` | `int` | Input tokens to add |
|
||||
| `output_tokens` | `int` | Output tokens to add |
|
||||
| `cost` | `float` | Estimated cost to add (USD) |
|
||||
|
||||
**Raises**: `SessionNotFoundError` if session not found.
|
||||
|
||||
## Error Hierarchy
|
||||
|
||||
```
|
||||
Exception
|
||||
-> SessionServiceError (base)
|
||||
-> SessionNotFoundError (session ID not found)
|
||||
-> SessionExportError (export failures)
|
||||
-> SessionImportError (import failures)
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
The service backs the following CLI commands:
|
||||
|
||||
| Command | Service Method |
|
||||
|------------------------|---------------------|
|
||||
| `agents session create` | `create()` |
|
||||
| `agents session list` | `list()` |
|
||||
| `agents session show` | `get()` |
|
||||
| `agents session delete` | `delete()` |
|
||||
| `agents session export` | `export_session()` |
|
||||
| `agents session import` | `import_session()` |
|
||||
| `agents session tell` | `append_message()` |
|
||||
@@ -0,0 +1,234 @@
|
||||
Feature: Session Domain Model
|
||||
As a developer
|
||||
I want session domain models for conversation management
|
||||
So that sessions can be created, tracked, and exported via CLI
|
||||
|
||||
# ---- Session Creation ----
|
||||
|
||||
Scenario: Create a session with valid ULID
|
||||
When I create a session with a valid ULID
|
||||
Then the session model should be created
|
||||
And the session model session_id should be a valid ULID
|
||||
|
||||
Scenario: Create a session with actor name
|
||||
When I create a session with actor name "local/orchestrator"
|
||||
Then the session model should be created
|
||||
And the session model actor_name should be "local/orchestrator"
|
||||
|
||||
Scenario: Create a session without actor name
|
||||
When I create a session without actor name
|
||||
Then the session model should be created
|
||||
And the session model actor_name should be empty
|
||||
|
||||
Scenario: Session has default namespace local
|
||||
When I create a session with a valid ULID
|
||||
Then the session model namespace should be "local"
|
||||
|
||||
# ---- Session ID Validation ----
|
||||
|
||||
Scenario: Session rejects invalid ULID format
|
||||
When I try to create a session with invalid ID "not-a-ulid"
|
||||
Then a session model validation error should be raised
|
||||
|
||||
# ---- Actor Name Validation ----
|
||||
|
||||
Scenario: Session rejects invalid actor name format
|
||||
When I try to create a session with invalid actor name "badformat"
|
||||
Then a session model validation error should be raised
|
||||
|
||||
Scenario: Session accepts None actor name
|
||||
When I create a session without actor name
|
||||
Then the session model should be created
|
||||
|
||||
# ---- Message Append with Auto-sequencing ----
|
||||
|
||||
Scenario: Append a user message to session
|
||||
Given a session with no messages
|
||||
When I append a user message "Hello"
|
||||
Then the session model should have 1 message
|
||||
And the session last message content should be "Hello"
|
||||
And the session last message role should be "user"
|
||||
And the session last message sequence should be 0
|
||||
|
||||
Scenario: Append multiple messages with auto-sequencing
|
||||
Given a session with no messages
|
||||
When I append a user message "Hello"
|
||||
And I append an assistant message "Hi there"
|
||||
Then the session model should have 2 messages
|
||||
And the session last message sequence should be 1
|
||||
|
||||
# ---- Message Role Validation ----
|
||||
|
||||
Scenario: Tool message requires tool_call_id
|
||||
When I try to create a tool message without tool_call_id
|
||||
Then a session model validation error should be raised
|
||||
And the session model error should mention "tool_call_id"
|
||||
|
||||
Scenario: Tool message with tool_call_id is valid
|
||||
When I create a tool message with tool_call_id "call_123"
|
||||
Then the session message should be created
|
||||
And the session message tool_call_id should be "call_123"
|
||||
|
||||
Scenario: User message does not require tool_call_id
|
||||
When I create a user message "Hello"
|
||||
Then the session message should be created
|
||||
|
||||
# ---- Content Validation ----
|
||||
|
||||
Scenario: Message rejects whitespace-only content
|
||||
When I try to create a message with whitespace-only content
|
||||
Then a session model validation error should be raised
|
||||
And the session model error should mention "whitespace"
|
||||
|
||||
Scenario: Message rejects empty content
|
||||
When I try to create a message with empty content
|
||||
Then a session model validation error should be raised
|
||||
|
||||
# ---- Message Ordering ----
|
||||
|
||||
Scenario: Messages must be ordered by sequence
|
||||
When I try to create a session with out-of-order messages
|
||||
Then a session model validation error should be raised
|
||||
And the session model error should mention "ordered by sequence"
|
||||
|
||||
# ---- Plan Linking ----
|
||||
|
||||
Scenario: Link a plan to session
|
||||
Given a session with no messages
|
||||
When I link plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the session
|
||||
Then the session should have 1 linked plan
|
||||
|
||||
Scenario: Plan linking deduplicates
|
||||
Given a session with no messages
|
||||
When I link plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the session
|
||||
And I link plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the session
|
||||
Then the session should have 1 linked plan
|
||||
|
||||
Scenario: Link multiple plans
|
||||
Given a session with no messages
|
||||
When I link plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the session
|
||||
And I link plan "01BX5ZZKBKACTAV9WEVGEMMVRY" to the session
|
||||
Then the session should have 2 linked plans
|
||||
|
||||
# ---- Token Usage Tracking ----
|
||||
|
||||
Scenario: Default token usage is zero
|
||||
When I create a session with a valid ULID
|
||||
Then the session token usage input_tokens should be 0
|
||||
And the session token usage output_tokens should be 0
|
||||
And the session token usage estimated_cost should be 0.0
|
||||
|
||||
Scenario: Token usage with values
|
||||
When I create a session with token usage 100 input 50 output 0.05 cost
|
||||
Then the session token usage input_tokens should be 100
|
||||
And the session token usage output_tokens should be 50
|
||||
And the session token usage estimated_cost should be 0.05
|
||||
|
||||
Scenario: Token usage rejects negative values
|
||||
When I try to create token usage with negative input tokens
|
||||
Then a session model validation error should be raised
|
||||
|
||||
# ---- Export Dict ----
|
||||
|
||||
Scenario: Export dict contains checksum
|
||||
Given a session with some messages
|
||||
When I export the session
|
||||
Then the export dict should have key "checksum"
|
||||
And the export dict should have key "schema_version"
|
||||
And the export dict should have key "messages"
|
||||
And the export dict should have key "session_id"
|
||||
|
||||
Scenario: Export dict round-trip preserves data
|
||||
Given a session with some messages
|
||||
When I export the session
|
||||
Then the export dict messages count should match session message count
|
||||
|
||||
# ---- CLI Dict ----
|
||||
|
||||
Scenario: CLI dict has required fields
|
||||
Given a session with some messages
|
||||
When I get the session CLI dict
|
||||
Then the session cli dict should have key "session_id"
|
||||
And the session cli dict should have key "message_count"
|
||||
And the session cli dict should have key "created_at"
|
||||
And the session cli dict should have key "updated_at"
|
||||
And the session cli dict should have key "token_usage"
|
||||
And the session cli dict should have key "namespace"
|
||||
|
||||
Scenario: CLI dict includes recent messages
|
||||
Given a session with some messages
|
||||
When I get the session CLI dict
|
||||
Then the session cli dict should have key "recent_messages"
|
||||
|
||||
Scenario: CLI dict includes actor when set
|
||||
When I create a session with actor name "local/orchestrator"
|
||||
And I get the session CLI dict
|
||||
Then the session cli dict should have key "actor_name"
|
||||
|
||||
Scenario: CLI dict includes automation level when set
|
||||
Given a session with automation level "full_automation"
|
||||
When I get the session CLI dict
|
||||
Then the session cli dict should have key "automation_level"
|
||||
|
||||
# ---- Empty Session Properties ----
|
||||
|
||||
Scenario: Empty session has zero message count
|
||||
Given a session with no messages
|
||||
Then the session message count should be 0
|
||||
And the session is_empty should be true
|
||||
And the session last_message should be none
|
||||
|
||||
# ---- get_messages with limit/offset ----
|
||||
|
||||
Scenario: get_messages with no limit returns all
|
||||
Given a session with 5 messages
|
||||
When I get messages with no limit
|
||||
Then I should get 5 messages
|
||||
|
||||
Scenario: get_messages with limit
|
||||
Given a session with 5 messages
|
||||
When I get messages with limit 3
|
||||
Then I should get 3 messages
|
||||
|
||||
Scenario: get_messages with offset
|
||||
Given a session with 5 messages
|
||||
When I get messages with offset 2
|
||||
Then I should get 3 messages
|
||||
|
||||
Scenario: get_messages with limit and offset
|
||||
Given a session with 5 messages
|
||||
When I get messages with limit 2 and offset 1
|
||||
Then I should get 2 messages
|
||||
|
||||
# ---- Session Error Types ----
|
||||
|
||||
Scenario: SessionServiceError is base error
|
||||
Then SessionServiceError should be an Exception subclass
|
||||
|
||||
Scenario: SessionNotFoundError inherits from SessionServiceError
|
||||
Then SessionNotFoundError should be a SessionServiceError subclass
|
||||
|
||||
Scenario: SessionExportError inherits from SessionServiceError
|
||||
Then SessionExportError should be a SessionServiceError subclass
|
||||
|
||||
Scenario: SessionImportError inherits from SessionServiceError
|
||||
Then SessionImportError should be a SessionServiceError subclass
|
||||
|
||||
# ---- MessageRole enum ----
|
||||
|
||||
Scenario: MessageRole has expected values
|
||||
Then the MessageRole enum should have values "user,assistant,system,tool"
|
||||
|
||||
# ---- Session metadata ----
|
||||
|
||||
Scenario: Session supports metadata
|
||||
When I create a session with metadata
|
||||
Then the session metadata should contain key "env"
|
||||
|
||||
# ---- Export dict linked plans ----
|
||||
|
||||
Scenario: Export dict includes linked plan IDs
|
||||
Given a session with no messages
|
||||
When I link plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the session
|
||||
And I export the session
|
||||
Then the export dict should have key "linked_plan_ids"
|
||||
@@ -0,0 +1,611 @@
|
||||
"""Step definitions for Session domain model tests."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from pydantic import ValidationError
|
||||
from ulid import ULID
|
||||
|
||||
from cleveragents.domain.models.core.session import (
|
||||
MessageRole,
|
||||
Session,
|
||||
SessionExportError,
|
||||
SessionImportError,
|
||||
SessionMessage,
|
||||
SessionNotFoundError,
|
||||
SessionServiceError,
|
||||
SessionTokenUsage,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_ULID = str(ULID())
|
||||
|
||||
|
||||
def _make_session(**overrides: Any) -> Session:
|
||||
"""Create a Session with sensible defaults, allowing overrides."""
|
||||
defaults: dict[str, Any] = {
|
||||
"session_id": str(ULID()),
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return Session(**defaults)
|
||||
|
||||
|
||||
def _make_message(**overrides: Any) -> SessionMessage:
|
||||
"""Create a SessionMessage with sensible defaults."""
|
||||
defaults: dict[str, Any] = {
|
||||
"message_id": str(ULID()),
|
||||
"role": MessageRole.USER,
|
||||
"content": "Test message",
|
||||
"sequence": 0,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return SessionMessage(**defaults)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session Creation Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I create a session with a valid ULID")
|
||||
def session_model_create_with_valid_ulid(context: Context) -> None:
|
||||
"""Create a session with a valid ULID."""
|
||||
context.session_model = _make_session()
|
||||
context.session_model_error = None
|
||||
|
||||
|
||||
@when('I create a session with actor name "{actor_name}"')
|
||||
def session_model_create_with_actor(context: Context, actor_name: str) -> None:
|
||||
"""Create a session with a specific actor name."""
|
||||
context.session_model = _make_session(actor_name=actor_name)
|
||||
context.session_model_error = None
|
||||
|
||||
|
||||
@when("I create a session without actor name")
|
||||
def session_model_create_without_actor(context: Context) -> None:
|
||||
"""Create a session without an actor name."""
|
||||
context.session_model = _make_session(actor_name=None)
|
||||
context.session_model_error = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session Validation Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I try to create a session with invalid ID "{session_id}"')
|
||||
def session_model_try_invalid_id(context: Context, session_id: str) -> None:
|
||||
"""Attempt to create a session with an invalid ID."""
|
||||
context.session_model_error = None
|
||||
context.session_model = None
|
||||
try:
|
||||
context.session_model = Session(session_id=session_id)
|
||||
except ValidationError as e:
|
||||
context.session_model_error = e
|
||||
|
||||
|
||||
@when('I try to create a session with invalid actor name "{actor_name}"')
|
||||
def session_model_try_invalid_actor(context: Context, actor_name: str) -> None:
|
||||
"""Attempt to create a session with an invalid actor name."""
|
||||
context.session_model_error = None
|
||||
context.session_model = None
|
||||
try:
|
||||
context.session_model = _make_session(actor_name=actor_name)
|
||||
except ValidationError as e:
|
||||
context.session_model_error = e
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session Assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the session model should be created")
|
||||
def session_model_check_created(context: Context) -> None:
|
||||
"""Verify the session was created."""
|
||||
assert context.session_model is not None, "Session should be created"
|
||||
|
||||
|
||||
@then("the session model session_id should be a valid ULID")
|
||||
def session_model_check_ulid(context: Context) -> None:
|
||||
"""Verify the session_id is a valid ULID."""
|
||||
import re
|
||||
|
||||
pattern = r"^[0-9A-HJKMNP-TV-Z]{26}$"
|
||||
assert re.match(pattern, context.session_model.session_id), (
|
||||
f"session_id '{context.session_model.session_id}' is not a valid ULID"
|
||||
)
|
||||
|
||||
|
||||
@then('the session model actor_name should be "{expected}"')
|
||||
def session_model_check_actor_name(context: Context, expected: str) -> None:
|
||||
"""Check the session actor_name."""
|
||||
assert context.session_model.actor_name == expected, (
|
||||
f"Expected actor_name '{expected}', got '{context.session_model.actor_name}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the session model actor_name should be empty")
|
||||
def session_model_check_actor_name_empty(context: Context) -> None:
|
||||
"""Check the session actor_name is None."""
|
||||
assert context.session_model.actor_name is None, (
|
||||
f"Expected actor_name to be None, got '{context.session_model.actor_name}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the session model namespace should be "{expected}"')
|
||||
def session_model_check_namespace(context: Context, expected: str) -> None:
|
||||
"""Check the session namespace."""
|
||||
assert context.session_model.namespace == expected, (
|
||||
f"Expected namespace '{expected}', got '{context.session_model.namespace}'"
|
||||
)
|
||||
|
||||
|
||||
@then("a session model validation error should be raised")
|
||||
def session_model_check_validation_error(context: Context) -> None:
|
||||
"""Verify that a validation error was raised."""
|
||||
assert context.session_model_error is not None, (
|
||||
"Expected a validation error to be raised"
|
||||
)
|
||||
|
||||
|
||||
@then('the session model error should mention "{text}"')
|
||||
def session_model_check_error_message(context: Context, text: str) -> None:
|
||||
"""Check the error message contains expected text."""
|
||||
error_str = str(context.session_model_error)
|
||||
assert text.lower() in error_str.lower(), (
|
||||
f"Expected error to mention '{text}', got: {error_str}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a session with no messages")
|
||||
def session_model_given_empty_session(context: Context) -> None:
|
||||
"""Create a session with no messages."""
|
||||
context.session_model = _make_session()
|
||||
context.session_model_error = None
|
||||
|
||||
|
||||
@when('I append a user message "{content}"')
|
||||
def session_model_append_user_message(context: Context, content: str) -> None:
|
||||
"""Append a user message to the session."""
|
||||
context.session_model.append_message(MessageRole.USER, content)
|
||||
|
||||
|
||||
@when('I append an assistant message "{content}"')
|
||||
def session_model_append_assistant_message(context: Context, content: str) -> None:
|
||||
"""Append an assistant message to the session."""
|
||||
context.session_model.append_message(MessageRole.ASSISTANT, content)
|
||||
|
||||
|
||||
@then("the session model should have {count:d} message")
|
||||
def session_model_check_message_count_singular(context: Context, count: int) -> None:
|
||||
"""Check message count (singular)."""
|
||||
actual = context.session_model.message_count
|
||||
assert actual == count, f"Expected {count} message(s), got {actual}"
|
||||
|
||||
|
||||
@then("the session model should have {count:d} messages")
|
||||
def session_model_check_message_count_plural(context: Context, count: int) -> None:
|
||||
"""Check message count (plural)."""
|
||||
actual = context.session_model.message_count
|
||||
assert actual == count, f"Expected {count} message(s), got {actual}"
|
||||
|
||||
|
||||
@then('the session last message content should be "{expected}"')
|
||||
def session_model_check_last_message_content(context: Context, expected: str) -> None:
|
||||
"""Check the last message content."""
|
||||
last = context.session_model.last_message
|
||||
assert last is not None, "Expected a last message"
|
||||
assert last.content == expected, (
|
||||
f"Expected content '{expected}', got '{last.content}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the session last message role should be "{expected}"')
|
||||
def session_model_check_last_message_role(context: Context, expected: str) -> None:
|
||||
"""Check the last message role."""
|
||||
last = context.session_model.last_message
|
||||
assert last is not None, "Expected a last message"
|
||||
assert last.role.value == expected, (
|
||||
f"Expected role '{expected}', got '{last.role.value}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the session last message sequence should be {expected:d}")
|
||||
def session_model_check_last_message_sequence(context: Context, expected: int) -> None:
|
||||
"""Check the last message sequence."""
|
||||
last = context.session_model.last_message
|
||||
assert last is not None, "Expected a last message"
|
||||
assert last.sequence == expected, (
|
||||
f"Expected sequence {expected}, got {last.sequence}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool Message Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I try to create a tool message without tool_call_id")
|
||||
def session_model_try_tool_message_no_call_id(context: Context) -> None:
|
||||
"""Attempt to create a tool message without tool_call_id."""
|
||||
context.session_model_error = None
|
||||
try:
|
||||
_make_message(role=MessageRole.TOOL, content="Result", tool_call_id=None)
|
||||
except ValidationError as e:
|
||||
context.session_model_error = e
|
||||
|
||||
|
||||
@when('I create a tool message with tool_call_id "{call_id}"')
|
||||
def session_model_create_tool_message(context: Context, call_id: str) -> None:
|
||||
"""Create a tool message with tool_call_id."""
|
||||
context.session_message = _make_message(
|
||||
role=MessageRole.TOOL, content="Result", tool_call_id=call_id
|
||||
)
|
||||
context.session_model_error = None
|
||||
|
||||
|
||||
@when('I create a user message "{content}"')
|
||||
def session_model_create_user_message(context: Context, content: str) -> None:
|
||||
"""Create a user message."""
|
||||
context.session_message = _make_message(role=MessageRole.USER, content=content)
|
||||
context.session_model_error = None
|
||||
|
||||
|
||||
@then("the session message should be created")
|
||||
def session_model_check_message_created(context: Context) -> None:
|
||||
"""Verify the message was created."""
|
||||
assert context.session_message is not None, "Message should be created"
|
||||
|
||||
|
||||
@then('the session message tool_call_id should be "{expected}"')
|
||||
def session_model_check_message_tool_call_id(context: Context, expected: str) -> None:
|
||||
"""Check the message tool_call_id."""
|
||||
assert context.session_message.tool_call_id == expected, (
|
||||
f"Expected tool_call_id '{expected}', "
|
||||
f"got '{context.session_message.tool_call_id}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content Validation Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I try to create a message with whitespace-only content")
|
||||
def session_model_try_whitespace_content(context: Context) -> None:
|
||||
"""Attempt to create a message with whitespace-only content."""
|
||||
context.session_model_error = None
|
||||
try:
|
||||
_make_message(content=" \t\n ")
|
||||
except ValidationError as e:
|
||||
context.session_model_error = e
|
||||
|
||||
|
||||
@when("I try to create a message with empty content")
|
||||
def session_model_try_empty_content(context: Context) -> None:
|
||||
"""Attempt to create a message with empty content."""
|
||||
context.session_model_error = None
|
||||
try:
|
||||
_make_message(content="")
|
||||
except ValidationError as e:
|
||||
context.session_model_error = e
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message Ordering Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I try to create a session with out-of-order messages")
|
||||
def session_model_try_out_of_order_messages(context: Context) -> None:
|
||||
"""Attempt to create a session with out-of-order messages."""
|
||||
context.session_model_error = None
|
||||
context.session_model = None
|
||||
msg1 = _make_message(sequence=1, content="Second")
|
||||
msg2 = _make_message(sequence=0, content="First")
|
||||
try:
|
||||
context.session_model = _make_session(messages=[msg1, msg2])
|
||||
except ValidationError as e:
|
||||
context.session_model_error = e
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plan Linking Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I link plan "{plan_id}" to the session')
|
||||
def session_model_link_plan(context: Context, plan_id: str) -> None:
|
||||
"""Link a plan to the session."""
|
||||
context.session_model.link_plan(plan_id)
|
||||
|
||||
|
||||
@then("the session should have {count:d} linked plan")
|
||||
def session_model_check_linked_plan_count_singular(
|
||||
context: Context, count: int
|
||||
) -> None:
|
||||
"""Check linked plan count (singular)."""
|
||||
actual = len(context.session_model.linked_plan_ids)
|
||||
assert actual == count, f"Expected {count} linked plan(s), got {actual}"
|
||||
|
||||
|
||||
@then("the session should have {count:d} linked plans")
|
||||
def session_model_check_linked_plan_count_plural(context: Context, count: int) -> None:
|
||||
"""Check linked plan count (plural)."""
|
||||
actual = len(context.session_model.linked_plan_ids)
|
||||
assert actual == count, f"Expected {count} linked plan(s), got {actual}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token Usage Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the session token usage input_tokens should be {expected:d}")
|
||||
def session_model_check_token_input(context: Context, expected: int) -> None:
|
||||
"""Check token usage input_tokens."""
|
||||
actual = context.session_model.token_usage.input_tokens
|
||||
assert actual == expected, f"Expected input_tokens {expected}, got {actual}"
|
||||
|
||||
|
||||
@then("the session token usage output_tokens should be {expected:d}")
|
||||
def session_model_check_token_output(context: Context, expected: int) -> None:
|
||||
"""Check token usage output_tokens."""
|
||||
actual = context.session_model.token_usage.output_tokens
|
||||
assert actual == expected, f"Expected output_tokens {expected}, got {actual}"
|
||||
|
||||
|
||||
@then("the session token usage estimated_cost should be {expected:g}")
|
||||
def session_model_check_token_cost(context: Context, expected: float) -> None:
|
||||
"""Check token usage estimated_cost."""
|
||||
actual = context.session_model.token_usage.estimated_cost
|
||||
assert abs(actual - expected) < 1e-9, (
|
||||
f"Expected estimated_cost {expected}, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
"I create a session with token usage {input_t:d} input"
|
||||
" {output_t:d} output {cost:g} cost"
|
||||
)
|
||||
def session_model_create_with_token_usage(
|
||||
context: Context, input_t: int, output_t: int, cost: float
|
||||
) -> None:
|
||||
"""Create a session with specific token usage."""
|
||||
context.session_model = _make_session(
|
||||
token_usage=SessionTokenUsage(
|
||||
input_tokens=input_t,
|
||||
output_tokens=output_t,
|
||||
estimated_cost=cost,
|
||||
)
|
||||
)
|
||||
context.session_model_error = None
|
||||
|
||||
|
||||
@when("I try to create token usage with negative input tokens")
|
||||
def session_model_try_negative_tokens(context: Context) -> None:
|
||||
"""Attempt to create token usage with negative input tokens."""
|
||||
context.session_model_error = None
|
||||
try:
|
||||
SessionTokenUsage(input_tokens=-1)
|
||||
except ValidationError as e:
|
||||
context.session_model_error = e
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export Dict Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a session with some messages")
|
||||
def session_model_given_session_with_messages(context: Context) -> None:
|
||||
"""Create a session with some messages."""
|
||||
context.session_model = _make_session()
|
||||
context.session_model.append_message(MessageRole.USER, "Hello")
|
||||
context.session_model.append_message(MessageRole.ASSISTANT, "Hi there")
|
||||
context.session_model_error = None
|
||||
|
||||
|
||||
@when("I export the session")
|
||||
def session_model_export(context: Context) -> None:
|
||||
"""Export the session to a dict."""
|
||||
context.session_export_dict = context.session_model.as_export_dict()
|
||||
|
||||
|
||||
@then('the export dict should have key "{key}"')
|
||||
def session_model_check_export_key(context: Context, key: str) -> None:
|
||||
"""Check the export dict has a specific key."""
|
||||
assert key in context.session_export_dict, (
|
||||
f"Expected key '{key}' in export dict, "
|
||||
f"keys: {list(context.session_export_dict)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the export dict messages count should match session message count")
|
||||
def session_model_check_export_messages_count(context: Context) -> None:
|
||||
"""Check export dict messages count matches session."""
|
||||
export_count = len(context.session_export_dict["messages"])
|
||||
session_count = context.session_model.message_count
|
||||
assert export_count == session_count, (
|
||||
f"Export has {export_count} messages, session has {session_count}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Dict Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I get the session CLI dict")
|
||||
def session_model_get_cli_dict(context: Context) -> None:
|
||||
"""Get the CLI dict for the session."""
|
||||
context.session_cli_dict = context.session_model.as_cli_dict()
|
||||
|
||||
|
||||
@then('the session cli dict should have key "{key}"')
|
||||
def session_model_check_cli_dict_key(context: Context, key: str) -> None:
|
||||
"""Check the CLI dict has a specific key."""
|
||||
assert key in context.session_cli_dict, (
|
||||
f"Expected key '{key}' in cli dict, keys: {list(context.session_cli_dict)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Automation Level Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a session with automation level "{level}"')
|
||||
def session_model_given_with_automation(context: Context, level: str) -> None:
|
||||
"""Create a session with a specific automation level."""
|
||||
context.session_model = _make_session(automation_level=level)
|
||||
context.session_model.append_message(MessageRole.USER, "Test")
|
||||
context.session_model_error = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Empty Session Property Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the session message count should be {expected:d}")
|
||||
def session_model_check_msg_count_property(context: Context, expected: int) -> None:
|
||||
"""Check message_count property."""
|
||||
actual = context.session_model.message_count
|
||||
assert actual == expected, f"Expected {expected}, got {actual}"
|
||||
|
||||
|
||||
@then("the session is_empty should be true")
|
||||
def session_model_check_is_empty_true(context: Context) -> None:
|
||||
"""Check is_empty is True."""
|
||||
assert context.session_model.is_empty is True, "Expected session to be empty"
|
||||
|
||||
|
||||
@then("the session last_message should be none")
|
||||
def session_model_check_last_message_none(context: Context) -> None:
|
||||
"""Check last_message is None."""
|
||||
assert context.session_model.last_message is None, (
|
||||
"Expected last_message to be None"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_messages Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a session with {count:d} messages")
|
||||
def session_model_given_n_messages(context: Context, count: int) -> None:
|
||||
"""Create a session with N messages."""
|
||||
context.session_model = _make_session()
|
||||
for i in range(count):
|
||||
context.session_model.append_message(MessageRole.USER, f"Message {i}")
|
||||
context.session_model_error = None
|
||||
|
||||
|
||||
@when("I get messages with no limit")
|
||||
def session_model_get_messages_no_limit(context: Context) -> None:
|
||||
"""Get all messages."""
|
||||
context.session_messages_result = context.session_model.get_messages()
|
||||
|
||||
|
||||
@when("I get messages with limit {limit:d}")
|
||||
def session_model_get_messages_with_limit(context: Context, limit: int) -> None:
|
||||
"""Get messages with a limit."""
|
||||
context.session_messages_result = context.session_model.get_messages(limit=limit)
|
||||
|
||||
|
||||
@when("I get messages with offset {offset:d}")
|
||||
def session_model_get_messages_with_offset(context: Context, offset: int) -> None:
|
||||
"""Get messages with an offset."""
|
||||
context.session_messages_result = context.session_model.get_messages(offset=offset)
|
||||
|
||||
|
||||
@when("I get messages with limit {limit:d} and offset {offset:d}")
|
||||
def session_model_get_messages_with_limit_offset(
|
||||
context: Context, limit: int, offset: int
|
||||
) -> None:
|
||||
"""Get messages with limit and offset."""
|
||||
context.session_messages_result = context.session_model.get_messages(
|
||||
limit=limit, offset=offset
|
||||
)
|
||||
|
||||
|
||||
@then("I should get {count:d} messages")
|
||||
def session_model_check_messages_result_count(context: Context, count: int) -> None:
|
||||
"""Check the number of messages returned."""
|
||||
actual = len(context.session_messages_result)
|
||||
assert actual == count, f"Expected {count} messages, got {actual}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error Type Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("SessionServiceError should be an Exception subclass")
|
||||
def session_model_check_service_error_base(context: Context) -> None:
|
||||
"""Verify SessionServiceError inherits from Exception."""
|
||||
assert issubclass(SessionServiceError, Exception)
|
||||
|
||||
|
||||
@then("SessionNotFoundError should be a SessionServiceError subclass")
|
||||
def session_model_check_not_found_error(context: Context) -> None:
|
||||
"""Verify SessionNotFoundError inherits from SessionServiceError."""
|
||||
assert issubclass(SessionNotFoundError, SessionServiceError)
|
||||
|
||||
|
||||
@then("SessionExportError should be a SessionServiceError subclass")
|
||||
def session_model_check_export_error(context: Context) -> None:
|
||||
"""Verify SessionExportError inherits from SessionServiceError."""
|
||||
assert issubclass(SessionExportError, SessionServiceError)
|
||||
|
||||
|
||||
@then("SessionImportError should be a SessionServiceError subclass")
|
||||
def session_model_check_import_error(context: Context) -> None:
|
||||
"""Verify SessionImportError inherits from SessionServiceError."""
|
||||
assert issubclass(SessionImportError, SessionServiceError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MessageRole Enum Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the MessageRole enum should have values "{values}"')
|
||||
def session_model_check_message_role_enum(context: Context, values: str) -> None:
|
||||
"""Verify MessageRole enum values."""
|
||||
expected = set(values.split(","))
|
||||
actual = {e.value for e in MessageRole}
|
||||
assert actual == expected, f"Expected {expected}, got {actual}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metadata Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I create a session with metadata")
|
||||
def session_model_create_with_metadata(context: Context) -> None:
|
||||
"""Create a session with metadata."""
|
||||
context.session_model = _make_session(metadata={"env": "test", "version": "1.0"})
|
||||
context.session_model_error = None
|
||||
|
||||
|
||||
@then('the session metadata should contain key "{key}"')
|
||||
def session_model_check_metadata_key(context: Context, key: str) -> None:
|
||||
"""Check metadata contains a key."""
|
||||
assert key in context.session_model.metadata, (
|
||||
f"Expected key '{key}' in metadata, "
|
||||
f"keys: {list(context.session_model.metadata)}"
|
||||
)
|
||||
@@ -69,6 +69,19 @@ from cleveragents.domain.models.core.resource import (
|
||||
SandboxStrategy,
|
||||
)
|
||||
|
||||
# Session domain model
|
||||
from cleveragents.domain.models.core.session import (
|
||||
MessageRole,
|
||||
Session,
|
||||
SessionExportError,
|
||||
SessionImportError,
|
||||
SessionMessage,
|
||||
SessionNotFoundError,
|
||||
SessionService,
|
||||
SessionServiceError,
|
||||
SessionTokenUsage,
|
||||
)
|
||||
|
||||
# Tool and Validation domain models
|
||||
from cleveragents.domain.models.core.tool import (
|
||||
BindingMode,
|
||||
@@ -106,6 +119,7 @@ __all__ = [
|
||||
"LifecyclePlan",
|
||||
"LinkedResource",
|
||||
"MaxContextCount",
|
||||
"MessageRole",
|
||||
"NamespacedName",
|
||||
"NamespacedProject",
|
||||
"Operation",
|
||||
@@ -133,6 +147,14 @@ __all__ = [
|
||||
"ResourceCapabilities",
|
||||
"ResourceSlot",
|
||||
"SandboxStrategy",
|
||||
"Session",
|
||||
"SessionExportError",
|
||||
"SessionImportError",
|
||||
"SessionMessage",
|
||||
"SessionNotFoundError",
|
||||
"SessionService",
|
||||
"SessionServiceError",
|
||||
"SessionTokenUsage",
|
||||
"SummaryForUpdateContextParams",
|
||||
"TemporalScope",
|
||||
"Tool",
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Session domain model for CleverAgents.
|
||||
|
||||
A Session is a persistent conversation thread tied to an orchestrator actor.
|
||||
It maintains message history across plans and serves as the user's
|
||||
natural-language interface.
|
||||
|
||||
Sessions are managed via CLI:
|
||||
agents session create [--actor <ACTOR>]
|
||||
agents session list
|
||||
agents session show <SESSION_ID>
|
||||
agents session delete <SESSION_ID>
|
||||
agents session export <SESSION_ID>
|
||||
agents session import --input <FILE>
|
||||
agents session tell --session <SESSION_ID> <PROMPT>
|
||||
|
||||
Based on docs/specification.md and ADR-004 (Pydantic Validation).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from ulid import ULID
|
||||
|
||||
# ULID is 26 characters, Crockford's base32
|
||||
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
|
||||
_ULID_RE = re.compile(ULID_PATTERN)
|
||||
|
||||
# Actor name pattern: namespace/name (both lowercase alphanumeric with hyphens)
|
||||
_ACTOR_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*/[a-z0-9][a-z0-9_-]*$")
|
||||
|
||||
# Export schema version
|
||||
EXPORT_SCHEMA_VERSION = "1.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MessageRole(StrEnum):
|
||||
"""Role of a message in a session conversation.
|
||||
|
||||
Follows standard chat-model conventions:
|
||||
- ``user``: Human input
|
||||
- ``assistant``: AI/agent response
|
||||
- ``system``: System-level instructions
|
||||
- ``tool``: Tool invocation result
|
||||
"""
|
||||
|
||||
USER = "user"
|
||||
ASSISTANT = "assistant"
|
||||
SYSTEM = "system"
|
||||
TOOL = "tool"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SessionMessage(BaseModel):
|
||||
"""A single message within a session conversation.
|
||||
|
||||
Messages are ordered by ``sequence`` within their session. Each message
|
||||
has a unique ULID identifier, a role, and content.
|
||||
"""
|
||||
|
||||
message_id: str = Field(
|
||||
...,
|
||||
description="Unique ULID identifier for this message",
|
||||
pattern=ULID_PATTERN,
|
||||
)
|
||||
role: MessageRole = Field(..., description="Role of the message sender")
|
||||
content: str = Field(
|
||||
..., min_length=1, description="Message content (must not be whitespace-only)"
|
||||
)
|
||||
sequence: int = Field(..., ge=0, description="Ordering index within the session")
|
||||
timestamp: datetime = Field(
|
||||
default_factory=datetime.now, description="When the message was created"
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Optional metadata for the message"
|
||||
)
|
||||
tool_call_id: str | None = Field(
|
||||
default=None,
|
||||
description="Tool call identifier (required when role is 'tool')",
|
||||
)
|
||||
|
||||
@field_validator("content", mode="before")
|
||||
@classmethod
|
||||
def validate_content_not_whitespace(cls: type[SessionMessage], v: str) -> str:
|
||||
"""Reject whitespace-only content before strip_whitespace runs."""
|
||||
if isinstance(v, str) and not v.strip():
|
||||
raise ValueError("Message content must not be whitespace-only")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_tool_call_id(self) -> SessionMessage:
|
||||
"""Ensure tool_call_id is present when role is tool."""
|
||||
if self.role == MessageRole.TOOL and not self.tool_call_id:
|
||||
raise ValueError("tool_call_id is required when message role is 'tool'")
|
||||
return self
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionTokenUsage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SessionTokenUsage(BaseModel):
|
||||
"""Token usage tracking for a session.
|
||||
|
||||
Accumulates input/output token counts and estimated cost across all
|
||||
messages and plan executions within the session.
|
||||
"""
|
||||
|
||||
input_tokens: int = Field(
|
||||
default=0, ge=0, description="Total input tokens consumed"
|
||||
)
|
||||
output_tokens: int = Field(
|
||||
default=0, ge=0, description="Total output tokens generated"
|
||||
)
|
||||
estimated_cost: float = Field(
|
||||
default=0.0, ge=0.0, description="Estimated total cost (USD)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Session(BaseModel):
|
||||
"""Domain model for a Session.
|
||||
|
||||
A Session is a persistent conversation thread tied to an orchestrator
|
||||
actor. It maintains message history across plans and serves as the
|
||||
user's natural-language interface.
|
||||
|
||||
Sessions are identified by a ULID and may optionally be bound to a
|
||||
specific actor via ``actor_name``.
|
||||
"""
|
||||
|
||||
session_id: str = Field(
|
||||
...,
|
||||
description="Unique ULID identifier for this session",
|
||||
pattern=ULID_PATTERN,
|
||||
)
|
||||
actor_name: str | None = Field(
|
||||
default=None,
|
||||
description="Namespaced actor reference (namespace/name format)",
|
||||
)
|
||||
namespace: str = Field(
|
||||
default="local",
|
||||
description="Namespace for session ownership",
|
||||
)
|
||||
messages: list[SessionMessage] = Field(
|
||||
default_factory=list,
|
||||
description="Ordered list of messages in this session",
|
||||
)
|
||||
linked_plan_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="ULID references to linked plans",
|
||||
)
|
||||
automation_level: str | None = Field(
|
||||
default=None,
|
||||
description="Session-level automation override",
|
||||
)
|
||||
token_usage: SessionTokenUsage = Field(
|
||||
default_factory=SessionTokenUsage,
|
||||
description="Accumulated token usage for this session",
|
||||
)
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.now,
|
||||
description="When the session was created",
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.now,
|
||||
description="When the session was last modified",
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Arbitrary session metadata",
|
||||
)
|
||||
|
||||
# -- Validators ---------------------------------------------------------
|
||||
|
||||
@field_validator("actor_name")
|
||||
@classmethod
|
||||
def validate_actor_name(cls: type[Session], v: str | None) -> str | None:
|
||||
"""Validate actor name matches namespace/name pattern."""
|
||||
if v is None:
|
||||
return None
|
||||
if not _ACTOR_NAME_PATTERN.match(v):
|
||||
raise ValueError(
|
||||
"actor_name must match 'namespace/name' pattern "
|
||||
"(lowercase alphanumeric with hyphens/underscores)"
|
||||
)
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_message_ordering(self) -> Session:
|
||||
"""Ensure messages are ordered by sequence."""
|
||||
if len(self.messages) > 1:
|
||||
sequences = [m.sequence for m in self.messages]
|
||||
if sequences != sorted(sequences):
|
||||
raise ValueError("Messages must be ordered by sequence")
|
||||
return self
|
||||
|
||||
# -- Properties ---------------------------------------------------------
|
||||
|
||||
@property
|
||||
def message_count(self) -> int:
|
||||
"""Return the number of messages in this session."""
|
||||
return len(self.messages)
|
||||
|
||||
@property
|
||||
def last_message(self) -> SessionMessage | None:
|
||||
"""Return the most recent message, or None if empty."""
|
||||
if not self.messages:
|
||||
return None
|
||||
return self.messages[-1]
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""Return True if the session has no messages."""
|
||||
return len(self.messages) == 0
|
||||
|
||||
# -- Methods ------------------------------------------------------------
|
||||
|
||||
def append_message(
|
||||
self,
|
||||
role: MessageRole,
|
||||
content: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
) -> SessionMessage:
|
||||
"""Append a new message to the session.
|
||||
|
||||
Auto-generates a ULID for the message, sets the sequence number
|
||||
based on existing messages, and updates the session timestamp.
|
||||
|
||||
Args:
|
||||
role: The message role.
|
||||
content: The message content.
|
||||
metadata: Optional metadata dict.
|
||||
tool_call_id: Required when role is 'tool'.
|
||||
|
||||
Returns:
|
||||
The newly created SessionMessage.
|
||||
"""
|
||||
next_sequence = self.messages[-1].sequence + 1 if self.messages else 0
|
||||
message = SessionMessage(
|
||||
message_id=str(ULID()),
|
||||
role=role,
|
||||
content=content,
|
||||
sequence=next_sequence,
|
||||
timestamp=datetime.now(),
|
||||
metadata=metadata or {},
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
self.messages.append(message)
|
||||
self.updated_at = datetime.now()
|
||||
return message
|
||||
|
||||
def get_messages(
|
||||
self, limit: int | None = None, offset: int = 0
|
||||
) -> list[SessionMessage]:
|
||||
"""Return messages with optional pagination.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of messages to return (None = all).
|
||||
offset: Number of messages to skip from the beginning.
|
||||
|
||||
Returns:
|
||||
A slice of the messages list.
|
||||
"""
|
||||
if limit is None:
|
||||
return list(self.messages[offset:])
|
||||
return list(self.messages[offset : offset + limit])
|
||||
|
||||
def link_plan(self, plan_id: str) -> None:
|
||||
"""Link a plan to this session (deduplicated).
|
||||
|
||||
Args:
|
||||
plan_id: The ULID of the plan to link.
|
||||
"""
|
||||
if plan_id not in self.linked_plan_ids:
|
||||
self.linked_plan_ids.append(plan_id)
|
||||
|
||||
def as_cli_dict(self) -> OrderedDict[str, Any]:
|
||||
"""Return a stable-ordered dictionary for CLI rendering.
|
||||
|
||||
Matches the output fields from ``agents session show``:
|
||||
ID, Actor, Messages count, Created/Updated, Automation level,
|
||||
Recent Messages, Linked Plans, Token Usage.
|
||||
"""
|
||||
result: OrderedDict[str, Any] = OrderedDict()
|
||||
result["session_id"] = self.session_id
|
||||
if self.actor_name:
|
||||
result["actor_name"] = self.actor_name
|
||||
result["namespace"] = self.namespace
|
||||
result["message_count"] = self.message_count
|
||||
result["created_at"] = self.created_at.isoformat()
|
||||
result["updated_at"] = self.updated_at.isoformat()
|
||||
if self.automation_level:
|
||||
result["automation_level"] = self.automation_level
|
||||
if self.messages:
|
||||
recent = self.messages[-5:]
|
||||
result["recent_messages"] = [
|
||||
{"role": m.role.value, "content": m.content} for m in recent
|
||||
]
|
||||
if self.linked_plan_ids:
|
||||
result["linked_plan_ids"] = list(self.linked_plan_ids)
|
||||
result["token_usage"] = {
|
||||
"input_tokens": self.token_usage.input_tokens,
|
||||
"output_tokens": self.token_usage.output_tokens,
|
||||
"estimated_cost": self.token_usage.estimated_cost,
|
||||
}
|
||||
if self.metadata:
|
||||
result["metadata"] = dict(self.metadata)
|
||||
return result
|
||||
|
||||
def as_export_dict(self) -> dict[str, Any]:
|
||||
"""Return a JSON-serializable dict for session export.
|
||||
|
||||
Includes messages, plan references, metadata, actor config,
|
||||
schema version, and a checksum for integrity verification.
|
||||
"""
|
||||
messages_data = [
|
||||
{
|
||||
"message_id": m.message_id,
|
||||
"role": m.role.value,
|
||||
"content": m.content,
|
||||
"sequence": m.sequence,
|
||||
"timestamp": m.timestamp.isoformat(),
|
||||
"metadata": m.metadata,
|
||||
"tool_call_id": m.tool_call_id,
|
||||
}
|
||||
for m in self.messages
|
||||
]
|
||||
export: dict[str, Any] = {
|
||||
"schema_version": EXPORT_SCHEMA_VERSION,
|
||||
"session_id": self.session_id,
|
||||
"actor_name": self.actor_name,
|
||||
"namespace": self.namespace,
|
||||
"messages": messages_data,
|
||||
"linked_plan_ids": list(self.linked_plan_ids),
|
||||
"automation_level": self.automation_level,
|
||||
"token_usage": {
|
||||
"input_tokens": self.token_usage.input_tokens,
|
||||
"output_tokens": self.token_usage.output_tokens,
|
||||
"estimated_cost": self.token_usage.estimated_cost,
|
||||
},
|
||||
"metadata": dict(self.metadata),
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
# Compute checksum over the canonical JSON (excluding checksum itself)
|
||||
canonical = json.dumps(export, sort_keys=True, default=str)
|
||||
export["checksum"] = hashlib.sha256(canonical.encode()).hexdigest()
|
||||
return export
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
use_enum_values=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SessionServiceError(Exception):
|
||||
"""Base error for session service operations."""
|
||||
|
||||
|
||||
class SessionNotFoundError(SessionServiceError):
|
||||
"""Raised when a session ID is not found."""
|
||||
|
||||
|
||||
class SessionExportError(SessionServiceError):
|
||||
"""Raised when session export fails."""
|
||||
|
||||
|
||||
class SessionImportError(SessionServiceError):
|
||||
"""Raised when session import fails (schema mismatch, corrupt data)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service Contract (Abstract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SessionService(ABC):
|
||||
"""Abstract interface for session management.
|
||||
|
||||
Implementations handle persistence, ULID generation, and lifecycle
|
||||
management for Session objects.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create(self, actor_name: str | None = None) -> Session:
|
||||
"""Create a new session.
|
||||
|
||||
Args:
|
||||
actor_name: Optional namespaced actor to bind to the session.
|
||||
|
||||
Returns:
|
||||
A newly created Session with a fresh ULID.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, session_id: str) -> Session:
|
||||
"""Retrieve a session by ID.
|
||||
|
||||
Args:
|
||||
session_id: The ULID of the session to retrieve.
|
||||
|
||||
Returns:
|
||||
The Session with the given ID.
|
||||
|
||||
Raises:
|
||||
SessionNotFoundError: If no session with the given ID exists.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def list(self) -> list[Session]:
|
||||
"""List all sessions.
|
||||
|
||||
Returns:
|
||||
A list of all sessions, ordered by creation time.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, session_id: str) -> None:
|
||||
"""Delete a session by ID.
|
||||
|
||||
Args:
|
||||
session_id: The ULID of the session to delete.
|
||||
|
||||
Raises:
|
||||
SessionNotFoundError: If no session with the given ID exists.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def append_message(
|
||||
self,
|
||||
session_id: str,
|
||||
role: MessageRole,
|
||||
content: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> SessionMessage:
|
||||
"""Append a message to a session.
|
||||
|
||||
Args:
|
||||
session_id: The ULID of the target session.
|
||||
role: The message role.
|
||||
content: The message content.
|
||||
metadata: Optional metadata dict.
|
||||
|
||||
Returns:
|
||||
The newly created SessionMessage.
|
||||
|
||||
Raises:
|
||||
SessionNotFoundError: If no session with the given ID exists.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def export_session(self, session_id: str) -> dict[str, Any]:
|
||||
"""Export a session as a JSON-serializable dict.
|
||||
|
||||
Args:
|
||||
session_id: The ULID of the session to export.
|
||||
|
||||
Returns:
|
||||
A dict containing the full session data with checksum.
|
||||
|
||||
Raises:
|
||||
SessionNotFoundError: If no session with the given ID exists.
|
||||
SessionExportError: If export fails.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def import_session(self, data: dict[str, Any]) -> Session:
|
||||
"""Import a session from an export dict.
|
||||
|
||||
Args:
|
||||
data: A dict previously produced by export_session.
|
||||
|
||||
Returns:
|
||||
The imported Session object.
|
||||
|
||||
Raises:
|
||||
SessionImportError: If import fails (bad schema, corrupt data).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def update_token_usage(
|
||||
self,
|
||||
session_id: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
cost: float,
|
||||
) -> None:
|
||||
"""Update token usage counters for a session.
|
||||
|
||||
Args:
|
||||
session_id: The ULID of the session to update.
|
||||
input_tokens: Number of input tokens to add.
|
||||
output_tokens: Number of output tokens to add.
|
||||
cost: Estimated cost to add (USD).
|
||||
|
||||
Raises:
|
||||
SessionNotFoundError: If no session with the given ID exists.
|
||||
"""
|
||||
Reference in New Issue
Block a user