mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
refactor(protocols): update protocol client implementations
This commit is contained in:
@@ -7,7 +7,6 @@ connecting to internal stars (plugins) embedded in the runtime.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from astrbot import logger
|
||||
@@ -27,7 +26,8 @@ class AstrbotAbpClient(BaseAstrbotAbpClient):
|
||||
def __init__(self) -> None:
|
||||
self._connected = False
|
||||
self._stars: dict[str, Any] = {}
|
||||
self._pending_requests: dict[str, asyncio.Future[Any]] = {}
|
||||
# Use a simple dict for pending requests; we avoid asyncio.Future here.
|
||||
self._pending_requests: dict[str, Any] = {}
|
||||
self._request_id = 0
|
||||
|
||||
@property
|
||||
@@ -65,8 +65,8 @@ class AstrbotAbpClient(BaseAstrbotAbpClient):
|
||||
request_id = f"{self._request_id}"
|
||||
self._request_id += 1
|
||||
|
||||
future: asyncio.Future[Any] = asyncio.Future()
|
||||
self._pending_requests[request_id] = future
|
||||
# No asyncio.Future used; store a placeholder entry for tracking if needed.
|
||||
self._pending_requests[request_id] = None
|
||||
|
||||
try:
|
||||
# Call the star's tool handler
|
||||
@@ -88,9 +88,6 @@ class AstrbotAbpClient(BaseAstrbotAbpClient):
|
||||
async def shutdown(self) -> None:
|
||||
"""Shutdown the ABP client connection."""
|
||||
self._connected = False
|
||||
# Cancel any pending requests
|
||||
for future in self._pending_requests.values():
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
# Clear any pending requests (no asyncio futures used in this implementation)
|
||||
self._pending_requests.clear()
|
||||
log.info("ABP client shut down.")
|
||||
|
||||
@@ -7,7 +7,6 @@ that provide language intelligence features (completions, diagnostics, etc.).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
@@ -36,7 +35,8 @@ class AstrbotLspClient(BaseAstrbotLspClient):
|
||||
self._pending_requests: dict[int, Any] = {}
|
||||
self._request_id = 0
|
||||
self._server_command: list[str] | None = None
|
||||
self._reader_task: asyncio.Task | None = None
|
||||
# anyio TaskGroup handle for background readers
|
||||
self._task_group: Any | None = None
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
@@ -77,8 +77,11 @@ class AstrbotLspClient(BaseAstrbotLspClient):
|
||||
self._server_command = command
|
||||
self._connected = True
|
||||
|
||||
# Start reading responses in background
|
||||
self._reader_task = asyncio.create_task(self._read_responses())
|
||||
# Start reading responses in background using anyio TaskGroup
|
||||
# Create and enter a TaskGroup so the reader runs until we close it at shutdown.
|
||||
self._task_group = anyio.create_task_group()
|
||||
await self._task_group.__aenter__()
|
||||
self._task_group.start_soon(self._read_responses)
|
||||
|
||||
# Send initialize request
|
||||
await self.send_request(
|
||||
@@ -201,7 +204,8 @@ class AstrbotLspClient(BaseAstrbotLspClient):
|
||||
|
||||
except anyio.EndOfStream:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
except anyio.get_cancelled_exc_class():
|
||||
# Task was cancelled via the TaskGroup cancel/exit during shutdown
|
||||
pass
|
||||
|
||||
async def _handle_notification(self, notification: dict[str, Any]) -> None:
|
||||
@@ -213,13 +217,13 @@ class AstrbotLspClient(BaseAstrbotLspClient):
|
||||
"""Shutdown the LSP client."""
|
||||
self._connected = False
|
||||
|
||||
if self._reader_task:
|
||||
self._reader_task.cancel()
|
||||
if self._task_group:
|
||||
try:
|
||||
await self._reader_task
|
||||
except asyncio.CancelledError:
|
||||
# Exit the TaskGroup, which cancels background tasks started within it
|
||||
await self._task_group.__aexit__(None, None, None)
|
||||
except anyio.get_cancelled_exc_class():
|
||||
pass
|
||||
self._reader_task = None
|
||||
self._task_group = None
|
||||
|
||||
if self._server_process:
|
||||
try:
|
||||
|
||||
@@ -16,7 +16,6 @@ from tenacity import (
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot._internal.abc.mcp.base_astrbot_mcp_client import (
|
||||
BaseAstrbotMcpClient,
|
||||
McpServerConfig,
|
||||
@@ -24,7 +23,8 @@ from astrbot._internal.abc.mcp.base_astrbot_mcp_client import (
|
||||
)
|
||||
from astrbot.core.utils.log_pipe import LogPipe
|
||||
|
||||
log = logger
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
try:
|
||||
import anyio
|
||||
@@ -44,6 +44,26 @@ except (ModuleNotFoundError, ImportError):
|
||||
)
|
||||
|
||||
|
||||
class TenacityLogger:
|
||||
"""Wraps a logging.Logger to satisfy tenacity's LoggerProtocol."""
|
||||
|
||||
__slots__ = ("_logger",)
|
||||
_logger: logging.Logger
|
||||
|
||||
def __init__(self, logger: logging.Logger) -> None:
|
||||
self._logger = logger
|
||||
|
||||
def log(
|
||||
self,
|
||||
level: int,
|
||||
msg: str,
|
||||
/,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._logger.log(level, msg, *args, **kwargs)
|
||||
|
||||
|
||||
def _prepare_config(config: dict) -> dict:
|
||||
"""Prepare configuration, handle nested format."""
|
||||
if config.get("mcpServers"):
|
||||
@@ -159,7 +179,7 @@ class McpClient(BaseAstrbotMcpClient):
|
||||
"""
|
||||
# MCP client is initialized on-demand via connect_to_server
|
||||
# This is a no-op stub to satisfy BaseAstrbotMcpClient
|
||||
log.debug("MCP client initialized.")
|
||||
logger.debug("MCP client initialized.")
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
@@ -424,7 +444,7 @@ class McpClient(BaseAstrbotMcpClient):
|
||||
retry=retry_if_exception_type(anyio.ClosedResourceError),
|
||||
stop=stop_after_attempt(2),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=3),
|
||||
before_sleep=cast(Any, before_sleep_log(logger, logging.WARNING)),
|
||||
before_sleep=before_sleep_log(TenacityLogger(logger), logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
async def _call_with_retry():
|
||||
|
||||
@@ -11,7 +11,6 @@ from typing import Any
|
||||
|
||||
import anyio
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot._internal.abc.base_astrbot_orchestrator import BaseAstrbotOrchestrator
|
||||
from astrbot._internal.protocols.abp.client import AstrbotAbpClient
|
||||
from astrbot._internal.protocols.acp.client import AstrbotAcpClient
|
||||
@@ -19,8 +18,6 @@ from astrbot._internal.protocols.lsp.client import AstrbotLspClient
|
||||
from astrbot._internal.protocols.mcp.client import McpClient
|
||||
from astrbot._internal.stars import RuntimeStatusStar
|
||||
|
||||
log = logger
|
||||
|
||||
|
||||
class AstrbotOrchestrator(BaseAstrbotOrchestrator):
|
||||
"""
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"""AstrBot Development Mode .
|
||||
|
||||
核心运行时测试.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import anyio
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
def dev() -> None:
|
||||
"""启动开发模式."""
|
||||
from astrbot._internal.runtime import bootstrap
|
||||
|
||||
try:
|
||||
anyio.run(bootstrap, backend="asyncio")
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
@@ -21,7 +21,7 @@ import sys
|
||||
import warnings
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import timedelta
|
||||
from typing import Generic
|
||||
from typing import Any, Generic
|
||||
|
||||
from tenacity import (
|
||||
before_sleep_log,
|
||||
@@ -31,13 +31,14 @@ from tenacity import (
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.utils.log_pipe import LogPipe
|
||||
|
||||
from .run_context import TContext
|
||||
from .tool import FunctionTool
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
warnings.warn(
|
||||
"astrbot.core.agent.mcp_client has been moved to astrbot._internal.mcp. "
|
||||
"Please update your imports.",
|
||||
@@ -61,6 +62,26 @@ except (ModuleNotFoundError, ImportError):
|
||||
)
|
||||
|
||||
|
||||
class TenacityLogger:
|
||||
"""Wraps a logging.Logger to satisfy tenacity's LoggerProtocol."""
|
||||
|
||||
__slots__ = ("_logger",)
|
||||
_logger: logging.Logger
|
||||
|
||||
def __init__(self, logger: logging.Logger) -> None:
|
||||
self._logger = logger
|
||||
|
||||
def log(
|
||||
self,
|
||||
level: int,
|
||||
msg: str,
|
||||
/,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._logger.log(level, msg, *args, **kwargs)
|
||||
|
||||
|
||||
def _prepare_config(config: dict) -> dict:
|
||||
"""Prepare configuration, handle nested format"""
|
||||
if config.get("mcpServers"):
|
||||
@@ -395,7 +416,7 @@ class MCPClient:
|
||||
retry=retry_if_exception_type(anyio.ClosedResourceError),
|
||||
stop=stop_after_attempt(2),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=3),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
before_sleep=before_sleep_log(TenacityLogger(logger), logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
async def _call_with_retry():
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-24
|
||||
@@ -0,0 +1,3 @@
|
||||
# rust-core-runtime-migration
|
||||
|
||||
将核心运行时从 Python 迁移到 Rust
|
||||
@@ -0,0 +1,188 @@
|
||||
## Context
|
||||
|
||||
AstrBot's core runtime is currently implemented in Python. While Python provides flexibility and rapid development, performance-critical components (orchestration, protocol management, message processing) would benefit from Rust's:
|
||||
- Memory safety without garbage collection
|
||||
- Zero-cost abstractions
|
||||
- Native performance for concurrent operations
|
||||
- Strong type safety at compile time
|
||||
|
||||
The Rust implementation provides a high-performance foundation that can be exposed to Python via pyo3 bindings.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Create a `astrbot-core` Rust crate with core runtime components
|
||||
- Implement thread-safe Orchestrator using RwLock
|
||||
- Define ProtocolClient trait for LSP, MCP, ACP, ABP clients
|
||||
- Provide TOML-based configuration management
|
||||
- Expose Python bindings via pyo3
|
||||
- CLI binary using clap
|
||||
|
||||
**Non-Goals:**
|
||||
- Not replacing the Python implementation immediately (coexistence)
|
||||
- Not implementing anyio (uses native Rust async/tokio)
|
||||
- Not creating a full ABP protocol implementation in Rust
|
||||
- Not implementing platform adapters or message pipeline
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Architecture: Stub with Python Integration
|
||||
|
||||
The initial Rust implementation is a **stub** that provides:
|
||||
- Structural definitions matching the expected interfaces
|
||||
- Thread-safe state management (RwLock)
|
||||
- Python bindings verification via pyo3
|
||||
|
||||
This allows:
|
||||
- Validating the pyo3 integration works
|
||||
- Ensuring clippy pedantic compliance
|
||||
- Establishing the project structure
|
||||
|
||||
### 2. Concurrency Model: RwLock for Thread Safety
|
||||
|
||||
```rust
|
||||
pub struct Orchestrator {
|
||||
running: RwLock<bool>,
|
||||
stars: RwLock<HashMap<String, String>>,
|
||||
protocol_lsp: RwLock<ProtocolStatus>,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Using `RwLock` allows:
|
||||
- Multiple readers concurrently (most operations are reads)
|
||||
- Exclusive writer (state changes)
|
||||
- No deadlocks (standard read-write lock pattern)
|
||||
|
||||
### 3. Error Handling: thiserror for Ergonomic Errors
|
||||
|
||||
```rust
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AstrBotError {
|
||||
#[error("Not connected: {0}")]
|
||||
NotConnected(String),
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Using `thiserror` provides:
|
||||
- Compile-time error message generation
|
||||
- `?` operator compatibility
|
||||
- Debug output for development
|
||||
|
||||
### 4. Python Bindings: GILOnceCell Singleton
|
||||
|
||||
```rust
|
||||
static ORCHESTRATOR: GILOnceCell<Py<PythonOrchestrator>> = GILOnceCell::new();
|
||||
|
||||
#[pyfunction]
|
||||
pub fn get_orchestrator(py: Python<'_>) -> PyResult<&'static Py<PythonOrchestrator>> {
|
||||
if ORCHESTRATOR.get(py).is_none() {
|
||||
ORCHESTRATOR.set(py, Py::new(py, PythonOrchestrator::new())?)?;
|
||||
}
|
||||
Ok(ORCHESTRATOR.get(py).expect("initialized"))
|
||||
}
|
||||
```
|
||||
|
||||
Using `GILOnceCell` provides:
|
||||
- Thread-safe global singleton
|
||||
- GIL-aware initialization
|
||||
- Lazy initialization on first Python access
|
||||
|
||||
### 5. Rust Rules Enforcement
|
||||
|
||||
```rust
|
||||
#![deny(unsafe_code)]
|
||||
#![deny(clippy::all)]
|
||||
#![deny(clippy::pedantic)]
|
||||
```
|
||||
|
||||
- **No unsafe**: All memory access is safe by construction
|
||||
- **No unwrap()**: Errors propagated via `?` or expect with messages
|
||||
- **Clippy pedantic**: Catches style issues and potential bugs
|
||||
|
||||
### 6. ProtocolClient Trait: Static Lifetime for Names
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait ProtocolClient: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Using `&'static str` ensures:
|
||||
- No lifetime issues from borrowed data
|
||||
- Compile-time guaranteed string validity
|
||||
- Simple implementation for hardcoded client names
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| pyo3 compatibility with Python 3.14 | Use `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` |
|
||||
| Two implementations to maintain | Rust is opt-in via feature flag |
|
||||
| Performance overhead of bindings | Rust called only for core operations |
|
||||
| Clippy pedantic false positives | Use `#[allow(...)]` for intentional patterns |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
rust/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── lib.rs # Crate root with module declarations
|
||||
│ ├── main.rs # CLI binary
|
||||
│ ├── error.rs # AstrBotError enum
|
||||
│ ├── orchestrator.rs # Core orchestrator
|
||||
│ ├── message.rs # Message types
|
||||
│ ├── stats.rs # RuntimeStats
|
||||
│ ├── protocol.rs # ProtocolClient trait + implementations
|
||||
│ ├── config.rs # Configuration structs
|
||||
│ └── python.rs # pyo3 bindings
|
||||
└── target/ # Build output (gitignored)
|
||||
```
|
||||
|
||||
## Cargo Features
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["python"]
|
||||
python = ["pyo3"]
|
||||
```
|
||||
|
||||
- Default enables Python bindings
|
||||
- Can build pure Rust library without Python
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Command |
|
||||
|-------|---------|
|
||||
| Clippy | `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 cargo clippy` |
|
||||
| Build | `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 cargo build` |
|
||||
| Python import | `python -c "from astrbot_core import PythonOrchestrator"` |
|
||||
| CLI help | `cargo run -- --help` |
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| error.rs | ✅ Complete | thiserror-based errors |
|
||||
| orchestrator.rs | ✅ Complete | Thread-safe with RwLock |
|
||||
| message.rs | ✅ Complete | serde serialization |
|
||||
| stats.rs | ✅ Complete | AtomicU64 message count |
|
||||
| protocol.rs | ✅ Complete | Trait + 4 client stubs |
|
||||
| config.rs | ✅ Complete | TOML load/save |
|
||||
| python.rs | ✅ Complete | pyo3 bindings |
|
||||
| main.rs | ✅ Complete | clap CLI |
|
||||
| lib.rs | ✅ Complete | Module declarations |
|
||||
| Clippy | ✅ Passing | No warnings |
|
||||
| Build | ✅ Passing | Compiles successfully |
|
||||
|
||||
## Next Steps (Future Work)
|
||||
|
||||
1. **Real Protocol Implementations**: Replace stub clients with actual LSP/MCP/ACP/ABP implementations
|
||||
2. **Python Integration**: Connect Rust orchestrator to Python platform adapters
|
||||
3. **Performance Benchmarking**: Compare Python vs Rust performance
|
||||
4. **Feature Parity**: Match all Python orchestrator functionality
|
||||
5. **Production Readiness**: Add more tests, error handling, edge cases
|
||||
@@ -0,0 +1,86 @@
|
||||
## Why
|
||||
|
||||
AstrBot's core runtime is currently implemented in Python. Performance-critical components (orchestration, protocol management, message processing) would benefit from Rust's memory safety, zero-cost abstractions, and native performance. Additionally, exposing core functionality via pyo3 allows seamless Python integration while leveraging Rust's strengths.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Create a new Rust crate `astrbot-core` in `rust/` directory
|
||||
- Implement core runtime components in Rust:
|
||||
- `Orchestrator`: Thread-safe runtime coordinator with RwLock
|
||||
- `ProtocolClient` trait: Unified interface for LSP, MCP, ACP, ABP clients
|
||||
- `Message` and `MessageType`: Message serialization with serde
|
||||
- `RuntimeStats`: Atomic message counting and uptime tracking
|
||||
- `Config`: TOML-based configuration management
|
||||
- Provide Python bindings via pyo3 for seamless integration
|
||||
- Follow strict Rust best practices:
|
||||
- No `unsafe` code
|
||||
- No `.unwrap()` - proper error handling
|
||||
- Clippy pedantic compliance
|
||||
- Full test coverage
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Python Layer (astrbot/core/)
|
||||
│
|
||||
▼ (pyo3 bindings)
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Rust Core (astrbot-core) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ │
|
||||
│ │ Orchestrator│ │ Config │ │ Stats │ │
|
||||
│ └─────────────┘ └─────────────┘ └──────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐│
|
||||
│ │ Protocol Clients ││
|
||||
│ │ LSP │ MCP │ ACP │ ABP ││
|
||||
│ └─────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `astrbot-core`: Rust-based high-performance core runtime with pyo3 bindings
|
||||
|
||||
### Modified Capabilities
|
||||
- (none - new implementation)
|
||||
|
||||
## Impact
|
||||
|
||||
- New directory: `rust/` containing Cargo.toml and src/
|
||||
- New files:
|
||||
- `rust/Cargo.toml`
|
||||
- `rust/src/lib.rs`
|
||||
- `rust/src/main.rs` (CLI binary)
|
||||
- `rust/src/error.rs`
|
||||
- `rust/src/orchestrator.rs`
|
||||
- `rust/src/message.rs`
|
||||
- `rust/src/stats.rs`
|
||||
- `rust/src/protocol.rs`
|
||||
- `rust/src/config.rs`
|
||||
- `rust/src/python.rs`
|
||||
- Python integration via `astrbot_core` Python module
|
||||
- CLI: `astrbot-core` binary with start/stats/health commands
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo clippy` passes with no warnings
|
||||
- `cargo build` compiles successfully
|
||||
- Python bindings importable: `from astrbot_core import PythonOrchestrator`
|
||||
- CLI functional: `astrbot-core --help`
|
||||
|
||||
## Relationship to OpenSpec Architecture
|
||||
|
||||
This change introduces a new implementation pathway that complements (not replaces) the existing Python architecture defined in `openspec/SPEC.md`. The Rust implementation:
|
||||
|
||||
1. Provides a reference implementation of the same interfaces (Orchestrator, ProtocolClient, etc.)
|
||||
2. Uses Rust idioms (no anyio - uses native Rust async/tokio)
|
||||
3. Is opt-in via pyo3 feature flag
|
||||
4. Coexists with Python implementation until Rust is production-ready
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Proposal created
|
||||
- [ ] Spec created
|
||||
- [ ] Design created
|
||||
- [ ] Tasks created
|
||||
- [ ] Implementation started
|
||||
@@ -0,0 +1,251 @@
|
||||
# AstrBot Core Runtime (Rust) Specification
|
||||
|
||||
## Overview
|
||||
|
||||
AstrBot Core Runtime is a high-performance Rust implementation of the core orchestrator, protocol clients, and configuration management. It provides Python bindings via pyo3 for seamless integration with the existing AstrBot Python codebase.
|
||||
|
||||
## Module Structure
|
||||
|
||||
### Core Modules
|
||||
|
||||
#### 1. orchestrator.rs - Runtime Orchestrator
|
||||
|
||||
Central coordinator managing protocol clients and star registry.
|
||||
|
||||
```rust
|
||||
pub struct Orchestrator {
|
||||
running: RwLock<bool>,
|
||||
stars: RwLock<HashMap<String, String>>,
|
||||
stats: RuntimeStats,
|
||||
protocol_lsp: RwLock<ProtocolStatus>,
|
||||
protocol_mcp: RwLock<ProtocolStatus>,
|
||||
protocol_acp: RwLock<ProtocolStatus>,
|
||||
protocol_abp: RwLock<ProtocolStatus>,
|
||||
}
|
||||
|
||||
impl Orchestrator {
|
||||
pub fn new() -> Self;
|
||||
pub fn start(&self) -> Result<(), AstrBotError>;
|
||||
pub fn stop(&self) -> Result<(), AstrBotError>;
|
||||
pub fn is_running(&self) -> bool;
|
||||
pub fn register_star(&self, name: &str, handler: &str) -> Result<(), AstrBotError>;
|
||||
pub fn unregister_star(&self, name: &str) -> Result<(), AstrBotError>;
|
||||
pub fn list_stars(&self) -> Vec<String>;
|
||||
pub fn record_activity(&self);
|
||||
pub fn stats(&self) -> RuntimeStats;
|
||||
pub fn get_protocol_status(&self, protocol: &str) -> Option<ProtocolStatus>;
|
||||
pub fn set_protocol_connected(&self, protocol: &str, connected: bool) -> Result<(), AstrBotError>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. protocol.rs - Protocol Client Trait
|
||||
|
||||
Unified interface for all protocol clients.
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait ProtocolClient: Send + Sync {
|
||||
async fn connect(&mut self) -> Result<(), AstrBotError>;
|
||||
async fn disconnect(&mut self) -> Result<(), AstrBotError>;
|
||||
fn is_connected(&self) -> bool;
|
||||
fn name(&self) -> &'static str;
|
||||
}
|
||||
```
|
||||
|
||||
Implementations:
|
||||
- `LspClient` - Language Server Protocol client
|
||||
- `McpClient` - Model Context Protocol client
|
||||
- `AcpClient` - AstrBot Communication Protocol client
|
||||
- `AbpClient` - AstrBot Protocol client
|
||||
|
||||
#### 3. message.rs - Message Types
|
||||
|
||||
Message structures with serde serialization.
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub sender: String,
|
||||
pub timestamp: f64,
|
||||
pub message_type: MessageType,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub enum MessageType {
|
||||
#[default]
|
||||
Text,
|
||||
Image,
|
||||
Audio,
|
||||
Video,
|
||||
File,
|
||||
System,
|
||||
Unknown,
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. stats.rs - Runtime Statistics
|
||||
|
||||
Thread-safe message counting and uptime tracking.
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeStats {
|
||||
message_count: AtomicU64,
|
||||
start_time: Instant,
|
||||
last_activity: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl RuntimeStats {
|
||||
pub fn new() -> Self;
|
||||
pub fn record_message(&self);
|
||||
pub fn message_count(&self) -> u64;
|
||||
pub fn uptime_seconds(&self) -> f64;
|
||||
pub fn last_activity_time(&self) -> Option<f64>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. config.rs - Configuration Management
|
||||
|
||||
TOML-based configuration with serde.
|
||||
|
||||
```rust
|
||||
pub struct Config {
|
||||
pub runtime: RuntimeConfig,
|
||||
pub protocols: ProtocolsConfig,
|
||||
pub logging: LoggingConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &PathBuf) -> anyhow::Result<Self>;
|
||||
pub fn save(&self, path: &PathBuf) -> anyhow::Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. error.rs - Error Types
|
||||
|
||||
Using thiserror for ergonomic error handling.
|
||||
|
||||
```rust
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AstrBotError {
|
||||
#[error("Not connected: {0}")]
|
||||
NotConnected(String),
|
||||
#[error("Connection failed: {0}")]
|
||||
ConnectionFailed(String),
|
||||
#[error("Protocol error: {0}")]
|
||||
Protocol(String),
|
||||
#[error("Timeout: {0}")]
|
||||
Timeout(String),
|
||||
#[error("Invalid state: {0}")]
|
||||
InvalidState(String),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
```
|
||||
|
||||
#### 7. python.rs - Python Bindings
|
||||
|
||||
pyo3 bindings for Python integration.
|
||||
|
||||
```rust
|
||||
#[pyclass]
|
||||
pub struct PythonOrchestrator {
|
||||
inner: Orchestrator,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PythonOrchestrator {
|
||||
#[new]
|
||||
pub fn new() -> Self;
|
||||
pub fn start(&self) -> PyResult<()>;
|
||||
pub fn stop(&self) -> PyResult<()>;
|
||||
pub fn is_running(&self) -> bool;
|
||||
pub fn register_star(&self, name: &str, handler: &str) -> PyResult<()>;
|
||||
pub fn unregister_star(&self, name: &str) -> PyResult<()>;
|
||||
pub fn list_stars(&self) -> Vec<String>;
|
||||
pub fn record_activity(&self);
|
||||
pub fn get_stats(&self) -> PyResult<Py<PyAny>>;
|
||||
pub fn set_protocol_connected(&self, protocol: &str, connected: bool) -> PyResult<()>;
|
||||
pub fn get_protocol_status(&self, protocol: &str) -> Option<Py<PyAny>>;
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn get_orchestrator(py: Python<'_>) -> PyResult<&'static Py<PythonOrchestrator>>;
|
||||
```
|
||||
|
||||
#### 8. main.rs - CLI Binary
|
||||
|
||||
Command-line interface using clap.
|
||||
|
||||
```rust
|
||||
#[derive(Parser, Debug)]
|
||||
enum Command {
|
||||
Start,
|
||||
Stats,
|
||||
Health,
|
||||
}
|
||||
```
|
||||
|
||||
Commands:
|
||||
- `start` - Start the astrbot-core runtime
|
||||
- `stats` - Display runtime statistics
|
||||
- `health` - Check runtime health status
|
||||
|
||||
## Rust Rules
|
||||
|
||||
1. **No unsafe code** - All memory access is safe
|
||||
2. **No .unwrap()** - Use `?` operator or `expect()` with descriptive messages
|
||||
3. **Clippy pedantic compliance** - Pass `cargo clippy` with no warnings
|
||||
4. **Full error handling** - All errors properly propagated
|
||||
|
||||
## Python Integration
|
||||
|
||||
The module is importable as `astrbot_core`:
|
||||
|
||||
```python
|
||||
from astrbot_core import PythonOrchestrator, get_orchestrator
|
||||
|
||||
# Get singleton
|
||||
orch = get_orchestrator()
|
||||
|
||||
# Use methods
|
||||
orch.start()
|
||||
orch.register_star("my-star", "handler-id")
|
||||
stars = orch.list_stars()
|
||||
```
|
||||
|
||||
## Cargo Features
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["python"]
|
||||
python = ["pyo3"]
|
||||
```
|
||||
|
||||
- `python`: Enable pyo3 bindings (default)
|
||||
- Without `python`: Pure Rust library without Python dependencies
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `serde` + `serde_json` - Serialization
|
||||
- `toml` - Configuration file parsing
|
||||
- `tokio` - Async runtime
|
||||
- `tracing` + `tracing-subscriber` - Logging
|
||||
- `anyhow` - Error handling
|
||||
- `thiserror` - Error derive
|
||||
- `async-trait` - Async trait methods
|
||||
- `clap` - CLI argument parsing
|
||||
- `pyo3` - Python bindings (optional)
|
||||
|
||||
## Verification Criteria
|
||||
|
||||
- [x] `cargo clippy` passes with no warnings
|
||||
- [x] `cargo build` compiles successfully
|
||||
- [x] `cargo test` passes (if tests exist)
|
||||
- [x] Python module imports successfully
|
||||
- [x] CLI `--help` works correctly
|
||||
@@ -0,0 +1,53 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## 1. Project Setup
|
||||
|
||||
- [x] 1.1 Create rust/ directory
|
||||
- [x] 1.2 Initialize with cargo init --name astrbot-core
|
||||
- [x] 1.3 Add Cargo.toml with dependencies (serde, tokio, pyo3, clap, etc.)
|
||||
- [x] 1.4 Create .cargo/config.toml for pyo3 forward compatibility
|
||||
|
||||
## 2. Core Modules
|
||||
|
||||
- [x] 2.1 Create lib.rs with module declarations and clippy settings
|
||||
- [x] 2.2 Create error.rs with AstrBotError enum using thiserror
|
||||
- [x] 2.3 Create orchestrator.rs with Orchestrator struct and methods
|
||||
- [x] 2.4 Create message.rs with Message and MessageType
|
||||
- [x] 2.5 Create stats.rs with RuntimeStats using AtomicU64
|
||||
- [x] 2.6 Create protocol.rs with ProtocolClient trait and client implementations
|
||||
- [x] 2.7 Create config.rs with Config and related structs
|
||||
- [x] 2.8 Create python.rs with pyo3 bindings
|
||||
|
||||
## 3. CLI Binary
|
||||
|
||||
- [x] 3.1 Create main.rs with clap CLI (start, stats, health commands)
|
||||
|
||||
## 4. Rust Rules Compliance
|
||||
|
||||
- [x] 4.1 Ensure no unsafe code (#![deny(unsafe_code)])
|
||||
- [x] 4.2 Ensure no .unwrap() without message (#[allow] where needed)
|
||||
- [x] 4.3 Add clippy pedantic settings (#![deny(clippy::pedantic)])
|
||||
- [x] 4.4 Fix all clippy warnings
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- [x] 5.1 Run `cargo clippy` - no warnings
|
||||
- [x] 5.2 Run `cargo build` - compiles successfully
|
||||
- [x] 5.3 Verify CLI works: `cargo run -- --help`
|
||||
|
||||
## 6. Documentation
|
||||
|
||||
- [x] 6.1 Create proposal.md
|
||||
- [x] 6.2 Create spec.md
|
||||
- [x] 6.3 Create design.md
|
||||
- [ ] 6.4 Create tasks.md (this file)
|
||||
|
||||
## 7. Future Work (Not in Scope)
|
||||
|
||||
- [ ] 7.1 Implement real LSP client functionality
|
||||
- [ ] 7.2 Implement real MCP client functionality
|
||||
- [ ] 7.3 Implement real ACP client functionality
|
||||
- [ ] 7.4 Implement real ABP client functionality
|
||||
- [ ] 7.5 Connect Rust orchestrator to Python platform adapters
|
||||
- [ ] 7.6 Add comprehensive test suite
|
||||
- [ ] 7.7 Performance benchmarking
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Bootstrap integration test - validates the orchestrator and gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
|
||||
async def test_bootstrap_components():
|
||||
"""Test that all bootstrap components can be imported and initialized."""
|
||||
print("=" * 60)
|
||||
print("AstrBot Bootstrap Integration Test")
|
||||
print("=" * 60)
|
||||
|
||||
# Test 1: Import all components
|
||||
print("\n[1] Testing imports...")
|
||||
try:
|
||||
from astrbot._internal.geteway.server import AstrbotGateway
|
||||
from astrbot._internal.runtime.orchestrator import AstrbotOrchestrator
|
||||
|
||||
print(" ✓ All imports successful")
|
||||
except Exception as e:
|
||||
print(f" ✗ Import failed: {e}")
|
||||
return False
|
||||
|
||||
# Test 2: Create orchestrator
|
||||
print("\n[2] Testing orchestrator creation...")
|
||||
try:
|
||||
orchestrator = AstrbotOrchestrator()
|
||||
print(" ✓ Orchestrator created")
|
||||
print(f" - LSP client: {type(orchestrator.lsp).__name__}")
|
||||
print(f" - MCP client: {type(orchestrator.mcp).__name__}")
|
||||
print(f" - ACP client: {type(orchestrator.acp).__name__}")
|
||||
print(f" - ABP client: {type(orchestrator.abp).__name__}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Orchestrator creation failed: {e}")
|
||||
return False
|
||||
|
||||
# Test 3: Test ABP star registration
|
||||
print("\n[3] Testing ABP star registration...")
|
||||
try:
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
# Create a mock star
|
||||
mock_star = MagicMock()
|
||||
mock_star.call_tool = AsyncMock(return_value="test_result")
|
||||
|
||||
# Register the star
|
||||
await orchestrator.register_star("test-star", mock_star)
|
||||
print(" ✓ Star registered")
|
||||
|
||||
# Verify registration
|
||||
retrieved = await orchestrator.get_star("test-star")
|
||||
if retrieved is mock_star:
|
||||
print(" ✓ Star retrieval works")
|
||||
else:
|
||||
print(" ✗ Star retrieval failed")
|
||||
|
||||
# List stars
|
||||
stars = await orchestrator.list_stars()
|
||||
print(f" ✓ Stars list: {stars}")
|
||||
|
||||
# Unregister
|
||||
await orchestrator.unregister_star("test-star")
|
||||
print(" ✓ Star unregistered")
|
||||
except Exception as e:
|
||||
print(f" ✗ ABP star test failed: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
# Test 4: Create gateway
|
||||
print("\n[4] Testing gateway creation...")
|
||||
try:
|
||||
gateway = AstrbotGateway(orchestrator)
|
||||
print(" ✓ Gateway created")
|
||||
print(f" - Host: {gateway._host}")
|
||||
print(f" - Port: {gateway._port}")
|
||||
print(f" - WebSocket manager: {type(gateway.ws_manager).__name__}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Gateway creation failed: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
# Test 5: Check anyio usage in components
|
||||
print("\n[5] Checking anyio compliance...")
|
||||
import inspect
|
||||
|
||||
orchestrator_source = inspect.getsource(orchestrator.__class__)
|
||||
if "asyncio" in orchestrator_source and "import asyncio" in orchestrator_source:
|
||||
print(" ⚠ Orchestrator imports asyncio (violation)")
|
||||
else:
|
||||
print(" ✓ Orchestrator uses anyio only")
|
||||
|
||||
gateway_source = inspect.getsource(gateway.__class__)
|
||||
if "import asyncio" in gateway_source:
|
||||
print(" ⚠ Gateway imports asyncio (violation)")
|
||||
else:
|
||||
print(" ✓ Gateway anyio check passed")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Bootstrap integration test completed")
|
||||
print("=" * 60)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run with asyncio since the test itself is sync
|
||||
result = asyncio.run(test_bootstrap_components())
|
||||
sys.exit(0 if result else 1)
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Test LSP client connecting to ty server via stdio."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
|
||||
async def test_lsp_ty_integration():
|
||||
"""Test that LSP client can connect to ty server via stdio."""
|
||||
print("=" * 60)
|
||||
print("LSP Client - ty Server Integration Test")
|
||||
print("=" * 60)
|
||||
|
||||
# Start ty server as subprocess
|
||||
print("\n[1] Starting ty server...")
|
||||
ty_process = await asyncio.create_subprocess_exec(
|
||||
"ty",
|
||||
"server",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
print(f" ✓ ty server started (PID: {ty_process.pid})")
|
||||
|
||||
# Import LSP client
|
||||
print("\n[2] Importing LSP client...")
|
||||
try:
|
||||
from astrbot._internal.protocols.lsp.client import AstrbotLspClient
|
||||
|
||||
client = AstrbotLspClient()
|
||||
print(" ✓ LSP client created")
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed to import/create client: {e}")
|
||||
ty_process.terminate()
|
||||
return False
|
||||
|
||||
# Connect to ty server
|
||||
print("\n[3] Connecting to ty server...")
|
||||
try:
|
||||
await client.connect_to_server(
|
||||
command=["ty", "server"],
|
||||
workspace_uri="file:///home/lightjunction/GITHUB/AstrBot",
|
||||
)
|
||||
print(" ✓ Connected to ty server")
|
||||
except Exception as e:
|
||||
print(
|
||||
f" ⚠ Connection failed (expected if ty doesn't support external connections): {e}"
|
||||
)
|
||||
# This is expected - ty server uses stdio but our client expects subprocess
|
||||
|
||||
# Test 4: Send initialize request
|
||||
print("\n[4] Testing LSP protocol...")
|
||||
try:
|
||||
result = await client.send_request(
|
||||
"initialize",
|
||||
{
|
||||
"processId": None,
|
||||
"rootUri": "file:///home/lightjunction/GITHUB/AstrBot",
|
||||
"capabilities": {},
|
||||
},
|
||||
)
|
||||
print(f" ✓ Initialize response: {result}")
|
||||
except Exception as e:
|
||||
print(f" ⚠ LSP request failed: {e}")
|
||||
|
||||
# Cleanup
|
||||
print("\n[5] Shutting down...")
|
||||
await client.shutdown()
|
||||
ty_process.terminate()
|
||||
await ty_process.wait()
|
||||
print(" ✓ Cleanup complete")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("LSP ty integration test completed")
|
||||
print("=" * 60)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(test_lsp_ty_integration())
|
||||
sys.exit(0 if result else 1)
|
||||
@@ -0,0 +1,802 @@
|
||||
"""Tests for astrbot._internal.tools.base module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot._internal.tools.base import (
|
||||
FunctionTool,
|
||||
ToolSchema,
|
||||
ToolSet,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# ToolSchema Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestToolSchema:
|
||||
"""Test suite for ToolSchema."""
|
||||
|
||||
def test_valid_parameters_schema(self):
|
||||
"""Valid JSON Schema parameters should pass validation."""
|
||||
schema = ToolSchema(
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"arg": {"type": "string", "description": "An argument"}},
|
||||
"required": ["arg"],
|
||||
},
|
||||
)
|
||||
assert schema.name == "test_tool"
|
||||
assert schema.description == "A test tool"
|
||||
assert schema.parameters["type"] == "object"
|
||||
|
||||
def test_empty_parameters(self):
|
||||
"""Empty parameters dict should be valid."""
|
||||
schema = ToolSchema(name="test", description="test", parameters={})
|
||||
assert schema.parameters == {}
|
||||
|
||||
def test_invalid_parameters_no_op(self):
|
||||
"""NOTE: ToolSchema is a plain @dataclass, not a Pydantic BaseModel.
|
||||
The @model_validator decorator has no effect, so validation is dead code.
|
||||
This test documents the current (broken) behavior for coverage.
|
||||
"""
|
||||
# This creates successfully because model_validator is a no-op on plain dataclass
|
||||
schema = ToolSchema(
|
||||
name="test",
|
||||
description="test",
|
||||
parameters={"type": "invalid_type_not_real"},
|
||||
)
|
||||
assert schema.parameters == {"type": "invalid_type_not_real"}
|
||||
"""Parameters without type field should still be valid since jsonschema validates structure."""
|
||||
# Actually this should be valid - jsonschema validates the schema itself
|
||||
schema = ToolSchema(
|
||||
name="test",
|
||||
description="test",
|
||||
parameters={"type": "string"},
|
||||
)
|
||||
assert schema.parameters["type"] == "string"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FunctionTool Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFunctionTool:
|
||||
"""Test suite for FunctionTool."""
|
||||
|
||||
def test_basic_function_tool(self):
|
||||
"""Basic tool creation with name, description, parameters."""
|
||||
tool = FunctionTool(
|
||||
name="my_tool",
|
||||
description="Does something useful",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
assert tool.name == "my_tool"
|
||||
assert tool.description == "Does something useful"
|
||||
assert tool.active is True
|
||||
assert tool.is_background_task is False
|
||||
assert tool.source == "mcp"
|
||||
|
||||
def test_function_tool_with_handler(self):
|
||||
"""Tool with an async handler."""
|
||||
handler = AsyncMock(return_value="result")
|
||||
|
||||
async def async_gen(**kwargs):
|
||||
yield "chunk1"
|
||||
yield "chunk2"
|
||||
|
||||
tool = FunctionTool(
|
||||
name="handler_tool",
|
||||
description="Tool with handler",
|
||||
parameters={},
|
||||
handler=handler,
|
||||
)
|
||||
assert tool.handler is handler
|
||||
|
||||
def test_function_tool_with_handler_module_path(self):
|
||||
"""Tool preserves handler_module_path."""
|
||||
tool = FunctionTool(
|
||||
name="path_tool",
|
||||
description="Tool with module path",
|
||||
parameters={},
|
||||
handler_module_path="mymodule.myfunction",
|
||||
)
|
||||
assert tool.handler_module_path == "mymodule.myfunction"
|
||||
|
||||
def test_function_tool_active_flag(self):
|
||||
"""Active flag can be set to False."""
|
||||
tool = FunctionTool(
|
||||
name="inactive",
|
||||
description="Not active",
|
||||
parameters={},
|
||||
active=False,
|
||||
)
|
||||
assert tool.active is False
|
||||
|
||||
def test_function_tool_background_task_flag(self):
|
||||
"""Background task flag can be set."""
|
||||
tool = FunctionTool(
|
||||
name="background",
|
||||
description="Background task",
|
||||
parameters={},
|
||||
is_background_task=True,
|
||||
)
|
||||
assert tool.is_background_task is True
|
||||
|
||||
def test_function_tool_source_defaults_to_mcp(self):
|
||||
"""Source defaults to 'mcp'."""
|
||||
tool = FunctionTool(name="t", description="t", parameters={})
|
||||
assert tool.source == "mcp"
|
||||
|
||||
def test_function_tool_source_can_be_plugin_or_internal(self):
|
||||
"""Source can be 'plugin' or 'internal'."""
|
||||
plugin_tool = FunctionTool(
|
||||
name="p", description="p", parameters={}, source="plugin"
|
||||
)
|
||||
internal_tool = FunctionTool(
|
||||
name="i", description="i", parameters={}, source="internal"
|
||||
)
|
||||
assert plugin_tool.source == "plugin"
|
||||
assert internal_tool.source == "internal"
|
||||
|
||||
def test_function_tool_repr(self):
|
||||
"""__repr__ returns correct string."""
|
||||
tool = FunctionTool(
|
||||
name="repr_tool",
|
||||
description="For repr test",
|
||||
parameters={"type": "object"},
|
||||
)
|
||||
r = repr(tool)
|
||||
assert "repr_tool" in r
|
||||
assert "parameters" in r
|
||||
assert "repr test" in r
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_raises_not_implemented(self):
|
||||
"""call() without handler raises NotImplementedError."""
|
||||
tool = FunctionTool(name="t", description="t", parameters={})
|
||||
with pytest.raises(NotImplementedError, match="must be implemented"):
|
||||
await tool.call(arg="value")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ToolSet Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestToolSetConstruction:
|
||||
"""Test ToolSet construction and basic operations."""
|
||||
|
||||
def test_empty_toolset(self):
|
||||
"""Empty ToolSet with namespace."""
|
||||
ts = ToolSet("my_namespace")
|
||||
assert ts.namespace == "my_namespace"
|
||||
assert len(ts) == 0
|
||||
assert ts.empty()
|
||||
|
||||
def test_toolset_from_list(self):
|
||||
"""ToolSet initialized with a list of tools."""
|
||||
tool1 = FunctionTool(name="tool1", description="First", parameters={})
|
||||
tool2 = FunctionTool(name="tool2", description="Second", parameters={})
|
||||
ts = ToolSet("ns", [tool1, tool2])
|
||||
assert len(ts) == 2
|
||||
assert not ts.empty()
|
||||
|
||||
def test_toolset_with_duplicate_names(self):
|
||||
"""Last tool with same name overwrites earlier one."""
|
||||
tool1 = FunctionTool(name="dup", description="First", parameters={})
|
||||
tool2 = FunctionTool(name="dup", description="Second", parameters={})
|
||||
ts = ToolSet("ns", [tool1, tool2])
|
||||
assert len(ts) == 1
|
||||
assert ts.get("dup").description == "Second"
|
||||
|
||||
|
||||
class TestToolSetAddRemove:
|
||||
"""Test ToolSet add/remove operations."""
|
||||
|
||||
def test_add_tool(self):
|
||||
"""add() puts tool in set."""
|
||||
ts = ToolSet("ns")
|
||||
tool = FunctionTool(name="add_test", description="Add test", parameters={})
|
||||
ts.add(tool)
|
||||
assert ts.get("add_test") is tool
|
||||
|
||||
def test_add_tool_alias(self):
|
||||
"""add_tool() is alias for add()."""
|
||||
ts = ToolSet("ns")
|
||||
tool = FunctionTool(name="alias_test", description="Alias test", parameters={})
|
||||
ts.add_tool(tool)
|
||||
assert ts.get("alias_test") is tool
|
||||
|
||||
def test_remove_tool(self):
|
||||
"""remove_tool() removes by name (void return)."""
|
||||
ts = ToolSet("ns")
|
||||
tool = FunctionTool(name="remove_me", description="Remove me", parameters={})
|
||||
ts.add(tool)
|
||||
ts.remove_tool("remove_me")
|
||||
assert ts.get("remove_me") is None
|
||||
|
||||
def test_remove_method(self):
|
||||
"""remove() removes and returns tool."""
|
||||
ts = ToolSet("ns")
|
||||
tool = FunctionTool(name="return_me", description="Return me", parameters={})
|
||||
ts.add(tool)
|
||||
result = ts.remove("return_me")
|
||||
assert result is tool
|
||||
assert ts.get("return_me") is None
|
||||
|
||||
def test_remove_nonexistent(self):
|
||||
"""remove() returns None for missing name."""
|
||||
ts = ToolSet("ns")
|
||||
result = ts.remove("does_not_exist")
|
||||
assert result is None
|
||||
|
||||
def test_get_tool_alias(self):
|
||||
"""get_tool() is alias for get()."""
|
||||
ts = ToolSet("ns")
|
||||
tool = FunctionTool(name="get_alias", description="Get alias", parameters={})
|
||||
ts.add(tool)
|
||||
assert ts.get_tool("get_alias") is tool
|
||||
|
||||
|
||||
class TestToolSetIteration:
|
||||
"""Test ToolSet iteration and length."""
|
||||
|
||||
def test_len(self):
|
||||
"""__len__ returns count."""
|
||||
ts = ToolSet("ns")
|
||||
assert len(ts) == 0
|
||||
ts.add(FunctionTool(name="a", description="a", parameters={}))
|
||||
assert len(ts) == 1
|
||||
ts.add(FunctionTool(name="b", description="b", parameters={}))
|
||||
assert len(ts) == 2
|
||||
|
||||
def test_bool_true_when_has_tools(self):
|
||||
"""__bool__ is True when tools exist."""
|
||||
ts = ToolSet("ns")
|
||||
assert not ts
|
||||
ts.add(FunctionTool(name="x", description="x", parameters={}))
|
||||
assert ts
|
||||
|
||||
def test_iter(self):
|
||||
"""__iter__ yields tools."""
|
||||
tool1 = FunctionTool(name="iter1", description="Iter 1", parameters={})
|
||||
tool2 = FunctionTool(name="iter2", description="Iter 2", parameters={})
|
||||
ts = ToolSet("ns", [tool1, tool2])
|
||||
tools = list(ts)
|
||||
assert tool1 in tools
|
||||
assert tool2 in tools
|
||||
|
||||
def test_list_tools(self):
|
||||
"""list_tools() returns all tools."""
|
||||
tool1 = FunctionTool(name="list1", description="List 1", parameters={})
|
||||
tool2 = FunctionTool(name="list2", description="List 2", parameters={})
|
||||
ts = ToolSet("ns", [tool1, tool2])
|
||||
assert len(ts.list_tools()) == 2
|
||||
|
||||
def test_tools_property(self):
|
||||
"""tools property returns list of tools."""
|
||||
tool = FunctionTool(name="prop", description="Prop", parameters={})
|
||||
ts = ToolSet("ns", [tool])
|
||||
assert tool in ts.tools
|
||||
|
||||
def test_names(self):
|
||||
"""names() returns list of tool names."""
|
||||
tool1 = FunctionTool(name="alpha", description="Alpha", parameters={})
|
||||
tool2 = FunctionTool(name="beta", description="Beta", parameters={})
|
||||
ts = ToolSet("ns", [tool1, tool2])
|
||||
assert set(ts.names()) == {"alpha", "beta"}
|
||||
|
||||
def test_empty_method(self):
|
||||
"""empty() returns True when no tools."""
|
||||
ts = ToolSet("ns")
|
||||
assert ts.empty()
|
||||
ts.add(FunctionTool(name="y", description="y", parameters={}))
|
||||
assert not ts.empty()
|
||||
|
||||
|
||||
class TestToolSetRepr:
|
||||
"""Test ToolSet string representations."""
|
||||
|
||||
def test_repr(self):
|
||||
"""__repr__ includes namespace and tools."""
|
||||
tool = FunctionTool(name="repr_t", description="R", parameters={})
|
||||
ts = ToolSet("repr_ns", [tool])
|
||||
r = repr(ts)
|
||||
assert "repr_ns" in r
|
||||
assert "repr_t" in r
|
||||
|
||||
def test_str(self):
|
||||
"""__str__ includes namespace and count."""
|
||||
ts = ToolSet("str_ns")
|
||||
assert "str_ns" in str(ts)
|
||||
assert "0 tools" in str(ts)
|
||||
ts.add(FunctionTool(name="s", description="s", parameters={}))
|
||||
assert "1 tools" in str(ts)
|
||||
|
||||
|
||||
class TestToolSetMerge:
|
||||
"""Test ToolSet merge and normalize."""
|
||||
|
||||
def test_merge(self):
|
||||
"""merge() adds all tools from another ToolSet."""
|
||||
ts1 = ToolSet("ns1")
|
||||
ts1.add(FunctionTool(name="keep", description="Keep", parameters={}))
|
||||
ts2 = ToolSet("ns2")
|
||||
ts2.add(FunctionTool(name="added", description="Added", parameters={}))
|
||||
ts1.merge(ts2)
|
||||
assert ts1.get("keep") is not None
|
||||
assert ts1.get("added") is not None
|
||||
assert len(ts1) == 2
|
||||
|
||||
def test_merge_overwrites_duplicate(self):
|
||||
"""merge() overwrites tools with same name."""
|
||||
ts1 = ToolSet("ns1")
|
||||
ts1.add(FunctionTool(name="dup", description="Original", parameters={}))
|
||||
ts2 = ToolSet("ns2")
|
||||
ts2.add(FunctionTool(name="dup", description="Merged", parameters={}))
|
||||
ts1.merge(ts2)
|
||||
assert ts1.get("dup").description == "Merged"
|
||||
|
||||
def test_normalize_sorts_by_name(self):
|
||||
"""normalize() sorts tools by name for deterministic output."""
|
||||
tool_c = FunctionTool(name="charlie", description="C", parameters={})
|
||||
tool_a = FunctionTool(name="alpha", description="A", parameters={})
|
||||
tool_b = FunctionTool(name="bravo", description="B", parameters={})
|
||||
ts = ToolSet("ns", [tool_c, tool_a, tool_b])
|
||||
ts.normalize()
|
||||
names = list(ts._tools.keys())
|
||||
assert names == ["alpha", "bravo", "charlie"]
|
||||
|
||||
|
||||
class TestToolSetLightToolSet:
|
||||
"""Test get_light_tool_set()."""
|
||||
|
||||
def test_light_tool_set_excludes_inactive(self):
|
||||
"""Inactive tools are excluded."""
|
||||
active = FunctionTool(
|
||||
name="active", description="Active", parameters={}, active=True
|
||||
)
|
||||
inactive = FunctionTool(
|
||||
name="inactive", description="Inactive", parameters={}, active=False
|
||||
)
|
||||
ts = ToolSet("ns", [active, inactive])
|
||||
light = ts.get_light_tool_set()
|
||||
assert light.get("active") is not None
|
||||
assert light.get("inactive") is None
|
||||
|
||||
def test_light_tool_set_preserves_name_and_description(self):
|
||||
"""Light tool set has name/description only."""
|
||||
tool = FunctionTool(
|
||||
name="light_test",
|
||||
description="Original description",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "string"}}},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
light = ts.get_light_tool_set()
|
||||
light_tool = light.get("light_test")
|
||||
assert light_tool.name == "light_test"
|
||||
assert light_tool.description == "Original description"
|
||||
assert light_tool.parameters == {"type": "object", "properties": {}}
|
||||
|
||||
def test_light_tool_set_has_empty_handler(self):
|
||||
"""Light tools have handler=None."""
|
||||
tool = FunctionTool(name="lh", description="LH", parameters={})
|
||||
ts = ToolSet("ns", [tool])
|
||||
light = ts.get_light_tool_set()
|
||||
assert light.get("lh").handler is None
|
||||
|
||||
|
||||
class TestToolSetParamOnlyToolSet:
|
||||
"""Test get_param_only_tool_set()."""
|
||||
|
||||
def test_param_only_excludes_inactive(self):
|
||||
"""Inactive tools are excluded."""
|
||||
active = FunctionTool(name="a", description="A", parameters={}, active=True)
|
||||
inactive = FunctionTool(name="i", description="I", parameters={}, active=False)
|
||||
ts = ToolSet("ns", [active, inactive])
|
||||
param = ts.get_param_only_tool_set()
|
||||
assert param.get("a") is not None
|
||||
assert param.get("i") is None
|
||||
|
||||
def test_param_only_preserves_parameters(self):
|
||||
"""Parameters are deep copied."""
|
||||
tool = FunctionTool(
|
||||
name="param_test",
|
||||
description="Keep this",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "integer"}}},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
param = ts.get_param_only_tool_set()
|
||||
param_tool = param.get("param_test")
|
||||
assert param_tool.parameters == {
|
||||
"type": "object",
|
||||
"properties": {"x": {"type": "integer"}},
|
||||
}
|
||||
assert param_tool.description == ""
|
||||
|
||||
def test_param_only_empty_parameters_defaults(self):
|
||||
"""Tools with no parameters get empty object schema."""
|
||||
tool = FunctionTool(name="no_params", description="No params", parameters=None)
|
||||
ts = ToolSet("ns", [tool])
|
||||
param = ts.get_param_only_tool_set()
|
||||
assert param.get("no_params").parameters == {"type": "object", "properties": {}}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ToolSet Schema Tests - OpenAI
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestToolSetOpenAISchema:
|
||||
"""Test openai_schema()."""
|
||||
|
||||
def test_empty_toolset(self):
|
||||
"""Empty toolset returns empty list."""
|
||||
ts = ToolSet("ns")
|
||||
assert ts.openai_schema() == []
|
||||
|
||||
def test_basic_openai_schema(self):
|
||||
"""Basic tool converts to OpenAI format."""
|
||||
tool = FunctionTool(
|
||||
name="openai_tool",
|
||||
description="An OpenAI tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.openai_schema()
|
||||
assert len(schema) == 1
|
||||
assert schema[0]["type"] == "function"
|
||||
assert schema[0]["function"]["name"] == "openai_tool"
|
||||
assert schema[0]["function"]["description"] == "An OpenAI tool"
|
||||
assert "parameters" in schema[0]["function"]
|
||||
|
||||
def test_openai_schema_no_description(self):
|
||||
"""Tool without description omits description field."""
|
||||
tool = FunctionTool(name="nodesc", description="", parameters={})
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.openai_schema()
|
||||
assert "description" not in schema[0]["function"]
|
||||
|
||||
def test_openai_schema_omit_empty_parameters_true(self):
|
||||
"""omit_empty_parameter_field=True removes empty parameters."""
|
||||
tool = FunctionTool(
|
||||
name="omit_empty",
|
||||
description="Test",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.openai_schema(omit_empty_parameter_field=True)
|
||||
assert "parameters" not in schema[0]["function"]
|
||||
|
||||
def test_openai_schema_omit_empty_with_properties(self):
|
||||
"""omit_empty=True but has properties -> keeps parameters."""
|
||||
tool = FunctionTool(
|
||||
name="keep_params",
|
||||
description="Test",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "string"}}},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.openai_schema(omit_empty_parameter_field=True)
|
||||
assert "parameters" in schema[0]["function"]
|
||||
|
||||
def test_openai_schema_null_parameters(self):
|
||||
"""Tool with parameters=None skips parameters field."""
|
||||
tool = FunctionTool(name="null_params", description="Test", parameters=None)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.openai_schema()
|
||||
# Since parameters is None, tool.parameters is None, so the condition
|
||||
# tool.parameters is not None is False, and omit_empty is False by default
|
||||
# so parameters should not be in the output
|
||||
assert "parameters" not in schema[0]["function"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ToolSet Schema Tests - Anthropic
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestToolSetAnthropicSchema:
|
||||
"""Test anthropic_schema()."""
|
||||
|
||||
def test_empty_toolset(self):
|
||||
"""Empty toolset returns empty list."""
|
||||
ts = ToolSet("ns")
|
||||
assert ts.anthropic_schema() == []
|
||||
|
||||
def test_basic_anthropic_schema(self):
|
||||
"""Basic tool converts to Anthropic format."""
|
||||
tool = FunctionTool(
|
||||
name="anthropic_tool",
|
||||
description="An Anthropic tool",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.anthropic_schema()
|
||||
assert len(schema) == 1
|
||||
assert schema[0]["name"] == "anthropic_tool"
|
||||
assert schema[0]["description"] == "An Anthropic tool"
|
||||
assert schema[0]["input_schema"]["properties"] == {"query": {"type": "string"}}
|
||||
assert schema[0]["input_schema"]["required"] == ["query"]
|
||||
|
||||
def test_anthropic_schema_no_parameters(self):
|
||||
"""Tool with no parameters gets empty input_schema."""
|
||||
tool = FunctionTool(name="no_params", description="No params", parameters={})
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.anthropic_schema()
|
||||
assert schema[0]["input_schema"] == {"type": "object"}
|
||||
|
||||
def test_anthropic_schema_no_description(self):
|
||||
"""Tool without description omits description field."""
|
||||
tool = FunctionTool(name="nodesc", description="", parameters={})
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.anthropic_schema()
|
||||
assert "description" not in schema[0]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ToolSet Schema Tests - Google GenAI
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestToolSetGoogleSchema:
|
||||
"""Test google_schema()."""
|
||||
|
||||
def test_empty_toolset(self):
|
||||
"""Empty toolset returns empty declarations."""
|
||||
ts = ToolSet("ns")
|
||||
assert ts.google_schema() == {}
|
||||
|
||||
def test_basic_google_schema(self):
|
||||
"""Basic tool converts to Google format."""
|
||||
tool = FunctionTool(
|
||||
name="google_tool",
|
||||
description="A Google tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.google_schema()
|
||||
assert "function_declarations" in schema
|
||||
assert len(schema["function_declarations"]) == 1
|
||||
decl = schema["function_declarations"][0]
|
||||
assert decl["name"] == "google_tool"
|
||||
assert decl["description"] == "A Google tool"
|
||||
|
||||
def test_google_convert_any_of(self):
|
||||
"""anyOf schemas are recursively converted."""
|
||||
tool = FunctionTool(
|
||||
name="anyof_tool",
|
||||
description="AnyOf test",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "integer"},
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.google_schema()
|
||||
props = schema["function_declarations"][0]["parameters"]["properties"]
|
||||
assert "anyOf" in props["value"]
|
||||
assert len(props["value"]["anyOf"]) == 2
|
||||
|
||||
def test_google_convert_array_with_items(self):
|
||||
"""Array types with items dict are converted."""
|
||||
tool = FunctionTool(
|
||||
name="array_tool",
|
||||
description="Array test",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.google_schema()
|
||||
props = schema["function_declarations"][0]["parameters"]["properties"]
|
||||
assert props["tags"]["type"] == "array"
|
||||
assert props["tags"]["items"] == {"type": "string"}
|
||||
|
||||
def test_google_convert_array_with_non_dict_items(self):
|
||||
"""Array types with non-dict items default to string."""
|
||||
tool = FunctionTool(
|
||||
name="array_tool2",
|
||||
description="Array test 2",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"items": {"type": "array", "items": "not_a_dict"}},
|
||||
},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.google_schema()
|
||||
props = schema["function_declarations"][0]["parameters"]["properties"]
|
||||
assert props["items"]["items"] == {"type": "string"}
|
||||
|
||||
def test_google_unsupported_type_becomes_null(self):
|
||||
"""Unsupported types become 'null'."""
|
||||
tool = FunctionTool(
|
||||
name="unsupported",
|
||||
description="Unsupported type",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"unknown": {"type": "unsupported_type_xyz"}},
|
||||
},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.google_schema()
|
||||
props = schema["function_declarations"][0]["parameters"]["properties"]
|
||||
assert props["unknown"]["type"] == "null"
|
||||
|
||||
def test_google_type_list_picks_non_null(self):
|
||||
"""Type list like ['string', 'null'] picks 'string'."""
|
||||
tool = FunctionTool(
|
||||
name="nullable_str",
|
||||
description="Nullable string",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": ["string", "null"]}},
|
||||
},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.google_schema()
|
||||
props = schema["function_declarations"][0]["parameters"]["properties"]
|
||||
assert props["name"]["type"] == "string"
|
||||
|
||||
def test_google_removes_default_and_additional_props(self):
|
||||
"""default and additionalProperties are removed during conversion.
|
||||
These fields survive convert_schema via support_fields (e.g. via 'description').
|
||||
"""
|
||||
tool = FunctionTool(
|
||||
name="cleanup",
|
||||
description="Cleanup test",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"description": "A field with default",
|
||||
"default": "foo",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.google_schema()
|
||||
field = schema["function_declarations"][0]["parameters"]["properties"]["field"]
|
||||
# description should be preserved, default should be removed
|
||||
assert field.get("description") == "A field with default"
|
||||
assert "default" not in field
|
||||
|
||||
def test_google_supported_fields_preserved(self):
|
||||
"""Supported fields like enum, minimum, maximum are preserved."""
|
||||
tool = FunctionTool(
|
||||
name="fields",
|
||||
description="Fields test",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["active", "inactive"],
|
||||
"description": "Status field",
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 10,
|
||||
"minItems": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.google_schema()
|
||||
props = schema["function_declarations"][0]["parameters"]["properties"]
|
||||
assert props["status"]["enum"] == ["active", "inactive"]
|
||||
assert props["status"]["description"] == "Status field"
|
||||
assert props["count"]["minimum"] == 0
|
||||
assert props["count"]["maximum"] == 100
|
||||
assert props["items"]["maxItems"] == 10
|
||||
assert props["items"]["minItems"] == 1
|
||||
|
||||
def test_google_format_fields(self):
|
||||
"""Format fields are preserved for supported types."""
|
||||
tool = FunctionTool(
|
||||
name="format_test",
|
||||
description="Format test",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dt": {"type": "string", "format": "date-time"},
|
||||
"enum_val": {"type": "string", "format": "enum"},
|
||||
"int32": {"type": "integer", "format": "int32"},
|
||||
"int64": {"type": "integer", "format": "int64"},
|
||||
"float_val": {"type": "number", "format": "float"},
|
||||
"double_val": {"type": "number", "format": "double"},
|
||||
},
|
||||
},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.google_schema()
|
||||
props = schema["function_declarations"][0]["parameters"]["properties"]
|
||||
assert props["dt"]["format"] == "date-time"
|
||||
assert props["int32"]["format"] == "int32"
|
||||
assert props["int64"]["format"] == "int64"
|
||||
assert props["float_val"]["format"] == "float"
|
||||
assert props["double_val"]["format"] == "double"
|
||||
|
||||
def test_google_unsupported_format_ignored(self):
|
||||
"""Format not in supported list is ignored."""
|
||||
tool = FunctionTool(
|
||||
name="bad_format",
|
||||
description="Bad format",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bad": {"type": "string", "format": "unsupported-format-xyz"}
|
||||
},
|
||||
},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
schema = ts.google_schema()
|
||||
props = schema["function_declarations"][0]["parameters"]["properties"]
|
||||
assert "format" not in props["bad"]
|
||||
|
||||
|
||||
class TestToolSetDeprecatedSchemaMethods:
|
||||
"""Test deprecated schema convenience methods."""
|
||||
|
||||
def test_get_func_desc_openai_style(self):
|
||||
"""get_func_desc_openai_style returns same as openai_schema."""
|
||||
tool = FunctionTool(name="dep_openai", description="Deprecated", parameters={})
|
||||
ts = ToolSet("ns", [tool])
|
||||
assert ts.get_func_desc_openai_style() == ts.openai_schema()
|
||||
|
||||
def test_get_func_desc_openai_style_with_flag(self):
|
||||
"""get_func_desc_openai_style passes omit_empty flag."""
|
||||
tool = FunctionTool(
|
||||
name="dep_omit",
|
||||
description="Omit",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
assert ts.get_func_desc_openai_style(
|
||||
omit_empty_parameter_field=True
|
||||
) == ts.openai_schema(omit_empty_parameter_field=True)
|
||||
|
||||
def test_get_func_desc_anthropic_style(self):
|
||||
"""get_func_desc_anthropic_style returns same as anthropic_schema."""
|
||||
tool = FunctionTool(
|
||||
name="dep_anthropic", description="Anthropic", parameters={}
|
||||
)
|
||||
ts = ToolSet("ns", [tool])
|
||||
assert ts.get_func_desc_anthropic_style() == ts.anthropic_schema()
|
||||
|
||||
def test_get_func_desc_google_genai_style(self):
|
||||
"""get_func_desc_google_genai_style returns same as google_schema."""
|
||||
tool = FunctionTool(name="dep_google", description="Google", parameters={})
|
||||
ts = ToolSet("ns", [tool])
|
||||
assert ts.get_func_desc_google_genai_style() == ts.google_schema()
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Tests for StarHandlerRegistry and StarHandlerMetadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.star.star_handler import (
|
||||
EventType,
|
||||
StarHandlerMetadata,
|
||||
StarHandlerRegistry,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry():
|
||||
"""Create a fresh StarHandlerRegistry."""
|
||||
return StarHandlerRegistry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_handler():
|
||||
"""Create a mock handler for testing."""
|
||||
|
||||
def make_handler(
|
||||
event_type: EventType,
|
||||
full_name: str,
|
||||
module_path: str = "test_module",
|
||||
enabled: bool = True,
|
||||
priority: int = 0,
|
||||
extras_configs: dict | None = None,
|
||||
) -> StarHandlerMetadata:
|
||||
handler = MagicMock(spec=StarHandlerMetadata)
|
||||
handler.event_type = event_type
|
||||
handler.handler_full_name = full_name
|
||||
handler.handler_name = full_name.split("_")[-1]
|
||||
handler.handler_module_path = module_path
|
||||
handler.enabled = enabled
|
||||
configs = extras_configs or {}
|
||||
if priority != 0:
|
||||
configs["priority"] = priority
|
||||
handler.extras_configs = configs
|
||||
return handler
|
||||
|
||||
return make_handler
|
||||
|
||||
|
||||
class TestStarHandlerRegistryAppend:
|
||||
"""Tests for StarHandlerRegistry.append()."""
|
||||
|
||||
def test_append_adds_to_map(self, registry, mock_handler):
|
||||
"""Append adds handler to star_handlers_map."""
|
||||
handler = mock_handler(EventType.AdapterMessageEvent, "test_handler")
|
||||
registry.append(handler)
|
||||
assert registry.star_handlers_map["test_handler"] is handler
|
||||
|
||||
def test_append_adds_to_list(self, registry, mock_handler):
|
||||
"""Append adds handler to _handlers list."""
|
||||
handler = mock_handler(EventType.AdapterMessageEvent, "test_handler")
|
||||
registry.append(handler)
|
||||
assert handler in registry._handlers
|
||||
|
||||
def test_append_sets_default_priority(self, registry, mock_handler):
|
||||
"""Append sets default priority=0 if not specified."""
|
||||
handler = mock_handler(EventType.AdapterMessageEvent, "test_handler")
|
||||
registry.append(handler)
|
||||
assert handler.extras_configs["priority"] == 0
|
||||
|
||||
def test_append_preserves_existing_priority(self, registry, mock_handler):
|
||||
"""Append preserves explicitly set priority."""
|
||||
handler = mock_handler(
|
||||
EventType.AdapterMessageEvent,
|
||||
"test_handler",
|
||||
priority=5,
|
||||
)
|
||||
registry.append(handler)
|
||||
assert handler.extras_configs["priority"] == 5
|
||||
|
||||
def test_append_sorts_by_priority_descending(self, registry, mock_handler):
|
||||
"""Append keeps handlers sorted by priority (highest first)."""
|
||||
h1 = mock_handler(EventType.AdapterMessageEvent, "low_priority", priority=1)
|
||||
h5 = mock_handler(EventType.AdapterMessageEvent, "high_priority", priority=5)
|
||||
h3 = mock_handler(EventType.AdapterMessageEvent, "mid_priority", priority=3)
|
||||
registry.append(h1)
|
||||
registry.append(h5)
|
||||
registry.append(h3)
|
||||
# Should be sorted: high(5), mid(3), low(1)
|
||||
priorities = [h.extras_configs["priority"] for h in registry._handlers]
|
||||
assert priorities == [5, 3, 1]
|
||||
|
||||
|
||||
class TestStarHandlerRegistryGetByEventType:
|
||||
"""Tests for StarHandlerRegistry.get_handlers_by_event_type()."""
|
||||
|
||||
def test_returns_handlers_matching_event_type(self, registry, mock_handler):
|
||||
"""Returns only handlers matching the specified event type."""
|
||||
adapter_handler = mock_handler(EventType.AdapterMessageEvent, "adapter_h")
|
||||
llm_handler = mock_handler(EventType.OnLLMRequestEvent, "llm_h")
|
||||
registry.append(adapter_handler)
|
||||
registry.append(llm_handler)
|
||||
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
|
||||
mock_map.get.return_value = MagicMock(activated=True, reserved=False)
|
||||
result = registry.get_handlers_by_event_type(EventType.AdapterMessageEvent)
|
||||
assert adapter_handler in result
|
||||
assert llm_handler not in result
|
||||
|
||||
def test_excludes_disabled_handlers(self, registry, mock_handler):
|
||||
"""Disabled handlers are excluded."""
|
||||
enabled_h = mock_handler(EventType.AdapterMessageEvent, "enabled", enabled=True)
|
||||
disabled_h = mock_handler(
|
||||
EventType.AdapterMessageEvent, "disabled", enabled=False
|
||||
)
|
||||
registry.append(enabled_h)
|
||||
registry.append(disabled_h)
|
||||
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
|
||||
mock_map.get.return_value = MagicMock(activated=True, reserved=False)
|
||||
result = registry.get_handlers_by_event_type(
|
||||
EventType.AdapterMessageEvent, only_activated=True
|
||||
)
|
||||
assert enabled_h in result
|
||||
assert disabled_h not in result
|
||||
|
||||
def test_only_activated_false_bypasses_star_map_check(self, registry, mock_handler):
|
||||
"""only_activated=False bypasses star_map activation check but still checks handler.enabled."""
|
||||
enabled_h = mock_handler(EventType.AdapterMessageEvent, "enabled", enabled=True)
|
||||
disabled_h = mock_handler(
|
||||
EventType.AdapterMessageEvent, "disabled", enabled=False
|
||||
)
|
||||
registry.append(enabled_h)
|
||||
registry.append(disabled_h)
|
||||
result = registry.get_handlers_by_event_type(
|
||||
EventType.AdapterMessageEvent, only_activated=False
|
||||
)
|
||||
assert enabled_h in result
|
||||
# handler.enabled is still checked even with only_activated=False
|
||||
assert disabled_h not in result
|
||||
|
||||
def test_plugin_not_activated_excluded(self, registry, mock_handler):
|
||||
"""Handlers from deactivated plugins are excluded."""
|
||||
handler = mock_handler(
|
||||
EventType.AdapterMessageEvent, "plugin_h", module_path="mod"
|
||||
)
|
||||
registry.append(handler)
|
||||
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
|
||||
mock_map.get.return_value = MagicMock(activated=False)
|
||||
result = registry.get_handlers_by_event_type(
|
||||
EventType.AdapterMessageEvent, only_activated=True
|
||||
)
|
||||
assert handler not in result
|
||||
|
||||
def test_plugin_not_in_star_map_excluded(self, registry, mock_handler):
|
||||
"""Handlers whose plugin is not in star_map are excluded."""
|
||||
handler = mock_handler(
|
||||
EventType.AdapterMessageEvent, "orphan_h", module_path="orphan"
|
||||
)
|
||||
registry.append(handler)
|
||||
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
|
||||
mock_map.get.return_value = None
|
||||
result = registry.get_handlers_by_event_type(
|
||||
EventType.AdapterMessageEvent, only_activated=True
|
||||
)
|
||||
assert handler not in result
|
||||
|
||||
def test_plugins_name_whitelist(self, registry, mock_handler):
|
||||
"""plugins_name filters to specific plugin names."""
|
||||
handler1 = mock_handler(
|
||||
EventType.AdapterMessageEvent, "h1", module_path="plugin_a"
|
||||
)
|
||||
handler2 = mock_handler(
|
||||
EventType.AdapterMessageEvent, "h2", module_path="plugin_b"
|
||||
)
|
||||
registry.append(handler1)
|
||||
registry.append(handler2)
|
||||
|
||||
def mock_get(path):
|
||||
m = MagicMock(activated=True, reserved=False)
|
||||
m.name = path # set name as actual string attribute
|
||||
return m
|
||||
|
||||
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
|
||||
mock_map.get.side_effect = mock_get
|
||||
result = registry.get_handlers_by_event_type(
|
||||
EventType.AdapterMessageEvent,
|
||||
plugins_name=["plugin_a"],
|
||||
)
|
||||
assert handler1 in result
|
||||
assert handler2 not in result
|
||||
|
||||
def test_plugins_name_wildcard_all(self, registry, mock_handler):
|
||||
"""plugins_name=['*'] includes all handlers (bypasses whitelist but not activation check)."""
|
||||
h1 = mock_handler(EventType.AdapterMessageEvent, "h1", module_path="p1")
|
||||
h2 = mock_handler(EventType.AdapterMessageEvent, "h2", module_path="p2")
|
||||
registry.append(h1)
|
||||
registry.append(h2)
|
||||
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
|
||||
mock_map.get.return_value = MagicMock(activated=True, reserved=False)
|
||||
result = registry.get_handlers_by_event_type(
|
||||
EventType.AdapterMessageEvent, plugins_name=["*"]
|
||||
)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_event_type_allowed_even_when_not_in_plugin_list(
|
||||
self, registry, mock_handler
|
||||
):
|
||||
"""Certain event types bypass the plugins_name filter."""
|
||||
handler = mock_handler(
|
||||
EventType.OnAstrBotLoadedEvent, "loaded_h", module_path="mod"
|
||||
)
|
||||
registry.append(handler)
|
||||
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
|
||||
mock_map.get.return_value = MagicMock(
|
||||
name="mod", activated=True, reserved=False
|
||||
)
|
||||
# Should include even though plugin not in plugins_name list
|
||||
result = registry.get_handlers_by_event_type(
|
||||
EventType.OnAstrBotLoadedEvent, plugins_name=["other"]
|
||||
)
|
||||
assert handler in result
|
||||
|
||||
def test_reserved_plugin_bypasses_whitelist(self, registry, mock_handler):
|
||||
"""Reserved plugins bypass the plugins_name whitelist."""
|
||||
handler = mock_handler(
|
||||
EventType.AdapterMessageEvent, "reserved_h", module_path="core_mod"
|
||||
)
|
||||
registry.append(handler)
|
||||
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
|
||||
mock_map.get.return_value = MagicMock(
|
||||
name="core", activated=True, reserved=True
|
||||
)
|
||||
result = registry.get_handlers_by_event_type(
|
||||
EventType.AdapterMessageEvent, plugins_name=["other"]
|
||||
)
|
||||
assert handler in result
|
||||
|
||||
|
||||
class TestStarHandlerRegistryGetByFullName:
|
||||
"""Tests for get_handler_by_full_name()."""
|
||||
|
||||
def test_returns_handler_by_name(self, registry, mock_handler):
|
||||
"""Returns the handler with the given full name."""
|
||||
h1 = mock_handler(EventType.AdapterMessageEvent, "handler_one")
|
||||
h2 = mock_handler(EventType.AdapterMessageEvent, "handler_two")
|
||||
registry.append(h1)
|
||||
registry.append(h2)
|
||||
result = registry.get_handler_by_full_name("handler_one")
|
||||
assert result is h1
|
||||
|
||||
def test_returns_none_for_missing_name(self, registry):
|
||||
"""Returns None for a name not in the registry."""
|
||||
result = registry.get_handler_by_full_name("nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestStarHandlerRegistryGetByModuleName:
|
||||
"""Tests for get_handlers_by_module_name()."""
|
||||
|
||||
def test_returns_handlers_for_module(self, registry, mock_handler):
|
||||
"""Returns all handlers from a specific module."""
|
||||
h1 = mock_handler(EventType.AdapterMessageEvent, "m1_h1", module_path="mod_a")
|
||||
h2 = mock_handler(EventType.OnLLMRequestEvent, "m1_h2", module_path="mod_a")
|
||||
h3 = mock_handler(EventType.AdapterMessageEvent, "m2_h1", module_path="mod_b")
|
||||
registry.append(h1)
|
||||
registry.append(h2)
|
||||
registry.append(h3)
|
||||
result = registry.get_handlers_by_module_name("mod_a")
|
||||
assert h1 in result
|
||||
assert h2 in result
|
||||
assert h3 not in result
|
||||
|
||||
def test_returns_empty_for_unknown_module(self, registry):
|
||||
"""Returns empty list for a module with no handlers."""
|
||||
result = registry.get_handlers_by_module_name("unknown_module")
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestStarHandlerRegistryClear:
|
||||
"""Tests for StarHandlerRegistry.clear()."""
|
||||
|
||||
def test_clear_removes_all_handlers(self, registry, mock_handler):
|
||||
"""clear() empties both maps and lists."""
|
||||
registry.append(mock_handler(EventType.AdapterMessageEvent, "h1"))
|
||||
registry.append(mock_handler(EventType.OnLLMRequestEvent, "h2"))
|
||||
registry.clear()
|
||||
assert len(registry.star_handlers_map) == 0
|
||||
assert len(registry._handlers) == 0
|
||||
|
||||
|
||||
class TestStarHandlerRegistryRemove:
|
||||
"""Tests for StarHandlerRegistry.remove()."""
|
||||
|
||||
def test_remove_existing_handler(self, registry, mock_handler):
|
||||
"""remove() removes the specified handler."""
|
||||
h1 = mock_handler(EventType.AdapterMessageEvent, "h1")
|
||||
h2 = mock_handler(EventType.AdapterMessageEvent, "h2")
|
||||
registry.append(h1)
|
||||
registry.append(h2)
|
||||
registry.remove(h1)
|
||||
assert "h1" not in registry.star_handlers_map
|
||||
assert h1 not in registry._handlers
|
||||
assert "h2" in registry.star_handlers_map
|
||||
|
||||
def test_remove_nonexistent_no_error(self, registry, mock_handler):
|
||||
"""remove() of non-existent handler does not raise."""
|
||||
handler = mock_handler(EventType.AdapterMessageEvent, "h1")
|
||||
registry.remove(handler) # Should not raise
|
||||
|
||||
|
||||
class TestStarHandlerRegistryIteration:
|
||||
"""Tests for __iter__ and __len__."""
|
||||
|
||||
def test_iter_yields_handlers(self, registry, mock_handler):
|
||||
"""__iter__ yields all handlers in priority order."""
|
||||
h1 = mock_handler(EventType.AdapterMessageEvent, "h1")
|
||||
h2 = mock_handler(EventType.AdapterMessageEvent, "h2")
|
||||
registry.append(h1)
|
||||
registry.append(h2)
|
||||
result = list(registry)
|
||||
assert h1 in result
|
||||
assert h2 in result
|
||||
|
||||
def test_len_returns_count(self, registry, mock_handler):
|
||||
"""__len__ returns number of handlers."""
|
||||
assert len(registry) == 0
|
||||
registry.append(mock_handler(EventType.AdapterMessageEvent, "h1"))
|
||||
assert len(registry) == 1
|
||||
registry.append(mock_handler(EventType.AdapterMessageEvent, "h2"))
|
||||
assert len(registry) == 2
|
||||
|
||||
|
||||
class TestStarHandlerMetadataPriority:
|
||||
"""Tests for StarHandlerMetadata.__lt__()."""
|
||||
|
||||
def test_lt_lower_priority(self):
|
||||
"""Handler with lower priority is 'less than' higher priority."""
|
||||
h_low = StarHandlerMetadata(
|
||||
event_type=EventType.AdapterMessageEvent,
|
||||
handler_full_name="low",
|
||||
handler_name="low",
|
||||
handler_module_path="m",
|
||||
handler=MagicMock(),
|
||||
event_filters=[],
|
||||
extras_configs={"priority": 1},
|
||||
)
|
||||
h_high = StarHandlerMetadata(
|
||||
event_type=EventType.AdapterMessageEvent,
|
||||
handler_full_name="high",
|
||||
handler_name="high",
|
||||
handler_module_path="m",
|
||||
handler=MagicMock(),
|
||||
event_filters=[],
|
||||
extras_configs={"priority": 5},
|
||||
)
|
||||
assert (h_low < h_high) is True
|
||||
assert (h_high < h_low) is False
|
||||
|
||||
def test_lt_default_priority(self):
|
||||
"""Handler with default priority (0) is less than non-zero."""
|
||||
h_default = StarHandlerMetadata(
|
||||
event_type=EventType.AdapterMessageEvent,
|
||||
handler_full_name="default",
|
||||
handler_name="default",
|
||||
handler_module_path="m",
|
||||
handler=MagicMock(),
|
||||
event_filters=[],
|
||||
)
|
||||
h_nonzero = StarHandlerMetadata(
|
||||
event_type=EventType.AdapterMessageEvent,
|
||||
handler_full_name="nonzero",
|
||||
handler_name="nonzero",
|
||||
handler_module_path="m",
|
||||
handler=MagicMock(),
|
||||
event_filters=[],
|
||||
extras_configs={"priority": 10},
|
||||
)
|
||||
assert (h_default < h_nonzero) is True
|
||||
|
||||
def test_lt_same_priority(self):
|
||||
"""Handlers with same priority return False for both comparisons."""
|
||||
h1 = StarHandlerMetadata(
|
||||
event_type=EventType.AdapterMessageEvent,
|
||||
handler_full_name="h1",
|
||||
handler_name="h1",
|
||||
handler_module_path="m",
|
||||
handler=MagicMock(),
|
||||
event_filters=[],
|
||||
extras_configs={"priority": 5},
|
||||
)
|
||||
h2 = StarHandlerMetadata(
|
||||
event_type=EventType.AdapterMessageEvent,
|
||||
handler_full_name="h2",
|
||||
handler_name="h2",
|
||||
handler_module_path="m",
|
||||
handler=MagicMock(),
|
||||
event_filters=[],
|
||||
extras_configs={"priority": 5},
|
||||
)
|
||||
assert (h1 < h2) is False
|
||||
assert (h2 < h1) is False
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Tests for UmopConfigRouter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.umop_config_router import UmopConfigRouter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sp():
|
||||
"""Create a mock SharedPreferences."""
|
||||
sp = AsyncMock()
|
||||
sp.get_async = AsyncMock(return_value={})
|
||||
sp.global_put = AsyncMock()
|
||||
return sp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def router(mock_sp):
|
||||
"""Create an UmopConfigRouter instance."""
|
||||
return UmopConfigRouter(mock_sp)
|
||||
|
||||
|
||||
class TestSplitUmo:
|
||||
"""Tests for _split_umo static method."""
|
||||
|
||||
def test_valid_umo_three_parts(self):
|
||||
"""Split a valid UMO with three parts."""
|
||||
result = UmopConfigRouter._split_umo("telegram:private:12345")
|
||||
assert result == ("telegram", "private", "12345")
|
||||
|
||||
def test_valid_umo_with_colons_in_session(self):
|
||||
"""UMO with colon in session_id (split on 3 parts max)."""
|
||||
result = UmopConfigRouter._split_umo("discord:group:channel:123")
|
||||
assert result == ("discord", "group", "channel:123")
|
||||
|
||||
def test_valid_umo_empty_parts(self):
|
||||
"""UMO with empty parts."""
|
||||
result = UmopConfigRouter._split_umo("telegram::user_456")
|
||||
assert result == ("telegram", "", "user_456")
|
||||
|
||||
def test_valid_umo_all_empty(self):
|
||||
"""UMO with all empty parts."""
|
||||
result = UmopConfigRouter._split_umo("::")
|
||||
assert result == ("", "", "")
|
||||
|
||||
def test_two_parts_returns_none(self):
|
||||
"""UMO with only two parts is invalid."""
|
||||
result = UmopConfigRouter._split_umo("telegram:private")
|
||||
assert result is None
|
||||
|
||||
def test_one_part_returns_none(self):
|
||||
"""UMO with only one part is invalid."""
|
||||
result = UmopConfigRouter._split_umo("telegram")
|
||||
assert result is None
|
||||
|
||||
def test_non_string_returns_none(self):
|
||||
"""UMO that is not a string returns None."""
|
||||
assert UmopConfigRouter._split_umo(None) is None
|
||||
assert UmopConfigRouter._split_umo(123) is None
|
||||
|
||||
def test_four_parts_returns_three(self):
|
||||
"""UMO with four parts splits to three (last keeps colon)."""
|
||||
result = UmopConfigRouter._split_umo("a:b:c:d")
|
||||
assert result == ("a", "b", "c:d")
|
||||
|
||||
|
||||
class TestIsUmoMatch:
|
||||
"""Tests for _is_umo_match method."""
|
||||
|
||||
def test_exact_match(self):
|
||||
"""Exact UMO matches."""
|
||||
router = UmopConfigRouter(MagicMock())
|
||||
router.umop_to_conf_id = {}
|
||||
assert (
|
||||
router._is_umo_match("telegram:private:123", "telegram:private:123") is True
|
||||
)
|
||||
|
||||
def test_wildcard_platform(self):
|
||||
"""Wildcard '*' in pattern matches any platform."""
|
||||
router = UmopConfigRouter(MagicMock())
|
||||
router.umop_to_conf_id = {}
|
||||
assert router._is_umo_match("*:group:456", "telegram:group:456") is True
|
||||
assert router._is_umo_match("*:group:456", "discord:group:456") is True
|
||||
|
||||
def test_wildcard_type(self):
|
||||
"""Wildcard in type position matches any type."""
|
||||
router = UmopConfigRouter(MagicMock())
|
||||
router.umop_to_conf_id = {}
|
||||
assert router._is_umo_match("telegram:*:123", "telegram:private:123") is True
|
||||
assert router._is_umo_match("telegram:*:123", "telegram:group:123") is True
|
||||
|
||||
def test_wildcard_session(self):
|
||||
"""Wildcard in session position matches any session."""
|
||||
router = UmopConfigRouter(MagicMock())
|
||||
router.umop_to_conf_id = {}
|
||||
assert (
|
||||
router._is_umo_match("telegram:private:*", "telegram:private:123") is True
|
||||
)
|
||||
assert (
|
||||
router._is_umo_match("telegram:private:*", "telegram:private:abc") is True
|
||||
)
|
||||
|
||||
def test_fnmatch_patterns(self):
|
||||
"""fnmatch-style patterns work."""
|
||||
router = UmopConfigRouter(MagicMock())
|
||||
router.umop_to_conf_id = {}
|
||||
assert router._is_umo_match("telegram:group:*", "telegram:group:123") is True
|
||||
assert router._is_umo_match("*:private:*", "telegram:private:123") is True
|
||||
assert router._is_umo_match("*:private:*", "discord:private:456") is True
|
||||
|
||||
def test_empty_pattern_matches_empty(self):
|
||||
"""Empty string in pattern matches empty string."""
|
||||
router = UmopConfigRouter(MagicMock())
|
||||
router.umop_to_conf_id = {}
|
||||
assert router._is_umo_match("telegram::123", "telegram::123") is True
|
||||
|
||||
def test_non_matching_pattern(self):
|
||||
"""Pattern that doesn't match returns False."""
|
||||
router = UmopConfigRouter(MagicMock())
|
||||
router.umop_to_conf_id = {}
|
||||
assert (
|
||||
router._is_umo_match("telegram:private:123", "discord:private:123") is False
|
||||
)
|
||||
assert (
|
||||
router._is_umo_match("telegram:private:123", "telegram:group:123") is False
|
||||
)
|
||||
|
||||
def test_invalid_patternUMO(self):
|
||||
"""Invalid pattern UMO returns False."""
|
||||
router = UmopConfigRouter(MagicMock())
|
||||
router.umop_to_conf_id = {}
|
||||
assert router._is_umo_match("invalid", "telegram:private:123") is False
|
||||
|
||||
def test_invalid_targetUMO(self):
|
||||
"""Invalid target UMO returns False."""
|
||||
router = UmopConfigRouter(MagicMock())
|
||||
router.umop_to_conf_id = {}
|
||||
assert router._is_umo_match("telegram:private:123", "invalid") is False
|
||||
|
||||
def test_both_invalid_return_false(self):
|
||||
"""Both invalid UMOs return False."""
|
||||
router = UmopConfigRouter(MagicMock())
|
||||
router.umop_to_conf_id = {}
|
||||
assert router._is_umo_match("invalid", "also_invalid") is False
|
||||
|
||||
|
||||
class TestGetConfIdForUmop:
|
||||
"""Tests for get_conf_id_for_umop method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finds_matching_route(self, router):
|
||||
"""Returns conf_id for matching pattern."""
|
||||
router.umop_to_conf_id = {
|
||||
"telegram:private:*": "config_1",
|
||||
"discord:group:*": "config_2",
|
||||
}
|
||||
result = router.get_conf_id_for_umop("telegram:private:123")
|
||||
assert result == "config_1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finds_matching_route_group(self, router):
|
||||
"""Returns conf_id for group message."""
|
||||
router.umop_to_conf_id = {
|
||||
"telegram:group:*": "group_config",
|
||||
}
|
||||
result = router.get_conf_id_for_umop("telegram:group:456")
|
||||
assert result == "group_config"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wildcard_pattern_matches(self, router):
|
||||
"""Wildcard pattern matches correctly."""
|
||||
router.umop_to_conf_id = {
|
||||
"*:private:*": "any_private",
|
||||
}
|
||||
result = router.get_conf_id_for_umop("discord:private:789")
|
||||
assert result == "any_private"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_match_returns_none(self, router):
|
||||
"""No matching pattern returns None."""
|
||||
router.umop_to_conf_id = {
|
||||
"telegram:private:*": "config_1",
|
||||
}
|
||||
result = router.get_conf_id_for_umop("discord:group:999")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_routing_table(self, router):
|
||||
"""Empty routing table returns None."""
|
||||
router.umop_to_conf_id = {}
|
||||
result = router.get_conf_id_for_umop("telegram:private:123")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestUpdateRoutingData:
|
||||
"""Tests for update_routing_data method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_routing_update(self, router, mock_sp):
|
||||
"""Valid routing dict is stored and persisted."""
|
||||
new_routing = {
|
||||
"telegram:private:*": "config_telegram",
|
||||
"discord:group:*": "config_discord",
|
||||
}
|
||||
await router.update_routing_data(new_routing)
|
||||
assert router.umop_to_conf_id == new_routing
|
||||
mock_sp.global_put.assert_called_once_with("umop_config_routing", new_routing)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_key_raises(self, router):
|
||||
"""Invalid UMO key raises ValueError."""
|
||||
new_routing = {
|
||||
"invalid_umo": "config_1",
|
||||
}
|
||||
with pytest.raises(ValueError, match="umop keys must be"):
|
||||
await router.update_routing_data(new_routing)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_invalid_key_raises(self, router):
|
||||
"""One invalid key among valid keys raises ValueError."""
|
||||
new_routing = {
|
||||
"telegram:private:*": "config_1",
|
||||
"invalid": "config_2",
|
||||
}
|
||||
with pytest.raises(ValueError, match="umop keys must be"):
|
||||
await router.update_routing_data(new_routing)
|
||||
|
||||
|
||||
class TestUpdateRoute:
|
||||
"""Tests for update_route method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_route_update(self, router, mock_sp):
|
||||
"""Valid umo and conf_id updates route and persists."""
|
||||
await router.update_route("telegram:group:*", "new_config")
|
||||
assert router.umop_to_conf_id["telegram:group:*"] == "new_config"
|
||||
mock_sp.global_put.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_umo_raises(self, router):
|
||||
"""Invalid UMO raises ValueError."""
|
||||
with pytest.raises(ValueError, match="umop must be a string"):
|
||||
await router.update_route("invalid", "conf")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_type_raises(self, router):
|
||||
"""Invalid type raises ValueError."""
|
||||
with pytest.raises(ValueError, match="umop must be a string"):
|
||||
await router.update_route("only_two_parts", "conf")
|
||||
|
||||
|
||||
class TestDeleteRoute:
|
||||
"""Tests for delete_route method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_existing_route(self, router, mock_sp):
|
||||
"""Deleting existing route removes it and persists."""
|
||||
router.umop_to_conf_id = {
|
||||
"telegram:private:*": "config_1",
|
||||
"discord:group:*": "config_2",
|
||||
}
|
||||
await router.delete_route("telegram:private:*")
|
||||
assert "telegram:private:*" not in router.umop_to_conf_id
|
||||
assert "discord:group:*" in router.umop_to_conf_id
|
||||
mock_sp.global_put.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_route_no_persist(self, router, mock_sp):
|
||||
"""Deleting non-existent route does NOT call persist (early return)."""
|
||||
router.umop_to_conf_id = {}
|
||||
await router.delete_route("telegram:private:*")
|
||||
mock_sp.global_put.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_invalid_umo_raises(self, router):
|
||||
"""Deleting invalid UMO raises ValueError."""
|
||||
with pytest.raises(ValueError, match="umop must be a string"):
|
||||
await router.delete_route("invalid")
|
||||
|
||||
|
||||
class TestInitialize:
|
||||
"""Tests for initialize method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_loads_routing_table(self, router, mock_sp):
|
||||
"""initialize loads routing table from SharedPreferences."""
|
||||
mock_sp.get_async.return_value = {
|
||||
"telegram:private:*": "loaded_config",
|
||||
}
|
||||
await router.initialize()
|
||||
assert router.umop_to_conf_id == {
|
||||
"telegram:private:*": "loaded_config",
|
||||
}
|
||||
Reference in New Issue
Block a user