初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,41 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.config.loader import load_models_config, load_providers_config
|
||||
|
||||
|
||||
class TestLoader:
|
||||
def test_load_models_config(self, tmp_path: Path):
|
||||
yaml_content = """
|
||||
version: 1
|
||||
models:
|
||||
- name: test-model
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
format: openai
|
||||
"""
|
||||
config_file = tmp_path / "models.yaml"
|
||||
config_file.write_text(yaml_content)
|
||||
models = load_models_config(config_file)
|
||||
assert len(models) == 1
|
||||
assert models[0].name == "test-model"
|
||||
|
||||
def test_load_providers_config(self, tmp_path: Path):
|
||||
yaml_content = """
|
||||
version: 1
|
||||
providers:
|
||||
deepseek:
|
||||
format: openai
|
||||
base_url_env: DEEPSEEK_BASE_URL
|
||||
api_key_env: DEEPSEEK_API_KEY
|
||||
"""
|
||||
config_file = tmp_path / "providers.yaml"
|
||||
config_file.write_text(yaml_content)
|
||||
providers = load_providers_config(config_file)
|
||||
assert "deepseek" in providers
|
||||
assert providers["deepseek"].format == "openai"
|
||||
|
||||
def test_missing_file(self):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_models_config(Path("/nonexistent/models.yaml"))
|
||||
@@ -0,0 +1,69 @@
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.config.models import (
|
||||
ModelConfig,
|
||||
ProviderConfig,
|
||||
derive_capabilities,
|
||||
)
|
||||
|
||||
|
||||
class TestModelConfig:
|
||||
def test_minimal_config(self):
|
||||
cfg = ModelConfig(
|
||||
name="deepseek-v4-flash",
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-flash",
|
||||
format="openai",
|
||||
)
|
||||
assert cfg.name == "deepseek-v4-flash"
|
||||
assert cfg.format == "openai"
|
||||
assert not cfg.supports_vision # defaulted
|
||||
|
||||
def test_capability_override(self):
|
||||
cfg = ModelConfig(
|
||||
name="claude-sonnet",
|
||||
provider="anthropic",
|
||||
model="claude-sonnet-4-6",
|
||||
format="anthropic",
|
||||
supports_vision=True,
|
||||
supports_tools=True,
|
||||
)
|
||||
assert cfg.supports_vision
|
||||
assert cfg.supports_tools
|
||||
|
||||
def test_capability_auto_derive(self):
|
||||
caps = derive_capabilities(provider="anthropic", model="claude-fable-5")
|
||||
assert "chat" in caps
|
||||
assert "tool_use" in caps
|
||||
assert "vision" in caps
|
||||
assert "reasoning" in caps
|
||||
|
||||
|
||||
class TestProviderConfig:
|
||||
def test_minimal(self):
|
||||
cfg = ProviderConfig(
|
||||
name="deepseek",
|
||||
format="openai",
|
||||
base_url_env="DEEPSEEK_BASE_URL",
|
||||
api_key_env="DEEPSEEK_API_KEY",
|
||||
)
|
||||
assert cfg.name == "deepseek"
|
||||
|
||||
def test_with_parameter_mapping(self):
|
||||
cfg = ProviderConfig(
|
||||
name="deepseek",
|
||||
format="openai",
|
||||
base_url_env="DEEPSEEK_BASE_URL",
|
||||
api_key_env="DEEPSEEK_API_KEY",
|
||||
parameter_mappings={
|
||||
"max_tokens": {"provider_param": "max_tokens"},
|
||||
"temperature": {"provider_param": "temperature", "min": 0, "max": 2},
|
||||
},
|
||||
)
|
||||
mapping = cfg.parameter_mappings["temperature"]
|
||||
assert mapping.min == 0
|
||||
assert mapping.max == 2
|
||||
|
||||
def test_format_validation(self):
|
||||
with pytest.raises(ValueError, match="format"):
|
||||
ProviderConfig(name="bad", format="unknown", base_url_env="X", api_key_env="Y")
|
||||
Whitespace-only changes.
@@ -0,0 +1,67 @@
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.core.billing import (
|
||||
BudgetController,
|
||||
CostCalculator,
|
||||
PricingInfo,
|
||||
UsageRecord,
|
||||
)
|
||||
|
||||
|
||||
class TestCostCalculator:
|
||||
def test_openai_pricing(self):
|
||||
pricing = PricingInfo(currency="USD", input_per_1m=5.0, output_per_1m=15.0)
|
||||
cost = CostCalculator.calculate(pricing, 1000, 500)
|
||||
assert cost.input_cost == 0.005 # 1000 / 1M * 5.0
|
||||
assert cost.output_cost == 0.0075 # 500 / 1M * 15.0
|
||||
assert cost.total == 0.0125
|
||||
assert cost.currency == "USD"
|
||||
|
||||
def test_no_output_pricing(self):
|
||||
pricing = PricingInfo(currency="CNY", input_per_1m=1.0, output_per_1m=None)
|
||||
cost = CostCalculator.calculate(pricing, 1000, 0)
|
||||
assert cost.input_cost == 0.001
|
||||
assert cost.output_cost == 0.0
|
||||
|
||||
def test_no_pricing(self):
|
||||
cost = CostCalculator.calculate(None, 1000, 500)
|
||||
assert cost.total == 0.0 # gracefully handles missing pricing
|
||||
|
||||
|
||||
class TestUsageRecord:
|
||||
def test_create_record(self):
|
||||
record = UsageRecord(
|
||||
model_name="deepseek-v4-flash",
|
||||
provider="deepseek",
|
||||
prompt_tokens=100,
|
||||
completion_tokens=50,
|
||||
total_tokens=150,
|
||||
currency="CNY",
|
||||
cost=0.0002,
|
||||
)
|
||||
assert record.total_tokens == 150
|
||||
|
||||
|
||||
class TestBudgetController:
|
||||
@pytest.fixture
|
||||
def controller(self):
|
||||
return BudgetController(
|
||||
daily_budget_cny=10.0,
|
||||
monthly_budget_cny=200.0,
|
||||
)
|
||||
|
||||
def test_within_budget(self, controller):
|
||||
controller.add_cost_cny(1.0)
|
||||
assert controller.allow_request()
|
||||
|
||||
def test_exceed_daily_budget(self, controller):
|
||||
controller.add_cost_cny(11.0)
|
||||
assert not controller.allow_request()
|
||||
|
||||
def test_reset_daily(self):
|
||||
controller = BudgetController(daily_budget_cny=10.0, monthly_budget_cny=200.0)
|
||||
controller.add_cost_cny(11.0)
|
||||
assert not controller.allow_request()
|
||||
# Simulate next day
|
||||
controller.reset_daily()
|
||||
assert controller.allow_request()
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ProviderConfig
|
||||
from app.ai_provider.core.registry import (
|
||||
ModelRegistry,
|
||||
ProviderRegistry,
|
||||
RegistryFactory,
|
||||
RegistryHolder,
|
||||
)
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
|
||||
|
||||
class TestModelRegistry:
|
||||
@pytest.fixture
|
||||
def sample_models(self):
|
||||
return [
|
||||
ModelConfig(
|
||||
name="fast-model",
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-flash",
|
||||
format="openai",
|
||||
tags=["default"],
|
||||
weight=100,
|
||||
),
|
||||
ModelConfig(
|
||||
name="pro-model",
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-pro",
|
||||
format="openai",
|
||||
tags=["fallback"],
|
||||
weight=50,
|
||||
),
|
||||
ModelConfig(
|
||||
name="vision-model",
|
||||
provider="anthropic",
|
||||
model="claude-sonnet-4-6",
|
||||
format="anthropic",
|
||||
supports_vision=True,
|
||||
),
|
||||
]
|
||||
|
||||
def test_get_model(self, sample_models):
|
||||
reg = ModelRegistry(sample_models)
|
||||
model = reg.get("fast-model")
|
||||
assert model.name == "fast-model"
|
||||
|
||||
def test_get_nonexistent(self, sample_models):
|
||||
reg = ModelRegistry(sample_models)
|
||||
with pytest.raises(AIProviderError):
|
||||
reg.get("nonexistent")
|
||||
|
||||
def test_default_model(self, sample_models):
|
||||
reg = ModelRegistry(sample_models)
|
||||
assert reg.get_default_name() == "fast-model"
|
||||
|
||||
def test_fallback_model(self, sample_models):
|
||||
reg = ModelRegistry(sample_models)
|
||||
assert reg.get_fallback_name() == "pro-model"
|
||||
|
||||
def test_resolve_by_alias(self, sample_models):
|
||||
reg = ModelRegistry(sample_models, aliases={"deepseek:deepseek-v4-flash": "fast-model"})
|
||||
model = reg.resolve("deepseek:deepseek-v4-flash")
|
||||
assert model.name == "fast-model"
|
||||
|
||||
def test_resolve_by_logical_name(self, sample_models):
|
||||
reg = ModelRegistry(sample_models)
|
||||
model = reg.resolve("fast-model")
|
||||
assert model.name == "fast-model"
|
||||
|
||||
def test_list_by_capability(self, sample_models):
|
||||
reg = ModelRegistry(sample_models)
|
||||
models = reg.list_by_capability("vision")
|
||||
assert len(models) == 1
|
||||
assert models[0].name == "vision-model"
|
||||
|
||||
def test_resolve_by_capability(self, sample_models):
|
||||
reg = ModelRegistry(sample_models)
|
||||
name = reg.resolve_by_capability("vision")
|
||||
assert name == "vision-model"
|
||||
|
||||
def test_weighted_random_resolve(self, sample_models):
|
||||
reg = ModelRegistry(sample_models)
|
||||
name = reg.resolve()
|
||||
assert name.name == "fast-model"
|
||||
|
||||
def test_contains_returns_true_for_registered(self, sample_models):
|
||||
reg = ModelRegistry(sample_models)
|
||||
assert reg.contains("fast-model") is True
|
||||
|
||||
def test_contains_returns_false_for_missing(self, sample_models):
|
||||
reg = ModelRegistry(sample_models)
|
||||
assert reg.contains("phantom-model") is False
|
||||
|
||||
def test_list_all_returns_all_models(self, sample_models):
|
||||
reg = ModelRegistry(sample_models)
|
||||
assert len(reg.list_all()) == 3
|
||||
|
||||
|
||||
class TestProviderRegistry:
|
||||
@pytest.fixture
|
||||
def sample_providers(self):
|
||||
return {
|
||||
"deepseek": ProviderConfig(
|
||||
name="deepseek",
|
||||
format="openai",
|
||||
base_url_env="DEEPSEEK_BASE_URL",
|
||||
api_key_env="DEEPSEEK_API_KEY",
|
||||
),
|
||||
"anthropic": ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="ANTHROPIC_BASE_URL",
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
),
|
||||
}
|
||||
|
||||
def test_get_provider(self, sample_providers):
|
||||
reg = ProviderRegistry(sample_providers)
|
||||
prov = reg.get("deepseek")
|
||||
assert prov.format == "openai"
|
||||
|
||||
def test_get_nonexistent(self, sample_providers):
|
||||
reg = ProviderRegistry(sample_providers)
|
||||
with pytest.raises(AIProviderError):
|
||||
reg.get("nonexistent")
|
||||
|
||||
def test_list_all(self, sample_providers):
|
||||
reg = ProviderRegistry(sample_providers)
|
||||
provs = reg.list_all()
|
||||
assert set(provs.keys()) == {"deepseek", "anthropic"}
|
||||
|
||||
|
||||
class TestRegistryFactory:
|
||||
def test_from_configs(self, tmp_path):
|
||||
models_yaml = tmp_path / "models.yaml"
|
||||
models_yaml.write_text(
|
||||
"""
|
||||
version: 1
|
||||
models:
|
||||
- name: test-model
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
format: openai
|
||||
"""
|
||||
)
|
||||
providers_yaml = tmp_path / "providers.yaml"
|
||||
providers_yaml.write_text(
|
||||
"""
|
||||
version: 1
|
||||
providers:
|
||||
deepseek:
|
||||
format: openai
|
||||
base_url_env: DEEPSEEK_BASE_URL
|
||||
api_key_env: DEEPSEEK_API_KEY
|
||||
"""
|
||||
)
|
||||
from app.ai_provider.config.loader import load_models_config, load_providers_config
|
||||
|
||||
models = load_models_config(models_yaml)
|
||||
providers = load_providers_config(providers_yaml)
|
||||
registry = RegistryFactory.create(models, providers)
|
||||
assert registry.model_registry.get("test-model") is not None
|
||||
assert registry.provider_registry.get("deepseek") is not None
|
||||
|
||||
def test_create_with_aliases(self, tmp_path):
|
||||
models_yaml = tmp_path / "models.yaml"
|
||||
models_yaml.write_text(
|
||||
"""
|
||||
version: 1
|
||||
models:
|
||||
- name: fast-model
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
format: openai
|
||||
tags: ["default"]
|
||||
"""
|
||||
)
|
||||
providers_yaml = tmp_path / "providers.yaml"
|
||||
providers_yaml.write_text(
|
||||
"""
|
||||
version: 1
|
||||
providers:
|
||||
deepseek:
|
||||
format: openai
|
||||
base_url_env: DEEPSEEK_BASE_URL
|
||||
api_key_env: DEEPSEEK_API_KEY
|
||||
"""
|
||||
)
|
||||
from app.ai_provider.config.loader import load_models_config, load_providers_config
|
||||
|
||||
models = load_models_config(models_yaml)
|
||||
providers = load_providers_config(providers_yaml)
|
||||
registry = RegistryFactory.create(
|
||||
models, providers, aliases={"deepseek:deepseek-v4-flash": "fast-model"}
|
||||
)
|
||||
assert registry.model_registry.resolve("deepseek:deepseek-v4-flash").name == "fast-model"
|
||||
|
||||
|
||||
class TestRegistryHolder:
|
||||
def test_holds_both_registries(self):
|
||||
model_reg = ModelRegistry(
|
||||
[
|
||||
ModelConfig(
|
||||
name="m1",
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-flash",
|
||||
format="openai",
|
||||
)
|
||||
]
|
||||
)
|
||||
prov_reg = ProviderRegistry(
|
||||
{
|
||||
"deepseek": ProviderConfig(
|
||||
name="deepseek",
|
||||
format="openai",
|
||||
base_url_env="DEEPSEEK_BASE_URL",
|
||||
api_key_env="DEEPSEEK_API_KEY",
|
||||
)
|
||||
}
|
||||
)
|
||||
holder = RegistryHolder(model_reg, prov_reg)
|
||||
assert holder.model_registry.get("m1") is not None
|
||||
assert holder.provider_registry.get("deepseek") is not None
|
||||
@@ -0,0 +1,109 @@
|
||||
# backend/tests/ai_provider/core/test_resilience.py
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.core.resilience import (
|
||||
CircuitBreaker,
|
||||
CircuitBreakerState,
|
||||
DowngradeAction,
|
||||
DowngradeChain,
|
||||
ResilienceFactory,
|
||||
RetryStrategy,
|
||||
)
|
||||
|
||||
|
||||
class TestCircuitBreaker:
|
||||
@pytest.fixture
|
||||
def breaker(self):
|
||||
return CircuitBreaker(
|
||||
name="test-breaker",
|
||||
failure_threshold=3,
|
||||
cooldown_seconds=1.0,
|
||||
)
|
||||
|
||||
def test_initial_state(self, breaker):
|
||||
assert breaker.state == CircuitBreakerState.CLOSED
|
||||
|
||||
def test_trip_after_threshold(self, breaker):
|
||||
breaker.record_failure()
|
||||
assert breaker.state == CircuitBreakerState.CLOSED
|
||||
breaker.record_failure()
|
||||
assert breaker.state == CircuitBreakerState.CLOSED
|
||||
breaker.record_failure()
|
||||
assert breaker.state == CircuitBreakerState.OPEN
|
||||
|
||||
def test_reset_on_success(self, breaker):
|
||||
breaker.record_failure()
|
||||
breaker.record_failure()
|
||||
breaker.record_success()
|
||||
assert breaker.state == CircuitBreakerState.CLOSED
|
||||
|
||||
def test_half_open_after_cooldown(self):
|
||||
breaker = CircuitBreaker(name="test", failure_threshold=1, cooldown_seconds=0.01)
|
||||
breaker.record_failure()
|
||||
assert breaker.state == CircuitBreakerState.OPEN
|
||||
asyncio.run(asyncio.sleep(0.02))
|
||||
assert breaker.state == CircuitBreakerState.HALF_OPEN
|
||||
|
||||
def test_trip_from_half_open(self):
|
||||
breaker = CircuitBreaker(name="test", failure_threshold=1, cooldown_seconds=0.01)
|
||||
breaker.record_failure()
|
||||
asyncio.run(asyncio.sleep(0.02))
|
||||
assert breaker.state == CircuitBreakerState.HALF_OPEN
|
||||
breaker.record_failure()
|
||||
assert breaker.state == CircuitBreakerState.OPEN
|
||||
|
||||
|
||||
class TestRetryStrategy:
|
||||
@pytest.fixture
|
||||
def strategy(self):
|
||||
return RetryStrategy(max_retries=3, base_delay=0.01, max_delay=0.1)
|
||||
|
||||
def test_should_retry(self, strategy):
|
||||
assert strategy.should_retry(0)
|
||||
assert strategy.should_retry(1)
|
||||
assert strategy.should_retry(2)
|
||||
assert not strategy.should_retry(3)
|
||||
|
||||
def test_delay_exponential(self, strategy):
|
||||
d0 = strategy.get_delay(0)
|
||||
d1 = strategy.get_delay(1)
|
||||
d2 = strategy.get_delay(2)
|
||||
assert d1 > d0
|
||||
assert d2 > d1
|
||||
|
||||
|
||||
class TestDowngradeChain:
|
||||
def test_fallback_chain(self):
|
||||
chain = DowngradeChain(
|
||||
[
|
||||
DowngradeAction(type="fallback_endpoint", target="backup-1"),
|
||||
DowngradeAction(type="fallback_endpoint", target="backup-2"),
|
||||
DowngradeAction(type="fallback_model", target="cheap-model"),
|
||||
]
|
||||
)
|
||||
assert chain.next_action() == ("fallback_endpoint", "backup-1")
|
||||
chain.advance()
|
||||
assert chain.next_action() == ("fallback_endpoint", "backup-2")
|
||||
chain.advance()
|
||||
assert chain.next_action() == ("fallback_model", "cheap-model")
|
||||
chain.advance()
|
||||
assert chain.is_exhausted()
|
||||
|
||||
|
||||
class TestResilienceFactory:
|
||||
def test_create_breaker(self):
|
||||
breaker = ResilienceFactory.create_circuit_breaker(
|
||||
name="test",
|
||||
failure_threshold=5,
|
||||
cooldown_seconds=30,
|
||||
)
|
||||
assert breaker.failure_threshold == 5
|
||||
|
||||
def test_create_retry(self):
|
||||
strategy = ResilienceFactory.create_retry_strategy(
|
||||
max_retries=3,
|
||||
base_delay=1.0,
|
||||
)
|
||||
assert strategy.max_retries == 3
|
||||
@@ -0,0 +1,543 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ParameterMapping, ProviderConfig
|
||||
from app.ai_provider.llm.anthropic_format import AnthropicFormatAdapter
|
||||
from app.ai_provider.types.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
|
||||
|
||||
class TestAnthropicMessageConversion:
|
||||
"""Conversion of internal message types to Anthropic content-block format."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
provider = ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="ANTHROPIC_BASE_URL",
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
parameter_mappings={
|
||||
"temperature": ParameterMapping(provider_param="temperature", min=0, max=1),
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens"),
|
||||
"top_p": ParameterMapping(provider_param="top_p", min=0, max=1),
|
||||
},
|
||||
)
|
||||
return AnthropicFormatAdapter(provider_config=provider)
|
||||
|
||||
def test_convert_human_message(self, adapter):
|
||||
"""HumanMessage becomes user role with a text content block."""
|
||||
msg = HumanMessage(content="Hello!")
|
||||
result = adapter._convert_message(msg)
|
||||
assert result["role"] == "user"
|
||||
assert result["content"] == [{"type": "text", "text": "Hello!"}]
|
||||
|
||||
def test_convert_ai_message(self, adapter):
|
||||
"""AIMessage becomes assistant role with a text content block."""
|
||||
msg = AIMessage(content="Sure, let me help.")
|
||||
result = adapter._convert_message(msg)
|
||||
assert result["role"] == "assistant"
|
||||
assert result["content"] == [{"type": "text", "text": "Sure, let me help."}]
|
||||
|
||||
def test_convert_ai_message_replays_raw_content_blocks(self, adapter):
|
||||
blocks = [
|
||||
{"type": "thinking", "thinking": "summary", "signature": "sig_1"},
|
||||
{"type": "tool_use", "id": "call_1", "name": "read_file", "input": {}},
|
||||
]
|
||||
msg = AIMessage(content="", raw_content_blocks=blocks)
|
||||
|
||||
assert adapter._convert_message(msg) == {"role": "assistant", "content": blocks}
|
||||
|
||||
def test_convert_ai_message_with_tool_calls(self, adapter):
|
||||
"""AIMessage with tool_calls produces tool_use content blocks."""
|
||||
msg = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{"name": "read_file", "args": {"path": "x.txt"}, "id": "call_1"},
|
||||
],
|
||||
)
|
||||
result = adapter._convert_message(msg)
|
||||
assert result["role"] == "assistant"
|
||||
assert result["content"] == [
|
||||
{"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "x.txt"}},
|
||||
]
|
||||
|
||||
def test_convert_ai_message_with_text_and_tool_calls(self, adapter):
|
||||
"""AIMessage with both text and tool_calls produces multiple content blocks."""
|
||||
msg = AIMessage(
|
||||
content="I will check that file.",
|
||||
tool_calls=[
|
||||
{"name": "read_file", "args": {"path": "x.txt"}, "id": "call_1"},
|
||||
{"name": "list_dir", "args": {"path": "/tmp"}, "id": "call_2"},
|
||||
],
|
||||
)
|
||||
result = adapter._convert_message(msg)
|
||||
assert result["role"] == "assistant"
|
||||
assert len(result["content"]) == 3
|
||||
assert result["content"][0] == {"type": "text", "text": "I will check that file."}
|
||||
assert result["content"][1] == {
|
||||
"type": "tool_use",
|
||||
"id": "call_1",
|
||||
"name": "read_file",
|
||||
"input": {"path": "x.txt"},
|
||||
}
|
||||
assert result["content"][2] == {
|
||||
"type": "tool_use",
|
||||
"id": "call_2",
|
||||
"name": "list_dir",
|
||||
"input": {"path": "/tmp"},
|
||||
}
|
||||
|
||||
def test_convert_tool_message(self, adapter):
|
||||
"""ToolMessage becomes user role with a tool_result content block."""
|
||||
msg = ToolMessage(content="File contents", tool_call_id="call_1")
|
||||
result = adapter._convert_message(msg)
|
||||
assert result["role"] == "user"
|
||||
assert result["content"] == [
|
||||
{"type": "tool_result", "tool_use_id": "call_1", "content": "File contents"},
|
||||
]
|
||||
|
||||
|
||||
class TestAnthropicRequestBuilding:
|
||||
"""Building the full request body for the Anthropic API."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
provider = ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="ANTHROPIC_BASE_URL",
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
parameter_mappings={
|
||||
"temperature": ParameterMapping(provider_param="temperature", min=0, max=1),
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens"),
|
||||
},
|
||||
)
|
||||
return AnthropicFormatAdapter(provider_config=provider)
|
||||
|
||||
@pytest.fixture
|
||||
def model(self):
|
||||
return ModelConfig(
|
||||
name="claude-sonnet-4",
|
||||
provider="anthropic",
|
||||
model="claude-sonnet-4-20250514",
|
||||
format="anthropic",
|
||||
max_output_tokens=8192,
|
||||
)
|
||||
|
||||
def test_build_request_body(self, adapter, model):
|
||||
"""Request body includes model, messages, and pipeline-processed params."""
|
||||
messages = [HumanMessage(content="Hi")]
|
||||
body = adapter.build_request(
|
||||
model=model,
|
||||
messages=messages,
|
||||
params={"temperature": 0.7, "max_tokens": 4096},
|
||||
)
|
||||
assert body["model"] == "claude-sonnet-4-20250514"
|
||||
assert body["temperature"] == 0.7
|
||||
assert body["max_tokens"] == 4096
|
||||
assert len(body["messages"]) == 1
|
||||
assert body["messages"][0]["role"] == "user"
|
||||
|
||||
def test_build_request_with_system_message(self, adapter, model):
|
||||
"""System message is placed in top-level 'system' field, not in messages."""
|
||||
messages = [
|
||||
SystemMessage(content="You are a helpful assistant."),
|
||||
HumanMessage(content="Hi"),
|
||||
]
|
||||
body = adapter.build_request(model=model, messages=messages)
|
||||
assert body["system"] == "You are a helpful assistant."
|
||||
# system message is removed from the messages array
|
||||
assert len(body["messages"]) == 1
|
||||
assert body["messages"][0]["role"] == "user"
|
||||
|
||||
def test_build_request_with_multiple_system_messages(self, adapter, model):
|
||||
"""Multiple system messages are concatenated with newlines."""
|
||||
messages = [
|
||||
SystemMessage(content="Be concise."),
|
||||
SystemMessage(content="Use Chinese."),
|
||||
HumanMessage(content="Hi"),
|
||||
]
|
||||
body = adapter.build_request(model=model, messages=messages)
|
||||
assert body["system"] == "Be concise.\nUse Chinese."
|
||||
assert len(body["messages"]) == 1
|
||||
|
||||
def test_build_request_sets_default_max_tokens(self, adapter, model):
|
||||
"""max_tokens defaults to model.max_output_tokens when not in params."""
|
||||
messages = [HumanMessage(content="Hi")]
|
||||
body = adapter.build_request(model=model, messages=messages, params={})
|
||||
assert body["max_tokens"] == 8192
|
||||
|
||||
def test_build_request_with_tools(self, adapter, model):
|
||||
"""Tools are converted to Anthropic format in the request."""
|
||||
messages = [HumanMessage(content="Read file x.txt")]
|
||||
openai_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file's contents",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
body = adapter.build_request(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=openai_tools,
|
||||
tool_choice="auto",
|
||||
)
|
||||
assert "tools" in body
|
||||
assert len(body["tools"]) == 1
|
||||
anthropic_tool = body["tools"][0]
|
||||
assert anthropic_tool["name"] == "read_file"
|
||||
assert anthropic_tool["description"] == "Read a file's contents"
|
||||
assert anthropic_tool["input_schema"] == {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"required": ["path"],
|
||||
}
|
||||
assert body["tool_choice"] == "auto"
|
||||
|
||||
def test_build_request_streaming(self, adapter, model):
|
||||
"""Streaming flag is passed through in the request."""
|
||||
messages = [HumanMessage(content="Hi")]
|
||||
body = adapter.build_request(model=model, messages=messages, stream=True)
|
||||
assert body["stream"] is True
|
||||
|
||||
|
||||
class TestAnthropicToolConversion:
|
||||
"""Converting tools between OpenAI and Anthropic formats."""
|
||||
|
||||
def test_convert_tools_standard(self):
|
||||
"""OpenAI format tools are converted to Anthropic format."""
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
},
|
||||
]
|
||||
result = AnthropicFormatAdapter._convert_tools(tools)
|
||||
assert len(result) == 1
|
||||
assert result[0] == {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"input_schema": {"type": "object"},
|
||||
}
|
||||
|
||||
def test_convert_tools_already_anthropic(self):
|
||||
"""Tools already in Anthropic format pass through correctly."""
|
||||
tools = [
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"input_schema": {"type": "object"},
|
||||
},
|
||||
]
|
||||
result = AnthropicFormatAdapter._convert_tools(tools)
|
||||
assert len(result) == 1
|
||||
assert result[0] == tools[0]
|
||||
|
||||
|
||||
class TestAnthropicResponseParsing:
|
||||
"""Parsing Anthropic API responses (non-streaming)."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
return AnthropicFormatAdapter(
|
||||
provider_config=ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="ANTHROPIC_BASE_URL",
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
)
|
||||
)
|
||||
|
||||
def test_parse_nonstreaming_text(self, adapter):
|
||||
"""Simple text response is parsed correctly."""
|
||||
raw = {
|
||||
"id": "msg_123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Hello, world!"}],
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||
}
|
||||
result = adapter.parse_nonstreaming_response(raw, "claude-sonnet-4")
|
||||
assert result.content == "Hello, world!"
|
||||
assert result.model_name == "claude-sonnet-4"
|
||||
assert result.usage is not None
|
||||
assert result.usage.prompt_tokens == 10
|
||||
assert result.usage.completion_tokens == 5
|
||||
assert result.usage.total_tokens == 15
|
||||
|
||||
def test_parse_nonstreaming_with_tool_calls(self, adapter):
|
||||
"""Response with tool_use blocks is parsed with tool_calls."""
|
||||
raw = {
|
||||
"id": "msg_456",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "I will read the file."},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tu_123",
|
||||
"name": "read_file",
|
||||
"input": {"path": "x.txt"},
|
||||
},
|
||||
],
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"stop_reason": "tool_use",
|
||||
"usage": {"input_tokens": 20, "output_tokens": 15},
|
||||
}
|
||||
result = adapter.parse_nonstreaming_response(raw, "claude-sonnet-4")
|
||||
assert result.content == "I will read the file."
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0] == {
|
||||
"name": "read_file",
|
||||
"args": {"path": "x.txt"},
|
||||
"id": "tu_123",
|
||||
}
|
||||
|
||||
def test_parse_nonstreaming_with_reasoning(self, adapter):
|
||||
"""Response with thinking blocks populates reasoning field."""
|
||||
raw = {
|
||||
"id": "msg_789",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Let me think step by step..."},
|
||||
{"type": "text", "text": "Here is the answer."},
|
||||
{"type": "signature", "signature": "sig_12345"},
|
||||
],
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 5, "output_tokens": 10},
|
||||
}
|
||||
result = adapter.parse_nonstreaming_response(raw, "claude-sonnet-4")
|
||||
assert result.content == "Here is the answer."
|
||||
# signature 块是加密签名,不进入 reasoning
|
||||
assert result.reasoning == "Let me think step by step..."
|
||||
|
||||
def test_parse_nonstreaming_no_usage(self, adapter):
|
||||
"""Response without usage data still parses correctly."""
|
||||
raw = {
|
||||
"id": "msg_000",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "No usage info."}],
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"stop_reason": "end_turn",
|
||||
}
|
||||
result = adapter.parse_nonstreaming_response(raw, "claude-sonnet-4")
|
||||
assert result.content == "No usage info."
|
||||
assert result.usage is None
|
||||
|
||||
def test_parse_content_blocks_empty(self):
|
||||
"""Empty content blocks produce empty text and no tool calls."""
|
||||
raw = {"content": []}
|
||||
text, tools, reasoning = AnthropicFormatAdapter._parse_content_blocks(raw)
|
||||
assert text == ""
|
||||
assert tools == []
|
||||
assert reasoning == ""
|
||||
|
||||
def test_bind_tools(self):
|
||||
"""bind_tools adds converted tools to the request body."""
|
||||
adapter = AnthropicFormatAdapter(
|
||||
provider_config=ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="ANTHROPIC_BASE_URL",
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
)
|
||||
)
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
},
|
||||
]
|
||||
body = {}
|
||||
adapter.bind_tools(body, tools, tool_choice="auto")
|
||||
assert body["tools"] == [
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"input_schema": {"type": "object"},
|
||||
},
|
||||
]
|
||||
assert body["tool_choice"] == "auto"
|
||||
|
||||
|
||||
class TestAnthropicStreamParsing:
|
||||
"""Parsing Anthropic SSE stream events."""
|
||||
|
||||
def test_parse_message_start(self):
|
||||
"""message_start event sets initial token usage."""
|
||||
data = {
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_123",
|
||||
"usage": {"input_tokens": 15, "output_tokens": 0},
|
||||
},
|
||||
}
|
||||
chunk = AnthropicFormatAdapter._parse_stream_event("message_start", data)
|
||||
assert chunk.usage is not None
|
||||
assert chunk.usage.prompt_tokens == 15
|
||||
assert chunk.usage.completion_tokens == 0
|
||||
assert chunk.delta_content == ""
|
||||
|
||||
def test_parse_content_block_start_text(self):
|
||||
"""content_block_start for text sets delta_content."""
|
||||
data = {
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": "Hello"},
|
||||
}
|
||||
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_start", data)
|
||||
assert chunk.delta_content == "Hello"
|
||||
|
||||
def test_parse_content_block_start_thinking(self):
|
||||
"""content_block_start for thinking sets delta_reasoning."""
|
||||
data = {
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "thinking", "thinking": "Let me think..."},
|
||||
}
|
||||
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_start", data)
|
||||
assert chunk.delta_reasoning == "Let me think..."
|
||||
|
||||
def test_parse_content_block_start_tool_use(self):
|
||||
"""content_block_start for tool_use creates a tool call chunk stub."""
|
||||
data = {
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": "tu_123",
|
||||
"name": "read_file",
|
||||
"input": {},
|
||||
},
|
||||
}
|
||||
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_start", data)
|
||||
assert len(chunk.tool_call_chunks) == 1
|
||||
assert chunk.tool_call_chunks[0]["index"] == 1
|
||||
assert chunk.tool_call_chunks[0]["id"] == "tu_123"
|
||||
assert chunk.tool_call_chunks[0]["name"] == "read_file"
|
||||
assert chunk.tool_call_chunks[0]["args"] == ""
|
||||
|
||||
def test_parse_content_block_delta_text(self):
|
||||
"""content_block_delta for text_delta appends content."""
|
||||
data = {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": " world"},
|
||||
}
|
||||
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_delta", data)
|
||||
assert chunk.delta_content == " world"
|
||||
|
||||
def test_parse_content_block_delta_thinking(self):
|
||||
"""content_block_delta for thinking_delta appends reasoning."""
|
||||
data = {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "thinking_delta", "thinking": " more..."},
|
||||
}
|
||||
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_delta", data)
|
||||
assert chunk.delta_reasoning == " more..."
|
||||
|
||||
def test_parse_content_block_delta_signature(self):
|
||||
"""content_block_delta for signature_delta is ignored (not reasoning)."""
|
||||
data = {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "signature_delta", "signature": "sig_abc"},
|
||||
}
|
||||
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_delta", data)
|
||||
# signature 不展示为 reasoning,但保留原始 delta 供 Agent 下一轮回放。
|
||||
assert chunk.delta_reasoning == ""
|
||||
assert chunk.content_block_index == 0
|
||||
assert chunk.content_block_delta == data["delta"]
|
||||
|
||||
def test_parse_content_block_delta_input_json(self):
|
||||
"""content_block_delta for input_json_delta creates a tool call arg chunk."""
|
||||
data = {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"path": "'},
|
||||
}
|
||||
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_delta", data)
|
||||
assert len(chunk.tool_call_chunks) == 1
|
||||
assert chunk.tool_call_chunks[0]["args"] == '{"path": "'
|
||||
|
||||
def test_parse_message_delta_stop(self):
|
||||
"""message_delta with end_turn sets finish_reason to 'stop'."""
|
||||
data = {
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
||||
"usage": {"output_tokens": 42},
|
||||
}
|
||||
chunk = AnthropicFormatAdapter._parse_stream_event("message_delta", data)
|
||||
assert chunk.finish_reason == "stop"
|
||||
assert chunk.usage is not None
|
||||
assert chunk.usage.completion_tokens == 42
|
||||
|
||||
def test_parse_message_delta_tool_use(self):
|
||||
"""message_delta with tool_use sets finish_reason to 'tool_calls'."""
|
||||
data = {
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "tool_use"},
|
||||
"usage": {"output_tokens": 10},
|
||||
}
|
||||
chunk = AnthropicFormatAdapter._parse_stream_event("message_delta", data)
|
||||
assert chunk.finish_reason == "tool_calls"
|
||||
|
||||
def test_parse_message_delta_max_tokens(self):
|
||||
"""message_delta with max_tokens stop reason."""
|
||||
data = {
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "max_tokens"},
|
||||
"usage": {"output_tokens": 5},
|
||||
}
|
||||
chunk = AnthropicFormatAdapter._parse_stream_event("message_delta", data)
|
||||
assert chunk.finish_reason == "length"
|
||||
|
||||
def test_parse_content_block_stop_noop(self):
|
||||
"""content_block_stop produces an empty chunk."""
|
||||
data = {"type": "content_block_stop", "index": 0}
|
||||
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_stop", data)
|
||||
assert chunk.delta_content == ""
|
||||
assert chunk.delta_reasoning == ""
|
||||
assert chunk.usage is None
|
||||
|
||||
|
||||
class TestAnthropicAuthHeaders:
|
||||
"""Anthropic-specific authentication headers."""
|
||||
|
||||
def test_headers_contain_api_key_and_version(self):
|
||||
"""Headers include x-api-key, anthropic-version, and content type."""
|
||||
adapter = AnthropicFormatAdapter(
|
||||
provider_config=ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="ANTHROPIC_BASE_URL",
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
)
|
||||
)
|
||||
headers = adapter._headers("sk-ant-12345")
|
||||
assert headers["x-api-key"] == "sk-ant-12345"
|
||||
assert headers["anthropic-version"] == "2023-06-01"
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ParameterMapping, ProviderConfig
|
||||
from app.ai_provider.llm.openai_format import OpenAIFormatAdapter
|
||||
from app.ai_provider.types.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
|
||||
|
||||
class TestOpenAIMessageConversion:
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
provider = ProviderConfig(
|
||||
name="deepseek",
|
||||
format="openai",
|
||||
base_url_env="DEEPSEEK_BASE_URL",
|
||||
api_key_env="DEEPSEEK_API_KEY",
|
||||
parameter_mappings={
|
||||
"temperature": ParameterMapping(provider_param="temperature", min=0, max=2),
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens"),
|
||||
},
|
||||
)
|
||||
return OpenAIFormatAdapter(provider_config=provider)
|
||||
|
||||
def test_convert_system_message(self, adapter):
|
||||
msg = SystemMessage(content="You are a helpful assistant.")
|
||||
result = adapter._convert_message(msg)
|
||||
assert result["role"] == "system"
|
||||
assert result["content"] == "You are a helpful assistant."
|
||||
|
||||
def test_convert_human_message(self, adapter):
|
||||
msg = HumanMessage(content="Hello!")
|
||||
result = adapter._convert_message(msg)
|
||||
assert result["role"] == "user"
|
||||
|
||||
def test_convert_ai_message(self, adapter):
|
||||
msg = AIMessage(
|
||||
content="Sure!",
|
||||
tool_calls=[{"name": "read_file", "args": {"path": "x.txt"}, "id": "call_1"}],
|
||||
)
|
||||
result = adapter._convert_message(msg)
|
||||
assert result["role"] == "assistant"
|
||||
assert "tool_calls" in result
|
||||
|
||||
def test_convert_tool_message(self, adapter):
|
||||
msg = ToolMessage(content="File contents", tool_call_id="call_1")
|
||||
result = adapter._convert_message(msg)
|
||||
assert result["role"] == "tool"
|
||||
assert result["tool_call_id"] == "call_1"
|
||||
|
||||
def test_build_request_body(self, adapter):
|
||||
model = ModelConfig(
|
||||
name="test", provider="deepseek", model="deepseek-v4-flash", format="openai"
|
||||
)
|
||||
messages = [HumanMessage(content="Hi")]
|
||||
body = adapter.build_request(
|
||||
model=model,
|
||||
messages=messages,
|
||||
params={"temperature": 0.7, "max_tokens": 4096},
|
||||
)
|
||||
assert body["model"] == "deepseek-v4-flash"
|
||||
assert body["temperature"] == 0.7
|
||||
assert body["max_tokens"] == 4096
|
||||
assert len(body["messages"]) == 1
|
||||
|
||||
def test_parse_chunk(self, adapter):
|
||||
raw = {"choices": [{"delta": {"content": "Hello"}, "index": 0}], "usage": None}
|
||||
chunk = adapter.parse_chunk(raw)
|
||||
assert chunk.delta_content == "Hello"
|
||||
|
||||
def test_parse_chunk_with_usage(self, adapter):
|
||||
raw = {
|
||||
"choices": [{"delta": {}, "index": 0, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
}
|
||||
chunk = adapter.parse_chunk(raw)
|
||||
assert chunk.usage is not None
|
||||
assert chunk.usage.prompt_tokens == 10
|
||||
|
||||
def test_parse_tool_call_chunk(self, adapter):
|
||||
raw = {
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 0,
|
||||
"id": "call_1",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": '{"path": "test.txt"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
chunk = adapter.parse_chunk(raw)
|
||||
assert len(chunk.tool_call_chunks) == 1
|
||||
assert chunk.tool_call_chunks[0]["name"] == "read_file"
|
||||
|
||||
def test_bind_tools(self, adapter):
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "parameters": {"type": "object"}},
|
||||
}
|
||||
]
|
||||
body = {}
|
||||
adapter.bind_tools(body, tools, tool_choice="auto")
|
||||
assert body["tools"] == tools
|
||||
assert body["tool_choice"] == "auto"
|
||||
@@ -0,0 +1,100 @@
|
||||
from app.ai_provider.llm.pool import (
|
||||
CostAwareStrategy,
|
||||
EndpointPool,
|
||||
LeastConnectionsStrategy,
|
||||
LowestLatencyStrategy,
|
||||
PoolFactory,
|
||||
PriorityFailoverStrategy,
|
||||
WeightedRoundRobinStrategy,
|
||||
)
|
||||
|
||||
|
||||
class TestWeightedRoundRobin:
|
||||
def test_distribution(self):
|
||||
endpoints = [{"name": "a", "weight": 100}, {"name": "b", "weight": 50}]
|
||||
strategy = WeightedRoundRobinStrategy(endpoints)
|
||||
picks = [strategy.select([])["name"] for _ in range(15)]
|
||||
assert picks.count("a") > picks.count("b")
|
||||
|
||||
def test_skip_excluded(self):
|
||||
endpoints = [{"name": "a", "weight": 100}, {"name": "b", "weight": 100}]
|
||||
strategy = WeightedRoundRobinStrategy(endpoints)
|
||||
pick = strategy.select(["a"])
|
||||
assert pick["name"] == "b"
|
||||
|
||||
|
||||
class TestPriorityFailover:
|
||||
def test_primary_selected(self):
|
||||
endpoints = [{"name": "primary", "priority": 10}, {"name": "backup", "priority": 20}]
|
||||
strategy = PriorityFailoverStrategy(endpoints)
|
||||
pick = strategy.select([])
|
||||
assert pick["name"] == "primary"
|
||||
|
||||
def test_failover_on_excluded(self):
|
||||
endpoints = [{"name": "primary", "priority": 10}, {"name": "backup", "priority": 20}]
|
||||
strategy = PriorityFailoverStrategy(endpoints)
|
||||
pick = strategy.select(["primary"])
|
||||
assert pick["name"] == "backup"
|
||||
|
||||
def test_exhausted(self):
|
||||
endpoints = [{"name": "a", "priority": 10}]
|
||||
strategy = PriorityFailoverStrategy(endpoints)
|
||||
pick = strategy.select(["a"])
|
||||
assert pick is None
|
||||
|
||||
|
||||
class TestLowestLatency:
|
||||
def test_pick_lowest_latency(self):
|
||||
endpoints = [{"name": "fast", "latency_ms": 100}, {"name": "slow", "latency_ms": 500}]
|
||||
strategy = LowestLatencyStrategy(endpoints)
|
||||
pick = strategy.select([])
|
||||
assert pick["name"] == "fast"
|
||||
|
||||
|
||||
class TestCostAware:
|
||||
def test_pick_cheapest(self):
|
||||
endpoints = [{"name": "cheap"}, {"name": "expensive"}]
|
||||
|
||||
def _cost(name: str) -> float:
|
||||
return 0.1 if name == "cheap" else 1.0
|
||||
|
||||
strategy = CostAwareStrategy(endpoints, get_cost=_cost)
|
||||
pick = strategy.select([])
|
||||
assert pick["name"] == "cheap"
|
||||
|
||||
|
||||
class TestLeastConnections:
|
||||
def test_pick_least_connections(self):
|
||||
endpoints = [{"name": "busy", "connections": 5}, {"name": "idle", "connections": 0}]
|
||||
strategy = LeastConnectionsStrategy(endpoints)
|
||||
pick = strategy.select([])
|
||||
assert pick["name"] == "idle"
|
||||
|
||||
|
||||
class TestEndpointPool:
|
||||
def test_select_with_weighted_round_robin(self):
|
||||
pool = EndpointPool(
|
||||
logical_model_name="test",
|
||||
endpoints_configs=[
|
||||
{"name": "a", "weight": 100, "status": "active"},
|
||||
{"name": "b", "weight": 50, "status": "active"},
|
||||
],
|
||||
strategy="weighted_round_robin",
|
||||
)
|
||||
selected = pool.select([])
|
||||
assert selected["name"] in ("a", "b")
|
||||
|
||||
def test_select_all_excluded_returns_none(self):
|
||||
pool = EndpointPool(
|
||||
logical_model_name="test",
|
||||
endpoints_configs=[{"name": "a", "weight": 100, "status": "active"}],
|
||||
)
|
||||
assert pool.select(["a"]) is None
|
||||
|
||||
def test_strategy_factory(self):
|
||||
pool = PoolFactory.create_pool(
|
||||
logical_model_name="test",
|
||||
endpoints=[{"name": "a", "weight": 100, "status": "active"}],
|
||||
strategy_name="priority_failover",
|
||||
)
|
||||
assert isinstance(pool.strategy, PriorityFailoverStrategy)
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.ai_provider.llm.stream import StreamFactory, StreamHandler, ToolCallAccumulator
|
||||
from app.ai_provider.types.chat import ChatChunk, TokenUsage
|
||||
|
||||
|
||||
class TestToolCallAccumulator:
|
||||
def test_accumulate_simple(self):
|
||||
acc = ToolCallAccumulator()
|
||||
acc.add_chunk(ChatChunk(delta_content="Hello"))
|
||||
assert acc.content == "Hello"
|
||||
|
||||
def test_accumulate_reasoning(self):
|
||||
acc = ToolCallAccumulator()
|
||||
acc.add_chunk(ChatChunk(delta_reasoning="I think"))
|
||||
acc.add_chunk(ChatChunk(delta_reasoning=" therefore"))
|
||||
assert acc.reasoning == "I think therefore"
|
||||
|
||||
def test_accumulate_tool_call(self):
|
||||
acc = ToolCallAccumulator()
|
||||
acc.add_chunk(
|
||||
ChatChunk(
|
||||
tool_call_chunks=[
|
||||
{
|
||||
"index": 0,
|
||||
"id": "call_1",
|
||||
"name": "read_file",
|
||||
"args": '{"path": "test.txt"}',
|
||||
},
|
||||
]
|
||||
)
|
||||
)
|
||||
result = acc.drain()
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "read_file"
|
||||
|
||||
def test_accumulate_tool_call_args(self):
|
||||
acc = ToolCallAccumulator()
|
||||
acc.add_chunk(ChatChunk(tool_call_chunks=[{"index": 0, "args": '{"path": "'}]))
|
||||
acc.add_chunk(ChatChunk(tool_call_chunks=[{"index": 0, "args": 'test.txt"}'}]))
|
||||
result = acc.drain()
|
||||
assert result[0]["args"] == '{"path": "test.txt"}'
|
||||
|
||||
def test_accumulate_usage(self):
|
||||
acc = ToolCallAccumulator()
|
||||
acc.add_chunk(
|
||||
ChatChunk(usage=TokenUsage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
|
||||
)
|
||||
assert acc.usage is not None
|
||||
assert acc.usage.prompt_tokens == 10
|
||||
|
||||
def test_empty_drain(self):
|
||||
acc = ToolCallAccumulator()
|
||||
result = acc.drain()
|
||||
assert result == []
|
||||
|
||||
def test_accumulate_multiple_tool_calls(self):
|
||||
acc = ToolCallAccumulator()
|
||||
acc.add_chunk(
|
||||
ChatChunk(
|
||||
tool_call_chunks=[
|
||||
{"index": 0, "id": "call_1", "name": "read_file", "args": '{"path": "'},
|
||||
{"index": 1, "id": "call_2", "name": "list_dir", "args": '{"path": "'},
|
||||
]
|
||||
)
|
||||
)
|
||||
acc.add_chunk(
|
||||
ChatChunk(
|
||||
tool_call_chunks=[
|
||||
{"index": 0, "args": 'x.txt"}'},
|
||||
{"index": 1, "args": '/tmp"}'},
|
||||
]
|
||||
)
|
||||
)
|
||||
result = acc.drain()
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "read_file"
|
||||
assert result[0]["args"] == '{"path": "x.txt"}'
|
||||
assert result[1]["name"] == "list_dir"
|
||||
assert result[1]["args"] == '{"path": "/tmp"}'
|
||||
|
||||
|
||||
class TestStreamHandler:
|
||||
def test_empty_result(self):
|
||||
handler = StreamHandler()
|
||||
result = handler.to_result(model_name="test-model")
|
||||
assert result.content == ""
|
||||
assert result.reasoning == ""
|
||||
assert result.tool_calls == []
|
||||
assert result.usage is None
|
||||
assert result.model_name == "test-model"
|
||||
|
||||
def test_stream_to_result(self):
|
||||
handler = StreamHandler()
|
||||
handler.add_chunk(ChatChunk(delta_content="Hello world"))
|
||||
handler.add_chunk(ChatChunk(delta_reasoning="Thinking..."))
|
||||
handler.add_chunk(
|
||||
ChatChunk(usage=TokenUsage(prompt_tokens=5, completion_tokens=3, total_tokens=8))
|
||||
)
|
||||
result = handler.to_result(model_name="test-model")
|
||||
assert result.content == "Hello world"
|
||||
assert result.reasoning == "Thinking..."
|
||||
assert result.usage is not None
|
||||
assert result.usage.total_tokens == 8
|
||||
assert result.model_name == "test-model"
|
||||
|
||||
def test_stream_with_tool_calls(self):
|
||||
handler = StreamHandler()
|
||||
handler.add_chunk(
|
||||
ChatChunk(
|
||||
tool_call_chunks=[
|
||||
{"index": 0, "id": "call_1", "name": "read_file", "args": '{"path": "'},
|
||||
]
|
||||
)
|
||||
)
|
||||
handler.add_chunk(
|
||||
ChatChunk(
|
||||
tool_call_chunks=[
|
||||
{"index": 0, "args": 'test.txt"}'},
|
||||
]
|
||||
)
|
||||
)
|
||||
result = handler.to_result(model_name="test-model")
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0]["name"] == "read_file"
|
||||
assert result.tool_calls[0]["args"] == {"path": "test.txt"}
|
||||
|
||||
def test_chunks_property(self):
|
||||
handler = StreamHandler()
|
||||
handler.add_chunk(ChatChunk(delta_content="Hello"))
|
||||
handler.add_chunk(ChatChunk(delta_content=" world"))
|
||||
assert len(handler.chunks) == 2
|
||||
|
||||
def test_stream_content_and_tool_calls_together(self):
|
||||
handler = StreamHandler()
|
||||
handler.add_chunk(ChatChunk(delta_content="I will read the file."))
|
||||
handler.add_chunk(
|
||||
ChatChunk(
|
||||
tool_call_chunks=[
|
||||
{"index": 0, "id": "call_1", "name": "read_file", "args": "{}"},
|
||||
]
|
||||
)
|
||||
)
|
||||
result = handler.to_result()
|
||||
assert result.content == "I will read the file."
|
||||
assert len(result.tool_calls) == 1
|
||||
|
||||
def test_non_json_args_fallback(self):
|
||||
handler = StreamHandler()
|
||||
handler.add_chunk(
|
||||
ChatChunk(
|
||||
tool_call_chunks=[
|
||||
{"index": 0, "id": "call_1", "name": "read_file", "args": "not valid json"},
|
||||
]
|
||||
)
|
||||
)
|
||||
result = handler.to_result()
|
||||
assert result.tool_calls[0]["args"] == {"_raw": "not valid json"}
|
||||
|
||||
|
||||
class TestStreamFactory:
|
||||
def test_create_handler(self):
|
||||
handler = StreamFactory.create_handler()
|
||||
assert isinstance(handler, StreamHandler)
|
||||
|
||||
def test_create_accumulator(self):
|
||||
acc = StreamFactory.create_accumulator()
|
||||
assert isinstance(acc, ToolCallAccumulator)
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.ai_provider.routing.multimodal import MultiModalDecision, MultiModalRouter
|
||||
|
||||
|
||||
class TestMultiModalDecision:
|
||||
def test_enum_values(self):
|
||||
assert MultiModalDecision.PLAIN_TEXT == "plain_text"
|
||||
assert MultiModalDecision.VISION == "vision"
|
||||
assert MultiModalDecision.THINKING == "thinking"
|
||||
assert MultiModalDecision.TOOL_USE == "tool_use"
|
||||
assert MultiModalDecision.STRUCTURED_OUTPUT == "structured_output"
|
||||
|
||||
def test_enum_is_str_enum(self):
|
||||
assert issubclass(MultiModalDecision, str)
|
||||
|
||||
|
||||
class TestMultiModalRouter:
|
||||
def test_plain_text_message(self):
|
||||
messages = [{"role": "user", "content": "Hello, world!"}]
|
||||
decisions = MultiModalRouter.decide_from_messages(messages)
|
||||
assert decisions == {MultiModalDecision.PLAIN_TEXT}
|
||||
|
||||
def test_plain_text_multiple_messages(self):
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "What is Python?"},
|
||||
{"role": "assistant", "content": "Python is a programming language."},
|
||||
]
|
||||
decisions = MultiModalRouter.decide_from_messages(messages)
|
||||
assert decisions == {MultiModalDecision.PLAIN_TEXT}
|
||||
|
||||
def test_vision_with_openai_image_url_block(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's in this image?"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
decisions = MultiModalRouter.decide_from_messages(messages)
|
||||
assert MultiModalDecision.VISION in decisions
|
||||
|
||||
def test_vision_with_image_type_block(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "base64", "media_type": "image/png", "data": "..."},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
decisions = MultiModalRouter.decide_from_messages(messages)
|
||||
assert MultiModalDecision.VISION in decisions
|
||||
|
||||
def test_plain_text_with_single_content_block(self):
|
||||
"""A single text-only content block should not trigger VISION."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello!"}],
|
||||
}
|
||||
]
|
||||
decisions = MultiModalRouter.decide_from_messages(messages)
|
||||
assert MultiModalDecision.VISION not in decisions
|
||||
|
||||
def test_tool_use_from_assistant_tool_calls(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "What's the weather?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "call_1", "function": {"name": "get_weather", "arguments": "{}"}}
|
||||
],
|
||||
},
|
||||
]
|
||||
decisions = MultiModalRouter.decide_from_messages(messages)
|
||||
assert MultiModalDecision.TOOL_USE in decisions
|
||||
|
||||
def test_tool_use_from_tool_role(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "What's the weather?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": "call_1", "name": "get_weather"}],
|
||||
},
|
||||
{"role": "tool", "content": "25°C", "tool_call_id": "call_1"},
|
||||
]
|
||||
decisions = MultiModalRouter.decide_from_messages(messages)
|
||||
assert MultiModalDecision.TOOL_USE in decisions
|
||||
|
||||
def test_thinking_from_reasoning_field(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "Think step by step"},
|
||||
{"role": "assistant", "content": "Final answer", "reasoning": "Let me think..."},
|
||||
]
|
||||
decisions = MultiModalRouter.decide_from_messages(messages)
|
||||
assert MultiModalDecision.THINKING in decisions
|
||||
|
||||
def test_empty_messages(self):
|
||||
decisions = MultiModalRouter.decide_from_messages([])
|
||||
assert decisions == {MultiModalDecision.PLAIN_TEXT}
|
||||
|
||||
def test_multiple_capabilities_combined(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this image step by step"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Let me think about this image...",
|
||||
"reasoning": "First, I see an image. Let me analyze it carefully.",
|
||||
"tool_calls": [
|
||||
{"id": "call_1", "function": {"name": "analyze_image", "arguments": "{}"}}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "content": "Analysis complete", "tool_call_id": "call_1"},
|
||||
]
|
||||
decisions = MultiModalRouter.decide_from_messages(messages)
|
||||
assert MultiModalDecision.VISION in decisions
|
||||
assert MultiModalDecision.THINKING in decisions
|
||||
assert MultiModalDecision.TOOL_USE in decisions
|
||||
|
||||
def test_decide_with_empty_content(self):
|
||||
messages = [{"role": "user", "content": ""}]
|
||||
decisions = MultiModalRouter.decide_from_messages(messages)
|
||||
assert decisions == {MultiModalDecision.PLAIN_TEXT}
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.ai_provider.routing.permissions import CapabilityPermissionController, PermissionRule
|
||||
|
||||
|
||||
class TestPermissionRule:
|
||||
def test_exact_match(self):
|
||||
rule = PermissionRule(model_pattern="fast-model", capability="vision", allowed=True)
|
||||
assert rule.match("fast-model") is True
|
||||
assert rule.match("pro-model") is False
|
||||
|
||||
def test_wildcard_match(self):
|
||||
rule = PermissionRule(model_pattern="deepseek-*", capability="thinking", allowed=False)
|
||||
assert rule.match("deepseek-v4-flash") is True
|
||||
assert rule.match("deepseek-v4-pro") is True
|
||||
assert rule.match("claude-sonnet-4-6") is False
|
||||
|
||||
def test_universal_match(self):
|
||||
rule = PermissionRule(model_pattern="*", capability="vision", allowed=True)
|
||||
assert rule.match("any-model") is True
|
||||
assert rule.match("another-one") is True
|
||||
|
||||
def test_suffix_wildcard(self):
|
||||
rule = PermissionRule(model_pattern="*-pro", capability="thinking", allowed=True)
|
||||
assert rule.match("deepseek-v4-pro") is True
|
||||
assert rule.match("fast-model") is False
|
||||
|
||||
def test_match_method_called_explicitly(self):
|
||||
rule = PermissionRule(model_pattern="vision-*", capability="vision")
|
||||
assert rule.match("vision-model") is True
|
||||
|
||||
def test_allowed_defaults_to_true(self):
|
||||
rule = PermissionRule(model_pattern="*", capability="vision")
|
||||
assert rule.allowed is True
|
||||
|
||||
def test_allowed_set_to_false(self):
|
||||
rule = PermissionRule(model_pattern="*", capability="vision", allowed=False)
|
||||
assert rule.allowed is False
|
||||
|
||||
def test_repr(self):
|
||||
rule = PermissionRule(model_pattern="*", capability="vision", allowed=True)
|
||||
r = repr(rule)
|
||||
assert "PermissionRule" in r
|
||||
assert "*" in r
|
||||
assert "vision" in r
|
||||
|
||||
def test_eq_same_rules(self):
|
||||
rule1 = PermissionRule(model_pattern="foo-*", capability="vision", allowed=True)
|
||||
rule2 = PermissionRule(model_pattern="foo-*", capability="vision", allowed=True)
|
||||
assert rule1 == rule2
|
||||
|
||||
def test_eq_different_rules(self):
|
||||
rule1 = PermissionRule(model_pattern="foo-*", capability="vision", allowed=True)
|
||||
rule2 = PermissionRule(model_pattern="bar-*", capability="vision", allowed=True)
|
||||
assert rule1 != rule2
|
||||
|
||||
|
||||
class TestCapabilityPermissionController:
|
||||
def test_allow_all_by_default(self):
|
||||
controller = CapabilityPermissionController([])
|
||||
assert controller.check("fast-model", "vision") is True
|
||||
|
||||
def test_deny_specific_capability_on_model(self):
|
||||
controller = CapabilityPermissionController(
|
||||
[
|
||||
PermissionRule(model_pattern="fast-*", capability="vision", allowed=False),
|
||||
]
|
||||
)
|
||||
assert controller.check("fast-model", "vision") is False
|
||||
assert controller.check("pro-model", "vision") is True
|
||||
|
||||
def test_deny_then_allow_more_specific(self):
|
||||
"""A more specific deny should override a broader allow."""
|
||||
controller = CapabilityPermissionController(
|
||||
[
|
||||
PermissionRule(model_pattern="*", capability="thinking", allowed=True),
|
||||
PermissionRule(model_pattern="fast-*", capability="thinking", allowed=False),
|
||||
]
|
||||
)
|
||||
assert controller.check("fast-model", "thinking") is False
|
||||
assert controller.check("pro-model", "thinking") is True
|
||||
|
||||
def test_filter_capabilities(self):
|
||||
controller = CapabilityPermissionController(
|
||||
[
|
||||
PermissionRule(model_pattern="*", capability="vision", allowed=False),
|
||||
PermissionRule(model_pattern="vision-*", capability="vision", allowed=True),
|
||||
]
|
||||
)
|
||||
filtered = controller.filter_capabilities("fast-model", {"chat", "vision", "thinking"})
|
||||
assert filtered == {"chat", "thinking"}
|
||||
|
||||
filtered_vision = controller.filter_capabilities(
|
||||
"vision-model", {"chat", "vision", "thinking"}
|
||||
)
|
||||
assert filtered_vision == {"chat", "vision", "thinking"}
|
||||
|
||||
def test_filter_with_no_rules(self):
|
||||
controller = CapabilityPermissionController([])
|
||||
filtered = controller.filter_capabilities("any-model", {"chat", "vision"})
|
||||
assert filtered == {"chat", "vision"}
|
||||
|
||||
def test_check_nonexistent_model(self):
|
||||
"""Non-existent models should still be checked against rules."""
|
||||
controller = CapabilityPermissionController(
|
||||
[
|
||||
PermissionRule(model_pattern="*", capability="vision", allowed=False),
|
||||
]
|
||||
)
|
||||
assert controller.check("unknown-model", "vision") is False
|
||||
|
||||
def test_multiple_rules_matching(self):
|
||||
"""When multiple rules match, the last one wins (most specific wins by order)."""
|
||||
controller = CapabilityPermissionController(
|
||||
[
|
||||
PermissionRule(model_pattern="*", capability="tool_use", allowed=True),
|
||||
PermissionRule(model_pattern="basic-*", capability="tool_use", allowed=False),
|
||||
PermissionRule(model_pattern="basic-pro", capability="tool_use", allowed=True),
|
||||
]
|
||||
)
|
||||
assert controller.check("basic-lite", "tool_use") is False
|
||||
assert controller.check("basic-pro", "tool_use") is True
|
||||
assert controller.check("pro-model", "tool_use") is True
|
||||
|
||||
def test_check_capability_not_in_rules(self):
|
||||
controller = CapabilityPermissionController(
|
||||
[
|
||||
PermissionRule(model_pattern="*", capability="vision", allowed=False),
|
||||
]
|
||||
)
|
||||
# 'chat' is not in any rule, so it's allowed by default
|
||||
assert controller.check("fast-model", "chat") is True
|
||||
|
||||
def test_is_allowed_model(self):
|
||||
controller = CapabilityPermissionController(
|
||||
[
|
||||
PermissionRule(model_pattern="*", capability="vision", allowed=False),
|
||||
]
|
||||
)
|
||||
assert controller.is_allowed("fast-model", "vision") is False
|
||||
assert controller.is_allowed("fast-model", "chat") is True
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig
|
||||
from app.ai_provider.core.registry import ModelRegistry
|
||||
from app.ai_provider.routing.multimodal import MultiModalDecision
|
||||
from app.ai_provider.routing.selector import ModelSelector
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
|
||||
|
||||
class TestModelSelector:
|
||||
@pytest.fixture
|
||||
def registry(self):
|
||||
models = [
|
||||
ModelConfig(
|
||||
name="fast-model",
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-flash",
|
||||
format="openai",
|
||||
tags=["default"],
|
||||
weight=100,
|
||||
),
|
||||
ModelConfig(
|
||||
name="pro-model",
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-pro",
|
||||
format="openai",
|
||||
tags=["fallback"],
|
||||
weight=50,
|
||||
),
|
||||
ModelConfig(
|
||||
name="vision-model",
|
||||
provider="anthropic",
|
||||
model="claude-sonnet-4-6",
|
||||
format="anthropic",
|
||||
supports_vision=True,
|
||||
supports_thinking=True,
|
||||
supports_tools=True,
|
||||
),
|
||||
ModelConfig(
|
||||
name="thinking-model",
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-thinking",
|
||||
format="openai",
|
||||
supports_reasoning_effort=True,
|
||||
supports_thinking=True,
|
||||
),
|
||||
]
|
||||
return ModelRegistry(models)
|
||||
|
||||
def test_select_default_model(self, registry):
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select({})
|
||||
assert model.name == "fast-model"
|
||||
|
||||
def test_select_with_preferred_model(self, registry):
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select({"preferred": "pro-model"})
|
||||
assert model.name == "pro-model"
|
||||
|
||||
def test_select_preferred_with_capability(self, registry):
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select({"preferred": "vision-model", "capabilities": {"vision"}})
|
||||
assert model.name == "vision-model"
|
||||
|
||||
def test_select_preferred_missing_required_capability(self, registry):
|
||||
"""Preferred model doesn't have required capability — falls back."""
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select({"preferred": "fast-model", "capabilities": {"vision"}})
|
||||
assert model.name != "fast-model"
|
||||
assert "vision" in model.resolved_capabilities()
|
||||
assert model.resolved_capabilities()["vision"] is True
|
||||
|
||||
def test_select_for_vision(self, registry):
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select({"capabilities": {"vision"}})
|
||||
assert model.name == "vision-model"
|
||||
|
||||
def test_select_for_thinking(self, registry):
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select({"capabilities": {"thinking"}})
|
||||
assert model.resolved_capabilities()["thinking"] is True
|
||||
|
||||
def test_select_for_tool_use(self, registry):
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select({"capabilities": {"tool_use"}})
|
||||
assert model.resolved_capabilities()["tool_use"] is True
|
||||
|
||||
def test_select_for_multiple_capabilities(self, registry):
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select({"capabilities": {"vision", "thinking"}})
|
||||
assert model.resolved_capabilities()["vision"] is True
|
||||
assert model.resolved_capabilities()["thinking"] is True
|
||||
|
||||
def test_select_from_multi_modal_decision(self, registry):
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select_from_decisions(
|
||||
{MultiModalDecision.VISION, MultiModalDecision.THINKING}
|
||||
)
|
||||
assert model.resolved_capabilities()["vision"] is True
|
||||
assert model.resolved_capabilities()["thinking"] is True
|
||||
|
||||
def test_select_from_plain_text_decision(self, registry):
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select_from_decisions({MultiModalDecision.PLAIN_TEXT})
|
||||
assert model.name == "fast-model"
|
||||
|
||||
def test_select_with_invalid_preferred(self, registry):
|
||||
selector = ModelSelector(registry)
|
||||
with pytest.raises(AIProviderError):
|
||||
selector.select({"preferred": "nonexistent-model"})
|
||||
|
||||
def test_select_with_capability_no_match(self, registry):
|
||||
"""No model supports an unknown capability."""
|
||||
selector = ModelSelector(registry)
|
||||
with pytest.raises(AIProviderError):
|
||||
selector.select({"capabilities": {"telepathy"}})
|
||||
|
||||
def test_select_allows_preferred_with_superset_capabilities(self, registry):
|
||||
"""Preferred model has MORE capabilities than required — should still match."""
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select({"preferred": "vision-model", "capabilities": {"tool_use"}})
|
||||
assert model.name == "vision-model"
|
||||
|
||||
def test_select_from_decisions_with_empty_set(self, registry):
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select_from_decisions(set())
|
||||
assert model.name == "fast-model"
|
||||
|
||||
def test_select_for_avoid_list(self, registry):
|
||||
"""Avoid certain models — prefer a non-avoided model."""
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select({"capabilities": {"chat"}, "avoid": {"fast-model"}})
|
||||
assert model.name != "fast-model"
|
||||
@@ -0,0 +1,61 @@
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ProviderConfig
|
||||
from app.ai_provider.core.registry import ModelRegistry, ProviderRegistry
|
||||
from app.ai_provider.manager import ProviderManager
|
||||
from app.ai_provider.types.messages import HumanMessage, SystemMessage
|
||||
|
||||
|
||||
class TestProviderManager:
|
||||
@pytest.fixture
|
||||
def manager(self):
|
||||
models = [
|
||||
ModelConfig(
|
||||
name="test",
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-flash",
|
||||
format="openai",
|
||||
tags=["default"],
|
||||
),
|
||||
]
|
||||
providers = {
|
||||
"deepseek": ProviderConfig(
|
||||
name="deepseek",
|
||||
format="openai",
|
||||
base_url_env="DEEPSEEK_BASE_URL",
|
||||
api_key_env="DEEPSEEK_API_KEY",
|
||||
),
|
||||
}
|
||||
model_reg = ModelRegistry(models)
|
||||
provider_reg = ProviderRegistry(providers)
|
||||
return ProviderManager(
|
||||
model_registry=model_reg,
|
||||
provider_registry=provider_reg,
|
||||
)
|
||||
|
||||
def test_resolve_model(self, manager):
|
||||
name = manager.resolve_model("test")
|
||||
assert name == "test"
|
||||
|
||||
def test_resolve_model_default(self, manager):
|
||||
name = manager.resolve_model()
|
||||
assert name == "test"
|
||||
|
||||
def test_get_adapter_for_model(self, manager):
|
||||
adapter = manager.get_adapter("test")
|
||||
from app.ai_provider.llm.openai_format import OpenAIFormatAdapter
|
||||
|
||||
assert isinstance(adapter, OpenAIFormatAdapter)
|
||||
|
||||
def test_get_provider_config(self, manager):
|
||||
prov = manager.get_provider_config("deepseek")
|
||||
assert prov.name == "deepseek"
|
||||
|
||||
def test_to_message_list(self, manager):
|
||||
msgs = manager.to_message_list(
|
||||
system_messages=["You are helpful."],
|
||||
history=[("user", "Hello")],
|
||||
)
|
||||
assert len(msgs) == 2
|
||||
assert isinstance(msgs[0], SystemMessage)
|
||||
assert isinstance(msgs[1], HumanMessage)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Tests for the parameter pipeline (Task 4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ParameterMapping, ProviderConfig
|
||||
from app.ai_provider.pipeline import PipelineContext, PipelineFactory
|
||||
|
||||
|
||||
class TestParameterPipeline:
|
||||
"""Test the config-driven 4-stage parameter pipeline."""
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(self):
|
||||
provider_cfg = ProviderConfig(
|
||||
name="deepseek",
|
||||
format="openai",
|
||||
base_url_env="DEEPSEEK_BASE_URL",
|
||||
api_key_env="DEEPSEEK_API_KEY",
|
||||
parameter_mappings={
|
||||
"temperature": ParameterMapping(provider_param="temperature", min=0, max=2),
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens", min=1),
|
||||
"top_p": ParameterMapping(provider_param="top_p", min=0, max=1),
|
||||
},
|
||||
)
|
||||
return PipelineFactory.create(provider_cfg)
|
||||
|
||||
def test_normalize_temperature(self, pipeline):
|
||||
ctx = PipelineContext(params={"temperature": 0.7})
|
||||
result = pipeline.run(ctx)
|
||||
assert result.params["temperature"] == 0.7
|
||||
|
||||
def test_clamp_temperature(self, pipeline):
|
||||
ctx = PipelineContext(params={"temperature": 5.0})
|
||||
result = pipeline.run(ctx)
|
||||
assert result.params["temperature"] == 2.0 # clamped to max
|
||||
|
||||
def test_filter_unknown_param(self, pipeline):
|
||||
ctx = PipelineContext(params={"temperature": 0.5, "unknown_param": "value"})
|
||||
result = pipeline.run(ctx)
|
||||
assert "unknown_param" not in result.params
|
||||
assert "temperature" in result.params
|
||||
|
||||
def test_inject_provider_defaults(self):
|
||||
provider_cfg = ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="ANTHROPIC_BASE_URL",
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
parameter_mappings={
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens", default=4096),
|
||||
},
|
||||
)
|
||||
pipeline = PipelineFactory.create(provider_cfg)
|
||||
ctx = PipelineContext(params={})
|
||||
result = pipeline.run(ctx)
|
||||
assert result.params["max_tokens"] == 4096
|
||||
|
||||
def test_transform_param(self):
|
||||
provider_cfg = ProviderConfig(
|
||||
name="openai",
|
||||
format="openai",
|
||||
base_url_env="OPENAI_BASE_URL",
|
||||
api_key_env="OPENAI_API_KEY",
|
||||
parameter_mappings={
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens", transform="int"),
|
||||
},
|
||||
)
|
||||
pipeline = PipelineFactory.create(provider_cfg)
|
||||
ctx = PipelineContext(params={"max_tokens": 4096.0})
|
||||
result = pipeline.run(ctx)
|
||||
assert isinstance(result.params["max_tokens"], int)
|
||||
|
||||
def test_capability_filter_vision(self):
|
||||
model = ModelConfig(
|
||||
name="test",
|
||||
provider="openai",
|
||||
model="gpt-4o",
|
||||
format="openai",
|
||||
supports_vision=False,
|
||||
)
|
||||
provider_cfg = ProviderConfig(
|
||||
name="openai", format="openai", base_url_env="X", api_key_env="Y"
|
||||
)
|
||||
pipeline = PipelineFactory.create(provider_cfg)
|
||||
ctx = PipelineContext(
|
||||
params={"image_url": "http://example.com/img.png"}, model_config=model
|
||||
)
|
||||
result = pipeline.run(ctx)
|
||||
assert "image_url" in result.filtered_params
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Tests for the parameter handler mechanism (Task 4 extension)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ParameterMapping, ProviderConfig
|
||||
from app.ai_provider.pipeline import PipelineContext, PipelineFactory
|
||||
from app.ai_provider.pipeline_handlers import ParameterHandlerRegistry
|
||||
|
||||
|
||||
class TestParameterHandlerRegistry:
|
||||
"""Test the handler registry itself."""
|
||||
|
||||
def test_register_and_get(self):
|
||||
@ParameterHandlerRegistry.register("test_handler_1")
|
||||
def my_handler(_ctx, _result, _param_key=None):
|
||||
pass
|
||||
|
||||
handler = ParameterHandlerRegistry.get("test_handler_1")
|
||||
assert handler is my_handler
|
||||
|
||||
def test_get_unknown_handler_raises(self):
|
||||
with pytest.raises(KeyError, match="Unknown parameter handler"):
|
||||
ParameterHandlerRegistry.get("nonexistent_handler")
|
||||
|
||||
def test_list_handlers(self):
|
||||
handlers = ParameterHandlerRegistry.list()
|
||||
assert "anthropic_thinking" in handlers
|
||||
assert "validate_max_tokens" in handlers
|
||||
assert "strip_unsupported_capability_params" in handlers
|
||||
|
||||
|
||||
class TestAnthropicThinkingHandler:
|
||||
"""Test the anthropic_thinking handler via the pipeline."""
|
||||
|
||||
def test_thinking_true_converts_to_dict(self):
|
||||
"""标量 thinking=True → {type: enabled}"""
|
||||
provider = ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="ANTHROPIC_BASE_URL",
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
parameter_mappings={
|
||||
"thinking": ParameterMapping(
|
||||
provider_param="thinking",
|
||||
handler="anthropic_thinking",
|
||||
),
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens"),
|
||||
},
|
||||
)
|
||||
pipeline = PipelineFactory.create(provider)
|
||||
ctx = PipelineContext(params={"thinking": True, "max_tokens": 4096})
|
||||
result = pipeline.run(ctx)
|
||||
assert result.params["thinking"] == {"type": "enabled"}
|
||||
assert result.params["max_tokens"] == 4096
|
||||
|
||||
def test_thinking_budget_enforces_max_tokens(self):
|
||||
"""thinking.budget_tokens 确保 max_tokens > budget_tokens"""
|
||||
provider = ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="X",
|
||||
api_key_env="Y",
|
||||
parameter_mappings={
|
||||
"thinking": ParameterMapping(
|
||||
provider_param="thinking",
|
||||
handler="anthropic_thinking",
|
||||
),
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens"),
|
||||
},
|
||||
)
|
||||
pipeline = PipelineFactory.create(provider)
|
||||
# budget=8192, max_tokens=4096 → 不满足,应被抬到 8193
|
||||
ctx = PipelineContext(params={"thinking": {"budget_tokens": 8192}, "max_tokens": 4096})
|
||||
result = pipeline.run(ctx)
|
||||
assert result.params["max_tokens"] >= 8193
|
||||
|
||||
def test_thinking_removes_temperature(self):
|
||||
"""开启 thinking 时 temperature 被移除"""
|
||||
provider = ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="X",
|
||||
api_key_env="Y",
|
||||
parameter_mappings={
|
||||
"thinking": ParameterMapping(
|
||||
provider_param="thinking",
|
||||
handler="anthropic_thinking",
|
||||
),
|
||||
"temperature": ParameterMapping(
|
||||
provider_param="temperature",
|
||||
min=0,
|
||||
max=1,
|
||||
),
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens"),
|
||||
},
|
||||
)
|
||||
pipeline = PipelineFactory.create(provider)
|
||||
ctx = PipelineContext(params={"thinking": True, "temperature": 0.7, "max_tokens": 4096})
|
||||
result = pipeline.run(ctx)
|
||||
assert "temperature" not in result.params
|
||||
|
||||
|
||||
class TestValidateMaxTokensHandler:
|
||||
"""Test the validate_max_tokens handler."""
|
||||
|
||||
def test_clamps_to_model_limit(self):
|
||||
"""max_tokens 应被裁剪到模型上限"""
|
||||
model = ModelConfig(
|
||||
name="test-model",
|
||||
provider="anthropic",
|
||||
model="claude-sonnet-4-6",
|
||||
format="anthropic",
|
||||
max_output_tokens=16000,
|
||||
)
|
||||
provider = ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="X",
|
||||
api_key_env="Y",
|
||||
parameter_mappings={
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens"),
|
||||
},
|
||||
parameter_handlers=["validate_max_tokens"],
|
||||
)
|
||||
pipeline = PipelineFactory.create(provider)
|
||||
ctx = PipelineContext(
|
||||
params={"max_tokens": 999999},
|
||||
model_config=model,
|
||||
provider_config=provider,
|
||||
)
|
||||
result = pipeline.run(ctx)
|
||||
assert result.params["max_tokens"] == 16000
|
||||
|
||||
def test_within_limit_unchanged(self):
|
||||
"""max_tokens 在合理范围内时不变"""
|
||||
model = ModelConfig(
|
||||
name="test-model",
|
||||
provider="anthropic",
|
||||
model="claude-sonnet-4-6",
|
||||
format="anthropic",
|
||||
max_output_tokens=16000,
|
||||
)
|
||||
provider = ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="X",
|
||||
api_key_env="Y",
|
||||
parameter_mappings={
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens"),
|
||||
},
|
||||
parameter_handlers=["validate_max_tokens"],
|
||||
)
|
||||
pipeline = PipelineFactory.create(provider)
|
||||
ctx = PipelineContext(
|
||||
params={"max_tokens": 8000},
|
||||
model_config=model,
|
||||
provider_config=provider,
|
||||
)
|
||||
result = pipeline.run(ctx)
|
||||
assert result.params["max_tokens"] == 8000
|
||||
|
||||
|
||||
class TestStripUnsupportedCapabilityParams:
|
||||
"""Test the strip_unsupported_capability_params handler."""
|
||||
|
||||
def test_strips_image_params_when_no_vision(self):
|
||||
"""模型不支持 vision 时移除 image_url"""
|
||||
model = ModelConfig(
|
||||
name="test-model",
|
||||
provider="openai",
|
||||
model="gpt-4o",
|
||||
format="openai",
|
||||
supports_vision=False,
|
||||
)
|
||||
provider = ProviderConfig(
|
||||
name="openai",
|
||||
format="openai",
|
||||
base_url_env="X",
|
||||
api_key_env="Y",
|
||||
parameter_mappings={
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens"),
|
||||
"image_url": ParameterMapping(provider_param="image_url"),
|
||||
},
|
||||
parameter_handlers=["strip_unsupported_capability_params"],
|
||||
)
|
||||
pipeline = PipelineFactory.create(provider)
|
||||
ctx = PipelineContext(
|
||||
params={"max_tokens": 100, "image_url": "http://example.com/img.png"},
|
||||
model_config=model,
|
||||
provider_config=provider,
|
||||
)
|
||||
result = pipeline.run(ctx)
|
||||
assert "image_url" not in result.params
|
||||
assert "max_tokens" in result.params
|
||||
|
||||
def test_keeps_image_params_when_vision_supported(self):
|
||||
"""模型支持 vision 时保留 image_url"""
|
||||
model = ModelConfig(
|
||||
name="test-model",
|
||||
provider="openai",
|
||||
model="gpt-4o",
|
||||
format="openai",
|
||||
supports_vision=True,
|
||||
)
|
||||
provider = ProviderConfig(
|
||||
name="openai",
|
||||
format="openai",
|
||||
base_url_env="X",
|
||||
api_key_env="Y",
|
||||
parameter_mappings={
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens"),
|
||||
"image_url": ParameterMapping(provider_param="image_url"),
|
||||
},
|
||||
parameter_handlers=["strip_unsupported_capability_params"],
|
||||
)
|
||||
pipeline = PipelineFactory.create(provider)
|
||||
ctx = PipelineContext(
|
||||
params={"max_tokens": 100, "image_url": "http://example.com/img.png"},
|
||||
model_config=model,
|
||||
provider_config=provider,
|
||||
)
|
||||
result = pipeline.run(ctx)
|
||||
assert "image_url" in result.params
|
||||
|
||||
def test_strips_thinking_when_not_supported(self):
|
||||
"""模型不支持 thinking 时移除 thinking 参数"""
|
||||
model = ModelConfig(
|
||||
name="test-model",
|
||||
provider="openai",
|
||||
model="gpt-4o",
|
||||
format="openai",
|
||||
supports_thinking=False,
|
||||
)
|
||||
provider = ProviderConfig(
|
||||
name="openai",
|
||||
format="openai",
|
||||
base_url_env="X",
|
||||
api_key_env="Y",
|
||||
parameter_mappings={
|
||||
"thinking": ParameterMapping(provider_param="thinking"),
|
||||
},
|
||||
parameter_handlers=["strip_unsupported_capability_params"],
|
||||
)
|
||||
pipeline = PipelineFactory.create(provider)
|
||||
ctx = PipelineContext(
|
||||
params={"thinking": True},
|
||||
model_config=model,
|
||||
provider_config=provider,
|
||||
)
|
||||
result = pipeline.run(ctx)
|
||||
assert "thinking" not in result.params
|
||||
|
||||
|
||||
class TestHandlerIntegration:
|
||||
"""Test full pipeline integration with handlers."""
|
||||
|
||||
def test_anthropic_pipeline_end_to_end(self):
|
||||
"""模拟完整的 Anthropic pipeline 流程"""
|
||||
provider = ProviderConfig(
|
||||
name="anthropic",
|
||||
format="anthropic",
|
||||
base_url_env="ANTHROPIC_BASE_URL",
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
parameter_mappings={
|
||||
"max_tokens": ParameterMapping(provider_param="max_tokens", default=4096),
|
||||
"thinking": ParameterMapping(
|
||||
provider_param="thinking", handler="anthropic_thinking"
|
||||
),
|
||||
"temperature": ParameterMapping(
|
||||
provider_param="temperature", min=0, max=1, default=1.0
|
||||
),
|
||||
},
|
||||
parameter_handlers=[
|
||||
"strip_unsupported_capability_params",
|
||||
"validate_max_tokens",
|
||||
],
|
||||
)
|
||||
model = ModelConfig(
|
||||
name="claude-sonnet",
|
||||
provider="anthropic",
|
||||
model="claude-sonnet-4-6",
|
||||
format="anthropic",
|
||||
supports_vision=True,
|
||||
supports_thinking=True,
|
||||
max_output_tokens=16000,
|
||||
)
|
||||
pipeline = PipelineFactory.create(provider)
|
||||
ctx = PipelineContext(
|
||||
params={
|
||||
"thinking": {"budget_tokens": 8000},
|
||||
"temperature": 0.7,
|
||||
},
|
||||
model_config=model,
|
||||
provider_config=provider,
|
||||
)
|
||||
result = pipeline.run(ctx)
|
||||
|
||||
# thinking → provider_param name
|
||||
assert "thinking" in result.params
|
||||
# max_tokens injected via default, then lifted by handler
|
||||
assert result.params["max_tokens"] >= 8001
|
||||
# temperature removed by handler
|
||||
assert "temperature" not in result.params
|
||||
@@ -0,0 +1,46 @@
|
||||
# backend/tests/ai_provider/types/test_messages.py
|
||||
from app.ai_provider.types.messages import (
|
||||
AIMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
|
||||
|
||||
class TestMessageCreation:
|
||||
def test_system_message(self):
|
||||
msg = SystemMessage(content="You are a helpful assistant.")
|
||||
assert msg.content == "You are a helpful assistant."
|
||||
assert msg.role == "system"
|
||||
|
||||
def test_human_message(self):
|
||||
msg = HumanMessage(content="Hello!")
|
||||
assert msg.content == "Hello!"
|
||||
assert msg.role == "user"
|
||||
|
||||
def test_ai_message_with_tool_calls(self):
|
||||
msg = AIMessage(
|
||||
content="I'll check that.",
|
||||
tool_calls=[{"name": "read_file", "args": {"path": "test.txt"}, "id": "call_1"}],
|
||||
)
|
||||
assert msg.tool_calls is not None
|
||||
assert len(msg.tool_calls) == 1
|
||||
assert msg.tool_calls[0]["name"] == "read_file"
|
||||
|
||||
def test_tool_message(self):
|
||||
msg = ToolMessage(content="File contents here", tool_call_id="call_1")
|
||||
assert msg.content == "File contents here"
|
||||
assert msg.tool_call_id == "call_1"
|
||||
|
||||
def test_ai_message_with_reasoning(self):
|
||||
msg = AIMessage(content="Answer", reasoning="I think therefore...")
|
||||
assert msg.reasoning == "I think therefore..."
|
||||
|
||||
def test_message_to_dict_roundtrip(self):
|
||||
original = HumanMessage(content="Hello")
|
||||
d = original.to_dict()
|
||||
assert d["role"] == "user"
|
||||
assert d["content"] == "Hello"
|
||||
restored = HumanMessage.from_dict(d)
|
||||
assert restored.content == original.content
|
||||
assert restored.role == original.role
|
||||
@@ -0,0 +1,21 @@
|
||||
# backend/tests/ai_provider/types/test_errors.py
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
|
||||
|
||||
class TestErrors:
|
||||
def test_error_with_code(self):
|
||||
err = AIProviderError(message="Model not found", code="MODEL_NOT_FOUND", status_code=404)
|
||||
assert err.code == "MODEL_NOT_FOUND"
|
||||
assert err.status_code == 404
|
||||
assert str(err) == "Model not found"
|
||||
|
||||
def test_error_from_provider(self):
|
||||
cause = ValueError("API returned 429")
|
||||
err = AIProviderError.from_provider(
|
||||
message="Rate limited",
|
||||
code="RATE_LIMITED",
|
||||
provider="deepseek",
|
||||
original=cause,
|
||||
)
|
||||
assert err.provider == "deepseek"
|
||||
assert err.original is cause
|
||||
Whitespace-only changes.
@@ -0,0 +1,189 @@
|
||||
"""Admin 人物模板管理 API 测试(§19.8)。
|
||||
|
||||
验证系统模板的 CRUD、引用检查、权限隔离。
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(
|
||||
admin_headers: dict[str, str],
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
headers=admin_headers,
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_client(
|
||||
auth_headers: dict[str, str],
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
headers=auth_headers,
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_list_templates(admin_client: AsyncClient) -> None:
|
||||
"""管理员可以列出所有系统模板。"""
|
||||
resp = await admin_client.get("/api/v1/admin/characters/templates")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert isinstance(body, list)
|
||||
# 至少包含 default 模板
|
||||
keys = [t["key"] for t in body]
|
||||
assert "default" in keys
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_template(admin_client: AsyncClient) -> None:
|
||||
"""管理员可以获取单个模板详情。"""
|
||||
# 先列出获取 key
|
||||
list_resp = await admin_client.get("/api/v1/admin/characters/templates")
|
||||
assert list_resp.status_code == 200
|
||||
templates = list_resp.json()
|
||||
if not templates:
|
||||
pytest.skip("无模板可用")
|
||||
key = templates[0]["key"]
|
||||
resp = await admin_client.get(
|
||||
f"/api/v1/admin/characters/templates/{key}",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["key"] == key
|
||||
assert "profile" in body
|
||||
assert "model_policy" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_create_template(admin_client: AsyncClient) -> None:
|
||||
"""管理员可以创建新系统模板。"""
|
||||
payload = {
|
||||
"key": "test_create_char",
|
||||
"name": "测试人物",
|
||||
"display_name": "测试",
|
||||
"language": "zh-CN",
|
||||
"profile": {
|
||||
"description": "自动化测试创建",
|
||||
"speaking_style": "简洁",
|
||||
},
|
||||
"model_policy": {
|
||||
"memory_extraction_model_hint": "memory-extraction",
|
||||
},
|
||||
}
|
||||
resp = await admin_client.post(
|
||||
"/api/v1/admin/characters/templates",
|
||||
json=payload,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["key"] == "test_create_char"
|
||||
assert body["name"] == "测试人物"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_create_duplicate_key(admin_client: AsyncClient) -> None:
|
||||
"""重复 key 应返回 409。"""
|
||||
payload = {
|
||||
"key": "test_dup_key",
|
||||
"name": "首次",
|
||||
}
|
||||
resp = await admin_client.post(
|
||||
"/api/v1/admin/characters/templates",
|
||||
json=payload,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
# 重复创建
|
||||
resp2 = await admin_client.post(
|
||||
"/api/v1/admin/characters/templates",
|
||||
json=payload,
|
||||
)
|
||||
assert resp2.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_update_template(admin_client: AsyncClient) -> None:
|
||||
"""管理员可以部分更新模板。"""
|
||||
# 先创建
|
||||
key = "test_update_tpl"
|
||||
await admin_client.post(
|
||||
"/api/v1/admin/characters/templates",
|
||||
json={"key": key, "name": "原名"},
|
||||
)
|
||||
# 更新
|
||||
resp = await admin_client.patch(
|
||||
f"/api/v1/admin/characters/templates/{key}",
|
||||
json={"name": "新名", "profile": {"description": "新描述"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["name"] == "新名"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_delete_template(admin_client: AsyncClient) -> None:
|
||||
"""管理员可以删除未被引用的模板。"""
|
||||
key = "test_del_tpl"
|
||||
resp = await admin_client.post(
|
||||
"/api/v1/admin/characters/templates",
|
||||
json={"key": key, "name": "待删除"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = await admin_client.delete(
|
||||
f"/api/v1/admin/characters/templates/{key}",
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# 确认已删除
|
||||
get_resp = await admin_client.get(
|
||||
f"/api/v1/admin/characters/templates/{key}",
|
||||
)
|
||||
assert get_resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_cannot_list_templates(user_client: AsyncClient) -> None:
|
||||
"""非管理员不能通过用户端 API 列出模板。"""
|
||||
resp = await user_client.get("/api/v1/characters/templates")
|
||||
assert resp.status_code in (403,)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_cannot_create_from_template(
|
||||
user_client: AsyncClient,
|
||||
) -> None:
|
||||
"""非管理员不能带 template_key 创建角色。"""
|
||||
resp = await user_client.post(
|
||||
"/api/v1/characters",
|
||||
json={"name": "hacker_attempt", "template_key": "default"},
|
||||
)
|
||||
assert resp.status_code in (403,)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_create_from_template(
|
||||
admin_client: AsyncClient,
|
||||
) -> None:
|
||||
"""管理员可以从模板创建角色。"""
|
||||
resp = await admin_client.post(
|
||||
"/api/v1/characters",
|
||||
json={"name": "admin_char_test", "template_key": "default"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "active"
|
||||
@@ -0,0 +1,126 @@
|
||||
"""认证端点 API 测试。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _jwt_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""所有 API 测试需要 JWT_SECRET 配置。"""
|
||||
monkeypatch.setattr("app.core.config.settings.jwt_secret", "test-jwt-secret-for-testing")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_and_login(client: AsyncClient) -> None:
|
||||
# Register
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "testuser", "email": "test@example.com", "password": "testpass123"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["username"] == "testuser"
|
||||
assert data["email"] == "test@example.com"
|
||||
user_id = data["id"]
|
||||
assert user_id.startswith("user_")
|
||||
|
||||
# Login
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "testuser", "password": "testpass123"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
assert data["user"]["id"] == user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_username(client: AsyncClient) -> None:
|
||||
# Register first user
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "dupe", "email": "dupe1@example.com", "password": "pass123"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Try duplicate username
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "dupe", "email": "dupe2@example.com", "password": "pass456"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["error"]["code"] == "conflict"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_password_login(client: AsyncClient) -> None:
|
||||
await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "wrongpass", "email": "wrong@example.com", "password": "correct"},
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "wrongpass", "password": "wrong"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_token(client: AsyncClient) -> None:
|
||||
# Register & login
|
||||
await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "refreshtest", "email": "refresh@example.com", "password": "pass"},
|
||||
)
|
||||
login_resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "refreshtest", "password": "pass"},
|
||||
)
|
||||
login_data = login_resp.json()
|
||||
refresh_token = login_data["refresh_token"]
|
||||
|
||||
# Refresh
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_me(client: AsyncClient) -> None:
|
||||
await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "metest", "email": "me@example.com", "password": "pass"},
|
||||
)
|
||||
login_resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "metest", "password": "pass"},
|
||||
)
|
||||
access_token = login_resp.json()["access_token"]
|
||||
|
||||
# GET /auth/me
|
||||
resp = await client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["username"] == "metest"
|
||||
assert data["email"] == "me@example.com"
|
||||
@@ -0,0 +1,161 @@
|
||||
"""角色头像上传 API 测试。
|
||||
|
||||
验证头像文件的上传、读取、替换、类型校验、大小限制与清理。
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
_AVATAR_PATH = "/api/v1/characters"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(
|
||||
admin_headers: dict[str, str],
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
headers=admin_headers,
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def active_char_id(admin_client: AsyncClient) -> str:
|
||||
"""获取当前激活角色 ID。"""
|
||||
resp = await admin_client.get(f"{_AVATAR_PATH}/active")
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_and_get_avatar(
|
||||
admin_client: AsyncClient, active_char_id: str,
|
||||
) -> None:
|
||||
"""上传头像后可以 GET 获取到图片文件。"""
|
||||
png_bytes = _make_test_png()
|
||||
upload = await admin_client.post(
|
||||
f"{_AVATAR_PATH}/{active_char_id}/avatar",
|
||||
files={"file": ("test.png", png_bytes, "image/png")},
|
||||
)
|
||||
assert upload.status_code == 200, upload.text
|
||||
body = upload.json()
|
||||
assert "avatar_url" in body
|
||||
assert active_char_id in body["avatar_url"]
|
||||
|
||||
# GET 获取图片
|
||||
get_resp = await admin_client.get(
|
||||
f"{_AVATAR_PATH}/{active_char_id}/avatar",
|
||||
)
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.headers["content-type"] == "image/png"
|
||||
assert len(await get_resp.aread()) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avatar_replace(
|
||||
admin_client: AsyncClient, active_char_id: str,
|
||||
) -> None:
|
||||
"""上传新头像会替换旧头像(同角色只保留一张)。"""
|
||||
# 上传第一张
|
||||
first = _make_test_png(width=10, height=10)
|
||||
r1 = await admin_client.post(
|
||||
f"{_AVATAR_PATH}/{active_char_id}/avatar",
|
||||
files={"file": ("first.png", first, "image/png")},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
|
||||
# 上传第二张
|
||||
second = _make_test_png(width=20, height=20)
|
||||
r2 = await admin_client.post(
|
||||
f"{_AVATAR_PATH}/{active_char_id}/avatar",
|
||||
files={"file": ("second.png", second, "image/png")},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
|
||||
# 读取头像文件大小应与第二张匹配
|
||||
get_resp = await admin_client.get(
|
||||
f"{_AVATAR_PATH}/{active_char_id}/avatar",
|
||||
)
|
||||
assert get_resp.status_code == 200
|
||||
body = await get_resp.aread()
|
||||
assert len(body) == len(second)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avatar_rejects_invalid_type(
|
||||
admin_client: AsyncClient, active_char_id: str,
|
||||
) -> None:
|
||||
"""不支持的文件类型应返回 4xx。"""
|
||||
resp = await admin_client.post(
|
||||
f"{_AVATAR_PATH}/{active_char_id}/avatar",
|
||||
files={"file": ("test.svg", b"<svg></svg>", "image/svg+xml")},
|
||||
)
|
||||
assert resp.status_code in (400, 422)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avatar_requires_auth(active_char_id: str) -> None:
|
||||
"""未认证用户不能上传头像。"""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
resp = await client.post(
|
||||
f"{_AVATAR_PATH}/{active_char_id}/avatar",
|
||||
files={"file": ("test.png", _make_test_png(), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avatar_not_found(admin_client: AsyncClient) -> None:
|
||||
"""不存在的角色上传头像应 404。"""
|
||||
resp = await admin_client.post(
|
||||
f"{_AVATAR_PATH}/character_nonexistent_id_00000/avatar",
|
||||
files={"file": ("test.png", _make_test_png(), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_avatar_no_image(
|
||||
admin_client: AsyncClient, active_char_id: str,
|
||||
) -> None:
|
||||
"""无头像时 GET 应 404。"""
|
||||
from app.services.avatar import delete_avatar
|
||||
|
||||
await delete_avatar(active_char_id)
|
||||
|
||||
resp = await admin_client.get(
|
||||
f"{_AVATAR_PATH}/{active_char_id}/avatar",
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── 辅助:生成最小 PNG ──
|
||||
|
||||
def _make_test_png(width: int = 1, height: int = 1) -> bytes:
|
||||
"""生成一个最小的有效 PNG 文件。"""
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
sig = b"\x89PNG\r\n\x1a\n"
|
||||
ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||
ihdr_crc = zlib.crc32(b"IHDR" + ihdr_data)
|
||||
ihdr = b"\x00\x00\x00\x0dIHDR" + ihdr_data + struct.pack(">I", ihdr_crc)
|
||||
raw = b"\x00" + b"\xff\x00\x00" * width
|
||||
raw *= height
|
||||
compressed = zlib.compress(raw)
|
||||
idat_crc = zlib.crc32(b"IDAT" + compressed)
|
||||
idat = struct.pack(">I", len(compressed)) + b"IDAT" + compressed + struct.pack(">I", idat_crc)
|
||||
iend_crc = zlib.crc32(b"IEND")
|
||||
iend = b"\x00\x00\x00\x00IEND" + struct.pack(">I", iend_crc)
|
||||
return sig + ihdr + idat + iend
|
||||
@@ -0,0 +1,97 @@
|
||||
"""角色卡导入 API 测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def _make_png_text(keyword: str, text: str) -> bytes:
|
||||
def chunk(ctype: bytes, data: bytes) -> bytes:
|
||||
body = ctype + data
|
||||
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
||||
|
||||
payload = keyword.encode("latin-1") + b"\x00" + text.encode("latin-1")
|
||||
return b"\x89PNG\r\n\x1a\n" + chunk(b"tEXt", payload) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
async def test_import_json_card(auth_client: httpx.AsyncClient) -> None:
|
||||
card = {"spec_version": "2.0", "data": {"name": "API卡", "personality": "p"}}
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/characters/import-card",
|
||||
files={"file": ("card.json", json.dumps(card).encode("utf-8"), "application/json")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "API卡"
|
||||
assert data["character_id"].startswith("character_")
|
||||
|
||||
|
||||
async def test_import_png_card(auth_client: httpx.AsyncClient) -> None:
|
||||
card = {"spec_version": "2.0", "data": {"name": "PNG卡"}}
|
||||
text = base64.b64encode(json.dumps(card).encode("utf-8")).decode("ascii")
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/characters/import-card",
|
||||
files={"file": ("card.png", _make_png_text("chara", text), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "PNG卡"
|
||||
|
||||
|
||||
async def test_import_card_with_lorebook(auth_client: httpx.AsyncClient) -> None:
|
||||
card = {
|
||||
"spec_version": "2.0",
|
||||
"data": {
|
||||
"name": "带书",
|
||||
"character_book": {"entries": [{"keys": ["a"], "content": "b"}]},
|
||||
},
|
||||
}
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/characters/import-card",
|
||||
files={"file": ("card.json", json.dumps(card).encode("utf-8"), "application/json")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["lorebook_id"] is not None
|
||||
assert data["entry_count"] == 1
|
||||
# 导入的人物可被列出
|
||||
listed = await auth_client.get("/api/v1/characters")
|
||||
assert any(c["id"] == data["character_id"] for c in listed.json()["items"])
|
||||
|
||||
|
||||
async def test_export_card_round_trip(auth_client: httpx.AsyncClient) -> None:
|
||||
# 导入一张带世谱的卡
|
||||
card = {
|
||||
"spec_version": "2.0",
|
||||
"data": {
|
||||
"name": "往返",
|
||||
"nickname": "返返",
|
||||
"personality": "p",
|
||||
"character_book": {"entries": [{"keys": ["k"], "content": "v"}]},
|
||||
},
|
||||
}
|
||||
imp = await auth_client.post(
|
||||
"/api/v1/characters/import-card",
|
||||
files={"file": ("c.json", json.dumps(card).encode("utf-8"), "application/json")},
|
||||
)
|
||||
assert imp.status_code == 200
|
||||
cid = imp.json()["character_id"]
|
||||
|
||||
# 导出
|
||||
exp = await auth_client.get(f"/api/v1/characters/{cid}/export-card")
|
||||
assert exp.status_code == 200
|
||||
exported = exp.json()
|
||||
assert exported["spec"] == "chara_card_v3"
|
||||
assert exported["data"]["name"] == "往返"
|
||||
assert exported["data"]["nickname"] == "返返"
|
||||
assert exported["data"]["personality"] == "p"
|
||||
assert exported["data"]["character_book"]["entries"][0]["keys"] == ["k"]
|
||||
|
||||
|
||||
async def test_export_unknown_character_404(auth_client: httpx.AsyncClient) -> None:
|
||||
exp = await auth_client.get("/api/v1/characters/character_missing/export-card")
|
||||
assert exp.status_code == 404
|
||||
@@ -0,0 +1,153 @@
|
||||
"""人物管理 API 测试(api-spec §6)。
|
||||
|
||||
注意:所有需要用户认证的端点使用 ``auth_headers`` 注入 Bearer token。
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
headers=auth_headers,
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_returns_seeded_default(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/characters")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] >= 1
|
||||
item = body["items"][0]
|
||||
assert item["id"].startswith("character_")
|
||||
assert item["status"] == "active"
|
||||
assert item["personality_version"] >= 1
|
||||
assert item["memory_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_character_defaults_to_wuji(client: AsyncClient) -> None:
|
||||
resp = await client.post("/api/v1/characters", json={"name": "新人物"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "wuji"
|
||||
assert body["name"] == "新人物"
|
||||
assert body["owner_scope"] is not None
|
||||
assert body["is_system_template"] is False
|
||||
assert body["model_policy"]["default_chat_model_hint"]
|
||||
|
||||
listed = await client.get("/api/v1/characters")
|
||||
assert listed.json()["total"] >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_character_rejects_empty_name(client: AsyncClient) -> None:
|
||||
resp = await client.post("/api/v1/characters", json={"name": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_filter_by_status(client: AsyncClient) -> None:
|
||||
await client.post("/api/v1/characters", json={"name": "空白人物"})
|
||||
resp = await client.get("/api/v1/characters?status=wuji")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["status"] == "wuji"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_character_and_not_found(client: AsyncClient) -> None:
|
||||
active = await client.get("/api/v1/characters/active")
|
||||
character_id = active.json()["id"]
|
||||
|
||||
resp = await client.get(f"/api/v1/characters/{character_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == character_id
|
||||
|
||||
missing = await client.get("/api/v1/characters/character_does_not_exist")
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_profile_and_model_policy(client: AsyncClient) -> None:
|
||||
character_id = (await client.get("/api/v1/characters/active")).json()["id"]
|
||||
|
||||
profile = await client.patch(
|
||||
f"/api/v1/characters/{character_id}/profile",
|
||||
json={"display_name": "昵称", "background": "背景故事"},
|
||||
)
|
||||
assert profile.status_code == 200
|
||||
assert profile.json()["display_name"] == "昵称"
|
||||
assert profile.json()["profile"]["background"] == "背景故事"
|
||||
|
||||
policy = await client.patch(
|
||||
f"/api/v1/characters/{character_id}/model-policy",
|
||||
json={"privacy_level": "sensitive", "default_chat_model_hint": "custom-chat"},
|
||||
)
|
||||
assert policy.status_code == 200
|
||||
body = policy.json()
|
||||
assert body["privacy_level"] == "sensitive"
|
||||
assert body["model_policy"]["privacy_level"] == "sensitive"
|
||||
assert body["model_policy"]["default_chat_model_hint"] == "custom-chat"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_switches_active_character(client: AsyncClient) -> None:
|
||||
char_a = (await client.get("/api/v1/characters/active")).json()["id"]
|
||||
char_b = (await client.post("/api/v1/characters", json={"name": "B"})).json()["id"]
|
||||
|
||||
resp = await client.post(f"/api/v1/characters/{char_b}/activate")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"active_character_id": char_b, "status": "active"}
|
||||
|
||||
# A 被切到 paused(A1 互斥)
|
||||
a_after = await client.get(f"/api/v1/characters/{char_a}")
|
||||
assert a_after.json()["status"] == "paused"
|
||||
|
||||
# 当前激活指向 B
|
||||
assert (await client.get("/api/v1/characters/active")).json()["id"] == char_b
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_resume_archive(client: AsyncClient) -> None:
|
||||
char_b = (await client.post("/api/v1/characters", json={"name": "B"})).json()["id"]
|
||||
await client.post(f"/api/v1/characters/{char_b}/activate")
|
||||
|
||||
paused = await client.post(f"/api/v1/characters/{char_b}/pause")
|
||||
assert paused.status_code == 200
|
||||
assert paused.json()["status"] == "paused"
|
||||
|
||||
# 暂停当前唯一激活人物后无 active
|
||||
no_active = await client.get("/api/v1/characters/active")
|
||||
assert no_active.status_code == 404
|
||||
|
||||
resumed = await client.post(f"/api/v1/characters/{char_b}/resume")
|
||||
assert resumed.status_code == 200
|
||||
assert resumed.json()["status"] == "active"
|
||||
|
||||
archived = await client.post(f"/api/v1/characters/{char_b}/archive")
|
||||
assert archived.status_code == 200
|
||||
assert archived.json()["status"] == "archived"
|
||||
|
||||
# 归档人物不可直接激活
|
||||
again = await client.post(f"/api/v1/characters/{char_b}/activate")
|
||||
assert again.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_rejects_non_active(client: AsyncClient) -> None:
|
||||
char_b = (await client.post("/api/v1/characters", json={"name": "B"})).json()["id"]
|
||||
# wuji 状态不可暂停
|
||||
resp = await client.post(f"/api/v1/characters/{char_b}/pause")
|
||||
assert resp.status_code == 409
|
||||
@@ -0,0 +1,144 @@
|
||||
"""内驱力 /drive API 测试。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.main import app
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
async def _seed(character_id: str, subgoal_status: str = "candidate") -> str:
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(
|
||||
character_id=character_id,
|
||||
trigger="test",
|
||||
status="completed",
|
||||
memory_count_at_trigger=20,
|
||||
)
|
||||
session.add(cycle)
|
||||
await session.flush()
|
||||
curios = CuriosityQuestion(
|
||||
character_id=character_id,
|
||||
cycle_id=cycle.id,
|
||||
question="用户为何频繁提到项目X",
|
||||
source="memory_gap",
|
||||
expected_value=0.7,
|
||||
urgency=0.5,
|
||||
risk_level="L2",
|
||||
)
|
||||
session.add(curios)
|
||||
await session.flush()
|
||||
sub = ExploratorySubgoal(
|
||||
character_id=character_id,
|
||||
cycle_id=cycle.id,
|
||||
curiosity_question_id=curios.id,
|
||||
title="询问项目目标",
|
||||
description="主动询问",
|
||||
expected_information_gain=0.6,
|
||||
interruption_cost=0.3,
|
||||
risk_level="L2",
|
||||
status=subgoal_status,
|
||||
)
|
||||
session.add(sub)
|
||||
await session.commit()
|
||||
return sub.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_curiosities(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed(character.id)
|
||||
|
||||
resp = await client.get(f"/api/v1/characters/{character.id}/curiosities")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["question"] == "用户为何频繁提到项目X"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_subgoals_with_status_filter(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed(character.id, "candidate")
|
||||
await _seed(character.id, "approved")
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/characters/{character.id}/subgoals", params={"status": "candidate"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_subgoal(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/subgoals/{sid}/approve")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defer_subgoal(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/subgoals/{sid}/defer")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "deferred"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_subgoal(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/subgoals/{sid}/reject")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "rejected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_subgoal_creates_goal(client: AsyncClient) -> None:
|
||||
"""审批通过次生目标后应自动创建太一目标。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.governance import Goal
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/subgoals/{sid}/approve")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# 验证生成了 Goal
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Goal).where(Goal.character_id == character.id)
|
||||
)
|
||||
goals = result.scalars().all()
|
||||
matching = [g for g in goals if g.title == "询问项目目标"]
|
||||
assert len(matching) == 1
|
||||
goal = matching[0]
|
||||
assert goal.level == "task"
|
||||
assert goal.type == "exploration"
|
||||
assert goal.status == "active"
|
||||
@@ -0,0 +1,106 @@
|
||||
"""演化候选审核 API 测试。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.main import app
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
from app.repositories.json_utils import dumps
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
async def _seed_cycle_and_candidate(character_id: str, **overrides) -> tuple[str, str]:
|
||||
base = {
|
||||
"type": "procedural",
|
||||
"source": "big_cycle",
|
||||
"level": "L2",
|
||||
"scope": "task_scope",
|
||||
"title": "确认假设",
|
||||
"proposal": "技术问题先确认前提",
|
||||
"delta_json": dumps({"title": "确认假设", "content": "先确认前提"}),
|
||||
"evidence_ids_json": "[]",
|
||||
"confidence": 0.8,
|
||||
"benefit_score": 0.7,
|
||||
"risk_score": 0.2,
|
||||
"drift_score": 0.0,
|
||||
"auto_apply_allowed": False,
|
||||
"status": "pending",
|
||||
}
|
||||
base.update(overrides)
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(
|
||||
character_id=character_id,
|
||||
trigger="test",
|
||||
status="completed",
|
||||
memory_count_at_trigger=20,
|
||||
)
|
||||
session.add(cycle)
|
||||
await session.flush()
|
||||
candidate = EvolutionCandidate(character_id=character_id, cycle_id=cycle.id, **base)
|
||||
session.add(candidate)
|
||||
await session.commit()
|
||||
return candidate.id, cycle.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_evolution_candidates(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_cycle_and_candidate(character.id)
|
||||
await _seed_cycle_and_candidate(character.id, status="applied")
|
||||
|
||||
resp = await client.get(f"/api/v1/characters/{character.id}/evolution-candidates")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
|
||||
pending = await client.get(
|
||||
f"/api/v1/characters/{character.id}/evolution-candidates", params={"status": "pending"}
|
||||
)
|
||||
assert pending.json()["total"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_pending_candidate_applies(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cand_id, _ = await _seed_cycle_and_candidate(character.id, type="procedural")
|
||||
|
||||
resp = await client.post(f"/api/v1/evolution-candidates/{cand_id}/approve")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "applied"
|
||||
assert data["resulting_target_id"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_pending_candidate(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cand_id, _ = await _seed_cycle_and_candidate(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/evolution-candidates/{cand_id}/reject")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "rejected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_not_implemented_type_returns_error(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cand_id, _ = await _seed_cycle_and_candidate(character.id, type="tool_strategy", level="L2")
|
||||
|
||||
resp = await client.post(f"/api/v1/evolution-candidates/{cand_id}/approve")
|
||||
assert resp.status_code in (400, 409, 501)
|
||||
assert resp.json()["error"]["code"] == "not_implemented"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""测试 per-character goals API。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_goals_by_character(client: AsyncClient, db_session: AsyncSession) -> None:
|
||||
"""GET /api/v1/goals?character_id=char_test 返回该人物的目标。"""
|
||||
from app.models.governance import Goal
|
||||
|
||||
goal = Goal(
|
||||
id="goal_api_test",
|
||||
title="API测试目标",
|
||||
level="task",
|
||||
character_id="char_test",
|
||||
status="active",
|
||||
)
|
||||
db_session.add(goal)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/v1/goals?character_id=char_test")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert any(g["id"] == "goal_api_test" for g in data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_goals_filters_status(client: AsyncClient, db_session: AsyncSession) -> None:
|
||||
"""GET /api/v1/goals?character_id=char_test&status=active 只返回 active。"""
|
||||
from app.models.governance import Goal
|
||||
|
||||
db_session.add_all(
|
||||
[
|
||||
Goal(id="goal_a1", title="a1", level="task", character_id="char_test", status="active"),
|
||||
Goal(
|
||||
id="goal_c1", title="c1", level="task", character_id="char_test", status="completed"
|
||||
),
|
||||
]
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/v1/goals?character_id=char_test&status=active")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == "goal_a1"
|
||||
@@ -0,0 +1,176 @@
|
||||
"""资料导入候选抽取测试(5b,异步后台 + mock ai_provider)。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.application.imports import drain_pending_extractions
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.main import app
|
||||
from app.models.user import User
|
||||
from app.services.auth import create_access_token
|
||||
|
||||
|
||||
async def _get_character_id(client: AsyncClient) -> str:
|
||||
"""通过已认证的 API 获取当前用户活跃角色 ID。"""
|
||||
resp = await client.get("/api/v1/characters/active")
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
class _FakeChatResult:
|
||||
def __init__(self, content: str) -> None:
|
||||
self.content = content
|
||||
|
||||
|
||||
_PROFILE_RESPONSE = (
|
||||
'[{"field":"background","value":"来自北京,从事设计工作",'
|
||||
'"confidence":0.9,"reason":"资料明确提及","evidence":[0,1]}]'
|
||||
)
|
||||
_MEMORY_RESPONSE = (
|
||||
'[{"type":"preference","title":"喜欢简洁回答","content":"用户偏好简洁直接的回答",'
|
||||
'"importance":0.8,"sensitivity":"low","reason":"多次提及","evidence":[0]}]'
|
||||
)
|
||||
_PERSONALITY_RESPONSE = (
|
||||
'[{"field":"expression_style","change":"温和且有条理","reason":"资料体现","evidence":[1]}]'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
async def fake_chat(messages, *, model_hint, **_kwargs):
|
||||
if model_hint == "memory-extraction":
|
||||
return _FakeChatResult(_MEMORY_RESPONSE)
|
||||
system_text = str(messages[0].content) if messages else ""
|
||||
if "人格" in system_text:
|
||||
return _FakeChatResult(_PERSONALITY_RESPONSE)
|
||||
return _FakeChatResult(_PROFILE_RESPONSE)
|
||||
|
||||
class _MockMessage:
|
||||
def __init__(self, content: str):
|
||||
self.content = content
|
||||
|
||||
class _MockManager:
|
||||
async def chat(self, messages, model_hint=None):
|
||||
return await fake_chat(messages, model_hint=model_hint)
|
||||
|
||||
def to_message_list(self, system, _messages):
|
||||
return [_MockMessage(s) for s in system]
|
||||
|
||||
monkeypatch.setattr("app.application.imports._get_provider_manager", lambda: _MockManager())
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
async def _create_import(client: AsyncClient, character_id: str) -> str:
|
||||
resp = await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports",
|
||||
json={
|
||||
"material_type": "background_story",
|
||||
"content": "我叫小魂,来自北京。\n\n我从事设计工作,喜欢简洁直接的回答。",
|
||||
},
|
||||
)
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_profile_candidates(client: AsyncClient) -> None:
|
||||
character_id = await _get_character_id(client)
|
||||
import_id = await _create_import(client, character_id)
|
||||
|
||||
triggered = await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates"
|
||||
)
|
||||
assert triggered.status_code == 200
|
||||
assert triggered.json()["status"] == "extracting_profile"
|
||||
|
||||
await drain_pending_extractions()
|
||||
|
||||
candidates = (
|
||||
await client.get(
|
||||
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates"
|
||||
)
|
||||
).json()
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0]["field"] == "background"
|
||||
assert candidates[0]["status"] == "pending"
|
||||
assert len(candidates[0]["evidence_ids"]) == 2 # 映射到 import_items.id
|
||||
|
||||
job = (await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}")).json()
|
||||
assert job["status"] == "reviewing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_memory_candidates(client: AsyncClient) -> None:
|
||||
character_id = await _get_character_id(client)
|
||||
import_id = await _create_import(client, character_id)
|
||||
|
||||
await client.post(f"/api/v1/characters/{character_id}/imports/{import_id}/memory-candidates")
|
||||
await drain_pending_extractions()
|
||||
|
||||
candidates = (
|
||||
await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}/memory-candidates")
|
||||
).json()
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0]["type"] == "preference"
|
||||
assert candidates[0]["title"] == "喜欢简洁回答"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_personality_proposals(client: AsyncClient) -> None:
|
||||
character_id = await _get_character_id(client)
|
||||
import_id = await _create_import(client, character_id)
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports/{import_id}/personality-proposals"
|
||||
)
|
||||
await drain_pending_extractions()
|
||||
|
||||
# 人格建议落入 personality_proposals(pending);无独立列表端点,以 job 进入 reviewing 验证
|
||||
job = (await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}")).json()
|
||||
assert job["status"] == "reviewing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extraction_failure_marks_job_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def failing_chat(*_args, **_kwargs):
|
||||
raise RuntimeError("模型不可用")
|
||||
|
||||
class _MockMessage:
|
||||
def __init__(self, content: str):
|
||||
self.content = content
|
||||
|
||||
class _FailingManager:
|
||||
async def chat(self, messages, model_hint=None):
|
||||
return await failing_chat(messages, model_hint=model_hint)
|
||||
|
||||
def to_message_list(self, system, _messages):
|
||||
return [_MockMessage(s) for s in system]
|
||||
|
||||
monkeypatch.setattr("app.application.imports._get_provider_manager", lambda: _FailingManager())
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(User).where(User.is_admin.is_(True)).limit(1))
|
||||
admin = result.scalar_one_or_none()
|
||||
assert admin is not None
|
||||
token = create_access_token(subject=admin.id, extra={"owner_scope": admin.id})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
async with AsyncClient(transport=transport, base_url="http://test", headers=headers) as client:
|
||||
character_id = await _get_character_id(client)
|
||||
import_id = await _create_import(client, character_id)
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates"
|
||||
)
|
||||
await drain_pending_extractions()
|
||||
|
||||
job = (await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}")).json()
|
||||
assert job["status"] == "failed"
|
||||
assert job["error_code"] == "extraction_failed"
|
||||
@@ -0,0 +1,142 @@
|
||||
"""资料导入候选审核测试(5c)。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.application.imports import drain_pending_extractions
|
||||
from app.main import app
|
||||
|
||||
|
||||
async def _get_character_id(client: AsyncClient) -> str:
|
||||
"""通过已认证的 API 获取当前用户活跃角色 ID。"""
|
||||
resp = await client.get("/api/v1/characters/active")
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
class _FakeChatResult:
|
||||
def __init__(self, content: str) -> None:
|
||||
self.content = content
|
||||
|
||||
|
||||
_PROFILE_RESPONSE = (
|
||||
'[{"field":"background","value":"来自北京,从事设计工作",'
|
||||
'"confidence":0.9,"reason":"资料提及","evidence":[0]}]'
|
||||
)
|
||||
_MEMORY_RESPONSE = (
|
||||
'[{"type":"preference","title":"喜欢简洁回答","content":"用户偏好简洁直接的回答",'
|
||||
'"importance":0.8,"sensitivity":"low","reason":"多次提及","evidence":[0]}]'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
async def fake_chat(*_args, model_hint, **_kwargs):
|
||||
if model_hint == "memory-extraction":
|
||||
return _FakeChatResult(_MEMORY_RESPONSE)
|
||||
return _FakeChatResult(_PROFILE_RESPONSE)
|
||||
|
||||
class _MockMessage:
|
||||
def __init__(self, content: str):
|
||||
self.content = content
|
||||
|
||||
class _MockManager:
|
||||
async def chat(self, _messages, model_hint=None):
|
||||
return await fake_chat(None, model_hint=model_hint)
|
||||
|
||||
def to_message_list(self, system, _messages):
|
||||
return [_MockMessage(s) for s in system]
|
||||
|
||||
monkeypatch.setattr("app.application.imports._get_provider_manager", lambda: _MockManager())
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
async def _setup(client: AsyncClient, kind: str) -> tuple[str, str, str]:
|
||||
character_id = await _get_character_id(client)
|
||||
import_id = (
|
||||
await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports",
|
||||
json={"material_type": "background_story", "content": "我叫小魂,来自北京。"},
|
||||
)
|
||||
).json()["id"]
|
||||
await client.post(f"/api/v1/characters/{character_id}/imports/{import_id}/{kind}")
|
||||
await drain_pending_extractions()
|
||||
candidates = (
|
||||
await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}/{kind}")
|
||||
).json()
|
||||
return character_id, import_id, candidates[0]["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_review_profile_approve_writes_profile(client: AsyncClient) -> None:
|
||||
character_id, _import_id, candidate_id = await _setup(client, "profile-candidates")
|
||||
|
||||
result = await client.post(
|
||||
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
|
||||
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
|
||||
)
|
||||
assert result.status_code == 200
|
||||
assert result.json()["applied"] == 1
|
||||
|
||||
character = (await client.get(f"/api/v1/characters/{character_id}")).json()
|
||||
assert character["profile"]["background"] == "来自北京,从事设计工作"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_review_memory_approve_creates_memory(client: AsyncClient) -> None:
|
||||
character_id, _import_id, candidate_id = await _setup(client, "memory-candidates")
|
||||
|
||||
result = await client.post(
|
||||
f"/api/v1/characters/{character_id}/memory-candidates/batch-review",
|
||||
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
|
||||
)
|
||||
assert result.json()["applied"] == 1
|
||||
|
||||
memories = (await client.get("/api/v1/memory")).json()
|
||||
assert memories["total"] >= 1
|
||||
assert any(m["title"] == "喜欢简洁回答" for m in memories["items"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_review_reject_marks_candidate(client: AsyncClient) -> None:
|
||||
character_id, import_id, candidate_id = await _setup(client, "profile-candidates")
|
||||
|
||||
result = await client.post(
|
||||
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
|
||||
json={"decisions": [{"candidate_id": candidate_id, "decision": "reject"}]},
|
||||
)
|
||||
assert result.json()["rejected"] == 1
|
||||
|
||||
rejected = (
|
||||
await client.get(
|
||||
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates",
|
||||
params={"status": "rejected"},
|
||||
)
|
||||
).json()
|
||||
assert any(c["id"] == candidate_id for c in rejected)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_review_skips_non_pending(client: AsyncClient) -> None:
|
||||
character_id, _import_id, candidate_id = await _setup(client, "profile-candidates")
|
||||
|
||||
# 先 approve
|
||||
await client.post(
|
||||
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
|
||||
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
|
||||
)
|
||||
# 再审同一候选(已 applied)→ skipped
|
||||
result = await client.post(
|
||||
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
|
||||
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
|
||||
)
|
||||
assert result.json()["skipped"] == 1
|
||||
assert result.json()["applied"] == 0
|
||||
@@ -0,0 +1,145 @@
|
||||
"""资料导入 API 测试(api-spec §8,5a 结构解析)。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def character_id(client: AsyncClient) -> str:
|
||||
"""从认证用户获取活跃角色 ID。"""
|
||||
resp = await client.get("/api/v1/characters/active")
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_import_text_parses_paragraphs(client: AsyncClient, character_id: str) -> None:
|
||||
resp = await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports",
|
||||
json={
|
||||
"material_type": "background_story",
|
||||
"format": "text",
|
||||
"content": "第一段背景。\n\n第二段背景。",
|
||||
"title": "背景资料",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
job = resp.json()
|
||||
assert job["id"].startswith("import_")
|
||||
assert job["status"] == "parsed"
|
||||
assert job["item_count"] == 2
|
||||
assert job["material_type"] == "background_story"
|
||||
|
||||
items = await client.get(f"/api/v1/characters/{character_id}/imports/{job['id']}/items")
|
||||
assert items.status_code == 200
|
||||
body = items.json()
|
||||
assert body["total"] == 2
|
||||
assert body["items"][0]["item_type"] == "paragraph"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_import_chat_history_json(client: AsyncClient, character_id: str) -> None:
|
||||
resp = await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports",
|
||||
json={
|
||||
"material_type": "chat_history",
|
||||
"format": "json",
|
||||
"content": '[{"role":"user","content":"你好"},{"role":"assistant","content":"你好呀"}]',
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
job = resp.json()
|
||||
assert job["item_count"] == 2
|
||||
items = (
|
||||
await client.get(f"/api/v1/characters/{character_id}/imports/{job['id']}/items")
|
||||
).json()["items"]
|
||||
assert items[0]["item_type"] == "message"
|
||||
assert items[0]["role"] == "user"
|
||||
assert items[1]["role"] == "assistant"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_import_and_not_found(client: AsyncClient, character_id: str) -> None:
|
||||
created = (
|
||||
await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports",
|
||||
json={"material_type": "diary", "content": "今天天气不错。"},
|
||||
)
|
||||
).json()
|
||||
|
||||
fetched = await client.get(f"/api/v1/characters/{character_id}/imports/{created['id']}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["id"] == created["id"]
|
||||
|
||||
missing = await client.get(f"/api/v1/characters/{character_id}/imports/import_missing")
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_imports(client: AsyncClient, character_id: str) -> None:
|
||||
await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports",
|
||||
json={"material_type": "diary", "content": "条目一"},
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports",
|
||||
json={"material_type": "diary", "content": "条目二"},
|
||||
)
|
||||
resp = await client.get(f"/api/v1/characters/{character_id}/imports")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_text_file(client: AsyncClient, character_id: str) -> None:
|
||||
resp = await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports/upload",
|
||||
files={"file": ("note.txt", b"hello world\n\nsecond paragraph", "text/plain")},
|
||||
data={"title": "笔记"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
job = resp.json()
|
||||
assert job["source_type"] == "uploaded_file"
|
||||
assert job["item_count"] == 2
|
||||
assert job["title"] == "笔记"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_audio_stores_metadata_only(client: AsyncClient, character_id: str) -> None:
|
||||
resp = await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports/upload",
|
||||
files={"file": ("sample.wav", b"fake audio bytes", "audio/wav")},
|
||||
data={
|
||||
"speaker_label": "人物本人",
|
||||
"consent_status": "granted",
|
||||
"usage_scope": "reference_only",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
job = resp.json()
|
||||
assert job["material_type"] == "voice_sample"
|
||||
assert job["media_type"] == "audio"
|
||||
assert job["status"] == "uploaded"
|
||||
assert job["item_count"] == 0 # 媒体资料不切片
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_import_rejects_empty_content(client: AsyncClient, character_id: str) -> None:
|
||||
resp = await client.post(
|
||||
f"/api/v1/characters/{character_id}/imports",
|
||||
json={"material_type": "background_story", "content": ""},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@@ -0,0 +1,79 @@
|
||||
"""世谱 API 集成测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
async def _default_character_id(auth_client: httpx.AsyncClient) -> str:
|
||||
resp = await auth_client.get("/api/v1/characters")
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["items"][0]["id"]
|
||||
|
||||
|
||||
async def test_create_list_get_lorebook(auth_client: httpx.AsyncClient) -> None:
|
||||
character_id = await _default_character_id(auth_client)
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/lorebooks",
|
||||
json={"name": "测试世谱", "character_id": character_id, "scope": "character"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
book = resp.json()
|
||||
book_id = book["id"]
|
||||
assert book["name"] == "测试世谱"
|
||||
assert book["scope"] == "character"
|
||||
|
||||
entry_resp = await auth_client.post(
|
||||
f"/api/v1/lorebooks/{book_id}/entries",
|
||||
json={"keys": ["猫"], "content": "她喜欢猫。", "position": "after_char"},
|
||||
)
|
||||
assert entry_resp.status_code == 200
|
||||
assert entry_resp.json()["keys"] == ["猫"]
|
||||
|
||||
listed = await auth_client.get(f"/api/v1/lorebooks?character_id={character_id}")
|
||||
assert listed.status_code == 200
|
||||
assert any(b["id"] == book_id for b in listed.json())
|
||||
|
||||
got = await auth_client.get(f"/api/v1/lorebooks/{book_id}")
|
||||
assert got.status_code == 200
|
||||
assert len(got.json()["entries"]) == 1
|
||||
|
||||
|
||||
async def test_update_entry(auth_client: httpx.AsyncClient) -> None:
|
||||
character_id = await _default_character_id(auth_client)
|
||||
book = (await auth_client.post("/api/v1/lorebooks", json={"character_id": character_id})).json()
|
||||
entry = (
|
||||
await auth_client.post(
|
||||
f"/api/v1/lorebooks/{book['id']}/entries",
|
||||
json={"keys": ["旧"], "content": "旧"},
|
||||
)
|
||||
).json()
|
||||
patched = await auth_client.patch(
|
||||
f"/api/v1/lorebooks/{book['id']}/entries/{entry['id']}",
|
||||
json={"keys": ["新"], "content": "新内容"},
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["keys"] == ["新"]
|
||||
assert patched.json()["content"] == "新内容"
|
||||
|
||||
|
||||
async def test_delete_lorebook_archives(auth_client: httpx.AsyncClient) -> None:
|
||||
character_id = await _default_character_id(auth_client)
|
||||
book = (
|
||||
await auth_client.post("/api/v1/lorebooks", json={"character_id": character_id})
|
||||
).json()
|
||||
dele = await auth_client.delete(f"/api/v1/lorebooks/{book['id']}")
|
||||
assert dele.status_code == 200
|
||||
# archived 后不再可见
|
||||
got = await auth_client.get(f"/api/v1/lorebooks/{book['id']}")
|
||||
assert got.status_code == 404
|
||||
|
||||
|
||||
async def test_other_owner_isolated(auth_client: httpx.AsyncClient) -> None:
|
||||
character_id = await _default_character_id(auth_client)
|
||||
book = (
|
||||
await auth_client.post("/api/v1/lorebooks", json={"character_id": character_id})
|
||||
).json()
|
||||
# book id 对当前用户可见
|
||||
assert (await auth_client.get(f"/api/v1/lorebooks/{book['id']}")).status_code == 200
|
||||
@@ -0,0 +1,77 @@
|
||||
"""MCP server API 测试(respx mock MCP server HTTP)。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_register_mcp_server(client: AsyncClient) -> None:
|
||||
jrpc_result = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {"tools": [{"name": "weather", "description": "查天气"}]},
|
||||
}
|
||||
respx.post("http://mcp.local/").mock(return_value=httpx.Response(200, json=jrpc_result))
|
||||
resp = await client.post(
|
||||
"/api/v1/tools/mcp-servers",
|
||||
json={"name": "weather-srv", "endpoint_url": "http://mcp.local"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "weather-srv"
|
||||
assert data["has_auth_token"] is False
|
||||
assert len(data["tools"]) == 1
|
||||
assert data["tools"][0]["name"] == "weather"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_and_delete_mcp_server(client: AsyncClient) -> None:
|
||||
jrpc_result = {"jsonrpc": "2.0", "id": 1, "result": {"tools": []}}
|
||||
respx.post("http://mcp.local/").mock(return_value=httpx.Response(200, json=jrpc_result))
|
||||
create = await client.post(
|
||||
"/api/v1/tools/mcp-servers",
|
||||
json={"name": "srv", "endpoint_url": "http://mcp.local"},
|
||||
)
|
||||
sid = create.json()["id"]
|
||||
|
||||
listed = await client.get("/api/v1/tools/mcp-servers")
|
||||
assert listed.status_code == 200
|
||||
assert len(listed.json()) == 1
|
||||
|
||||
deleted = await client.delete(f"/api/v1/tools/mcp-servers/{sid}")
|
||||
assert deleted.status_code == 204
|
||||
|
||||
listed2 = await client.get("/api/v1/tools/mcp-servers")
|
||||
assert listed2.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_register_duplicate_conflict(client: AsyncClient) -> None:
|
||||
jrpc_result = {"jsonrpc": "2.0", "id": 1, "result": {"tools": []}}
|
||||
respx.post("http://mcp.local/").mock(return_value=httpx.Response(200, json=jrpc_result))
|
||||
await client.post(
|
||||
"/api/v1/tools/mcp-servers",
|
||||
json={"name": "dup", "endpoint_url": "http://mcp.local"},
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/tools/mcp-servers",
|
||||
json={"name": "dup", "endpoint_url": "http://mcp.local"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["error"]["code"] == "conflict"
|
||||
@@ -0,0 +1,78 @@
|
||||
"""模型网关 endpoint 覆盖 API 测试(api-spec §4.4,DB 优先于 .env/yaml)。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
def _first_endpoint(gateway: dict) -> tuple[str, str]:
|
||||
model = gateway["models"][0]
|
||||
return model["name"], model["endpoints"][0]["name"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_models_has_endpoint_override_fields(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/settings/models")
|
||||
assert resp.status_code == 200
|
||||
ep = resp.json()["models"][0]["endpoints"][0]
|
||||
assert "priority" in ep
|
||||
assert "is_overridden" in ep
|
||||
assert "has_url_override" in ep
|
||||
assert "has_key_override" in ep
|
||||
assert ep["is_overridden"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_endpoint_status_override(client: AsyncClient) -> None:
|
||||
gateway = (await client.get("/api/v1/settings/models")).json()
|
||||
model, endpoint = _first_endpoint(gateway)
|
||||
|
||||
resp = await client.patch(
|
||||
f"/api/v1/settings/models/{model}/endpoints/{endpoint}",
|
||||
json={"status": "inactive"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["is_overridden"] is True
|
||||
assert body["status"] == "inactive"
|
||||
|
||||
# GET 反映覆盖
|
||||
gateway2 = (await client.get("/api/v1/settings/models")).json()
|
||||
eps = [m for m in gateway2["models"] if m["name"] == model][0]["endpoints"]
|
||||
ep = [e for e in eps if e["name"] == endpoint][0]
|
||||
assert ep["is_overridden"] is True
|
||||
assert ep["status"] == "inactive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_endpoint_key_override_redacted(client: AsyncClient) -> None:
|
||||
gateway = (await client.get("/api/v1/settings/models")).json()
|
||||
model, endpoint = _first_endpoint(gateway)
|
||||
|
||||
resp = await client.patch(
|
||||
f"/api/v1/settings/models/{model}/endpoints/{endpoint}",
|
||||
json={"api_key": "sk-test-secret-value"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_key_override"] is True
|
||||
# 脱敏:响应体不含 api_key 原值
|
||||
assert "sk-test-secret-value" not in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_endpoint_not_found(client: AsyncClient) -> None:
|
||||
resp = await client.patch(
|
||||
"/api/v1/settings/models/no_such_model/endpoints/primary",
|
||||
json={"status": "inactive"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,68 @@
|
||||
"""owner_scope 全链路贯穿集成测试。
|
||||
|
||||
验证两个不同用户的 character/preferences 数据互不可见。
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _jwt_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""所有 API 测试需要 JWT_SECRET 配置。"""
|
||||
monkeypatch.setattr("app.core.config.settings.jwt_secret", "test-jwt-secret-for-testing")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
async def _register_and_login(client: AsyncClient, suffix: str) -> dict:
|
||||
"""辅助:注册并登录,返回 access_token 和 user_id。"""
|
||||
username = f"user_{suffix}"
|
||||
email = f"{suffix}@test.com"
|
||||
await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": username, "email": email, "password": "pass"},
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": username, "password": "pass"},
|
||||
)
|
||||
data = resp.json()
|
||||
return {
|
||||
"access_token": data["access_token"],
|
||||
"user_id": data["user"]["id"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_users_see_own_characters_only(client: AsyncClient) -> None:
|
||||
user_a = await _register_and_login(client, "a")
|
||||
user_b = await _register_and_login(client, "b")
|
||||
|
||||
# 用户 A 查看角色列表
|
||||
resp_a = await client.get(
|
||||
"/api/v1/characters",
|
||||
headers={"Authorization": f"Bearer {user_a['access_token']}"},
|
||||
)
|
||||
assert resp_a.status_code == 200
|
||||
|
||||
# 用户 B 查看角色列表
|
||||
resp_b = await client.get(
|
||||
"/api/v1/characters",
|
||||
headers={"Authorization": f"Bearer {user_b['access_token']}"},
|
||||
)
|
||||
assert resp_b.status_code == 200
|
||||
|
||||
# 由于角色是按 owner_scope 过滤的,双方看不到对方的角色
|
||||
# 因为 seed_default_data 只为第一个用户(admin)创建了角色,
|
||||
# 用户 A 是自己注册的,所以没有角色(注册不自动创建角色)
|
||||
# 这个测试验证查询不会跨用户串数据
|
||||
@@ -0,0 +1,63 @@
|
||||
"""人格提议列表 API 测试(api-spec §11,审批墙数据源)。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_proposals_empty(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/personality/proposals")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_proposals_filter_by_status(client: AsyncClient) -> None:
|
||||
proposed = await client.post(
|
||||
"/api/v1/personality/propose-update",
|
||||
json={"fields": {"identity_profile": "更新的身份画像"}, "reason": "测试提议"},
|
||||
)
|
||||
assert proposed.status_code == 201
|
||||
proposal_id = proposed.json()["proposal_id"]
|
||||
|
||||
listed = await client.get("/api/v1/personality/proposals")
|
||||
assert listed.status_code == 200
|
||||
body = listed.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["proposal_id"] == proposal_id
|
||||
assert body["items"][0]["status"] == "pending"
|
||||
|
||||
pending = await client.get("/api/v1/personality/proposals?status=pending")
|
||||
assert pending.json()["total"] == 1
|
||||
|
||||
approved = await client.get("/api/v1/personality/proposals?status=approved")
|
||||
assert approved.json()["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_proposals_pagination(client: AsyncClient) -> None:
|
||||
for i in range(3):
|
||||
await client.post(
|
||||
"/api/v1/personality/propose-update",
|
||||
json={"fields": {"identity_profile": f"v{i}"}, "reason": f"r{i}"},
|
||||
)
|
||||
page1 = await client.get("/api/v1/personality/proposals?page=1&page_size=2")
|
||||
assert page1.json()["total"] == 3
|
||||
assert len(page1.json()["items"]) == 2
|
||||
page2 = await client.get("/api/v1/personality/proposals?page=2&page_size=2")
|
||||
assert len(page2.json()["items"]) == 1
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Run 列表 API 测试(归档页运行时间河数据源)。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_runs_returns_page(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/runs")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "items" in body
|
||||
assert "total" in body
|
||||
assert body["page"] == 1
|
||||
assert isinstance(body["items"], list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_runs_filters_accepted(client: AsyncClient) -> None:
|
||||
# 过滤参数被接受;seed 后无 run,验证端点工作且不报错
|
||||
resp = await client.get("/api/v1/runs?status=completed&character_id=character_none")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_detail_not_found(client: AsyncClient) -> None:
|
||||
# 列表端点("")不破坏详情端点(/{run_id})的路由顺序
|
||||
resp = await client.get("/api/v1/runs/run_missing")
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Skill 能力包 API 测试。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_list_skills(client: AsyncClient) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/tools/skills",
|
||||
json={
|
||||
"name": "coding",
|
||||
"trigger": "代码,编程",
|
||||
"prompt_fragment": "你是代码助手",
|
||||
"tool_bindings": ["read_file"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["name"] == "coding"
|
||||
assert resp.json()["tool_bindings"] == ["read_file"]
|
||||
|
||||
listed = await client.get("/api/v1/tools/skills")
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()[0]["name"] == "coding"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill(client: AsyncClient) -> None:
|
||||
create = await client.post("/api/v1/tools/skills", json={"name": "s", "trigger": "代码"})
|
||||
sid = create.json()["id"]
|
||||
|
||||
resp = await client.patch(f"/api/v1/tools/skills/{sid}", json={"status": "disabled"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "disabled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_skill(client: AsyncClient) -> None:
|
||||
create = await client.post("/api/v1/tools/skills", json={"name": "s", "trigger": "代码"})
|
||||
sid = create.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/api/v1/tools/skills/{sid}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
listed = await client.get("/api/v1/tools/skills")
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_conflict(client: AsyncClient) -> None:
|
||||
await client.post("/api/v1/tools/skills", json={"name": "dup"})
|
||||
resp = await client.post("/api/v1/tools/skills", json={"name": "dup"})
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["error"]["code"] == "conflict"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_status_filter(client: AsyncClient) -> None:
|
||||
await client.post("/api/v1/tools/skills", json={"name": "a", "status": "enabled"})
|
||||
await client.post("/api/v1/tools/skills", json={"name": "b", "status": "disabled"})
|
||||
|
||||
resp = await client.get("/api/v1/tools/skills", params={"status": "enabled"})
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
assert resp.json()[0]["name"] == "a"
|
||||
@@ -0,0 +1,150 @@
|
||||
"""工具与确认 API 测试:TestClient + respx 模拟沙箱。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.deps import get_confirmation_store, get_tool_runtime
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.core.config import SandboxSettings
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.main import app
|
||||
from app.modules.runtime.events import InMemoryEventStore
|
||||
from app.modules.safety.confirmation import InMemoryConfirmationStore
|
||||
from app.modules.safety.governor import PermissionPolicy, SafetyGovernor, ToolPolicy
|
||||
|
||||
|
||||
def _settings() -> SandboxSettings:
|
||||
return SandboxSettings(
|
||||
rust_sandbox_url="http://sandbox.test",
|
||||
sandbox_auth_enabled=True,
|
||||
sandbox_auth_secret="shared-secret",
|
||||
sandbox_default_timeout_ms=30000,
|
||||
sandbox_auth_ttl_seconds=30,
|
||||
)
|
||||
|
||||
|
||||
def _ok() -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ok": True,
|
||||
"exit_code": 0,
|
||||
"stdout": "42\n",
|
||||
"stderr": "",
|
||||
"duration_ms": 3,
|
||||
"timeout": False,
|
||||
"truncated": False,
|
||||
"trace_id": "t1",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runtime_bundle() -> Iterator[None]:
|
||||
"""注入 mock ToolRuntime / 确认存储到 app 依赖覆盖;返回值无意义。"""
|
||||
store = InMemoryEventStore()
|
||||
confirmations = InMemoryConfirmationStore()
|
||||
http = httpx.AsyncClient()
|
||||
sandbox = SandboxClient(http, _settings())
|
||||
runtime = ToolRuntime(sandbox, SafetyGovernor(), store)
|
||||
app.dependency_overrides[get_tool_runtime] = lambda: runtime
|
||||
app.dependency_overrides[get_confirmation_store] = lambda: confirmations
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_list_tools(runtime_bundle: None) -> None:
|
||||
_ = runtime_bundle # fixture 仅用于副作用(注入依赖覆盖)
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/v1/tools")
|
||||
assert resp.status_code == 200
|
||||
names = {tool["name"] for tool in resp.json()}
|
||||
assert {"datetime", "shell", "fetch_url"} <= names
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_invoke_datetime_completed(runtime_bundle: None) -> None:
|
||||
_ = runtime_bundle
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/api/v1/tools/datetime/invoke",
|
||||
json={"input": {}, "trace_id": "t1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
assert body["result"]["stdout"] == "42\n"
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_shell_invoke_then_approve(runtime_bundle: None) -> None:
|
||||
_ = runtime_bundle
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/api/v1/tools/shell/invoke",
|
||||
json={"input": {"command": "echo hi"}, "trace_id": "t1", "run_id": "run_tools_test"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
ask = resp.json()
|
||||
assert ask["status"] == "requires_confirmation"
|
||||
assert ask["risk_level"] == "L3"
|
||||
confirmation_id = ask["confirmation_id"]
|
||||
|
||||
approved = client.post(f"/api/v1/safety/confirmations/{confirmation_id}/approve")
|
||||
assert approved.status_code == 200
|
||||
assert approved.json()["status"] == "resolved"
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_deny_then_approve_returns_404(runtime_bundle: None) -> None:
|
||||
_ = runtime_bundle
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/api/v1/tools/shell/invoke",
|
||||
json={"input": {"command": "echo hi"}, "run_id": "run_tools_test2"},
|
||||
)
|
||||
confirmation_id = resp.json()["confirmation_id"]
|
||||
|
||||
denied = client.post(f"/api/v1/safety/confirmations/{confirmation_id}/deny")
|
||||
assert denied.status_code == 200
|
||||
assert denied.json()["status"] in ("cancelled", "resolved")
|
||||
|
||||
again = client.post(f"/api/v1/safety/confirmations/{confirmation_id}/approve")
|
||||
assert again.status_code == 404
|
||||
assert again.json()["error"]["code"] == "not_found"
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_forbidden_tool_returns_403() -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
|
||||
store = InMemoryEventStore()
|
||||
confirmations = InMemoryConfirmationStore()
|
||||
http = httpx.AsyncClient()
|
||||
sandbox = SandboxClient(http, _settings())
|
||||
governor = SafetyGovernor(
|
||||
{"shell": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
|
||||
)
|
||||
runtime = ToolRuntime(sandbox, governor, store)
|
||||
app.dependency_overrides[get_tool_runtime] = lambda: runtime
|
||||
app.dependency_overrides[get_confirmation_store] = lambda: confirmations
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/api/v1/tools/shell/invoke", json={"input": {"command": "rm -rf /"}}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["error"]["code"] == "permission_denied"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""GET /tools/capabilities 统一能力列举测试。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
from app.services import skills as skills_service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_capabilities_includes_skills(client: AsyncClient) -> None:
|
||||
await skills_service.create_skill(name="coding", trigger="代码", prompt_fragment="代码助手")
|
||||
resp = await client.get("/api/v1/tools/capabilities")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# runtime 在测试环境可能未就绪(ASGITransport 不跑 lifespan),至少含 skill
|
||||
skill_caps = [c for c in data if c["source"] == "skill"]
|
||||
assert any(c["name"] == "coding" for c in skill_caps)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_capabilities_source_values(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/tools/capabilities")
|
||||
assert resp.status_code == 200
|
||||
for c in resp.json():
|
||||
assert c["source"] in {"builtin", "mcp", "skill"}
|
||||
assert "name" in c and "risk_level" in c
|
||||
@@ -0,0 +1,104 @@
|
||||
"""世界模型 API 测试(河图枢运行时,api-spec §12)。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_entities_empty(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/world/entities")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_get_entity(client: AsyncClient) -> None:
|
||||
created = await client.post(
|
||||
"/api/v1/world/entities",
|
||||
json={"name": "用户项目", "type": "project", "state_type": "fact", "confidence": 0.9},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
entity = created.json()
|
||||
assert entity["id"].startswith("world_entity_")
|
||||
assert entity["name"] == "用户项目"
|
||||
assert entity["type"] == "project"
|
||||
assert entity["state_type"] == "fact"
|
||||
assert entity["character_id"].startswith("character_")
|
||||
|
||||
fetched = await client.get(f"/api/v1/world/entities/{entity['id']}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["id"] == entity["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_entity_not_found(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/world/entities/world_entity_missing")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_entities_filter_by_type(client: AsyncClient) -> None:
|
||||
await client.post("/api/v1/world/entities", json={"name": "概念A", "type": "concept"})
|
||||
await client.post("/api/v1/world/entities", json={"name": "项目B", "type": "project"})
|
||||
|
||||
filtered = await client.get("/api/v1/world/entities?type=project")
|
||||
assert filtered.status_code == 200
|
||||
body = filtered.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["type"] == "project"
|
||||
|
||||
all_entities = await client.get("/api/v1/world/entities")
|
||||
assert all_entities.json()["total"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relation(client: AsyncClient) -> None:
|
||||
user = (
|
||||
await client.post("/api/v1/world/entities", json={"name": "用户", "type": "person"})
|
||||
).json()["id"]
|
||||
project = (
|
||||
await client.post("/api/v1/world/entities", json={"name": "魂镜项目", "type": "project"})
|
||||
).json()["id"]
|
||||
|
||||
created = await client.post(
|
||||
"/api/v1/world/relations",
|
||||
json={"subject_id": user, "predicate": "正在进行", "object_id": project},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
relation = created.json()
|
||||
assert relation["id"].startswith("world_relation_")
|
||||
assert relation["predicate"] == "正在进行"
|
||||
assert relation["subject_id"] == user
|
||||
assert relation["object_id"] == project
|
||||
|
||||
listed = await client.get("/api/v1/world/relations")
|
||||
assert listed.json()["total"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relation_rejects_missing_entity(client: AsyncClient) -> None:
|
||||
project = (
|
||||
await client.post("/api/v1/world/entities", json={"name": "项目", "type": "project"})
|
||||
).json()["id"]
|
||||
|
||||
# 主体不存在
|
||||
resp = await client.post(
|
||||
"/api/v1/world/relations",
|
||||
json={"subject_id": "world_entity_missing", "predicate": "依赖", "object_id": project},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,203 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.agent import (
|
||||
compose_system_messages,
|
||||
next_step_for_tool,
|
||||
step_event_payload,
|
||||
task_from_deliberative_plan,
|
||||
task_plan_payload,
|
||||
)
|
||||
from app.application.agent.skill_loader import load_skill_manifests
|
||||
from app.modules.agent import (
|
||||
AgentDefinition,
|
||||
AgentTask,
|
||||
ExecutionPolicy,
|
||||
Plan,
|
||||
PlanStep,
|
||||
ToolPolicy,
|
||||
)
|
||||
from app.modules.planner import DeliberativePlan
|
||||
from app.modules.planner import PlanStep as DeliberativePlanStep
|
||||
from app.modules.skills import parse_skill_manifest
|
||||
|
||||
|
||||
def test_agent_definition_keeps_minimal_execution_and_tool_policy() -> None:
|
||||
agent = AgentDefinition(
|
||||
name="工师",
|
||||
system_prompt="完成已授权的工程任务。",
|
||||
skill_refs=("代码审查",),
|
||||
tool_policy=ToolPolicy(("read_file", "shell")),
|
||||
execution_policy=ExecutionPolicy(max_iterations=3),
|
||||
)
|
||||
|
||||
assert agent.skill_refs == ("代码审查",)
|
||||
assert agent.tool_policy.allowed_tools == ("read_file", "shell")
|
||||
assert agent.execution_policy.max_iterations == 3
|
||||
|
||||
|
||||
def test_agent_task_keeps_plan_as_runtime_only_contract() -> None:
|
||||
task = AgentTask(
|
||||
id="task_1",
|
||||
objective="审查当前变更",
|
||||
agent_name="工师",
|
||||
plan=Plan(
|
||||
steps=(PlanStep(id="step_1", title="读取变更", tool="read_file"),),
|
||||
status="approved",
|
||||
),
|
||||
)
|
||||
|
||||
assert task.status == "queued"
|
||||
assert task.plan is not None
|
||||
assert task.plan.steps[0].status == "pending"
|
||||
|
||||
|
||||
def test_deliberative_plan_becomes_traceable_agent_task() -> None:
|
||||
task = task_from_deliberative_plan(
|
||||
DeliberativePlan(
|
||||
title="调研计划",
|
||||
steps=[
|
||||
DeliberativePlanStep(
|
||||
id=1,
|
||||
title="检索资料",
|
||||
description="读取相关资料",
|
||||
tool="fetch_url",
|
||||
risk_level="L2",
|
||||
)
|
||||
],
|
||||
assumptions=["资料可访问"],
|
||||
risks=["来源不完整"],
|
||||
),
|
||||
run_id="run_1",
|
||||
agent_name="学士",
|
||||
objective="调研主题",
|
||||
)
|
||||
|
||||
assert task.status == "running"
|
||||
assert task.plan is not None
|
||||
assert task.plan.steps[0].id == "1"
|
||||
assert task_plan_payload(task)["steps"] == [
|
||||
{
|
||||
"id": "1",
|
||||
"title": "检索资料",
|
||||
"description": "读取相关资料",
|
||||
"tool": "fetch_url",
|
||||
"risk_level": "L2",
|
||||
"status": "pending",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_tool_maps_only_to_its_next_uncompleted_plan_step() -> None:
|
||||
task = AgentTask(
|
||||
id="task_1",
|
||||
objective="执行计划",
|
||||
agent_name="工师",
|
||||
plan=Plan(
|
||||
steps=(
|
||||
PlanStep(id="1", title="读取", tool="read_file"),
|
||||
PlanStep(id="2", title="再次读取", tool="read_file"),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
first = next_step_for_tool(task, "read_file", set())
|
||||
second = next_step_for_tool(task, "read_file", {"1"})
|
||||
|
||||
assert first is not None and first.id == "1"
|
||||
assert second is not None and second.id == "2"
|
||||
assert next_step_for_tool(task, "shell", set()) is None
|
||||
assert step_event_payload(task, first, status="completed", result_summary="读取成功") == {
|
||||
"task_id": "task_1",
|
||||
"step_id": "1",
|
||||
"title": "读取",
|
||||
"tool": "read_file",
|
||||
"risk_level": "L2",
|
||||
"status": "completed",
|
||||
"result_summary": "读取成功",
|
||||
}
|
||||
|
||||
|
||||
def test_execution_policy_rejects_invalid_iteration_limit() -> None:
|
||||
with pytest.raises(ValueError, match="max_iterations"):
|
||||
ExecutionPolicy(max_iterations=0)
|
||||
|
||||
|
||||
def test_manifest_and_prompt_composer_keep_stable_order() -> None:
|
||||
skill = SimpleNamespace(
|
||||
name="代码审查",
|
||||
description="审查变更",
|
||||
trigger="审查, review",
|
||||
prompt_fragment="先理解调用路径,再报告问题。",
|
||||
risk_level="L1",
|
||||
status="enabled",
|
||||
tool_bindings_json='["read_file"]',
|
||||
)
|
||||
manifest = parse_skill_manifest(skill, ["read_file"])
|
||||
|
||||
messages = compose_system_messages(
|
||||
["全局约束"],
|
||||
AgentDefinition(name="工师", system_prompt="只执行已授权任务。"),
|
||||
[manifest],
|
||||
)
|
||||
|
||||
assert manifest.triggers == ("审查", "review")
|
||||
assert messages == [
|
||||
"全局约束",
|
||||
"【Agent:工师】\n只执行已授权任务。",
|
||||
"【代码审查·能力注入】\n先理解调用路径,再报告问题。",
|
||||
]
|
||||
|
||||
|
||||
def test_default_tools_filters_explicit_allowlist() -> None:
|
||||
from app.application.agent.tools import default_tools
|
||||
|
||||
tools = default_tools(allowed_tools=("read_file",))
|
||||
|
||||
assert [tool["function"]["name"] for tool in tools] == ["read_file"]
|
||||
|
||||
|
||||
async def test_skill_loader_unions_matches_with_explicit_references(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
skills = [
|
||||
SimpleNamespace(
|
||||
name="代码审查",
|
||||
description="审查",
|
||||
trigger="审查",
|
||||
prompt_fragment="审查流程",
|
||||
risk_level="L1",
|
||||
status="enabled",
|
||||
tool_bindings_json='["read_file"]',
|
||||
),
|
||||
SimpleNamespace(
|
||||
name="网页研究",
|
||||
description="研究",
|
||||
trigger="研究",
|
||||
prompt_fragment="研究流程",
|
||||
risk_level="L2",
|
||||
status="enabled",
|
||||
tool_bindings_json='["web_search"]',
|
||||
),
|
||||
]
|
||||
|
||||
async def fake_match_active_skills(_context: str):
|
||||
return [skills[0]]
|
||||
|
||||
async def fake_get_enabled_skills_by_names(names: tuple[str, ...]):
|
||||
return [skill for skill in skills if skill.name in names]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.application.agent.skill_loader.match_active_skills",
|
||||
fake_match_active_skills,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.application.agent.skill_loader.get_enabled_skills_by_names",
|
||||
fake_get_enabled_skills_by_names,
|
||||
)
|
||||
|
||||
manifests = await load_skill_manifests("请审查", skill_refs=("网页研究",))
|
||||
|
||||
assert [manifest.name for manifest in manifests] == ["代码审查", "网页研究"]
|
||||
assert manifests[0].allowed_tools == ("read_file",)
|
||||
@@ -0,0 +1,235 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.types.messages import HumanMessage
|
||||
from app.application.agent import AgentContext, AgentRunContext, ExtensionRegistry
|
||||
from app.application.agent.python_skills import (
|
||||
PythonSkillCatalog,
|
||||
PythonSkillDefinition,
|
||||
activate_python_skills,
|
||||
discover_python_skills,
|
||||
)
|
||||
|
||||
|
||||
def _write_skill(package: Path, module: str, *, name: str, trigger: str) -> None:
|
||||
(package / f"{module}.py").write_text(
|
||||
f"""
|
||||
from app.application.agent.python_skills import PythonSkillDefinition
|
||||
from app.ai_provider.types.messages import HumanMessage
|
||||
|
||||
async def transform(context, run):
|
||||
return type(context)(
|
||||
messages=(*context.messages, HumanMessage(content={name!r})),
|
||||
tools=context.tools,
|
||||
model_hint=context.model_hint,
|
||||
)
|
||||
|
||||
def setup(registry):
|
||||
registry.add_context_transformer(transform)
|
||||
|
||||
SKILL = PythonSkillDefinition(
|
||||
name={name!r},
|
||||
description={name!r},
|
||||
triggers=({trigger!r},),
|
||||
instructions={name!r},
|
||||
setup=setup,
|
||||
)
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
async def test_catalog_discovers_without_activating_and_matches_per_run(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
package = tmp_path / "example_skills"
|
||||
package.mkdir()
|
||||
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||
_write_skill(package, "b_skill", name="乙", trigger="研究")
|
||||
_write_skill(package, "a_skill", name="甲", trigger="审查")
|
||||
(package / "_ignored.py").write_text(
|
||||
"raise AssertionError('私有模块不应装载')",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
catalog = discover_python_skills(("example_skills", "example_skills"))
|
||||
registry = ExtensionRegistry()
|
||||
unchanged = await registry.transform_context(
|
||||
AgentContext(messages=(HumanMessage(content="开始"),), tools=()),
|
||||
AgentRunContext(),
|
||||
)
|
||||
matched = catalog.match("请做研究", skill_refs=("甲",))
|
||||
activation = await activate_python_skills(registry, matched)
|
||||
transformed = await registry.transform_context(
|
||||
AgentContext(messages=(HumanMessage(content="开始"),), tools=()),
|
||||
AgentRunContext(),
|
||||
)
|
||||
await activation.close()
|
||||
|
||||
assert [skill.name for skill in catalog.skills] == ["甲", "乙"]
|
||||
assert [message.content for message in unchanged.messages] == ["开始"]
|
||||
assert [skill.name for skill in matched] == ["甲", "乙"]
|
||||
assert [message.content for message in transformed.messages] == ["开始", "甲", "乙"]
|
||||
|
||||
|
||||
async def test_activation_cleanup_is_run_scoped() -> None:
|
||||
cleaned: list[str] = []
|
||||
|
||||
def setup(registry: ExtensionRegistry):
|
||||
async def transform(context: AgentContext, _run: AgentRunContext) -> AgentContext:
|
||||
return AgentContext(
|
||||
(*context.messages, HumanMessage(content="active")),
|
||||
context.tools,
|
||||
context.model_hint,
|
||||
)
|
||||
|
||||
registry.add_context_transformer(transform)
|
||||
return lambda: cleaned.append("closed")
|
||||
|
||||
skill = PythonSkillDefinition("guard", "", ("guard",), "", setup)
|
||||
catalog = PythonSkillCatalog((skill,))
|
||||
first_registry = ExtensionRegistry()
|
||||
second_registry = ExtensionRegistry()
|
||||
|
||||
activation = await activate_python_skills(first_registry, catalog.match("guard"))
|
||||
first = await first_registry.transform_context(AgentContext((), ()), AgentRunContext())
|
||||
second = await second_registry.transform_context(AgentContext((), ()), AgentRunContext())
|
||||
await activation.close()
|
||||
|
||||
assert [message.content for message in first.messages] == ["active"]
|
||||
assert second.messages == ()
|
||||
assert cleaned == ["closed"]
|
||||
|
||||
|
||||
def test_catalog_supports_custom_matcher_with_run_metadata() -> None:
|
||||
skill = PythonSkillDefinition(
|
||||
"metadata_skill",
|
||||
"",
|
||||
(),
|
||||
"",
|
||||
lambda _registry: None,
|
||||
match=lambda text, metadata: text == "执行" and metadata.get("channel") == "voice",
|
||||
)
|
||||
catalog = PythonSkillCatalog((skill,))
|
||||
|
||||
assert catalog.match("执行", metadata={"channel": "text"}) == ()
|
||||
assert catalog.match("执行", metadata={"channel": "voice"}) == (skill,)
|
||||
|
||||
|
||||
def test_catalog_rejects_duplicate_names() -> None:
|
||||
skill = PythonSkillDefinition("same", "", (), "", lambda _registry: None)
|
||||
|
||||
with pytest.raises(ValueError, match="名称重复"):
|
||||
PythonSkillCatalog((skill, skill))
|
||||
|
||||
|
||||
def test_discovery_rejects_module_without_definition(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
package = tmp_path / "invalid_skills"
|
||||
package.mkdir()
|
||||
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||
(package / "broken.py").write_text("SKILL_NAME = '坏技能'", encoding="utf-8")
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
with pytest.raises(TypeError, match="缺少 SKILL 定义"):
|
||||
discover_python_skills(("invalid_skills",))
|
||||
|
||||
|
||||
async def test_budget_guard_skill_stops_when_usage_exceeds_threshold() -> None:
|
||||
from app.ai_provider.types.chat import TokenUsage
|
||||
from app.application.agent.extensions import TurnSnapshot
|
||||
from app.skills.budget_guard import _BUDGET_TOTAL_TOKENS, SKILL
|
||||
|
||||
registry = ExtensionRegistry()
|
||||
activation = await activate_python_skills(registry, (SKILL,))
|
||||
|
||||
over_budget = TurnSnapshot(
|
||||
iteration=4,
|
||||
content="",
|
||||
reasoning="",
|
||||
tool_calls=(),
|
||||
tool_results=(),
|
||||
usage=TokenUsage(total_tokens=_BUDGET_TOTAL_TOKENS + 1),
|
||||
)
|
||||
within_budget = TurnSnapshot(
|
||||
iteration=1,
|
||||
content="",
|
||||
reasoning="",
|
||||
tool_calls=(),
|
||||
tool_results=(),
|
||||
usage=TokenUsage(total_tokens=max(_BUDGET_TOTAL_TOKENS - 1, 0)),
|
||||
)
|
||||
|
||||
assert await registry.should_stop(over_budget, AgentRunContext()) is True
|
||||
assert await registry.should_stop(within_budget, AgentRunContext()) is False
|
||||
await activation.close()
|
||||
|
||||
|
||||
async def test_context_budget_folds_long_history() -> None:
|
||||
from app.ai_provider.types.messages import HumanMessage
|
||||
from app.application.agent.extensions import AgentContext
|
||||
from app.skills.context_budget import SKILL
|
||||
|
||||
registry = ExtensionRegistry()
|
||||
activation = await activate_python_skills(registry, (SKILL,))
|
||||
messages = tuple(HumanMessage(content="x" * 1000) for _ in range(20))
|
||||
ctx = AgentContext(messages=messages, tools=())
|
||||
|
||||
trimmed = await registry.transform_context(ctx, AgentRunContext())
|
||||
|
||||
assert len(trimmed.messages) < len(messages)
|
||||
assert "已省略" in trimmed.messages[0].content
|
||||
await activation.close()
|
||||
|
||||
|
||||
async def test_arg_guard_rejects_oversized_input() -> None:
|
||||
from app.application.agent.extensions import ToolCallContext
|
||||
from app.skills.arg_guard import _MAX_ARG_CHARS, SKILL
|
||||
|
||||
registry = ExtensionRegistry()
|
||||
activation = await activate_python_skills(registry, (SKILL,))
|
||||
oversized = ToolCallContext(
|
||||
"call_1", "write_file", {"content": "x" * (_MAX_ARG_CHARS + 10)}, ()
|
||||
)
|
||||
normal = ToolCallContext("call_2", "read_file", {"path": "/a"}, ())
|
||||
|
||||
big = await registry.before_tool_call(oversized, AgentRunContext())
|
||||
small = await registry.before_tool_call(normal, AgentRunContext())
|
||||
|
||||
assert big.allowed is False
|
||||
assert big.reason is not None
|
||||
assert small.allowed is True
|
||||
await activation.close()
|
||||
|
||||
|
||||
async def test_run_metrics_listens_through_settled() -> None:
|
||||
from app.application.agent.extensions import AgentEvent
|
||||
from app.skills.run_metrics import SKILL
|
||||
|
||||
registry = ExtensionRegistry()
|
||||
activation = await activate_python_skills(registry, (SKILL,))
|
||||
run = AgentRunContext(run_id="run_metrics_test")
|
||||
|
||||
await registry.publish(AgentEvent("agent.started"), run)
|
||||
await registry.publish(AgentEvent("agent.turn.started", {"iteration": 1}), run)
|
||||
await registry.publish(
|
||||
AgentEvent("tool.started", {"tool_call_id": "c1", "tool_name": "search"}),
|
||||
run,
|
||||
)
|
||||
await registry.publish(
|
||||
AgentEvent("tool.completed", {"tool_call_id": "c1", "status": "success"}),
|
||||
run,
|
||||
)
|
||||
await registry.publish(
|
||||
AgentEvent("tool.completed", {"tool_call_id": "c2", "status": "denied"}),
|
||||
run,
|
||||
)
|
||||
await registry.publish(AgentEvent("agent.turn.started", {"iteration": 2}), run)
|
||||
await registry.publish(AgentEvent("agent.settled", {"status": "completed"}), run)
|
||||
await activation.close()
|
||||
# 指标经 loguru 输出;此处验证含工具级事件的事件链被完整消费且 settled 不抛错。
|
||||
@@ -0,0 +1,405 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.types.chat import TokenUsage
|
||||
from app.ai_provider.types.messages import HumanMessage
|
||||
from app.application.agent import (
|
||||
AfterToolCallDecision,
|
||||
Agent,
|
||||
AgentConfig,
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentRunContext,
|
||||
AgentTurnResult,
|
||||
BeforeToolCallDecision,
|
||||
ExtensionRegistry,
|
||||
PendingMessageQueue,
|
||||
QueueMode,
|
||||
ToolCallContext,
|
||||
TurnSnapshot,
|
||||
)
|
||||
from app.modules.tool_runtime.definition import ToolResult
|
||||
|
||||
|
||||
def test_pending_message_queue_supports_both_modes() -> None:
|
||||
queue = PendingMessageQueue(QueueMode.one_at_a_time)
|
||||
queue.put(HumanMessage(content="一"))
|
||||
queue.put(HumanMessage(content="二"))
|
||||
|
||||
assert [message.content for message in queue.drain()] == ["一"]
|
||||
assert [message.content for message in queue.drain()] == ["二"]
|
||||
|
||||
queue.mode = QueueMode.all
|
||||
queue.put(HumanMessage(content="三"))
|
||||
queue.put(HumanMessage(content="四"))
|
||||
assert [message.content for message in queue.drain()] == ["三", "四"]
|
||||
|
||||
|
||||
async def test_extension_registry_orders_context_and_tool_hooks() -> None:
|
||||
registry = ExtensionRegistry()
|
||||
run = AgentRunContext(run_id="run_1")
|
||||
|
||||
async def first(context: AgentContext, _run: AgentRunContext) -> AgentContext:
|
||||
return AgentContext(
|
||||
messages=(*context.messages, HumanMessage(content="first")),
|
||||
tools=context.tools,
|
||||
model_hint=context.model_hint,
|
||||
)
|
||||
|
||||
async def second(context: AgentContext, _run: AgentRunContext) -> AgentContext:
|
||||
return AgentContext(
|
||||
messages=(*context.messages, HumanMessage(content="second")),
|
||||
tools=context.tools,
|
||||
model_hint=context.model_hint,
|
||||
)
|
||||
|
||||
async def rewrite(
|
||||
_call: ToolCallContext, _run: AgentRunContext
|
||||
) -> BeforeToolCallDecision:
|
||||
return BeforeToolCallDecision(input={"value": 2})
|
||||
|
||||
async def terminate(
|
||||
_call: ToolCallContext,
|
||||
result: ToolResult,
|
||||
_run: AgentRunContext,
|
||||
) -> AfterToolCallDecision:
|
||||
return AfterToolCallDecision(result=result, terminate=True)
|
||||
|
||||
registry.add_context_transformer(first)
|
||||
registry.add_context_transformer(second)
|
||||
registry.add_before_tool_hook(rewrite)
|
||||
registry.add_after_tool_hook(terminate)
|
||||
|
||||
transformed = await registry.transform_context(AgentContext((), ()), run)
|
||||
assert [message.content for message in transformed.messages] == ["first", "second"]
|
||||
|
||||
call = ToolCallContext("call_1", "tool", {"value": 1}, ())
|
||||
before = await registry.before_tool_call(call, run)
|
||||
assert before.input == {"value": 2}
|
||||
|
||||
result = ToolResult(True, 0, "ok", "", 1, False, False, "trace_1")
|
||||
after = await registry.after_tool_call(call, result, run)
|
||||
assert after.result is result
|
||||
assert after.terminate is True
|
||||
|
||||
|
||||
async def test_extension_registry_extend_preserves_registration_order() -> None:
|
||||
registry = ExtensionRegistry()
|
||||
skill_registry = ExtensionRegistry()
|
||||
|
||||
async def base(context: AgentContext, _run: AgentRunContext) -> AgentContext:
|
||||
return AgentContext((*context.messages, HumanMessage(content="base")), context.tools)
|
||||
|
||||
async def skill(context: AgentContext, _run: AgentRunContext) -> AgentContext:
|
||||
return AgentContext((*context.messages, HumanMessage(content="skill")), context.tools)
|
||||
|
||||
registry.add_context_transformer(base)
|
||||
skill_registry.add_context_transformer(skill)
|
||||
registry.extend(skill_registry)
|
||||
|
||||
transformed = await registry.transform_context(AgentContext((), ()), AgentRunContext())
|
||||
|
||||
assert [message.content for message in transformed.messages] == ["base", "skill"]
|
||||
|
||||
|
||||
async def test_stateful_agent_consumes_follow_up_and_publishes_events() -> None:
|
||||
calls: list[list[str]] = []
|
||||
events: list[str] = []
|
||||
|
||||
async def runner(*, messages: list[Any], **_kwargs: object) -> AgentTurnResult:
|
||||
calls.append([message.content for message in messages])
|
||||
messages.append(HumanMessage(content=f"assistant-{len(calls)}"))
|
||||
return AgentTurnResult(
|
||||
content=f"done-{len(calls)}",
|
||||
reasoning="",
|
||||
iterations=1,
|
||||
tool_history=[{"name": f"tool-{len(calls)}"}],
|
||||
usage=TokenUsage(total_tokens=10),
|
||||
)
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
|
||||
async def listen(event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
events.append(event.type)
|
||||
|
||||
extensions.add_event_listener(listen)
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=MagicMock(), max_iterations=2),
|
||||
extensions=extensions,
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
async def enqueue_follow_up(event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
if event.type == "agent.started":
|
||||
assert agent.follow_up("再做一步") is True
|
||||
|
||||
extensions.add_event_listener(enqueue_follow_up)
|
||||
result = await agent.prompt("开始")
|
||||
|
||||
assert result.content == "done-2"
|
||||
assert calls == [["开始"], ["开始", "assistant-1", "再做一步"]]
|
||||
assert result.iterations == 2
|
||||
assert result.usage.total_tokens == 20
|
||||
assert [item["name"] for item in result.tool_history] == ["tool-1", "tool-2"]
|
||||
assert events == ["agent.started", "agent.ended", "agent.settled"]
|
||||
assert agent.state.status == "completed"
|
||||
assert agent.state.streaming_message == "done-2"
|
||||
|
||||
|
||||
async def test_stateful_agent_does_not_continue_after_runner_truncates() -> None:
|
||||
calls = 0
|
||||
|
||||
async def runner(*, messages: list[Any], **_kwargs: object) -> AgentTurnResult:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
messages.append(HumanMessage(content="assistant"))
|
||||
return AgentTurnResult(content="partial", reasoning="", iterations=1, truncated=True)
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=None, max_iterations=3),
|
||||
extensions=extensions,
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
async def enqueue(event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
if event.type == "agent.started":
|
||||
assert agent.follow_up("稍后处理") is True
|
||||
|
||||
extensions.add_event_listener(enqueue)
|
||||
result = await agent.prompt("开始")
|
||||
|
||||
assert calls == 1
|
||||
assert result.truncated is True
|
||||
assert [message.content for message in agent.follow_ups.drain()] == ["稍后处理"]
|
||||
|
||||
|
||||
async def test_follow_up_is_preserved_when_iteration_budget_is_exhausted() -> None:
|
||||
async def runner(*, messages: list[Any], **_kwargs: object) -> AgentTurnResult:
|
||||
messages.append(HumanMessage(content="assistant"))
|
||||
return AgentTurnResult(content="done", reasoning="", iterations=1)
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=None, max_iterations=1),
|
||||
extensions=extensions,
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
async def enqueue(event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
if event.type == "agent.started":
|
||||
assert agent.follow_up("稍后处理") is True
|
||||
|
||||
extensions.add_event_listener(enqueue)
|
||||
result = await agent.prompt("开始")
|
||||
|
||||
assert result.truncated is True
|
||||
assert bool(agent.follow_ups) is True
|
||||
assert [message.content for message in agent.follow_ups.drain()] == ["稍后处理"]
|
||||
|
||||
|
||||
async def test_started_listener_failure_restores_idle_state() -> None:
|
||||
extensions = ExtensionRegistry()
|
||||
|
||||
async def fail(_event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
raise RuntimeError("event store unavailable")
|
||||
|
||||
extensions.add_event_listener(fail)
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=MagicMock()),
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="event store unavailable"):
|
||||
await agent.prompt("开始")
|
||||
await agent.wait_for_idle()
|
||||
assert agent.state.status == "failed"
|
||||
|
||||
|
||||
async def test_stateful_agent_abort_cancels_active_run() -> None:
|
||||
started = asyncio.Event()
|
||||
events: list[str] = []
|
||||
|
||||
async def runner(**_kwargs: object) -> AgentTurnResult:
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
|
||||
async def listen(event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
events.append(event.type)
|
||||
|
||||
extensions.add_event_listener(listen)
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=MagicMock()),
|
||||
extensions=extensions,
|
||||
runner=runner,
|
||||
)
|
||||
task = asyncio.create_task(agent.prompt("开始"))
|
||||
await started.wait()
|
||||
|
||||
assert await agent.abort() is True
|
||||
assert agent.run_context.cancellation.cancelled is True
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await agent.wait_for_idle()
|
||||
assert agent.state.status == "cancelled"
|
||||
assert events == ["agent.started", "agent.cancelled", "agent.settled"]
|
||||
|
||||
|
||||
async def test_agent_replaces_cancelled_token_on_next_run() -> None:
|
||||
async def runner(**_kwargs: object) -> AgentTurnResult:
|
||||
return AgentTurnResult(content="done", reasoning="", iterations=1)
|
||||
|
||||
agent = Agent(AgentConfig(tools=(), tool_runtime=None), runner=runner)
|
||||
agent.run_context.cancellation.cancel()
|
||||
|
||||
await agent.prompt("重新开始")
|
||||
|
||||
assert agent.run_context.cancellation.cancelled is False
|
||||
|
||||
|
||||
async def test_extension_registry_should_stop_short_circuits_on_first_policy() -> None:
|
||||
registry = ExtensionRegistry()
|
||||
run = AgentRunContext(run_id="run_1")
|
||||
turn = TurnSnapshot(
|
||||
iteration=1,
|
||||
content="done",
|
||||
reasoning="",
|
||||
tool_calls=(),
|
||||
tool_results=(),
|
||||
)
|
||||
calls: list[str] = []
|
||||
|
||||
async def keep_going(snapshot: TurnSnapshot, _run: AgentRunContext) -> bool:
|
||||
calls.append("keep_going")
|
||||
assert snapshot is turn
|
||||
return False
|
||||
|
||||
async def stop_now(_snapshot: TurnSnapshot, _run: AgentRunContext) -> bool:
|
||||
calls.append("stop_now")
|
||||
return True
|
||||
|
||||
async def never_called(_snapshot: TurnSnapshot, _run: AgentRunContext) -> bool:
|
||||
calls.append("never_called")
|
||||
return False
|
||||
|
||||
registry.add_turn_policy(keep_going)
|
||||
registry.add_turn_policy(stop_now)
|
||||
registry.add_turn_policy(never_called)
|
||||
|
||||
assert await registry.should_stop(turn, run) is True
|
||||
assert calls == ["keep_going", "stop_now"]
|
||||
|
||||
|
||||
async def test_extension_registry_should_stop_false_when_all_policies_decline() -> None:
|
||||
registry = ExtensionRegistry()
|
||||
turn = TurnSnapshot(
|
||||
iteration=2,
|
||||
content="",
|
||||
reasoning="",
|
||||
tool_calls=({"name": "tool", "args": "{}"},),
|
||||
tool_results=(),
|
||||
)
|
||||
|
||||
async def decline(_snapshot: TurnSnapshot, _run: AgentRunContext) -> bool:
|
||||
return False
|
||||
|
||||
registry.add_turn_policy(decline)
|
||||
registry.add_turn_policy(decline)
|
||||
|
||||
assert await registry.should_stop(turn, AgentRunContext()) is False
|
||||
|
||||
|
||||
async def test_extension_registry_should_stop_respects_cancellation() -> None:
|
||||
registry = ExtensionRegistry()
|
||||
run = AgentRunContext()
|
||||
run.cancellation.cancel()
|
||||
turn = TurnSnapshot(
|
||||
iteration=1,
|
||||
content="",
|
||||
reasoning="",
|
||||
tool_calls=(),
|
||||
tool_results=(),
|
||||
)
|
||||
|
||||
async def policy(_snapshot: TurnSnapshot, _run: AgentRunContext) -> bool:
|
||||
return False
|
||||
|
||||
# raise_if_cancelled 在 policy 遍历循环内,故至少注册一个 policy 才会触发;
|
||||
# 这与 transform_context / before_tool_call / after_tool_call 一致:空注册时
|
||||
# 方法无 await 点,不额外检查取消信号。
|
||||
registry.add_turn_policy(policy)
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await registry.should_stop(turn, run)
|
||||
|
||||
|
||||
async def test_on_tool_event_publishes_tool_lifecycle_to_extensions() -> None:
|
||||
"""_on_tool_event 既转发 SSE,又把同一事件发布进扩展事件流。"""
|
||||
events: list[tuple[str, dict[str, Any]]] = []
|
||||
sse_events: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
async def on_tool_event(event_type: str, payload: dict[str, Any]) -> None:
|
||||
sse_events.append((event_type, payload))
|
||||
|
||||
async def listen(event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
events.append((event.type, dict(event.payload)))
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
extensions.add_event_listener(listen)
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=None, on_tool_event=on_tool_event),
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
await agent._on_tool_event(
|
||||
"tool.started", {"tool_call_id": "c1", "tool_name": "search"}
|
||||
)
|
||||
await agent._on_tool_event(
|
||||
"tool.completed", {"tool_call_id": "c1", "status": "success"}
|
||||
)
|
||||
await agent._on_tool_event(
|
||||
"tool.completed", {"tool_call_id": "c2", "status": "denied"}
|
||||
)
|
||||
|
||||
assert [item[0] for item in sse_events] == [
|
||||
"tool.started",
|
||||
"tool.completed",
|
||||
"tool.completed",
|
||||
]
|
||||
assert [item[0] for item in events] == [
|
||||
"tool.started",
|
||||
"tool.completed",
|
||||
"tool.completed",
|
||||
]
|
||||
assert events[1][1]["status"] == "success"
|
||||
# pending 状态维护未被桥接改动破坏
|
||||
assert "c1" not in agent.state.pending_tool_calls
|
||||
|
||||
|
||||
async def test_on_tool_event_swallows_listener_failure() -> None:
|
||||
"""扩展 listener 抛异常不应阻断 SSE 转发或工具事件主流程。"""
|
||||
sse: list[str] = []
|
||||
|
||||
async def on_tool_event(event_type: str, _payload: dict[str, Any]) -> None:
|
||||
sse.append(event_type)
|
||||
|
||||
async def boom(_event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
raise RuntimeError("listener boom")
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
extensions.add_event_listener(boom)
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=None, on_tool_event=on_tool_event),
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
# 不抛:异常被吞,SSE 仍照常转发
|
||||
await agent._on_tool_event("tool.started", {"tool_call_id": "c1"})
|
||||
assert sse == ["tool.started"]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""道体注入与完整性校验测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.chat.send_message import _contract_system_messages
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
from app.modules.runtime.integrity import validate_daoti_integrity
|
||||
|
||||
|
||||
def _contract(**kw: object) -> RuntimeContract:
|
||||
base: dict[str, object] = {
|
||||
"character_id": "c",
|
||||
"session_id": "s",
|
||||
"run_id": "r",
|
||||
"model_hint": "m",
|
||||
"trace_id": "t",
|
||||
}
|
||||
base.update(kw)
|
||||
return RuntimeContract(**base)
|
||||
|
||||
|
||||
def test_system_prompt_injected() -> None:
|
||||
msgs = _contract_system_messages(_contract(system_prompt="特殊系统指令"))
|
||||
assert "特殊系统指令" in msgs
|
||||
|
||||
|
||||
def test_mission_injected() -> None:
|
||||
msgs = _contract_system_messages(_contract(mission="守护用户"))
|
||||
assert any("守护用户" in m for m in msgs)
|
||||
|
||||
|
||||
def test_safety_guidelines_precede_safety_constraints() -> None:
|
||||
c = _contract(safety_guidelines="道体边界", safety_constraints=["司命约束"])
|
||||
msgs = _contract_system_messages(c)
|
||||
assert msgs.index("行为边界:\n道体边界") < msgs.index("安全约束:\n- 司命约束")
|
||||
|
||||
|
||||
def test_integrity_pass_when_consistent() -> None:
|
||||
c = _contract(mission="M", safety_guidelines="S")
|
||||
validate_daoti_integrity(c, mission="M", safety_guidelines="S") # 不抛即通过
|
||||
|
||||
|
||||
def test_integrity_raises_on_mission_tamper() -> None:
|
||||
c = _contract(mission="M")
|
||||
with pytest.raises(ValueError):
|
||||
validate_daoti_integrity(c, mission="被篡改")
|
||||
|
||||
|
||||
def test_integrity_raises_on_safety_tamper() -> None:
|
||||
c = _contract(safety_guidelines="S")
|
||||
with pytest.raises(ValueError):
|
||||
validate_daoti_integrity(c, safety_guidelines="被篡改")
|
||||
|
||||
|
||||
def test_integrity_skips_none_fields() -> None:
|
||||
# 道体字段未设(None)时跳过校验
|
||||
validate_daoti_integrity(_contract(), mission=None, safety_guidelines=None)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""灵核情绪状态 system prompt 注入测试(设计 §11.5)。
|
||||
|
||||
验证 ``_contract_system_messages`` 把 ``emotion_state`` 注入到正确位置
|
||||
(personality_summary 后、memory_context 前),且 emotion_state=None 时不注入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.chat.send_message import _contract_system_messages
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
|
||||
|
||||
def _contract(**overrides) -> RuntimeContract:
|
||||
base = {
|
||||
"character_id": "c1",
|
||||
"session_id": "s1",
|
||||
"run_id": "r1",
|
||||
"model_hint": "m",
|
||||
"trace_id": "t",
|
||||
}
|
||||
base.update(overrides)
|
||||
return RuntimeContract(**base)
|
||||
|
||||
|
||||
def test_emotion_state_injected_between_personality_and_memory() -> None:
|
||||
contract = _contract(
|
||||
personality_summary="人格X",
|
||||
emotion_state="[当前情绪] 感动(强度 70%)",
|
||||
memory_context="记忆Y",
|
||||
)
|
||||
messages = _contract_system_messages(contract)
|
||||
personality_idx = next(i for i, m in enumerate(messages) if "人格X" in m)
|
||||
emotion_idx = next(i for i, m in enumerate(messages) if "感动" in m)
|
||||
memory_idx = next(i for i, m in enumerate(messages) if "记忆Y" in m)
|
||||
assert personality_idx < emotion_idx < memory_idx
|
||||
|
||||
|
||||
def test_emotion_state_omitted_when_none() -> None:
|
||||
contract = _contract(personality_summary="人格X", memory_context="记忆Y")
|
||||
messages = _contract_system_messages(contract)
|
||||
assert all("当前情绪" not in m for m in messages)
|
||||
|
||||
|
||||
def test_emotion_state_included_without_memory() -> None:
|
||||
"""emotion_state 独立于 memory_context 存在。"""
|
||||
contract = _contract(emotion_state="[当前情绪] 关切(强度 50%)")
|
||||
messages = _contract_system_messages(contract)
|
||||
assert any("关切" in m for m in messages)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""世谱注入位置测试:before/after 相对 personality_summary。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.chat.send_message import _contract_system_messages
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
|
||||
|
||||
def _contract(**kw: object) -> RuntimeContract:
|
||||
base: dict[str, object] = {
|
||||
"character_id": "c",
|
||||
"session_id": "s",
|
||||
"run_id": "r",
|
||||
"model_hint": "m",
|
||||
"trace_id": "t",
|
||||
}
|
||||
base.update(kw)
|
||||
return RuntimeContract(**base)
|
||||
|
||||
|
||||
def test_lorebook_before_precedes_personality() -> None:
|
||||
c = _contract(personality_summary="人格X", lorebook_before="世界设定前")
|
||||
msgs = _contract_system_messages(c)
|
||||
assert msgs.index("世界设定前") < msgs.index("当前人格:\n人格X")
|
||||
|
||||
|
||||
def test_lorebook_after_follows_personality() -> None:
|
||||
c = _contract(personality_summary="人格X", lorebook_after="世界设定后")
|
||||
msgs = _contract_system_messages(c)
|
||||
assert msgs.index("当前人格:\n人格X") < msgs.index("世界设定后")
|
||||
|
||||
|
||||
def test_no_lorebook_when_none() -> None:
|
||||
msgs = _contract_system_messages(_contract())
|
||||
assert not any("世界设定" in m for m in msgs)
|
||||
|
||||
|
||||
def test_lorebook_without_personality_still_injects() -> None:
|
||||
c = _contract(lorebook_before="前", lorebook_after="后")
|
||||
msgs = _contract_system_messages(c)
|
||||
assert "前" in msgs and "后" in msgs
|
||||
@@ -0,0 +1,8 @@
|
||||
"""测试 send_message 中的目标自动注入(集成验证)。"""
|
||||
|
||||
|
||||
def test_auto_persist_session_goal_is_callable():
|
||||
"""验证 auto_persist_session_goal 函数可导入且可调用。"""
|
||||
from app.services.goal_persist import auto_persist_session_goal
|
||||
|
||||
assert callable(auto_persist_session_goal)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""洛书 Deliberative 规划器测试。"""
|
||||
|
||||
from app.application.planning import (
|
||||
DeliberativePlan,
|
||||
PlanStep,
|
||||
build_planning_prompt,
|
||||
format_plan_for_prompt,
|
||||
parse_plan,
|
||||
should_plan,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_plan_full() -> None:
|
||||
"""完整 JSON 计划解析。"""
|
||||
raw = """```json
|
||||
{
|
||||
"title": "调研项目X",
|
||||
"assumptions": ["需要模型网关RAG能力"],
|
||||
"risks": ["外部API可能超时"],
|
||||
"steps": [
|
||||
{"id": 1, "title": "搜索文档", "description": "搜索关于项目X的文档",
|
||||
"tool": "web_search", "risk_level": "L2"},
|
||||
{"id": 2, "title": "阅读结果", "description": "阅读搜索结果",
|
||||
"tool": "read_file", "risk_level": "L1"},
|
||||
{"id": 3, "title": "汇总", "description": "汇总分析",
|
||||
"tool": null, "risk_level": "L1"}
|
||||
]
|
||||
}
|
||||
```"""
|
||||
plan = parse_plan(raw)
|
||||
assert plan is not None
|
||||
assert plan.title == "调研项目X"
|
||||
assert len(plan.assumptions) == 1
|
||||
assert len(plan.risks) == 1
|
||||
assert len(plan.steps) == 3
|
||||
assert plan.steps[0].tool == "web_search"
|
||||
assert plan.steps[2].tool is None
|
||||
assert plan.steps[0].risk_level == "L2"
|
||||
|
||||
|
||||
def test_parse_plan_no_fence() -> None:
|
||||
"""无 ``` 围栏的 JSON 也能解析。"""
|
||||
raw = '{"title": "简单计划", "steps": [{"id": 1, "title": "执行", "description": ""}]}'
|
||||
plan = parse_plan(raw)
|
||||
assert plan is not None
|
||||
assert plan.title == "简单计划"
|
||||
assert len(plan.steps) == 1
|
||||
|
||||
|
||||
def test_parse_plan_missing_title() -> None:
|
||||
"""缺少标题 → None。"""
|
||||
raw = '{"steps": [{"id": 1, "title": "执行", "description": ""}]}'
|
||||
assert parse_plan(raw) is None
|
||||
|
||||
|
||||
def test_parse_plan_empty_steps() -> None:
|
||||
"""空步骤列表 → None。"""
|
||||
raw = '{"title": "空计划", "steps": []}'
|
||||
assert parse_plan(raw) is None
|
||||
|
||||
|
||||
def test_parse_plan_invalid_json() -> None:
|
||||
"""非法 JSON → None。"""
|
||||
assert parse_plan("根本不是JSON") is None
|
||||
|
||||
|
||||
def test_parse_plan_not_dict() -> None:
|
||||
"""返回非对象 → None。"""
|
||||
assert parse_plan("[1, 2, 3]") is None
|
||||
|
||||
|
||||
def test_should_plan_triggers() -> None:
|
||||
"""包含关键词的请求应触发规划。"""
|
||||
triggers = [
|
||||
"搜索一下项目X的背景",
|
||||
"帮我调研一下最新技术",
|
||||
"分析这个方案的优缺点",
|
||||
"比较A和B的差异",
|
||||
"研究一下这个问题",
|
||||
"search for relevant docs",
|
||||
"research the market",
|
||||
]
|
||||
for t in triggers:
|
||||
assert should_plan(t), f"should plan: {t}"
|
||||
|
||||
|
||||
def test_should_plan_no_triggers() -> None:
|
||||
"""简单请求不应触发。"""
|
||||
no_triggers = [
|
||||
"你好",
|
||||
"再见",
|
||||
"今天天气怎么样",
|
||||
"帮我写首诗",
|
||||
]
|
||||
for t in no_triggers:
|
||||
assert not should_plan(t), f"should not plan: {t}"
|
||||
|
||||
|
||||
def test_build_planning_prompt() -> None:
|
||||
"""规划 prompt 包含必要结构。"""
|
||||
prompt = build_planning_prompt("搜索资料", memory_context="用户关心项目X")
|
||||
assert "NO_PLAN_NEEDED" in prompt
|
||||
assert "搜索资料" in prompt
|
||||
assert "用户关心项目X" in prompt
|
||||
|
||||
|
||||
def test_format_plan_for_prompt() -> None:
|
||||
"""计划格式化后包含步骤和元信息。"""
|
||||
plan = DeliberativePlan(
|
||||
title="测试计划",
|
||||
steps=[
|
||||
PlanStep(id=1, title="第一步", description="做A", tool="tool1", risk_level="L2"),
|
||||
PlanStep(id=2, title="第二步", description="做B", risk_level="L1"),
|
||||
],
|
||||
assumptions=["假设1"],
|
||||
risks=["风险1"],
|
||||
)
|
||||
text = format_plan_for_prompt(plan)
|
||||
assert "测试计划" in text
|
||||
assert "第一步" in text
|
||||
assert "tool1" in text
|
||||
assert "第二步" in text
|
||||
assert "假设1" in text
|
||||
assert "风险1" in text
|
||||
|
||||
|
||||
def test_format_plan_empty_assumptions_and_risks() -> None:
|
||||
"""无假设/风险时格式化正常。"""
|
||||
plan = DeliberativePlan(
|
||||
title="简单",
|
||||
steps=[PlanStep(id=1, title="做", description="")],
|
||||
)
|
||||
text = format_plan_for_prompt(plan)
|
||||
assert "简单" in text
|
||||
assert "做" in text
|
||||
@@ -0,0 +1,64 @@
|
||||
"""大周天循环骨架测试(触发 + 阶段化执行)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.memory import Memory
|
||||
from app.repositories import big_cycle as big_cycle_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
|
||||
async def _seed_memories(character_id: str, count: int) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
for i in range(count):
|
||||
session.add(
|
||||
Memory(
|
||||
character_id=character_id,
|
||||
type="user",
|
||||
title=f"记忆{i}",
|
||||
content=f"内容{i}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_runs_when_threshold_met() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert cycle_id is not None
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
|
||||
assert cycle is not None
|
||||
assert cycle.status == "completed"
|
||||
assert cycle.memory_count_at_trigger >= 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_skips_below_threshold() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
# 默认人物 0 记忆,低于阈值
|
||||
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert cycle_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_respects_min_interval() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
first = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert first is not None
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
# 间隔窗口内再次触发 → 跳过
|
||||
second = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert second is None
|
||||
@@ -0,0 +1,118 @@
|
||||
"""大周天内驱力生成测试(_phase_evaluation 子步骤 _generate_drive)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.memory import Memory
|
||||
from app.repositories import drive as drive_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
_DRIVE_RESPONSE = (
|
||||
'[{"question":"用户为何频繁提到项目X","source":"memory_gap",'
|
||||
'"expected_value":0.7,"urgency":0.5,"risk_level":"L2",'
|
||||
'"related_entities":["项目X"],'
|
||||
'"subgoals":[{"title":"询问项目X目标","description":"主动询问用户",'
|
||||
'"expected_information_gain":0.6,"interruption_cost":0.3,"risk_level":"L4"}]}]'
|
||||
)
|
||||
|
||||
|
||||
async def _seed_memories(character_id: str, count: int) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
for i in range(count):
|
||||
session.add(
|
||||
Memory(
|
||||
character_id=character_id,
|
||||
type="user",
|
||||
title=f"记忆{i}",
|
||||
content=f"内容{i}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_drive_chat(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_chat(*_args, **_kwargs):
|
||||
return _DRIVE_RESPONSE
|
||||
|
||||
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_generates_curiosity_and_subgoal(
|
||||
mock_drive_chat, # noqa: ARG001 - pytest fixture 靠 monkeypatch 副作用生效
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert cycle_id is not None
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
curiosities, c_total = await drive_repo.list_curiosities(session, character.id)
|
||||
subgoals, s_total = await drive_repo.list_subgoals(session, character.id)
|
||||
|
||||
assert c_total == 1
|
||||
assert curiosities[0].question == "用户为何频繁提到项目X"
|
||||
assert curiosities[0].source == "memory_gap"
|
||||
assert curiosities[0].cycle_id == cycle_id
|
||||
assert s_total == 1
|
||||
assert subgoals[0].status == "candidate"
|
||||
assert subgoals[0].curiosity_question_id == curiosities[0].id
|
||||
assert subgoals[0].title == "询问项目X目标"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_auto_approves_low_risk_subgoal(
|
||||
mock_drive_chat, # noqa: ARG001
|
||||
) -> None:
|
||||
"""低风险次生目标(L0-L2)应在大周天中自动通过并生成 Goal。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.governance import Goal
|
||||
|
||||
# 使用低风险的子目标响应
|
||||
_low_risk_response = (
|
||||
'[{"question":"还有哪些未知领域","source":"world_uncertainty",'
|
||||
'"expected_value":0.6,"urgency":0.4,"risk_level":"L1",'
|
||||
'"subgoals":[{"title":"探索未知领域","description":"自动探索",'
|
||||
'"expected_information_gain":0.5,"interruption_cost":0.2,"risk_level":"L1"}]}]'
|
||||
)
|
||||
|
||||
async def fake_low_risk(*_args, **_kwargs):
|
||||
return _low_risk_response
|
||||
|
||||
import app.application.big_cycle as bc
|
||||
|
||||
original = bc._call_model
|
||||
bc._call_model = fake_low_risk
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert cycle_id is not None
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
bc._call_model = original
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
subgoals, s_total = await drive_repo.list_subgoals(session, character.id)
|
||||
assert s_total == 1
|
||||
assert subgoals[0].status in ("approved", "auto_execute_pending"), (
|
||||
f"预期 approved 或 auto_execute_pending,实际={subgoals[0].status}"
|
||||
)
|
||||
|
||||
# 验证自动生成了 Goal
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Goal).where(Goal.character_id == character.id)
|
||||
)
|
||||
goals = result.scalars().all()
|
||||
matching = [g for g in goals if g.title == "探索未知领域"]
|
||||
assert len(matching) == 1
|
||||
assert matching[0].level == "task"
|
||||
@@ -0,0 +1,142 @@
|
||||
"""大周天候选生成 + 半自动应用调度测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
|
||||
from app.core.config import settings
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.memory import Memory
|
||||
from app.repositories import big_cycle as big_cycle_repo
|
||||
from app.repositories import evolution as evolution_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
_MIXED_RESPONSE = (
|
||||
"["
|
||||
'{"type":"personality","level":"L4","scope":"character_scope",'
|
||||
'"title":"身份演化","proposal":"调整身份表达",'
|
||||
'"delta":{"identity_profile":"新的身份描述"},'
|
||||
'"confidence":0.7,"benefit_score":0.6,"risk_score":0.4,'
|
||||
'"auto_apply_allowed":false,"evidence":[0]},'
|
||||
'{"type":"procedural","level":"L2","scope":"task_scope",'
|
||||
'"title":"确认假设","proposal":"技术问题先确认前提",'
|
||||
'"delta":{"title":"确认假设","content":"先确认前提再作答"},'
|
||||
'"confidence":0.8,"benefit_score":0.7,"risk_score":0.2,'
|
||||
'"auto_apply_allowed":true,"evidence":[0]}'
|
||||
"]"
|
||||
)
|
||||
|
||||
|
||||
async def _seed_memories(character_id: str, count: int) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
for i in range(count):
|
||||
session.add(
|
||||
Memory(
|
||||
character_id=character_id,
|
||||
type="user",
|
||||
title=f"记忆{i}",
|
||||
content=f"内容{i}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_evolution_chat(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_chat(*_args, **_kwargs):
|
||||
return _MIXED_RESPONSE
|
||||
|
||||
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_generates_candidates_and_auto_applies(
|
||||
mock_evolution_chat, # noqa: ARG001 - pytest fixture 靠 monkeypatch 副作用生效
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert cycle_id is not None
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
|
||||
candidates, total = await evolution_repo.list_candidates(session, character.id)
|
||||
|
||||
assert cycle is not None
|
||||
assert cycle.status == "completed"
|
||||
assert cycle.evaluation_snapshot_json is not None
|
||||
assert total == 2
|
||||
|
||||
personality = next(c for c in candidates if c.type == "personality")
|
||||
assert personality.status == "pending" # L4 auto_apply_allowed=false
|
||||
assert personality.level == "L4"
|
||||
assert personality.source == "big_cycle"
|
||||
assert personality.cycle_id == cycle_id
|
||||
|
||||
procedural = next(c for c in candidates if c.type == "procedural")
|
||||
assert procedural.status == "applied" # L2 auto_apply_allowed=true
|
||||
assert procedural.resulting_target_id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_apply_degrades_when_applier_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
response = (
|
||||
'[{"type":"tool_strategy","level":"L2","scope":"task_scope",'
|
||||
'"title":"工具策略","proposal":"偏好shell",'
|
||||
'"delta":{},"auto_apply_allowed":true}]'
|
||||
)
|
||||
|
||||
async def fake_chat(*_args, **_kwargs):
|
||||
return response
|
||||
|
||||
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
candidates, _ = await evolution_repo.list_candidates(session, character.id)
|
||||
assert len(candidates) == 1
|
||||
cand = candidates[0]
|
||||
assert cand.status == "pending" # applier 缺失,降级不自动应用
|
||||
assert cand.auto_apply_allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_uses_configured_model_hint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict = {}
|
||||
result = type("FakeResponse", (), {"content": "[]"})()
|
||||
|
||||
class _FakeManager:
|
||||
def to_message_list(self, *_args: object, **_kwargs: object) -> list:
|
||||
return []
|
||||
|
||||
async def chat(self, messages: object, model_hint: str | None = None) -> object: # noqa: ARG002
|
||||
captured["model_hint"] = model_hint
|
||||
return result
|
||||
|
||||
def fake_get_provider_manager() -> _FakeManager:
|
||||
return _FakeManager()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.application.big_cycle._get_provider_manager", fake_get_provider_manager
|
||||
)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
assert captured["model_hint"] == settings.big_cycle_model
|
||||
@@ -0,0 +1,76 @@
|
||||
"""大周天记忆/世界抽取生成测试(_phase_memory 阶段)。
|
||||
|
||||
高置信度(≥0.7)低敏感度记忆候选自动落地到 memories 表;
|
||||
世界实体候选(confidence=0.6)低于阈值仍留 pending。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.memory import Memory
|
||||
from app.repositories import world as world_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
_EXTRACTION_RESPONSE = (
|
||||
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
|
||||
'"importance":0.8,"sensitivity":"low","confidence":0.7}],'
|
||||
'"entities":[{"type":"project","name":"项目X","attributes":{"status":"active"},'
|
||||
'"state_type":"inference","confidence":0.6}]}'
|
||||
)
|
||||
|
||||
|
||||
async def _seed_memories(character_id: str, count: int) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
for i in range(count):
|
||||
session.add(
|
||||
Memory(
|
||||
character_id=character_id,
|
||||
type="user",
|
||||
title=f"记忆{i}",
|
||||
content=f"内容{i}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_extraction_chat(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_chat(*_args, **_kwargs):
|
||||
return _EXTRACTION_RESPONSE
|
||||
|
||||
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_extracts_memory_and_world(
|
||||
mock_extraction_chat, # noqa: ARG001 - pytest fixture 靠 monkeypatch 副作用生效
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert cycle_id is not None
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 高置信度(≥0.7)低敏感度记忆自动落地到 memories 表
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(Memory).where(
|
||||
Memory.character_id == character.id,
|
||||
Memory.source == "big_cycle",
|
||||
)
|
||||
)
|
||||
auto_memories = result.scalars().all()
|
||||
# 世界实体候选(confidence=0.6 < 0.7)仍留 pending
|
||||
entities, _ = await world_repo.list_entities(session, character.id, status="pending")
|
||||
|
||||
assert len(auto_memories) == 1
|
||||
assert auto_memories[0].title == "喜欢简洁"
|
||||
assert len(entities) == 1
|
||||
assert entities[0].name == "项目X"
|
||||
assert entities[0].status == "pending"
|
||||
assert entities[0].state_type == "inference"
|
||||
@@ -0,0 +1,171 @@
|
||||
"""大周天中的目标进度更新测试。
|
||||
|
||||
验证 _phase_goal_progress 在独立环境中按预期工作:
|
||||
- 活跃的 task/session 目标进度 +0.1
|
||||
- 进度上限 1.0
|
||||
- 非活跃目标不受影响
|
||||
- 进度达 1.0 的 task 目标状态标记为 completed
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.big_cycle import _phase_goal_progress
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.governance import Goal
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress_increments_active_tasks() -> None:
|
||||
"""task 级别活跃目标在大周天后进度增加 0.1。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(character_id="char_pg1", trigger="test")
|
||||
session.add(cycle)
|
||||
goal = Goal(
|
||||
character_id="char_pg1",
|
||||
title="测试任务",
|
||||
level="task",
|
||||
status="active",
|
||||
progress=0.3,
|
||||
)
|
||||
session.add(goal)
|
||||
await session.flush()
|
||||
|
||||
await _phase_goal_progress(session, cycle)
|
||||
|
||||
assert goal.progress == 0.4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress_caps_at_one() -> None:
|
||||
"""进度达到 1.0 后不再增加。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(character_id="char_pg2", trigger="test")
|
||||
session.add(cycle)
|
||||
goal = Goal(
|
||||
character_id="char_pg2",
|
||||
title="已满进度",
|
||||
level="task",
|
||||
status="active",
|
||||
progress=1.0,
|
||||
)
|
||||
session.add(goal)
|
||||
await session.flush()
|
||||
|
||||
await _phase_goal_progress(session, cycle)
|
||||
|
||||
# 已达上限,不再增加
|
||||
assert goal.progress == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress_skips_non_active() -> None:
|
||||
"""非活跃目标不被更新。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(character_id="char_pg3", trigger="test")
|
||||
session.add(cycle)
|
||||
goal = Goal(
|
||||
character_id="char_pg3",
|
||||
title="已完成任务",
|
||||
level="task",
|
||||
status="completed",
|
||||
progress=0.5,
|
||||
)
|
||||
session.add(goal)
|
||||
await session.flush()
|
||||
|
||||
await _phase_goal_progress(session, cycle)
|
||||
|
||||
assert goal.progress == 0.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress_skips_highest_level() -> None:
|
||||
"""highest 级别目标不被大周天自动更新。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(character_id="char_pg4", trigger="test")
|
||||
session.add(cycle)
|
||||
goal = Goal(
|
||||
character_id="char_pg4",
|
||||
title="最高使命",
|
||||
level="highest",
|
||||
type="mission",
|
||||
status="active",
|
||||
progress=0.5,
|
||||
)
|
||||
session.add(goal)
|
||||
await session.flush()
|
||||
|
||||
await _phase_goal_progress(session, cycle)
|
||||
|
||||
assert goal.progress == 0.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress_only_updates_own_character() -> None:
|
||||
"""仅更新当前 character 的目标,不影响其他角色。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(character_id="char_pg5", trigger="test")
|
||||
session.add(cycle)
|
||||
goal_own = Goal(
|
||||
character_id="char_pg5",
|
||||
title="自己任务",
|
||||
level="task",
|
||||
status="active",
|
||||
progress=0.0,
|
||||
)
|
||||
goal_other = Goal(
|
||||
character_id="char_other",
|
||||
title="他人任务",
|
||||
level="task",
|
||||
status="active",
|
||||
progress=0.0,
|
||||
)
|
||||
session.add_all([goal_own, goal_other])
|
||||
await session.flush()
|
||||
|
||||
await _phase_goal_progress(session, cycle)
|
||||
|
||||
assert goal_own.progress == 0.1
|
||||
assert goal_other.progress == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress_completes_task_at_one() -> None:
|
||||
"""task 级别目标进度达到 1.0 后状态变为 completed。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(character_id="char_pg6", trigger="test")
|
||||
session.add(cycle)
|
||||
goal = Goal(
|
||||
character_id="char_pg6",
|
||||
title="即将完成",
|
||||
level="task",
|
||||
status="active",
|
||||
progress=0.95,
|
||||
)
|
||||
session.add(goal)
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
cycle_id = cycle.id
|
||||
goal_id = goal.id
|
||||
|
||||
# 清理可能被前序测试设置的 event_store,避免 emit_goal_event 的独立 session 冲突
|
||||
from app.main import app as _app
|
||||
|
||||
_app.state.__dict__.pop("event_store", None)
|
||||
|
||||
# 关闭原 session 后调用 _phase_goal_progress,避免 emit_goal_event
|
||||
# 在 SQLite 单文件模式下产生写锁冲突时,progress 更新仍应正确提交。
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle_ref = await session.get(BigCycle, cycle_id)
|
||||
try: # noqa: SIM105 — emit_goal_event 失败不影响 goal 更新
|
||||
await _phase_goal_progress(session, cycle_ref)
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
goal_ref = await session.get(Goal, goal_id)
|
||||
assert goal_ref is not None
|
||||
assert goal_ref.progress == 1.0
|
||||
assert goal_ref.status == "completed"
|
||||
@@ -0,0 +1,100 @@
|
||||
"""小周天每轮记忆候选 + 反思抽取测试。
|
||||
|
||||
高置信度(≥0.7)低敏感度记忆候选直接落地到 memories 表而非 MemoryCandidate。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.application.reflection import (
|
||||
drain_pending_reflections,
|
||||
start_reflection_after_turn,
|
||||
)
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.memory import Memory
|
||||
from app.repositories import candidates as candidates_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
_REFLECTION_RESPONSE = (
|
||||
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
|
||||
'"importance":0.8,"sensitivity":"low","confidence":0.7}],'
|
||||
'"reflection":"本轮应更主动确认用户意图。"}'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reflection_chat(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_chat(*_args, **_kwargs):
|
||||
return _REFLECTION_RESPONSE
|
||||
|
||||
monkeypatch.setattr("app.application.reflection._call_model", fake_chat)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflection_extracts_memory_and_writes_reflection(
|
||||
mock_reflection_chat, # noqa: ARG001
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
|
||||
start_reflection_after_turn(character.id, "session_test", "你好", "你好,我是魂镜。")
|
||||
await drain_pending_reflections()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 高置信度记忆直接落地到 memories 表(非 MemoryCandidate)
|
||||
auto_mem = (
|
||||
await session.execute(
|
||||
select(Memory).where(
|
||||
Memory.character_id == character.id,
|
||||
Memory.type == "preference",
|
||||
Memory.source == "reflection",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
# 反思文本也落地为 memories 表
|
||||
ref_mem = (
|
||||
await session.execute(
|
||||
select(Memory).where(
|
||||
Memory.character_id == character.id,
|
||||
Memory.type == "reflection",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
assert auto_mem is not None
|
||||
assert auto_mem.title == "喜欢简洁"
|
||||
assert ref_mem is not None
|
||||
assert ref_mem.source == "reflection"
|
||||
assert ref_mem.status == "active" # Memory 无 status 字段,用 default
|
||||
assert "确认用户意图" in ref_mem.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflection_without_memories_only_writes_reflection(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def fake_chat(*_args, **_kwargs):
|
||||
return '{"memories":[],"reflection":"只反思不抽记忆。"}'
|
||||
|
||||
monkeypatch.setattr("app.application.reflection._call_model", fake_chat)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
|
||||
start_reflection_after_turn(character.id, "session_test", "hi", "你好。")
|
||||
await drain_pending_reflections()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
cands = await candidates_repo.list_memory_candidates(session, character.id, source="cycle")
|
||||
ref_mem = (
|
||||
await session.execute(
|
||||
select(Memory).where(
|
||||
Memory.character_id == character.id,
|
||||
Memory.type == "reflection",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
# 无记忆候选(空 memories 数组),只有反思
|
||||
assert len(cands) == 0
|
||||
assert ref_mem is not None
|
||||
assert "只反思" in ref_mem.content
|
||||
@@ -0,0 +1,179 @@
|
||||
"""对话 temporal_context 注入测试(辰正 §8.1)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.application.agent.runtime import AgentTurnResult
|
||||
from app.application.chat.send_message import _contract_system_messages
|
||||
from app.main import app
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
|
||||
|
||||
def _make_contract(temporal: str | None) -> RuntimeContract:
|
||||
return RuntimeContract(
|
||||
character_id="char_x",
|
||||
session_id="sess_x",
|
||||
run_id="run_x",
|
||||
model_hint="default",
|
||||
system_instructions=["base"],
|
||||
personality_summary="人格X",
|
||||
memory_context="记忆Y",
|
||||
temporal_context=temporal,
|
||||
trace_id="trace_x",
|
||||
)
|
||||
|
||||
|
||||
def test_contract_system_messages_includes_temporal_context():
|
||||
messages = _contract_system_messages(_make_contract("现在是子时·深夜(01:42)。"))
|
||||
assert any("子时·深夜" in m for m in messages)
|
||||
|
||||
|
||||
def test_contract_system_messages_no_temporal_context_when_none():
|
||||
messages = _contract_system_messages(_make_contract(None))
|
||||
assert all("子时" not in m for m in messages)
|
||||
|
||||
|
||||
def test_contract_system_messages_temporal_after_memory_before_safety():
|
||||
"""注入位置:记忆之后、安全约束之前(设计 §8.1)。"""
|
||||
contract = RuntimeContract(
|
||||
character_id="c",
|
||||
session_id="s",
|
||||
run_id="r",
|
||||
model_hint="m",
|
||||
system_instructions=["base"],
|
||||
memory_context="MEMORY_MARKER",
|
||||
temporal_context="TEMPORAL_MARKER",
|
||||
safety_constraints=["SAFETY_MARKER"],
|
||||
trace_id="t",
|
||||
)
|
||||
messages = _contract_system_messages(contract)
|
||||
joined = "\n".join(messages)
|
||||
assert joined.index("MEMORY_MARKER") < joined.index("TEMPORAL_MARKER")
|
||||
assert joined.index("TEMPORAL_MARKER") < joined.index("SAFETY_MARKER")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §12 降级路径守护(设计 §12:辰正永不阻塞对话)
|
||||
# 1. event_store=None → 跳过辰正调用,temporal_context 保持 None,对话照常完成
|
||||
# 2. compute_live_snapshot raise → except 捕获,temporal_context 保持 None,对话不阻塞
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _create_session(client: AsyncClient) -> str:
|
||||
resp = await client.post("/api/v1/chat/sessions", json={"title": "辰正降级"})
|
||||
assert resp.status_code == 200
|
||||
return str(resp.json()["session_id"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporal_client(
|
||||
auth_headers: dict[str, str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""HTTP client:mock run_agent_turn,并通过 messages 捕获最终 system 消息用于断言。
|
||||
|
||||
复用 test_send_message_agent.py 的 mock 边界(只 mock run_agent_turn,跑真实 send_message
|
||||
全链路),让降级路径在生产代码路径中被真实驱动。
|
||||
"""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def capture_agent_turn(*, messages: Any, **_kwargs: object) -> AgentTurnResult:
|
||||
captured["messages"] = messages
|
||||
return AgentTurnResult(content="好的。", reasoning="")
|
||||
|
||||
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", capture_agent_turn)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
http_client._temporal_captured = captured # type: ignore[attr-defined]
|
||||
yield http_client
|
||||
|
||||
|
||||
def _system_text(messages: list[Any]) -> str:
|
||||
return "\n".join(str(m.content) for m in messages if getattr(m, "role", "") == "system")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_context_skipped_when_event_store_is_none(
|
||||
temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""§12 降径 1:event_store=None 时跳过辰正调用,对话照常完成且不注入 temporal_context。"""
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
|
||||
# 降径 1:明确将 event_store 置 None(route 经 getattr 默认也是 None,此处显式断言降径)
|
||||
# 测试 app.state 默认无 event_store 属性,monkeypatch.setattr 对对象属性可在不存在时
|
||||
# 创建并在 teardown 时删除(恢复"无属性"原状)。
|
||||
monkeypatch.setattr(app.state, "event_store", None, raising=False)
|
||||
|
||||
# spy:若被调用即标记(不应发生)
|
||||
called: dict[str, bool] = {"hit": False}
|
||||
|
||||
import app.services.time_sense as time_sense_mod
|
||||
|
||||
real_compute = time_sense_mod.compute_live_snapshot
|
||||
|
||||
async def spy_compute(*args: Any, **kwargs: Any) -> Any:
|
||||
called["hit"] = True
|
||||
return await real_compute(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(time_sense_mod, "compute_live_snapshot", spy_compute)
|
||||
|
||||
session_id = await _create_session(temporal_client)
|
||||
sent = await temporal_client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "你好", "modality": "text"},
|
||||
)
|
||||
assert sent.status_code == 200
|
||||
await drain_pending_streams()
|
||||
|
||||
# 真实行为断言:辰正未被调用
|
||||
assert called["hit"] is False
|
||||
# 对话完成:run_agent_turn 被正常调用且 system 消息中无辰正上下文标记
|
||||
captured = temporal_client._temporal_captured # type: ignore[attr-defined]
|
||||
sys_text = _system_text(captured["messages"])
|
||||
assert "今日命中" not in sys_text # render_temporal_context 的稳定首行尾块标记
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_context_swallowed_when_compute_raises(
|
||||
temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""§12 降径 2:compute_live_snapshot raise 时 except 捕获,对话不阻塞且 temporal_context=None。"""
|
||||
|
||||
async def boom(*_args: Any, **_kwargs: Any) -> Any:
|
||||
raise RuntimeError("辰正服务故障(模拟)")
|
||||
|
||||
monkeypatch.setattr("app.services.time_sense.compute_live_snapshot", boom)
|
||||
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
|
||||
session_id = await _create_session(temporal_client)
|
||||
sent = await temporal_client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "你好", "modality": "text"},
|
||||
)
|
||||
# 真实行为断言:异常被 except 吞掉,HTTP 仍 200,对话照常完成
|
||||
assert sent.status_code == 200
|
||||
run_id = str(sent.json()["run_id"])
|
||||
await drain_pending_streams()
|
||||
|
||||
# 对话成功落库:run 进入终态 completed(而非 failed)
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.runtime import Run
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
run = (await db.execute(select(Run).where(Run.id == run_id))).scalar_one()
|
||||
assert run.status == "completed"
|
||||
|
||||
# temporal_context 未注入:system 消息中无辰正上下文标记
|
||||
captured = temporal_client._temporal_captured # type: ignore[attr-defined]
|
||||
sys_text = _system_text(captured["messages"])
|
||||
assert "子时" not in sys_text and "时态" not in sys_text
|
||||
@@ -0,0 +1,86 @@
|
||||
"""内置工具规格测试:覆盖风险分级、env 传参与必填校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.tool_runtime.builtins import builtin_specs
|
||||
from app.modules.safety.governor import PermissionPolicy
|
||||
|
||||
|
||||
def test_builtin_specs_cover_risk_levels() -> None:
|
||||
specs = builtin_specs()
|
||||
assert {"L0", "L1", "L2", "L3"} <= {s.risk_level for s in specs.values()}
|
||||
assert {
|
||||
"datetime",
|
||||
"read_file",
|
||||
"list_dir",
|
||||
"write_file",
|
||||
"shell",
|
||||
"fetch_url",
|
||||
} == set(specs)
|
||||
|
||||
|
||||
def test_write_file_is_ask_and_side_effect() -> None:
|
||||
spec = builtin_specs()["write_file"]
|
||||
assert spec.permission_policy is PermissionPolicy.ask
|
||||
assert spec.side_effect is True
|
||||
|
||||
|
||||
def test_read_tools_allow_and_no_side_effect() -> None:
|
||||
specs = builtin_specs()
|
||||
for name in ["datetime", "read_file", "list_dir"]:
|
||||
assert specs[name].permission_policy is PermissionPolicy.allow
|
||||
assert specs[name].side_effect is False
|
||||
|
||||
|
||||
def test_file_tools_use_workspace() -> None:
|
||||
req = builtin_specs()["read_file"].build_request({"path": "a.txt"})
|
||||
assert req.workdir == "workspace"
|
||||
assert req.env.get("PATH_ARG") == "a.txt"
|
||||
|
||||
|
||||
def test_read_file_pagination_builds_sed() -> None:
|
||||
"""read_file 传 offset/limit 时用 sed 行分页,缺省时 cat 全文。"""
|
||||
specs = builtin_specs()
|
||||
|
||||
full = specs["read_file"].build_request({"path": "a.txt"})
|
||||
assert full.argv[2] == 'cat -- "$PATH_ARG"'
|
||||
|
||||
paged = specs["read_file"].build_request({"path": "a.txt", "offset": 10, "limit": 5})
|
||||
assert paged.argv[2] == "sed -n '10,14p' \"$PATH_ARG\""
|
||||
assert paged.env.get("PATH_ARG") == "a.txt"
|
||||
assert paged.workdir == "workspace"
|
||||
|
||||
tail = specs["read_file"].build_request({"path": "a.txt", "offset": 100})
|
||||
assert tail.argv[2] == "sed -n '100,$p' \"$PATH_ARG\""
|
||||
|
||||
|
||||
def test_read_file_pagination_ignores_bad_input() -> None:
|
||||
"""offset/limit 非正整数时回退全文 cat,不误构造非法 sed 地址。"""
|
||||
spec = builtin_specs()["read_file"]
|
||||
assert (
|
||||
spec.build_request({"path": "a", "offset": "x", "limit": -3}).argv[2]
|
||||
== 'cat -- "$PATH_ARG"'
|
||||
)
|
||||
assert spec.build_request({"path": "a", "offset": 0}).argv[2] == 'cat -- "$PATH_ARG"'
|
||||
|
||||
|
||||
def test_required_inputs_validated() -> None:
|
||||
specs = builtin_specs()
|
||||
with pytest.raises(ValueError):
|
||||
specs["read_file"].build_request({})
|
||||
with pytest.raises(ValueError):
|
||||
specs["write_file"].build_request({"content": "x"})
|
||||
with pytest.raises(ValueError):
|
||||
specs["shell"].build_request({})
|
||||
|
||||
|
||||
def test_fetch_url_uses_executor_not_sandbox() -> None:
|
||||
"""fetch_url 使用 executor 直接执行,不走沙箱。"""
|
||||
specs = builtin_specs()
|
||||
spec = specs["fetch_url"]
|
||||
assert spec.executor is not None
|
||||
assert spec.permission_policy is PermissionPolicy.ask
|
||||
assert spec.side_effect is True
|
||||
assert spec.audit_required is True
|
||||
@@ -0,0 +1,74 @@
|
||||
"""MCP 工具转 ToolSpec 测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.tool_runtime import mcp as mcp_module
|
||||
from app.application.tool_runtime.mcp import make_mcp_tool_specs
|
||||
from app.modules.safety.governor import PermissionPolicy
|
||||
from app.modules.tool_runtime.definition import ToolResult
|
||||
|
||||
|
||||
class _Server:
|
||||
def __init__(self, name, endpoint_url, auth_token=None):
|
||||
self.name = name
|
||||
self.endpoint_url = endpoint_url
|
||||
self.auth_token = auth_token
|
||||
|
||||
|
||||
def test_make_mcp_tool_specs_basic() -> None:
|
||||
tools = [{"name": "weather", "description": "查天气", "risk_level": "L1"}]
|
||||
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
|
||||
assert len(specs) == 1
|
||||
s = specs[0]
|
||||
assert s.name == "mcp_srv_weather"
|
||||
assert s.description == "查天气"
|
||||
assert s.risk_level == "L1"
|
||||
assert s.permission_policy == PermissionPolicy.allow
|
||||
|
||||
|
||||
def test_make_mcp_tool_specs_default_risk_l2() -> None:
|
||||
tools = [{"name": "t"}]
|
||||
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
|
||||
assert specs[0].risk_level == "L2"
|
||||
assert specs[0].permission_policy == PermissionPolicy.ask
|
||||
|
||||
|
||||
def test_make_mcp_tool_specs_skips_unnamed() -> None:
|
||||
tools = [{"name": "t"}, {"description": "无名"}]
|
||||
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
|
||||
assert len(specs) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_executor_calls_call_tool(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_call_tool(
|
||||
_http_client, endpoint_url, tool_name, input, auth_token=None, **_kwargs
|
||||
):
|
||||
captured.update(tool=tool_name, input=input, endpoint=endpoint_url, auth=auth_token)
|
||||
return ToolResult(
|
||||
ok=True,
|
||||
exit_code=0,
|
||||
stdout="ok",
|
||||
stderr="",
|
||||
duration_ms=1,
|
||||
timeout=False,
|
||||
truncated=False,
|
||||
trace_id="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mcp_module, "call_tool", fake_call_tool)
|
||||
|
||||
tools = [{"name": "t"}]
|
||||
specs = make_mcp_tool_specs(
|
||||
_Server("srv", "http://mcp.local", auth_token="tok"), tools, http_client="HTTP"
|
||||
)
|
||||
result = await specs[0].executor({"x": 1}, None)
|
||||
assert result.ok is True
|
||||
assert captured == {
|
||||
"tool": "t",
|
||||
"input": {"x": 1},
|
||||
"endpoint": "http://mcp.local",
|
||||
"auth": "tok",
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.application.agent.tools import to_ai_provider_tools
|
||||
from app.application.skill_executor import SkillExecutor
|
||||
from app.application.tool_runtime.view import RunScopedToolView
|
||||
from app.modules.skills.manifest import SkillManifest
|
||||
from app.modules.tool_runtime.definition import ToolResult, ToolSpec
|
||||
|
||||
|
||||
def _spec(name: str) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name=name,
|
||||
description=f"{name} description",
|
||||
risk_level="L1",
|
||||
permission_policy=MagicMock(),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"value": {"type": "string"}},
|
||||
"required": ["value"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_aliases_are_isolated_and_execute_through_base_runtime() -> None:
|
||||
runtime = MagicMock()
|
||||
runtime.list_specs.return_value = [_spec("shared_tool")]
|
||||
runtime.execute = AsyncMock(
|
||||
return_value=ToolResult(True, 0, "ok", "", 1, False, False, "trace_1")
|
||||
)
|
||||
first = RunScopedToolView(runtime)
|
||||
second = RunScopedToolView(runtime)
|
||||
|
||||
assert first.bind_alias("skill:甲/shared_tool", "shared_tool", description_prefix="[甲] ")
|
||||
assert second.bind_alias("skill:乙/shared_tool", "shared_tool", description_prefix="[乙] ")
|
||||
|
||||
first_names = {spec.name for spec in first.list_specs()}
|
||||
second_names = {spec.name for spec in second.list_specs()}
|
||||
result = await first.execute(
|
||||
"skill:甲/shared_tool",
|
||||
{"value": "x"},
|
||||
run_id="run_1",
|
||||
character_id="char_1",
|
||||
)
|
||||
|
||||
assert "skill:甲/shared_tool" in first_names
|
||||
assert "skill:乙/shared_tool" not in first_names
|
||||
assert "skill:乙/shared_tool" in second_names
|
||||
assert "skill:甲/shared_tool" not in second_names
|
||||
assert result.stdout == "ok"
|
||||
runtime.execute.assert_awaited_once_with(
|
||||
"shared_tool",
|
||||
{"value": "x"},
|
||||
run_id="run_1",
|
||||
character_id="char_1",
|
||||
trace_id=None,
|
||||
confirmed=False,
|
||||
context=None,
|
||||
)
|
||||
runtime.register.assert_not_called()
|
||||
runtime.unregister.assert_not_called()
|
||||
|
||||
|
||||
def test_alias_keeps_target_input_schema() -> None:
|
||||
runtime = MagicMock()
|
||||
runtime.list_specs.return_value = [_spec("custom_tool")]
|
||||
view = RunScopedToolView(runtime)
|
||||
view.bind_alias("skill:研究/custom_tool", "custom_tool")
|
||||
|
||||
tools = to_ai_provider_tools(view.list_specs())
|
||||
alias = next(tool for tool in tools if tool["function"]["name"] == "skill:研究/custom_tool")
|
||||
|
||||
assert alias["function"]["parameters"]["required"] == ["value"]
|
||||
|
||||
|
||||
async def test_skill_executor_binds_only_current_run_aliases(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
manifest = SkillManifest(
|
||||
name="研究",
|
||||
description="",
|
||||
triggers=("研究",),
|
||||
prompt="研究说明",
|
||||
allowed_tools=("shared_tool", "missing"),
|
||||
risk_level="L1",
|
||||
)
|
||||
|
||||
async def load(*_args, **_kwargs):
|
||||
return [manifest]
|
||||
|
||||
monkeypatch.setattr("app.application.skill_executor.load_skill_manifests", load)
|
||||
runtime = MagicMock()
|
||||
runtime.list_specs.return_value = [_spec("shared_tool")]
|
||||
view = RunScopedToolView(runtime)
|
||||
messages: list[str] = []
|
||||
|
||||
await SkillExecutor(view).match_and_activate("请研究", messages, run_id="run_1")
|
||||
|
||||
assert messages == ["【研究·能力注入】\n研究说明"]
|
||||
assert "skill:研究/shared_tool" in view.specs_by_name()
|
||||
assert "skill:研究/missing" not in view.specs_by_name()
|
||||
runtime.register.assert_not_called()
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Tool Runtime 编排闭环测试:司命评估 → 沙箱执行 → 一炁事件回写。
|
||||
|
||||
沙箱用 respx 模拟,事件存储用 InMemoryEventStore。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.core.config import SandboxSettings
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.modules.runtime.events import InMemoryEventStore
|
||||
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
|
||||
from app.modules.safety.governor import PermissionPolicy, SafetyGovernor, ToolPolicy
|
||||
|
||||
|
||||
def _settings() -> SandboxSettings:
|
||||
return SandboxSettings(
|
||||
rust_sandbox_url="http://sandbox.test",
|
||||
sandbox_auth_enabled=True,
|
||||
sandbox_auth_secret="shared-secret",
|
||||
sandbox_default_timeout_ms=30000,
|
||||
sandbox_auth_ttl_seconds=30,
|
||||
)
|
||||
|
||||
|
||||
def _ok_response() -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ok": True,
|
||||
"exit_code": 0,
|
||||
"stdout": "42\n",
|
||||
"stderr": "",
|
||||
"duration_ms": 3,
|
||||
"timeout": False,
|
||||
"truncated": False,
|
||||
"trace_id": "t1",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sandbox_client() -> AsyncIterator[SandboxClient]:
|
||||
async with httpx.AsyncClient() as http:
|
||||
yield SandboxClient(http, _settings())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_allow_executes_and_records_started_completed(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
result = await runtime.execute("datetime", {}, run_id="run_1", trace_id="t1")
|
||||
|
||||
assert result.ok
|
||||
assert result.stdout == "42\n"
|
||||
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
|
||||
assert all(e.run_id == "run_1" for e in store.all())
|
||||
|
||||
|
||||
async def test_cancelled_sandbox_execute_requests_remote_cancel() -> None:
|
||||
sandbox = MagicMock()
|
||||
started = asyncio.Event()
|
||||
|
||||
async def exec_forever(*_args, **_kwargs):
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
sandbox.exec = AsyncMock(side_effect=exec_forever)
|
||||
sandbox.cancel = AsyncMock(return_value=True)
|
||||
runtime = ToolRuntime(sandbox, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
task = asyncio.create_task(runtime.execute("datetime", {}, trace_id="trace_cancel"))
|
||||
await started.wait()
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
sandbox.cancel.assert_awaited_once()
|
||||
assert sandbox.cancel.await_args.args[0] == "trace_cancel"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_high_risk_requires_confirmation(sandbox_client: SandboxClient) -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
with pytest.raises(RequiresConfirmation):
|
||||
await runtime.execute("shell", {"command": "echo hi"}, run_id="run_1")
|
||||
|
||||
# 未确认不应进入执行,故无 started 事件
|
||||
assert all(e.type != "tool.started" for e in store.all())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_confirmed_high_risk_executes(sandbox_client: SandboxClient) -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
result = await runtime.execute("shell", {"command": "echo hi"}, run_id="run_1", confirmed=True)
|
||||
|
||||
assert result.ok
|
||||
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_forbidden_records_failed_event(sandbox_client: SandboxClient) -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
governor = SafetyGovernor(
|
||||
{"shell": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
|
||||
)
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, governor, store)
|
||||
|
||||
with pytest.raises(PermissionDenied):
|
||||
await runtime.execute("shell", {"command": "x"}, run_id="run_1", trace_id="t1")
|
||||
|
||||
failed = [e for e in store.all() if e.type == "tool.failed"]
|
||||
assert len(failed) == 1
|
||||
assert failed[0].payload["error_code"] == "permission_denied"
|
||||
assert failed[0].trace_id == "t1"
|
||||
|
||||
|
||||
async def test_unknown_tool_denied(sandbox_client: SandboxClient) -> None:
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
with pytest.raises(PermissionDenied):
|
||||
await runtime.execute("nope", {}, run_id="run_1")
|
||||
|
||||
assert any(e.type == "tool.failed" for e in store.all())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_tool_isolates_workdir_by_character(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""文件类工具的 workdir 应按角色隔离,同一角色跨 run 共享 workspace。"""
|
||||
route = respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
await runtime.execute(
|
||||
"write_file",
|
||||
{"path": "a.txt", "content": "x"},
|
||||
character_id="char_01HABC",
|
||||
run_id="run_42",
|
||||
confirmed=True,
|
||||
)
|
||||
|
||||
body = json.loads(route.calls.last.request.content)
|
||||
# workdir 按 character_id 隔离(而非 run_id),保证同一角色跨 run 文件互通
|
||||
assert body["workdir"] == "workspace/char_01HABC"
|
||||
|
||||
|
||||
async def test_isolate_workdir_sanitizes_and_falls_back(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""隔离键经 sanitize 杜绝路径逃逸;缺主体/run/trace 时逐级回退。"""
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
# 路径分隔符与 .. 被 sanitize 清除,无法逃逸沙箱根
|
||||
traversed = runtime._isolate_workdir(
|
||||
"workspace", subject_id="../../etc", run_id=None, trace_id=None
|
||||
)
|
||||
key = traversed.removeprefix("workspace/")
|
||||
assert key == "etc"
|
||||
assert "/" not in key and ".." not in key
|
||||
# 正常 subject_id(character_id)原样保留
|
||||
assert (
|
||||
runtime._isolate_workdir("workspace", subject_id="char_01HABC", run_id=None, trace_id=None)
|
||||
== "workspace/char_01HABC"
|
||||
)
|
||||
# 缺 subject_id 时回退 run_id
|
||||
assert (
|
||||
runtime._isolate_workdir("workspace", subject_id=None, run_id="run_01HABC", trace_id=None)
|
||||
== "workspace/run_01HABC"
|
||||
)
|
||||
# 缺 subject_id/run_id/trace 时回退 adhoc 前缀
|
||||
assert runtime._isolate_workdir(
|
||||
"workspace", subject_id=None, run_id=None, trace_id=None
|
||||
).startswith("workspace/adhoc_")
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_execute_stream_forwards_stdout_and_records_events(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""流式执行:转发 stdout/done,started/completed 落一炁,stdout 增量不落库。"""
|
||||
sse = (
|
||||
b"event: stdout\n"
|
||||
b"data: aGVsbG8K\n"
|
||||
b"\n"
|
||||
b"event: done\n"
|
||||
b'data: {"exit_code":0,"duration_ms":3,"timeout":false,"cancelled":false,"truncated":false}\n'
|
||||
b"\n"
|
||||
)
|
||||
respx.post("http://sandbox.test/v1/exec/stream").mock(
|
||||
return_value=httpx.Response(200, content=sse)
|
||||
)
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
events = [ev async for ev in runtime.execute_stream("datetime", {}, run_id="run_1")]
|
||||
|
||||
assert [e.kind for e in events] == ["stdout", "done"]
|
||||
assert events[-1].payload["exit_code"] == 0
|
||||
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
|
||||
assert all(e.run_id == "run_1" for e in store.all())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_execute_stream_requires_confirmation_for_high_risk(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""流式调用未确认的高风险工具:抛 RequiresConfirmation(端点据此发 requires_confirmation 事件)。"""
|
||||
respx.post("http://sandbox.test/v1/exec/stream").mock(return_value=_ok_response())
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
with pytest.raises(RequiresConfirmation):
|
||||
async for _ in runtime.execute_stream("shell", {"command": "echo hi"}):
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# executor 工具集成测试(fetch_url 走直接执行路径)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_executor_tool_runs_directly_and_records_events(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""executor 工具(fetch_url)走直接执行路径,跳过沙箱,started/completed 落库。"""
|
||||
respx.get("https://example.test").mock(
|
||||
return_value=httpx.Response(200, text="<html><body>Hello</body></html>")
|
||||
)
|
||||
async with httpx.AsyncClient() as tools_http:
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(
|
||||
sandbox_client,
|
||||
SafetyGovernor(),
|
||||
store,
|
||||
http_client=tools_http,
|
||||
)
|
||||
# fetch_url permission=ask,confirmed=True 绕过确认。
|
||||
result = await runtime.execute(
|
||||
"fetch_url",
|
||||
{"url": "https://example.test"},
|
||||
run_id="run_fu",
|
||||
trace_id="t_fu",
|
||||
confirmed=True,
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert "Hello" in result.stdout
|
||||
assert result.trace_id == "t_fu"
|
||||
# 沙箱未被调用(直接执行路径)
|
||||
events = store.all()
|
||||
assert [e.type for e in events] == ["tool.started", "tool.completed"]
|
||||
assert all(e.run_id == "run_fu" for e in events)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_executor_tool_requires_confirmation_when_ask(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""executor 工具的 permission_policy=ask 同样触发 RequiresConfirmation。"""
|
||||
async with httpx.AsyncClient() as tools_http:
|
||||
runtime = ToolRuntime(
|
||||
sandbox_client,
|
||||
SafetyGovernor(),
|
||||
InMemoryEventStore(),
|
||||
http_client=tools_http,
|
||||
)
|
||||
with pytest.raises(RequiresConfirmation):
|
||||
await runtime.execute(
|
||||
"fetch_url",
|
||||
{"url": "https://example.test"},
|
||||
)
|
||||
|
||||
|
||||
async def test_executor_tool_without_http_client_returns_error(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""未注入 http_client 时 executor 返回 ok=False(由 executor 自行兜底)。"""
|
||||
runtime = ToolRuntime(
|
||||
sandbox_client,
|
||||
SafetyGovernor(),
|
||||
InMemoryEventStore(),
|
||||
# 故意不传 http_client
|
||||
)
|
||||
result = await runtime.execute(
|
||||
"fetch_url",
|
||||
{"url": "https://example.test"},
|
||||
confirmed=True,
|
||||
)
|
||||
|
||||
assert not result.ok
|
||||
assert "http_client" in result.stderr
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 热刷新:builtin_specs() 的变更对已存在的 ToolRuntime 实例立即生效,无需重启
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_builtin_changes_reflected_in_list_specs_without_reinit(
|
||||
sandbox_client: SandboxClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""删除/修改 builtin 工具后,同一个 ToolRuntime 实例的 list_specs 立即反映变化。"""
|
||||
from app.application.tool_runtime import builtins as builtins_module
|
||||
|
||||
original = builtins_module.builtin_specs()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
# 基线:包含全部 builtin 工具
|
||||
baseline_names = {s.name for s in runtime.list_specs()}
|
||||
assert "datetime" in baseline_names
|
||||
assert "shell" in baseline_names
|
||||
|
||||
# 模拟 builtin_specs 删除了 shell(例如配置关闭或被下线)
|
||||
pruned = {k: v for k, v in original.items() if k != "shell"}
|
||||
|
||||
def _pruned_specs() -> dict:
|
||||
return dict(pruned)
|
||||
|
||||
monkeypatch.setattr(builtins_module, "builtin_specs", _pruned_specs)
|
||||
|
||||
after_names = {s.name for s in runtime.list_specs()}
|
||||
assert "datetime" in after_names
|
||||
assert "shell" not in after_names # 删除立即生效,未重新构造 ToolRuntime
|
||||
|
||||
|
||||
def test_register_overrides_builtin_and_survives_builtin_change(
|
||||
sandbox_client: SandboxClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""register 的工具覆盖 builtin 同名;builtin 变更不影响已注册的覆盖层。"""
|
||||
from app.application.tool_runtime import builtins as builtins_module
|
||||
from app.modules.tool_runtime.definition import ToolSpec
|
||||
|
||||
custom_shell = ToolSpec(
|
||||
name="shell",
|
||||
description="自定义 shell(覆盖 builtin)",
|
||||
risk_level="L1",
|
||||
permission_policy=PermissionPolicy.allow,
|
||||
build_request=lambda _input: _ok_exec_request(),
|
||||
side_effect=False,
|
||||
)
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
runtime.register(custom_shell)
|
||||
|
||||
registered = {s.name: s for s in runtime.list_specs()}
|
||||
assert registered["shell"].description == "自定义 shell(覆盖 builtin)"
|
||||
|
||||
# builtin 变更(如整体被清空)不应影响 register 叠加的工具
|
||||
monkeypatch.setattr(builtins_module, "builtin_specs", lambda: {})
|
||||
|
||||
after = {s.name: s for s in runtime.list_specs()}
|
||||
assert after["shell"].description == "自定义 shell(覆盖 builtin)"
|
||||
|
||||
|
||||
def _ok_exec_request():
|
||||
"""供 register 测试构造 ToolSpec.build_request 用的占位 ExecRequest。"""
|
||||
from app.integrations.sandbox.models import ExecRequest
|
||||
|
||||
return ExecRequest(argv=["sh", "-c", "echo ok"])
|
||||
@@ -0,0 +1,117 @@
|
||||
"""fetch_url executor 单元测试。
|
||||
|
||||
mock 边界:
|
||||
- ``respx`` 模拟 httpx.AsyncClient 的 GET(不真实联网)。
|
||||
- executor 直接调用,不经过 ToolRuntime(runtime 集成路径由 test_runtime 覆盖)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.application.tool_runtime.web_tools import _fetch_url_executor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def http_client() -> AsyncIterator[httpx.AsyncClient]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
yield client
|
||||
|
||||
|
||||
def _ctx(http_client: httpx.AsyncClient, **extra: Any) -> dict[str, Any]:
|
||||
"""构造 executor 上下文。"""
|
||||
base: dict[str, Any] = {"http_client": http_client, "trace_id": "trace_test"}
|
||||
base.update(extra)
|
||||
return base
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_url_converts_html_to_markdown(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""fetch_url 把 HTML 转成 markdown 并截断。"""
|
||||
html = (
|
||||
"<html><head><title>T</title></head>"
|
||||
"<body><h1>标题</h1><p>这是一段正文。</p>"
|
||||
'<a href="https://example.com">链接</a></body></html>'
|
||||
)
|
||||
respx.get("https://example.com/page").mock(return_value=httpx.Response(200, text=html))
|
||||
|
||||
result = await _fetch_url_executor({"url": "https://example.com/page"}, _ctx(http_client))
|
||||
|
||||
assert result.ok
|
||||
assert "标题" in result.stdout
|
||||
assert "这是一段正文。" in result.stdout
|
||||
# html2text 默认保留链接
|
||||
assert "example.com" in result.stdout
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_url_truncates_to_max_chars(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""max_chars 截断生效。"""
|
||||
long_text = "A" * 500
|
||||
html = f"<html><body><p>{long_text}</p></body></html>"
|
||||
respx.get("https://example.com/big").mock(return_value=httpx.Response(200, text=html))
|
||||
|
||||
result = await _fetch_url_executor(
|
||||
{"url": "https://example.com/big", "max_chars": 50}, _ctx(http_client)
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert "已截断" in result.stdout
|
||||
assert len(result.stdout) < 500
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_url_http_error_returns_error(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""HTTP 错误返回 ok=False。"""
|
||||
respx.get("https://example.com/missing").mock(
|
||||
return_value=httpx.Response(404, text="not found")
|
||||
)
|
||||
|
||||
result = await _fetch_url_executor({"url": "https://example.com/missing"}, _ctx(http_client))
|
||||
|
||||
assert not result.ok
|
||||
assert "404" in result.stderr
|
||||
|
||||
|
||||
async def test_fetch_url_missing_url_returns_error(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""缺 url 输入时返回 ok=False。"""
|
||||
result = await _fetch_url_executor({}, _ctx(http_client))
|
||||
|
||||
assert not result.ok
|
||||
assert "url" in result.stderr
|
||||
|
||||
|
||||
async def test_fetch_url_missing_http_client_returns_error() -> None:
|
||||
"""缺注入的 http_client 时返回 ok=False。"""
|
||||
result = await _fetch_url_executor({"url": "https://example.com"}, None)
|
||||
|
||||
assert not result.ok
|
||||
assert "http_client" in result.stderr
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_url_network_error_returns_error(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""网络错误(HTTPError)返回 ok=False 而不抛异常。"""
|
||||
respx.get("https://example.com/neterr").mock(
|
||||
side_effect=httpx.ConnectError("connection refused")
|
||||
)
|
||||
|
||||
result = await _fetch_url_executor({"url": "https://example.com/neterr"}, _ctx(http_client))
|
||||
|
||||
assert not result.ok
|
||||
assert "请求失败" in result.stderr
|
||||
@@ -0,0 +1,170 @@
|
||||
"""测试环境配置。
|
||||
|
||||
数据库支持两种模式:
|
||||
- SQLite(默认):自动创建临时文件库,每次测试 drop_all/create_all 隔离
|
||||
- PostgreSQL:设环境变量 ``HUNJING_TEST_DATABASE_URL``,指向测试数据库
|
||||
(名称须含 ``test``),例如:
|
||||
``HUNJING_TEST_DATABASE_URL=postgresql+asyncpg://hunjing:change-me@127.0.0.1:5432/hunjing_test``
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
_TEST_DIR = Path(tempfile.mkdtemp(prefix="hunjing_pytest_"))
|
||||
|
||||
# ── 数据库选择 ────────────────────────────────────────────────
|
||||
# 如果环境变量 HUNJING_TEST_DATABASE_URL 已设,走 PostgreSQL;
|
||||
# 否则使用 SQLite 临时文件。
|
||||
_HUNJING_TEST_DATABASE_URL = os.environ.get("HUNJING_TEST_DATABASE_URL", "")
|
||||
|
||||
if _HUNJING_TEST_DATABASE_URL:
|
||||
os.environ["DATABASE_URL"] = _HUNJING_TEST_DATABASE_URL
|
||||
_TEST_DB_PATH = None
|
||||
else:
|
||||
_TEST_DB_PATH = _TEST_DIR / "test_hunjing.sqlite3"
|
||||
os.environ["SQLITE_DATABASE_PATH"] = str(_TEST_DB_PATH)
|
||||
|
||||
os.environ["JWT_SECRET"] = "test-jwt-secret-for-testing-pytest"
|
||||
|
||||
|
||||
def _assert_test_database_url(database_url: str) -> None:
|
||||
if database_url.startswith("sqlite+aiosqlite:///"):
|
||||
if "test" not in database_url and "pytest" not in database_url:
|
||||
raise AssertionError("测试 SQLite 数据库 URL 必须指向测试路径。")
|
||||
elif database_url.startswith("postgresql"):
|
||||
if "test" not in database_url.lower():
|
||||
raise AssertionError("测试 PostgreSQL 数据库 URL 必须指向测试数据库(名称含 test)。")
|
||||
else:
|
||||
raise AssertionError(f"不支持的测试数据库类型:{database_url}")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def reset_database() -> AsyncGenerator[None, None]:
|
||||
from app.db.base import Base
|
||||
from app.db.init_data import seed_default_data
|
||||
from app.db.session import AsyncSessionLocal, configure_database
|
||||
from app.db.session import engine as _engine
|
||||
|
||||
_assert_test_database_url(str(_engine.url))
|
||||
# PG 模式下重新初始化 engine,避免连接池在旧的 event loop 上下文中
|
||||
if str(_engine.url).startswith("postgresql"):
|
||||
from app.core.config import settings
|
||||
|
||||
configure_database(settings.resolved_database_url)
|
||||
from app.db.session import engine as _engine
|
||||
|
||||
async with _engine.begin() as conn:
|
||||
if str(_engine.url).startswith("postgresql"):
|
||||
# PG: 显式 CASCADE + 断开连接池,避免 FK 约束阻塞
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await _engine.dispose()
|
||||
configure_database(str(_engine.url))
|
||||
from app.db.session import engine as _engine
|
||||
else:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with AsyncSessionLocal() as session:
|
||||
await seed_default_data(session)
|
||||
# 清 AI provider endpoint 覆盖缓存(模块级,跨测试隔离)
|
||||
from app.services.ai_provider_admin import set_endpoint_overrides_cache
|
||||
|
||||
set_endpoint_overrides_cache([])
|
||||
# 清理可能被前序 TestClient 启动的 KnockingPivot 后台任务,避免写锁冲突
|
||||
from app.main import app as _app
|
||||
|
||||
pivot = getattr(_app.state, "knocking_pivot", None)
|
||||
if pivot is not None:
|
||||
try: # noqa: SIM105 — pivot.stop() 是协程,contextlib.suppress 不适用
|
||||
await pivot.stop()
|
||||
except Exception:
|
||||
pass
|
||||
_app.state.__dict__.pop("knocking_pivot", None)
|
||||
yield
|
||||
_assert_test_database_url(str(_engine.url))
|
||||
async with _engine.begin() as conn:
|
||||
if str(_engine.url).startswith("postgresql"):
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await _engine.dispose()
|
||||
else:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_headers() -> dict[str, str]:
|
||||
"""创建测试用户并返回 ``Authorization: Bearer <token>`` 请求头。"""
|
||||
import uuid
|
||||
|
||||
from app.core.ids import new_user_id
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.user import User
|
||||
from app.services.auth import create_access_token, hash_password
|
||||
from app.services.characters import ensure_default_character_for_owner
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
id=new_user_id(),
|
||||
username=f"tester_{suffix}",
|
||||
email=f"{suffix}@test.local",
|
||||
password_hash=hash_password("pass"),
|
||||
is_active=True,
|
||||
is_admin=False,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
user_id = user.id
|
||||
await ensure_default_character_for_owner(session, user_id)
|
||||
await session.commit()
|
||||
token = create_access_token(subject=user_id, extra={"owner_scope": user_id})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(auth_headers: dict[str, str]) -> AsyncIterator[httpx.AsyncClient]:
|
||||
"""带 JWT 认证的 HTTP 客户端(默认 Header 注入 Bearer token)。"""
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
headers=auth_headers,
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_headers() -> dict[str, str]:
|
||||
"""创建管理员测试用户并返回认证请求头。"""
|
||||
import uuid
|
||||
|
||||
from app.core.ids import new_user_id
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.user import User
|
||||
from app.services.auth import create_access_token, hash_password
|
||||
from app.services.characters import ensure_default_character_for_owner
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
id=new_user_id(),
|
||||
username=f"admin_{suffix}",
|
||||
email=f"admin_{suffix}@test.local",
|
||||
password_hash=hash_password("pass"),
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
user_id = user.id
|
||||
await ensure_default_character_for_owner(session, user_id)
|
||||
await session.commit()
|
||||
token = create_access_token(subject=user_id, extra={"owner_scope": user_id})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""TimeConfig 加载与派生属性测试。"""
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def test_time_config_defaults_present():
|
||||
assert settings.user_timezone == "Asia/Shanghai"
|
||||
assert settings.home_timezone == "Asia/Shanghai"
|
||||
assert settings.offline_reunion_impact_minutes == 4320
|
||||
assert settings.care_enabled is True
|
||||
|
||||
|
||||
def test_duration_nodes_parsed():
|
||||
nodes = settings.duration_nodes
|
||||
assert 100 in nodes
|
||||
assert 365 in nodes
|
||||
assert 1000 in nodes
|
||||
|
||||
|
||||
def test_strong_solar_terms_parsed():
|
||||
terms = settings.strong_solar_terms
|
||||
assert "立春" in terms
|
||||
assert "冬至" in terms
|
||||
@@ -0,0 +1,53 @@
|
||||
"""司命台治理种子数据测试。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.governance_seed import _GOALS, ensure_governance_seed
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.governance import Goal
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
class TestGoalSeedsHaveCharacterId:
|
||||
"""_GOALS 每条 seed 都包含 character_id 与 type 字段。"""
|
||||
|
||||
def test_all_goals_have_character_id_key(self) -> None:
|
||||
for item in _GOALS:
|
||||
assert "character_id" in item, f"{item['id']} 缺少 character_id"
|
||||
|
||||
def test_all_goals_have_type_key(self) -> None:
|
||||
for item in _GOALS:
|
||||
assert "type" in item, f"{item['id']} 缺少 type"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_goals_persist_with_new_fields(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""验证种子写入数据库后 character_id / type 字段被正确持久化。"""
|
||||
# 先清理已有数据,确保从头写入种子
|
||||
await db_session.execute(Goal.__table__.delete())
|
||||
await db_session.commit()
|
||||
|
||||
await ensure_governance_seed(db_session)
|
||||
|
||||
result = await db_session.execute(select(Goal))
|
||||
goals = result.scalars().all()
|
||||
|
||||
assert len(goals) > 0
|
||||
for g in goals:
|
||||
assert hasattr(g, "character_id"), f"{g.id} 缺少 character_id 属性"
|
||||
assert hasattr(g, "type"), f"{g.id} 缺少 type 属性"
|
||||
# character_id 在 seed 中全为 None,确保字段可空
|
||||
assert g.character_id is None
|
||||
# type 应与 seed 定义一致
|
||||
assert g.type == "mission", f"{g.id} type 应为 mission,实际 {g.type}"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""MCP HTTP client 测试。
|
||||
|
||||
测试 client.py 公共函数。
|
||||
"""
|
||||
|
||||
from app.integrations.mcp.client import _auth_headers, _parse_call_result
|
||||
|
||||
|
||||
class TestAuthHeaders:
|
||||
def test_with_token(self) -> None:
|
||||
assert _auth_headers("secret") == {"Authorization": "Bearer secret"}
|
||||
|
||||
def test_without_token(self) -> None:
|
||||
assert _auth_headers(None) == {}
|
||||
|
||||
|
||||
class TestParseCallResult:
|
||||
def test_success(self) -> None:
|
||||
result = _parse_call_result(
|
||||
{
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": "结果"}],
|
||||
}
|
||||
}
|
||||
)
|
||||
assert result.ok is True
|
||||
assert result.stdout == "结果"
|
||||
|
||||
def test_error(self) -> None:
|
||||
result = _parse_call_result({"error": {"message": "出错了"}})
|
||||
assert result.ok is False
|
||||
assert "出错了" in result.stderr
|
||||
|
||||
def test_is_error_flag(self) -> None:
|
||||
result = _parse_call_result(
|
||||
{
|
||||
"result": {
|
||||
"content": [],
|
||||
"isError": True,
|
||||
}
|
||||
}
|
||||
)
|
||||
assert result.ok is False
|
||||
assert result.exit_code == 1
|
||||
@@ -0,0 +1,177 @@
|
||||
"""AuditEventSink / CompositeEventSink 单元测试。
|
||||
|
||||
审计 sink 把沙箱 tool.completed / tool.failed 转写为 AuditEvent:completed →
|
||||
tool.executed / allow,failed → tool.failed / deny;argv(来自 started)作为
|
||||
input_summary 交 record_audit_event 脱敏。record_audit_event 的数据访问已被 mock,
|
||||
本测试聚焦 sink 的「事件 → 审计字段」映射与脱敏入口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.integrations.sandbox.audit_sink import AuditEventSink
|
||||
from app.integrations.sandbox.events import (
|
||||
CompositeEventSink,
|
||||
SandboxEvent,
|
||||
completed_event,
|
||||
failed_event,
|
||||
started_event,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session():
|
||||
yield AsyncMock()
|
||||
|
||||
|
||||
def _sink() -> AuditEventSink:
|
||||
return AuditEventSink(_fake_session, run_id="run_1", character_id="char_1")
|
||||
|
||||
|
||||
async def test_completed_writes_executed_allow() -> None:
|
||||
sink = _sink()
|
||||
await sink.emit(
|
||||
started_event(
|
||||
tool="shell", argv=["ls", "-la"], risk_level="L2", trace_id="t1", run_id="run_1"
|
||||
)
|
||||
)
|
||||
with patch(
|
||||
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
|
||||
) as rec:
|
||||
await sink.emit(
|
||||
completed_event(
|
||||
tool="shell",
|
||||
trace_id="t1",
|
||||
run_id="run_1",
|
||||
exit_code=0,
|
||||
duration_ms=42,
|
||||
timeout=False,
|
||||
truncated=False,
|
||||
stdout_len=10,
|
||||
stderr_len=0,
|
||||
)
|
||||
)
|
||||
rec.assert_awaited_once()
|
||||
kw = rec.call_args.kwargs
|
||||
assert kw["action"] == "tool.executed"
|
||||
assert kw["decision"] == "allow"
|
||||
assert kw["pivot"] == "tool_runtime"
|
||||
assert kw["risk_level"] == "L2"
|
||||
assert kw["input_summary"] == "ls -la"
|
||||
assert kw["resource_id"] == "shell"
|
||||
assert kw["trace_id"] == "t1"
|
||||
assert kw["character_id"] == "char_1"
|
||||
assert "exit=0" in kw["output_summary"]
|
||||
|
||||
|
||||
async def test_failed_writes_failed_deny() -> None:
|
||||
sink = _sink()
|
||||
await sink.emit(
|
||||
started_event(
|
||||
tool="shell", argv=["sh", "-c", "bad"], risk_level="L3", trace_id="t1", run_id="run_1"
|
||||
)
|
||||
)
|
||||
with patch(
|
||||
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
|
||||
) as rec:
|
||||
await sink.emit(
|
||||
failed_event(
|
||||
tool="shell",
|
||||
trace_id="t1",
|
||||
run_id="run_1",
|
||||
error_code="exec_timeout",
|
||||
error_message="执行超时",
|
||||
)
|
||||
)
|
||||
rec.assert_awaited_once()
|
||||
kw = rec.call_args.kwargs
|
||||
assert kw["action"] == "tool.failed"
|
||||
assert kw["decision"] == "deny"
|
||||
assert kw["risk_level"] == "L3"
|
||||
assert kw["reason"] == "exec_timeout"
|
||||
assert kw["output_summary"] == "执行超时"
|
||||
|
||||
|
||||
async def test_started_does_not_write_audit() -> None:
|
||||
"""started 只缓存上下文,不写审计。"""
|
||||
sink = _sink()
|
||||
with patch(
|
||||
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
|
||||
) as rec:
|
||||
await sink.emit(
|
||||
started_event(tool="shell", argv=["ls"], risk_level="L1", trace_id="t1", run_id="run_1")
|
||||
)
|
||||
rec.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_completed_summary_marks_timeout_and_truncation() -> None:
|
||||
sink = _sink()
|
||||
await sink.emit(
|
||||
started_event(tool="shell", argv=["ls"], risk_level="L1", trace_id="t1", run_id="run_1")
|
||||
)
|
||||
with patch(
|
||||
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
|
||||
) as rec:
|
||||
await sink.emit(
|
||||
completed_event(
|
||||
tool="shell",
|
||||
trace_id="t1",
|
||||
run_id="run_1",
|
||||
exit_code=None,
|
||||
duration_ms=5000,
|
||||
timeout=True,
|
||||
truncated=True,
|
||||
stdout_len=0,
|
||||
stderr_len=0,
|
||||
)
|
||||
)
|
||||
summary = rec.call_args.kwargs["output_summary"]
|
||||
assert "timeout" in summary
|
||||
assert "truncated" in summary
|
||||
|
||||
|
||||
async def test_empty_argv_yields_none_input_summary() -> None:
|
||||
"""executor 路径(fetch_url 等)started argv 为空,input_summary 应为 None。"""
|
||||
sink = _sink()
|
||||
await sink.emit(
|
||||
started_event(tool="fetch_url", argv=[], risk_level="L2", trace_id="t1", run_id="run_1")
|
||||
)
|
||||
with patch(
|
||||
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
|
||||
) as rec:
|
||||
await sink.emit(
|
||||
completed_event(
|
||||
tool="fetch_url",
|
||||
trace_id="t1",
|
||||
run_id="run_1",
|
||||
exit_code=0,
|
||||
duration_ms=100,
|
||||
timeout=False,
|
||||
truncated=False,
|
||||
stdout_len=5,
|
||||
stderr_len=0,
|
||||
)
|
||||
)
|
||||
assert rec.call_args.kwargs["input_summary"] is None
|
||||
|
||||
|
||||
async def test_unknown_event_type_ignored() -> None:
|
||||
"""非 started/completed/failed 的事件不触发审计写入。"""
|
||||
sink = _sink()
|
||||
with patch(
|
||||
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
|
||||
) as rec:
|
||||
await sink.emit(SandboxEvent(type="tool.stdout", payload={"tool": "shell"}))
|
||||
rec.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_composite_sink_fans_out_to_all() -> None:
|
||||
a = AsyncMock()
|
||||
b = AsyncMock()
|
||||
composite = CompositeEventSink([a, b])
|
||||
ev = SandboxEvent(type="tool.started", payload={"tool": "x"})
|
||||
await composite.emit(ev)
|
||||
a.emit.assert_awaited_once_with(ev)
|
||||
b.emit.assert_awaited_once_with(ev)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""授权凭证签发 / 校验单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.integrations.sandbox.auth import (
|
||||
AuthorizationContext,
|
||||
compute_body_hash,
|
||||
compute_file_op_hash,
|
||||
sign_authorization,
|
||||
verify_authorization,
|
||||
)
|
||||
|
||||
SECRET = "python-rust-shared-secret"
|
||||
# 固定向量,与 Rust sandbox/src/auth.rs 的 body_hash_known_vector_matches_python 共享
|
||||
ARGV = ["sh", "-c", "echo hi"]
|
||||
STDIN: str | None = None
|
||||
|
||||
|
||||
def _ctx() -> AuthorizationContext:
|
||||
return AuthorizationContext(
|
||||
trace_id="trace_1",
|
||||
risk_level="L1",
|
||||
tool="shell",
|
||||
character_id="character_01H",
|
||||
run_id="run_01H",
|
||||
)
|
||||
|
||||
|
||||
def test_sign_then_verify_roundtrip() -> None:
|
||||
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN, ttl_seconds=60)
|
||||
payload = verify_authorization(SECRET, token)
|
||||
assert payload["trace_id"] == "trace_1"
|
||||
assert payload["risk_level"] == "L1"
|
||||
assert payload["tool"] == "shell"
|
||||
|
||||
|
||||
def test_verify_rejects_wrong_secret() -> None:
|
||||
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
|
||||
with pytest.raises(ValueError):
|
||||
verify_authorization("other-secret", token)
|
||||
|
||||
|
||||
def test_verify_rejects_tampered_signature() -> None:
|
||||
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
|
||||
tampered = token[:-2] + ("AA" if token[-2:] != "AA" else "BB")
|
||||
with pytest.raises(ValueError):
|
||||
verify_authorization(SECRET, tampered)
|
||||
|
||||
|
||||
def test_verify_rejects_expired() -> None:
|
||||
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN, ttl_seconds=-10)
|
||||
with pytest.raises(ValueError):
|
||||
verify_authorization(SECRET, token)
|
||||
|
||||
|
||||
def test_verify_rejects_malformed() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
verify_authorization(SECRET, "not-a-valid-token")
|
||||
|
||||
|
||||
def test_sign_requires_secret() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
sign_authorization("", _ctx(), ARGV, STDIN)
|
||||
|
||||
|
||||
def test_payload_carries_body_hash() -> None:
|
||||
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
|
||||
payload = verify_authorization(SECRET, token)
|
||||
assert payload["body_hash"] == compute_body_hash(ARGV, STDIN)
|
||||
|
||||
|
||||
def test_body_hash_differs_per_argv() -> None:
|
||||
"""凭证绑死具体执行内容:不同 argv 产生不同 body_hash,杜绝重放任意命令。"""
|
||||
t1 = sign_authorization(SECRET, _ctx(), ["sh", "-c", "echo a"], None)
|
||||
t2 = sign_authorization(SECRET, _ctx(), ["sh", "-c", "echo b"], None)
|
||||
p1 = verify_authorization(SECRET, t1)
|
||||
p2 = verify_authorization(SECRET, t2)
|
||||
assert p1["body_hash"] != p2["body_hash"]
|
||||
|
||||
|
||||
def test_compute_body_hash_known_vector() -> None:
|
||||
"""与 Rust compute_body_hash 共享固定向量,保证跨端字节级一致。"""
|
||||
assert (
|
||||
compute_body_hash(ARGV, STDIN)
|
||||
== "f5fa97ea2ee92c505174672da52c59c952e49c32fa56a1db5bd983c10a829540"
|
||||
)
|
||||
|
||||
|
||||
def test_compute_body_hash_none_vs_empty_stdin() -> None:
|
||||
assert compute_body_hash(["sh"], None) != compute_body_hash(["sh"], "")
|
||||
|
||||
|
||||
def test_compute_file_op_hash_known_vector() -> None:
|
||||
"""与 Rust compute_file_op_hash 共享固定向量,保证跨端字节级一致。"""
|
||||
assert (
|
||||
compute_file_op_hash("read", "data/note.txt")
|
||||
== "717f67252c61cf1681cb3c6018d3801b79a511564664567b57d12d775bb8fa53"
|
||||
)
|
||||
|
||||
|
||||
def test_compute_file_op_hash_differs_per_op_and_path() -> None:
|
||||
"""凭证绑死具体文件操作:op 或 path 不同则摘要不同。"""
|
||||
assert compute_file_op_hash("read", "a.txt") != compute_file_op_hash("write", "a.txt")
|
||||
assert compute_file_op_hash("read", "a.txt") != compute_file_op_hash("read", "b.txt")
|
||||
|
||||
|
||||
def test_sign_carries_file_op_hash_only_for_file_ops() -> None:
|
||||
"""exec 凭证 file_op_hash 为 None;文件凭证携带 {op, path} 摘要。"""
|
||||
# exec 路径:argv 非 None → body_hash 绑定,file_op_hash 为 None
|
||||
exec_token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
|
||||
exec_payload = verify_authorization(SECRET, exec_token)
|
||||
assert exec_payload["file_op_hash"] is None
|
||||
assert exec_payload["body_hash"] == compute_body_hash(ARGV, STDIN)
|
||||
# 文件路径:argv=None → body_hash 为 None,file_op_hash 绑定 {op, path}
|
||||
file_token = sign_authorization(SECRET, _ctx(), None, None, file_op=("read", "data/note.txt"))
|
||||
file_payload = verify_authorization(SECRET, file_token)
|
||||
assert file_payload["body_hash"] is None
|
||||
assert file_payload["file_op_hash"] == compute_file_op_hash("read", "data/note.txt")
|
||||
|
||||
|
||||
def test_sign_carries_sorted_rlimit_overrides() -> None:
|
||||
"""rlimit_overrides 随凭证下发,且按 key 排序(对齐 Rust serde BTreeMap 字母序)。"""
|
||||
ctx = AuthorizationContext(
|
||||
trace_id="trace_1",
|
||||
risk_level="L1",
|
||||
tool="python_runner",
|
||||
# 反序插入,验证 sign 时被排序
|
||||
rlimit_overrides={"fsize": 67_108_864, "as": 536_870_912},
|
||||
)
|
||||
token = sign_authorization(SECRET, ctx, ARGV, STDIN)
|
||||
payload = verify_authorization(SECRET, token)
|
||||
# 排序后 as 在 fsize 之前(字母序),与 Rust BTreeMap 序列化一致
|
||||
assert payload["rlimit_overrides"] == {"as": 536_870_912, "fsize": 67_108_864}
|
||||
# rlimit_overrides 属授权属性,不进 body_hash
|
||||
assert payload["body_hash"] == compute_body_hash(ARGV, STDIN)
|
||||
|
||||
|
||||
def test_sign_without_rlimit_overrides_is_null() -> None:
|
||||
"""无 rlimit_overrides 时 payload 字段为 null(对齐 Rust Option None → serde null)。"""
|
||||
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
|
||||
payload = verify_authorization(SECRET, token)
|
||||
assert payload["rlimit_overrides"] is None
|
||||
|
||||
|
||||
def test_compute_body_hash_code_matches_known_vectors() -> None:
|
||||
"""与 Rust compute_body_hash_code 共享固定向量,保证跨端字节级一致。"""
|
||||
from app.integrations.sandbox.auth import compute_body_hash_code
|
||||
|
||||
assert (
|
||||
compute_body_hash_code("python", "print(1+1)", None)
|
||||
== "e7d633605676c3b19c642ee2437d91f9fb22562aaffad40f217d84c587322b69"
|
||||
)
|
||||
assert (
|
||||
compute_body_hash_code("python", "print(1+1)", "42")
|
||||
== "f933764421a59d1578697189fb76374511c5d7e4f1ad03ad37da3241568c35d5"
|
||||
)
|
||||
assert (
|
||||
compute_body_hash_code("node", "print(1+1)", None)
|
||||
== "a0440aef4bbcf83d89127ddf89949019b6be939a1c02702d22b35b40b21862aa"
|
||||
)
|
||||
|
||||
|
||||
def test_sign_authorization_code_mode_carries_isolation_mode() -> None:
|
||||
"""code 模式:body_hash 按 {language,code,stdin} 算;payload 末尾含 isolation_mode。"""
|
||||
from app.integrations.sandbox.auth import (
|
||||
AuthorizationContext,
|
||||
compute_body_hash_code,
|
||||
sign_authorization,
|
||||
verify_authorization,
|
||||
)
|
||||
|
||||
ctx = AuthorizationContext(
|
||||
trace_id="trace_1",
|
||||
risk_level="L3",
|
||||
tool="shell",
|
||||
isolation_mode="container",
|
||||
)
|
||||
token = sign_authorization(
|
||||
"shared-secret",
|
||||
ctx,
|
||||
code="print(1+1)",
|
||||
language="python",
|
||||
stdin=None,
|
||||
)
|
||||
payload = verify_authorization("shared-secret", token)
|
||||
assert list(payload)[-1] == "isolation_mode"
|
||||
assert payload["isolation_mode"] == "container"
|
||||
assert payload["body_hash"] == compute_body_hash_code("python", "print(1+1)", None)
|
||||
|
||||
|
||||
def test_sign_authorization_argv_mode_backward_compatible_positional() -> None:
|
||||
"""既有位置参数调用不受影响:argv/stdin 位置传、ttl_seconds 关键字传,仍可验签。"""
|
||||
import base64
|
||||
|
||||
from app.integrations.sandbox.auth import AuthorizationContext, sign_authorization
|
||||
|
||||
ctx = AuthorizationContext(trace_id="t", risk_level="L1", tool="shell")
|
||||
# 既有调用形式:argv/stdin 位置参数(与仓库现有 test_auth.py 一致)
|
||||
token = sign_authorization("s", ctx, ["python3", "-c", "print(1)"], None)
|
||||
payload_b64 = token.split(".", 1)[0]
|
||||
pad = "=" * (-len(payload_b64) % 4)
|
||||
payload_json = base64.urlsafe_b64decode(payload_b64 + pad).decode("utf-8")
|
||||
# 末尾追加 isolation_mode(旧字段顺序不变),None 时为 null
|
||||
assert payload_json.endswith('"isolation_mode":null}')
|
||||
@@ -0,0 +1,338 @@
|
||||
"""沙箱客户端测试:用 respx 模拟 httpx,验证授权头注入、事件回写与取消。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.core.config import SandboxSettings
|
||||
from app.integrations.sandbox.auth import AuthorizationContext
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.integrations.sandbox.events import SandboxEvent
|
||||
from app.integrations.sandbox.exceptions import (
|
||||
SandboxBadRequest,
|
||||
SandboxNotFound,
|
||||
SandboxUnauthorized,
|
||||
SandboxUnavailable,
|
||||
)
|
||||
from app.integrations.sandbox.models import (
|
||||
ExecRequest,
|
||||
FileListRequest,
|
||||
FileReadRequest,
|
||||
FileStatRequest,
|
||||
FileWriteRequest,
|
||||
)
|
||||
|
||||
|
||||
def _settings(auth_enabled: bool = True) -> SandboxSettings:
|
||||
return SandboxSettings(
|
||||
rust_sandbox_url="http://sandbox.test",
|
||||
sandbox_auth_enabled=auth_enabled,
|
||||
sandbox_auth_secret="shared-secret",
|
||||
sandbox_default_timeout_ms=30000,
|
||||
sandbox_auth_ttl_seconds=30,
|
||||
)
|
||||
|
||||
|
||||
def _ctx() -> AuthorizationContext:
|
||||
return AuthorizationContext(trace_id="trace_1", risk_level="L1", tool="shell")
|
||||
|
||||
|
||||
class _RecordingSink:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[SandboxEvent] = []
|
||||
|
||||
async def emit(self, event: SandboxEvent) -> None:
|
||||
self.events.append(event)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_success_sends_bearer_and_emits_events() -> None:
|
||||
route = respx.post("http://sandbox.test/v1/exec").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ok": True,
|
||||
"exit_code": 0,
|
||||
"stdout": "42\n",
|
||||
"stderr": "",
|
||||
"duration_ms": 4,
|
||||
"timeout": False,
|
||||
"truncated": False,
|
||||
"trace_id": "trace_1",
|
||||
},
|
||||
)
|
||||
)
|
||||
sink = _RecordingSink()
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings(), sink)
|
||||
resp = await sandbox.exec(ExecRequest(argv=["sh", "-c", "echo $((6*7))"]), _ctx())
|
||||
|
||||
assert route.called
|
||||
assert resp.ok
|
||||
assert resp.stdout == "42\n"
|
||||
auth_header = route.calls.last.request.headers.get("authorization", "")
|
||||
assert auth_header.startswith("Bearer ")
|
||||
assert [e.type for e in sink.events] == ["tool.started", "tool.completed"]
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_unauthorized_emits_failed_event() -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(
|
||||
return_value=httpx.Response(
|
||||
401,
|
||||
json={"error": {"code": "sandbox_unauthorized", "message": "凭证无效"}},
|
||||
)
|
||||
)
|
||||
sink = _RecordingSink()
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings(), sink)
|
||||
with pytest.raises(SandboxUnauthorized):
|
||||
await sandbox.exec(ExecRequest(argv=["sh", "-c", "x"]), _ctx())
|
||||
|
||||
assert sink.events[-1].type == "tool.failed"
|
||||
assert sink.events[-1].payload["error_code"] == "sandbox_unauthorized"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_network_error_raises_unavailable() -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(side_effect=httpx.ConnectError("boom"))
|
||||
sink = _RecordingSink()
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings(), sink)
|
||||
with pytest.raises(SandboxUnavailable):
|
||||
await sandbox.exec(ExecRequest(argv=["sh", "-c", "x"]), _ctx())
|
||||
|
||||
assert sink.events[-1].type == "tool.failed"
|
||||
assert sink.events[-1].payload["error_code"] == "sandbox_unavailable"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_cancel_returns_true_on_hit() -> None:
|
||||
respx.post("http://sandbox.test/v1/cancel").mock(
|
||||
return_value=httpx.Response(200, json={"cancelled": True, "trace_id": "trace_1"})
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
assert await sandbox.cancel("trace_1", _ctx()) is True
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_cancel_raises_not_found() -> None:
|
||||
respx.post("http://sandbox.test/v1/cancel").mock(
|
||||
return_value=httpx.Response(
|
||||
404,
|
||||
json={"error": {"code": "not_found", "message": "任务不存在"}},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
with pytest.raises(SandboxNotFound):
|
||||
await sandbox.cancel("trace_1", _ctx())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_stream_yields_stdout_then_done() -> None:
|
||||
sse = (
|
||||
b"event: stdout\n"
|
||||
b"data: aGVsbG8K\n" # base64(urlsafe-no-pad) 片段
|
||||
b"\n"
|
||||
b"event: done\n"
|
||||
b'data: {"exit_code":0,"duration_ms":5}\n'
|
||||
b"\n"
|
||||
)
|
||||
respx.post("http://sandbox.test/v1/exec/stream").mock(
|
||||
return_value=httpx.Response(200, content=sse)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
events = [
|
||||
ev
|
||||
async for ev in sandbox.exec_stream(ExecRequest(argv=["sh", "-c", "echo hi"]), _ctx())
|
||||
]
|
||||
|
||||
assert [e.kind for e in events] == ["stdout", "done"]
|
||||
assert events[0].payload == "aGVsbG8K"
|
||||
assert events[1].payload["exit_code"] == 0
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_stream_propagates_unauthorized() -> None:
|
||||
respx.post("http://sandbox.test/v1/exec/stream").mock(
|
||||
return_value=httpx.Response(
|
||||
401,
|
||||
json={"error": {"code": "sandbox_unauthorized", "message": "凭证无效"}},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
with pytest.raises(SandboxUnauthorized):
|
||||
async for _ in sandbox.exec_stream(ExecRequest(argv=["sh", "-c", "x"]), _ctx()):
|
||||
pass
|
||||
|
||||
|
||||
async def test_auth_enabled_requires_secret() -> None:
|
||||
settings = SandboxSettings(
|
||||
rust_sandbox_url="http://sandbox.test",
|
||||
sandbox_auth_enabled=True,
|
||||
sandbox_auth_secret="",
|
||||
sandbox_default_timeout_ms=30000,
|
||||
sandbox_auth_ttl_seconds=30,
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
SandboxClient(httpx.AsyncClient(), settings)
|
||||
|
||||
|
||||
# ---- 文件 IO(file_op 凭证注入 + 事件回写 + 错误映射) ----
|
||||
|
||||
|
||||
def _b64url(content: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(content).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_read_sends_credential_and_decodes_content() -> None:
|
||||
"""read:注入 file_op 凭证 + started/completed 事件 + base64 解码回原内容。"""
|
||||
content = b"hello-sandbox"
|
||||
route = respx.post("http://sandbox.test/v1/file/read").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"content_b64": _b64url(content),
|
||||
"size": len(content),
|
||||
"offset": 0,
|
||||
"returned": len(content),
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
sink = _RecordingSink()
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings(), sink)
|
||||
resp = await sandbox.file_read(FileReadRequest(path="data/note.txt"), _ctx())
|
||||
|
||||
assert route.called
|
||||
assert resp.returned == len(content)
|
||||
assert base64.urlsafe_b64decode(resp.content_b64 + "==") == content
|
||||
assert route.calls.last.request.headers.get("authorization", "").startswith("Bearer ")
|
||||
assert [e.type for e in sink.events] == ["tool.started", "tool.completed"]
|
||||
# argv 记 [file:read, path],供审计 input_summary
|
||||
assert sink.events[0].payload["argv"] == ["file:read", "data/note.txt"]
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_write_returns_bytes_written() -> None:
|
||||
"""write:二进制安全(含 UTF-8 多字节),返回写入字节数。"""
|
||||
content = "乾坤一炁".encode()
|
||||
respx.post("http://sandbox.test/v1/file/write").mock(
|
||||
return_value=httpx.Response(200, json={"bytes_written": len(content)})
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
resp = await sandbox.file_write(
|
||||
FileWriteRequest(path="out.txt", content_b64=_b64url(content)), _ctx()
|
||||
)
|
||||
assert resp.bytes_written == len(content)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_list_returns_entries() -> None:
|
||||
respx.post("http://sandbox.test/v1/file/list").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"entries": [{"name": "note.txt", "kind": "file", "size": 12}]},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
resp = await sandbox.file_list(FileListRequest(), _ctx())
|
||||
assert resp.entries[0].name == "note.txt"
|
||||
assert resp.entries[0].kind == "file"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_stat_returns_metadata() -> None:
|
||||
respx.post("http://sandbox.test/v1/file/stat").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"kind": "file", "size": 42, "modified_unix": 1_700_000_000}
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
resp = await sandbox.file_stat(FileStatRequest(path="data/note.txt"), _ctx())
|
||||
assert resp.kind == "file"
|
||||
assert resp.size == 42
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_read_maps_bad_request_to_exception() -> None:
|
||||
"""路径越界/类型不符 → 400 → SandboxBadRequest + failed 事件。"""
|
||||
respx.post("http://sandbox.test/v1/file/read").mock(
|
||||
return_value=httpx.Response(
|
||||
400, json={"error": {"code": "bad_request", "message": "路径越界"}}
|
||||
)
|
||||
)
|
||||
sink = _RecordingSink()
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings(), sink)
|
||||
with pytest.raises(SandboxBadRequest):
|
||||
await sandbox.file_read(FileReadRequest(path="../../etc/passwd"), _ctx())
|
||||
assert sink.events[-1].type == "tool.failed"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_code_mode_signs_with_body_hash_code() -> None:
|
||||
"""code 模式:授权凭证 body_hash 按 {language,code,stdin} 算,与 argv 模式不同。"""
|
||||
from app.integrations.sandbox.auth import compute_body_hash_code, verify_authorization
|
||||
|
||||
route = respx.post("http://sandbox.test/v1/exec").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ok": True,
|
||||
"exit_code": 0,
|
||||
"stdout": "2\n",
|
||||
"stderr": "",
|
||||
"duration_ms": 4,
|
||||
"timeout": False,
|
||||
"truncated": False,
|
||||
"trace_id": "trace_1",
|
||||
"isolation_mode": "process",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sc = SandboxClient(client, _settings(), None)
|
||||
await sc.exec(ExecRequest(code="print(1+1)", language="python"), _ctx())
|
||||
|
||||
assert route.called
|
||||
token = route.calls[0].request.headers["Authorization"].removeprefix("Bearer ")
|
||||
payload = verify_authorization("shared-secret", token)
|
||||
assert payload["body_hash"] == compute_body_hash_code("python", "print(1+1)", None)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_code_mode_response_carries_isolation_mode() -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ok": True,
|
||||
"exit_code": 0,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"duration_ms": 1,
|
||||
"timeout": False,
|
||||
"truncated": False,
|
||||
"trace_id": "t",
|
||||
"isolation_mode": "process",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sc = SandboxClient(client, _settings(), None)
|
||||
resp = await sc.exec(ExecRequest(code="print(1)"), _ctx())
|
||||
assert resp.isolation_mode == "process"
|
||||
@@ -0,0 +1,312 @@
|
||||
"""跨端集成测试:起真实 Rust 沙箱进程,验证后端 SandboxClient 全链路。
|
||||
|
||||
覆盖 mock(respx)测试无法触及的真实跨语言契约:HMAC 授权(body_hash 两端一致)、
|
||||
同步 exec、超时 SIGKILL 进程组、流式 SSE(exec_stream)、cancel。
|
||||
|
||||
前置:沙箱二进制需存在;fixture 检测不到时自动 ``cargo build``,失败则 skip。
|
||||
fixture 为 session 级,多测试共享一个沙箱进程(各用独立 trace_id / 临时工作目录)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.core.config import SandboxSettings
|
||||
from app.integrations.sandbox.auth import AuthorizationContext
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.integrations.sandbox.exceptions import SandboxBadRequest
|
||||
from app.integrations.sandbox.models import (
|
||||
ExecRequest,
|
||||
FileListRequest,
|
||||
FileReadRequest,
|
||||
FileStatRequest,
|
||||
FileWriteRequest,
|
||||
)
|
||||
|
||||
# backend/tests/integrations/sandbox/test_e2e.py → parents[4] = 项目根
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
SANDBOX_BIN = PROJECT_ROOT / "sandbox" / "target" / "debug" / "hunjing-sandbox"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
return port
|
||||
|
||||
|
||||
def _wait_health(base: str, timeout: float = 20.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if httpx.get(f"{base}/healthz", timeout=1).status_code == 200:
|
||||
return True
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def sandbox_server():
|
||||
"""编译(若需)并启动真实沙箱进程,yield (base_url, secret)。"""
|
||||
if not SANDBOX_BIN.exists():
|
||||
try:
|
||||
subprocess.run(
|
||||
["cargo", "build"],
|
||||
cwd=PROJECT_ROOT / "sandbox",
|
||||
check=True,
|
||||
timeout=180,
|
||||
capture_output=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
pytest.skip("沙箱二进制不可用且 cargo build 失败,跳过跨端 E2E")
|
||||
|
||||
secret = secrets.token_hex(16)
|
||||
port = _free_port()
|
||||
workdir = tempfile.mkdtemp(prefix="sbx-e2e-")
|
||||
env = {
|
||||
**os.environ,
|
||||
"SANDBOX_AUTH_ENABLED": "true",
|
||||
"SANDBOX_AUTH_SECRET": secret,
|
||||
"SANDBOX_PORT": str(port),
|
||||
"SANDBOX_HOST": "127.0.0.1",
|
||||
"SANDBOX_BASE_WORKDIR": workdir,
|
||||
"RUST_LOG": "warn",
|
||||
}
|
||||
proc = subprocess.Popen([str(SANDBOX_BIN)], env=env)
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
if not _wait_health(base):
|
||||
pytest.skip("沙箱启动超时,跳过跨端 E2E")
|
||||
yield base, secret
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _settings(base: str, secret: str) -> SandboxSettings:
|
||||
return SandboxSettings(
|
||||
rust_sandbox_url=base,
|
||||
sandbox_auth_enabled=True,
|
||||
sandbox_auth_secret=secret,
|
||||
sandbox_default_timeout_ms=30000,
|
||||
sandbox_auth_ttl_seconds=30,
|
||||
)
|
||||
|
||||
|
||||
def _ctx(trace_id: str = "trace_e2e", tool: str = "shell") -> AuthorizationContext:
|
||||
return AuthorizationContext(trace_id=trace_id, risk_level="L1", tool=tool)
|
||||
|
||||
|
||||
async def test_e2e_exec_echo(sandbox_server: tuple[str, str]) -> None:
|
||||
"""同步 exec:HMAC + body_hash 授权链路,echo 输出正确捕获。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
resp = await client.exec(
|
||||
ExecRequest(argv=["sh", "-c", "echo hello-e2e"], trace_id="t-echo"),
|
||||
_ctx("t-echo", "echo"),
|
||||
)
|
||||
assert resp.ok
|
||||
assert resp.exit_code == 0
|
||||
assert resp.stdout.strip() == "hello-e2e"
|
||||
|
||||
|
||||
async def test_e2e_exec_timeout(sandbox_server: tuple[str, str]) -> None:
|
||||
"""超时:到点 SIGKILL 进程组,timeout=True 且快速返回。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
resp = await client.exec(
|
||||
ExecRequest(argv=["sh", "-c", "sleep 30"], timeout_ms=200, trace_id="t-to"),
|
||||
_ctx("t-to"),
|
||||
)
|
||||
assert resp.timeout
|
||||
assert resp.duration_ms < 5000
|
||||
|
||||
|
||||
async def test_e2e_exec_stream(sandbox_server: tuple[str, str]) -> None:
|
||||
"""流式 SSE:真实 stdout 增量 + done 结束事件。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
events = [
|
||||
ev
|
||||
async for ev in client.exec_stream(
|
||||
ExecRequest(argv=["sh", "-c", "echo line-a; echo line-b"], trace_id="t-stream"),
|
||||
_ctx("t-stream"),
|
||||
)
|
||||
]
|
||||
kinds = [e.kind for e in events]
|
||||
assert "stdout" in kinds
|
||||
assert kinds[-1] == "done"
|
||||
assert events[-1].payload["exit_code"] == 0
|
||||
|
||||
|
||||
async def test_e2e_cancel_running_exec(sandbox_server: tuple[str, str]) -> None:
|
||||
"""取消:并发 cancel 命中运行中任务,exec 以 cancelled=True 返回。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
|
||||
async def run() -> object:
|
||||
return await client.exec(
|
||||
ExecRequest(argv=["sh", "-c", "sleep 30"], trace_id="t-cancel"),
|
||||
_ctx("t-cancel"),
|
||||
)
|
||||
|
||||
task = asyncio.create_task(run())
|
||||
await asyncio.sleep(0.6) # 等任务注册到沙箱 running 表
|
||||
cancelled = await client.cancel("t-cancel", _ctx("t-cancel", "cancel"))
|
||||
assert cancelled
|
||||
result = await task
|
||||
assert result.cancelled # type: ignore[attr-defined]
|
||||
assert result.duration_ms < 5000 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# ---- 文件 IO 跨端(真起 Rust 沙箱:file_op_hash HMAC 一致 + 路径校验 + 反重放) ----
|
||||
|
||||
|
||||
def _b64url(content: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(content).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(text: str) -> bytes:
|
||||
pad = "=" * (-len(text) % 4)
|
||||
return base64.urlsafe_b64decode(text + pad)
|
||||
|
||||
|
||||
async def test_e2e_file_write_then_read(sandbox_server: tuple[str, str]) -> None:
|
||||
"""文件读写往返:write → read 回原内容,验证 file_op_hash 两端字节级一致。"""
|
||||
base, secret = sandbox_server
|
||||
content = "乾坤一炁\n".encode()
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
w = await client.file_write(
|
||||
FileWriteRequest(path="notes/draft.md", content_b64=_b64url(content)),
|
||||
_ctx("t-fw", "file_write"),
|
||||
)
|
||||
assert w.bytes_written == len(content)
|
||||
r = await client.file_read(
|
||||
FileReadRequest(path="notes/draft.md"), _ctx("t-fr", "file_read")
|
||||
)
|
||||
assert r.size == len(content)
|
||||
assert r.returned == len(content)
|
||||
assert _b64url_decode(r.content_b64) == content
|
||||
|
||||
|
||||
async def test_e2e_file_list_and_stat(sandbox_server: tuple[str, str]) -> None:
|
||||
"""list 列出目录条目,stat 返回文件元信息。"""
|
||||
base, secret = sandbox_server
|
||||
content = b"stat-target"
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
await client.file_write(
|
||||
FileWriteRequest(path="lsbox/target.txt", content_b64=_b64url(content)),
|
||||
_ctx("t-fp", "file_write"),
|
||||
)
|
||||
listing = await client.file_list(FileListRequest(path="lsbox"), _ctx("t-fl", "file_list"))
|
||||
stat = await client.file_stat(
|
||||
FileStatRequest(path="lsbox/target.txt"), _ctx("t-fs", "file_stat")
|
||||
)
|
||||
assert "target.txt" in [e.name for e in listing.entries]
|
||||
assert stat.kind == "file"
|
||||
assert stat.size == len(content)
|
||||
|
||||
|
||||
async def test_e2e_file_read_rejects_traversal(sandbox_server: tuple[str, str]) -> None:
|
||||
"""路径越界被拒:file_op_hash 跨端一致(凭证通过)+ 词法预检查先行 → 400。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
with pytest.raises(SandboxBadRequest):
|
||||
await client.file_read(
|
||||
FileReadRequest(path="../../etc/passwd"), _ctx("t-trav", "file_read")
|
||||
)
|
||||
|
||||
|
||||
async def test_e2e_file_op_hash_mismatch_rejected(sandbox_server: tuple[str, str]) -> None:
|
||||
"""反重放:read a.txt 的凭证不能用于读 b.txt → 401(file_op_hash 校验优先于文件存在性)。"""
|
||||
from app.integrations.sandbox.auth import sign_authorization
|
||||
|
||||
base, secret = sandbox_server
|
||||
ctx = _ctx("t-mismatch", "file_read")
|
||||
# 签 read a.txt 的凭证,却请求读 b.txt
|
||||
token = sign_authorization(secret, ctx, None, None, file_op=("read", "a.txt"))
|
||||
async with httpx.AsyncClient() as http:
|
||||
resp = await http.post(
|
||||
f"{base}/v1/file/read",
|
||||
json={"path": "b.txt"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---- code 模式跨端(P0:转译为 argv 走进程路径 + body_hash_code 字节级一致) ----
|
||||
|
||||
|
||||
async def test_e2e_code_mode_executes_python(sandbox_server: tuple[str, str]) -> None:
|
||||
"""code 模式跨端:Rust 与 Python body_hash 字节级一致(401 即不一致),python3 实际执行。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
resp = await client.exec(
|
||||
ExecRequest(code="import sys\nprint(sys.version_info[0])"),
|
||||
_ctx("trace_code_1", "python"),
|
||||
)
|
||||
assert resp.ok, f"code 模式执行失败: {resp.stderr}"
|
||||
assert resp.stdout.strip() == "3"
|
||||
assert resp.isolation_mode == "process"
|
||||
|
||||
|
||||
async def test_e2e_code_mode_rejects_when_argv_also_given(sandbox_server: tuple[str, str]) -> None:
|
||||
"""互斥校验在沙箱端兜底:argv+code 同时给出应非 200(client 模型已挡,e2e 兜底)。"""
|
||||
base, _ = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
# 绕过 client 模型校验,直接裸 POST 验证沙箱端拒绝
|
||||
resp = await http.post(
|
||||
f"{base}/v1/exec",
|
||||
json={"argv": ["python3"], "code": "print(1)"},
|
||||
timeout=10.0,
|
||||
)
|
||||
# 未带授权头 → 401 先于 400;此处断言非 200,证明沙箱拒绝
|
||||
assert resp.status_code in (400, 401)
|
||||
|
||||
|
||||
async def test_e2e_isolation_mode_container_downgrades_to_process(
|
||||
sandbox_server: tuple[str, str],
|
||||
) -> None:
|
||||
"""P0 过渡态:司命签发 container,沙箱降级 process 并在响应回显 process。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
resp = await client.exec(
|
||||
ExecRequest(code="print(1)"),
|
||||
AuthorizationContext(
|
||||
trace_id="trace_iso",
|
||||
risk_level="L3",
|
||||
tool="shell",
|
||||
isolation_mode="container",
|
||||
),
|
||||
)
|
||||
assert resp.ok
|
||||
assert resp.isolation_mode == "process"
|
||||
@@ -0,0 +1,36 @@
|
||||
"""沙箱请求/响应模型测试:code/argv 互斥、language 默认、isolation_mode 回显。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.integrations.sandbox.models import ExecRequest, ExecResponse
|
||||
|
||||
|
||||
def test_exec_request_code_mode_defaults_language_to_python() -> None:
|
||||
req = ExecRequest(code="print(1)")
|
||||
assert req.code == "print(1)"
|
||||
assert req.language == "python"
|
||||
assert req.argv is None
|
||||
|
||||
|
||||
def test_exec_request_argv_mode_still_works() -> None:
|
||||
req = ExecRequest(argv=["python3", "-c", "print(1)"])
|
||||
assert req.argv == ["python3", "-c", "print(1)"]
|
||||
assert req.code is None
|
||||
|
||||
|
||||
def test_exec_request_rejects_both_argv_and_code() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ExecRequest(argv=["python3"], code="print(1)")
|
||||
|
||||
|
||||
def test_exec_request_rejects_neither_argv_nor_code() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ExecRequest()
|
||||
|
||||
|
||||
def test_exec_response_defaults_isolation_mode() -> None:
|
||||
resp = ExecResponse(ok=True, trace_id="t1")
|
||||
assert resp.isolation_mode == "process"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""测试 Goal ORM 模型新增字段。"""
|
||||
|
||||
from app.models.governance import Goal
|
||||
|
||||
|
||||
def test_goal_has_character_id():
|
||||
"""Goal 模型新增 character_id 字段。"""
|
||||
assert hasattr(Goal, "character_id")
|
||||
|
||||
|
||||
def test_goal_has_type():
|
||||
"""Goal 模型新增 type 字段,默认 session。"""
|
||||
assert hasattr(Goal, "type")
|
||||
|
||||
|
||||
def test_goal_has_progress():
|
||||
"""Goal 模型新增 progress 字段,默认 0.0。"""
|
||||
goal = Goal(title="test", level="task")
|
||||
assert goal.type == "session"
|
||||
assert goal.progress == 0.0
|
||||
assert goal.character_id is None
|
||||
Whitespace-only changes.
@@ -0,0 +1,96 @@
|
||||
"""大周天演化候选抽取纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.extract import (
|
||||
EvolutionCandidateDraft,
|
||||
parse_evolution_candidates,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_standard_json_array() -> None:
|
||||
raw = (
|
||||
'[{"type":"personality","level":"L4","scope":"character_scope",'
|
||||
'"title":"调整身份表达","proposal":"更主动介绍自身能力边界",'
|
||||
'"delta":{"identity_profile":"新的身份描述"},"confidence":0.8,'
|
||||
'"benefit_score":0.7,"risk_score":0.3,"auto_apply_allowed":false,'
|
||||
'"evidence":[0,1]}]'
|
||||
)
|
||||
drafts = parse_evolution_candidates(raw)
|
||||
assert len(drafts) == 1
|
||||
d = drafts[0]
|
||||
assert d.type == "personality"
|
||||
assert d.level == "L4"
|
||||
assert d.scope == "character_scope"
|
||||
assert d.title == "调整身份表达"
|
||||
assert d.proposal == "更主动介绍自身能力边界"
|
||||
assert d.delta == {"identity_profile": "新的身份描述"}
|
||||
assert d.confidence == 0.8
|
||||
assert d.auto_apply_allowed is False
|
||||
assert d.source == "big_cycle"
|
||||
assert d.evidence == [0, 1]
|
||||
|
||||
|
||||
def test_parse_markdown_code_block() -> None:
|
||||
raw = (
|
||||
'```json\n[{"type":"procedural","level":"L2","scope":"task_scope",'
|
||||
'"title":"t","proposal":"p"}]\n```'
|
||||
)
|
||||
drafts = parse_evolution_candidates(raw)
|
||||
assert len(drafts) == 1
|
||||
assert drafts[0].type == "procedural"
|
||||
|
||||
|
||||
def test_parse_text_mixed_json() -> None:
|
||||
raw = (
|
||||
'候选如下:\n[{"type":"mood","level":"L1","scope":"character_scope",'
|
||||
'"title":"t","proposal":"p"}]\n以上。'
|
||||
)
|
||||
drafts = parse_evolution_candidates(raw)
|
||||
assert len(drafts) == 1
|
||||
|
||||
|
||||
def test_parse_invalid_json_returns_empty() -> None:
|
||||
assert parse_evolution_candidates("不是JSON") == []
|
||||
assert parse_evolution_candidates("[]") == []
|
||||
|
||||
|
||||
def test_skip_entries_missing_required_fields() -> None:
|
||||
raw = (
|
||||
'[{"type":"personality","title":"缺proposal"},'
|
||||
'{"title":"缺type","proposal":"p"},'
|
||||
'{"type":"x","proposal":"缺title"}]'
|
||||
)
|
||||
assert parse_evolution_candidates(raw) == []
|
||||
|
||||
|
||||
def test_defaults_use_safe_pessimistic_values() -> None:
|
||||
"""缺省时取最严格默认:level=L4(必须人工)、auto_apply_allowed=False。"""
|
||||
raw = '[{"type":"personality","title":"t","proposal":"p"}]'
|
||||
d = parse_evolution_candidates(raw)[0]
|
||||
assert d.level == "L4"
|
||||
assert d.scope == "character_scope"
|
||||
assert d.auto_apply_allowed is False
|
||||
assert d.confidence == 0.5
|
||||
assert d.benefit_score == 0.5
|
||||
assert d.risk_score == 0.5
|
||||
assert d.drift_score == 0.0
|
||||
assert d.delta == {}
|
||||
|
||||
|
||||
def test_clamp_and_default_scores() -> None:
|
||||
raw = (
|
||||
'[{"type":"personality","title":"t","proposal":"p",'
|
||||
'"confidence":1.5,"benefit_score":-0.2,"risk_score":"high"}]'
|
||||
)
|
||||
d = parse_evolution_candidates(raw)[0]
|
||||
assert d.confidence == 1.0
|
||||
assert d.benefit_score == 0.0
|
||||
assert d.risk_score == 0.5 # 非法值 → 默认
|
||||
|
||||
|
||||
def test_auto_apply_allowed_truthy() -> None:
|
||||
raw = (
|
||||
'[{"type":"procedural","level":"L2","title":"t","proposal":"p","auto_apply_allowed":true}]'
|
||||
)
|
||||
d = parse_evolution_candidates(raw)[0]
|
||||
assert d.auto_apply_allowed is True
|
||||
assert isinstance(d, EvolutionCandidateDraft)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""大周天演化候选 prompt 构建纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.prompts import (
|
||||
EvaluationSnapshot,
|
||||
build_evolution_extraction_messages,
|
||||
)
|
||||
|
||||
|
||||
def _snapshot() -> EvaluationSnapshot:
|
||||
return EvaluationSnapshot(
|
||||
character_id="character_x",
|
||||
memory_count=25,
|
||||
recent_memories=[("喜欢简洁回答", "用户偏好简洁"), ("来自北京", "用户所在地")],
|
||||
personality_summary="身份:魂镜;表达风格:温和",
|
||||
world_entity_count=3,
|
||||
conclusion="近期互动偏技术话题,表达可更简洁",
|
||||
)
|
||||
|
||||
|
||||
def test_messages_return_system_user_pair() -> None:
|
||||
system, user = build_evolution_extraction_messages(_snapshot())
|
||||
assert isinstance(system, str) and isinstance(user, str)
|
||||
assert system and user
|
||||
|
||||
|
||||
def test_system_describes_contract_fields() -> None:
|
||||
system, _ = build_evolution_extraction_messages(_snapshot())
|
||||
for token in (
|
||||
"type",
|
||||
"level",
|
||||
"scope",
|
||||
"proposal",
|
||||
"delta",
|
||||
"auto_apply_allowed",
|
||||
):
|
||||
assert token in system
|
||||
|
||||
|
||||
def test_system_lists_evolution_types() -> None:
|
||||
system, _ = build_evolution_extraction_messages(_snapshot())
|
||||
for t in ("personality", "procedural", "tool_strategy", "memory"):
|
||||
assert t in system
|
||||
|
||||
|
||||
def test_system_states_level_semantics() -> None:
|
||||
system, _ = build_evolution_extraction_messages(_snapshot())
|
||||
assert "L4" in system
|
||||
|
||||
|
||||
def test_user_includes_snapshot_signals() -> None:
|
||||
_, user = build_evolution_extraction_messages(_snapshot())
|
||||
assert "喜欢简洁回答" in user
|
||||
assert "身份:魂镜" in user
|
||||
assert "25" in user
|
||||
|
||||
|
||||
def test_user_handles_empty_personality() -> None:
|
||||
snap = _snapshot()
|
||||
snap.personality_summary = ""
|
||||
_, user = build_evolution_extraction_messages(snap)
|
||||
assert isinstance(user, str)
|
||||
assert len(user) > 0
|
||||
@@ -0,0 +1,95 @@
|
||||
"""大周天内驱力抽取纯函数测试。"""
|
||||
|
||||
from app.modules.drive.extract import (
|
||||
CuriosityDraft,
|
||||
SubgoalDraft,
|
||||
parse_curiosities,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_standard_with_subgoals() -> None:
|
||||
raw = (
|
||||
'[{"question":"用户为何频繁提到项目X","source":"memory_gap",'
|
||||
'"expected_value":0.7,"urgency":0.5,"risk_level":"L2",'
|
||||
'"related_entities":["项目X"],'
|
||||
'"subgoals":[{"title":"询问项目X目标","description":"主动询问用户",'
|
||||
'"expected_information_gain":0.6,"interruption_cost":0.3,"risk_level":"L2"}]}]'
|
||||
)
|
||||
drafts = parse_curiosities(raw)
|
||||
assert len(drafts) == 1
|
||||
c = drafts[0]
|
||||
assert c.question == "用户为何频繁提到项目X"
|
||||
assert c.source == "memory_gap"
|
||||
assert c.expected_value == 0.7
|
||||
assert c.risk_level == "L2"
|
||||
assert c.related_entities == ["项目X"]
|
||||
assert len(c.subgoals) == 1
|
||||
sg = c.subgoals[0]
|
||||
assert sg.title == "询问项目X目标"
|
||||
assert sg.expected_information_gain == 0.6
|
||||
assert sg.interruption_cost == 0.3
|
||||
|
||||
|
||||
def test_parse_markdown_block() -> None:
|
||||
raw = '```json\n[{"question":"q"}]\n```'
|
||||
drafts = parse_curiosities(raw)
|
||||
assert len(drafts) == 1
|
||||
assert drafts[0].question == "q"
|
||||
|
||||
|
||||
def test_parse_invalid_returns_empty() -> None:
|
||||
assert parse_curiosities("不是JSON") == []
|
||||
assert parse_curiosities("[]") == []
|
||||
|
||||
|
||||
def test_skip_missing_question() -> None:
|
||||
raw = '[{"source":"memory_gap"},{"question":"q"}]'
|
||||
drafts = parse_curiosities(raw)
|
||||
assert len(drafts) == 1
|
||||
assert drafts[0].question == "q"
|
||||
|
||||
|
||||
def test_defaults_use_safe_pessimistic_values() -> None:
|
||||
"""缺省取最严格默认:risk_level=L4、source=reflection。"""
|
||||
raw = '[{"question":"q"}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert c.source == "reflection"
|
||||
assert c.risk_level == "L4"
|
||||
assert c.expected_value == 0.5
|
||||
assert c.urgency == 0.5
|
||||
assert c.related_entities == []
|
||||
assert c.subgoals == []
|
||||
|
||||
|
||||
def test_clamp_values() -> None:
|
||||
raw = '[{"question":"q","expected_value":1.5,"urgency":-0.2}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert c.expected_value == 1.0
|
||||
assert c.urgency == 0.0
|
||||
|
||||
|
||||
def test_invalid_source_defaults() -> None:
|
||||
raw = '[{"question":"q","source":"unknown_source"}]'
|
||||
assert parse_curiosities(raw)[0].source == "reflection"
|
||||
|
||||
|
||||
def test_invalid_risk_level_defaults() -> None:
|
||||
raw = '[{"question":"q","risk_level":"L9"}]'
|
||||
assert parse_curiosities(raw)[0].risk_level == "L4"
|
||||
|
||||
|
||||
def test_subgoal_defaults_and_types() -> None:
|
||||
raw = '[{"question":"q","subgoals":[{"title":"t","description":"d"}]}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert isinstance(c, CuriosityDraft)
|
||||
sg = c.subgoals[0]
|
||||
assert isinstance(sg, SubgoalDraft)
|
||||
assert sg.risk_level == "L4" # 安全默认
|
||||
assert sg.expected_information_gain == 0.5
|
||||
assert sg.interruption_cost == 0.5
|
||||
|
||||
|
||||
def test_skip_subgoal_missing_title_or_description() -> None:
|
||||
raw = '[{"question":"q","subgoals":[{"title":"t"},{"description":"d"},{"title":"t","description":"d"}]}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert len(c.subgoals) == 1 # 只有完整的保留
|
||||
@@ -0,0 +1,47 @@
|
||||
"""大周天内驱力 prompt 构建纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.prompts import EvaluationSnapshot
|
||||
from app.modules.drive.prompts import build_drive_extraction_messages
|
||||
|
||||
|
||||
def _snapshot() -> EvaluationSnapshot:
|
||||
return EvaluationSnapshot(
|
||||
character_id="character_x",
|
||||
memory_count=15,
|
||||
recent_memories=[("用户偏好简洁", "用户偏好简洁直接的回答")],
|
||||
personality_summary="身份:魂镜",
|
||||
world_entity_count=4,
|
||||
conclusion="近期对话偏技术,存在项目背景缺口",
|
||||
)
|
||||
|
||||
|
||||
def test_messages_return_system_user_pair() -> None:
|
||||
system, user = build_drive_extraction_messages(_snapshot())
|
||||
assert isinstance(system, str) and isinstance(user, str)
|
||||
assert system and user
|
||||
|
||||
|
||||
def test_system_describes_contract_fields() -> None:
|
||||
system, _ = build_drive_extraction_messages(_snapshot())
|
||||
for token in (
|
||||
"question",
|
||||
"source",
|
||||
"expected_value",
|
||||
"urgency",
|
||||
"risk_level",
|
||||
"subgoals",
|
||||
):
|
||||
assert token in system
|
||||
|
||||
|
||||
def test_system_lists_sources() -> None:
|
||||
system, _ = build_drive_extraction_messages(_snapshot())
|
||||
for src in ("world_uncertainty", "memory_gap", "reflection", "goal_tension"):
|
||||
assert src in system
|
||||
|
||||
|
||||
def test_user_includes_snapshot_signals() -> None:
|
||||
_, user = build_drive_extraction_messages(_snapshot())
|
||||
assert "用户偏好简洁" in user
|
||||
assert "身份:魂镜" in user
|
||||
assert "15" in user
|
||||
@@ -0,0 +1,110 @@
|
||||
"""大周天记忆/世界抽取纯函数测试。"""
|
||||
|
||||
from app.modules.extraction.extract import (
|
||||
MemoryCandidateDraft,
|
||||
WorldEntityDraft,
|
||||
parse_extraction,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_standard_object() -> None:
|
||||
raw = (
|
||||
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
|
||||
'"importance":0.8,"sensitivity":"low","confidence":0.7,"reason":"多次提及"}],'
|
||||
'"entities":[{"type":"project","name":"项目X","attributes":{"status":"active"},'
|
||||
'"state_type":"inference","confidence":0.6}]}'
|
||||
)
|
||||
memories, entities = parse_extraction(raw)
|
||||
assert len(memories) == 1
|
||||
assert memories[0].title == "喜欢简洁"
|
||||
assert memories[0].type == "preference"
|
||||
assert memories[0].importance == 0.8
|
||||
assert len(entities) == 1
|
||||
assert entities[0].name == "项目X"
|
||||
assert entities[0].type == "project"
|
||||
assert entities[0].attributes == {"status": "active"}
|
||||
assert entities[0].state_type == "inference"
|
||||
|
||||
|
||||
def test_parse_markdown_block() -> None:
|
||||
raw = '```json\n{"memories":[],"entities":[]}\n```'
|
||||
memories, entities = parse_extraction(raw)
|
||||
assert memories == []
|
||||
assert entities == []
|
||||
|
||||
|
||||
def test_parse_text_mixed() -> None:
|
||||
raw = '抽取结果:\n{"memories":[{"title":"t","content":"c"}],"entities":[]}\n以上'
|
||||
memories, entities = parse_extraction(raw)
|
||||
assert len(memories) == 1
|
||||
assert entities == []
|
||||
|
||||
|
||||
def test_parse_invalid_returns_empty() -> None:
|
||||
assert parse_extraction("不是JSON") == ([], [])
|
||||
assert parse_extraction("[]") == ([], []) # 数组不是对象
|
||||
|
||||
|
||||
def test_missing_keys_returns_empty_lists() -> None:
|
||||
memories, entities = parse_extraction('{"memories":[]}')
|
||||
assert memories == []
|
||||
assert entities == []
|
||||
|
||||
|
||||
def test_skip_memory_missing_title_or_content() -> None:
|
||||
raw = '{"memories":[{"title":"t"},{"content":"c"},{"title":"t","content":"c"}]}'
|
||||
memories, _ = parse_extraction(raw)
|
||||
assert len(memories) == 1
|
||||
|
||||
|
||||
def test_skip_entity_missing_name() -> None:
|
||||
raw = '{"entities":[{"type":"project"},{"name":"有名字"}]}'
|
||||
_, entities = parse_extraction(raw)
|
||||
assert len(entities) == 1
|
||||
assert entities[0].name == "有名字"
|
||||
|
||||
|
||||
def test_memory_safe_defaults() -> None:
|
||||
raw = '{"memories":[{"title":"t","content":"c"}]}'
|
||||
m = parse_extraction(raw)[0][0]
|
||||
assert m.sensitivity == "low"
|
||||
assert m.type == "other"
|
||||
assert m.confidence == 0.5
|
||||
assert m.importance == 0.5
|
||||
|
||||
|
||||
def test_entity_safe_defaults() -> None:
|
||||
raw = '{"entities":[{"name":"n"}]}'
|
||||
e = parse_extraction(raw)[1][0]
|
||||
assert e.state_type == "inference" # 非 fact,降权
|
||||
assert e.type == "concept"
|
||||
assert e.confidence == 0.5
|
||||
assert e.attributes == {}
|
||||
|
||||
|
||||
def test_clamp_values() -> None:
|
||||
raw = (
|
||||
'{"memories":[{"title":"t","content":"c","importance":1.5,"confidence":-0.2}],'
|
||||
'"entities":[{"name":"n","confidence":2.0}]}'
|
||||
)
|
||||
memories, entities = parse_extraction(raw)
|
||||
assert memories[0].importance == 1.0
|
||||
assert memories[0].confidence == 0.0
|
||||
assert entities[0].confidence == 1.0
|
||||
|
||||
|
||||
def test_invalid_sensitivity_defaults() -> None:
|
||||
raw = '{"memories":[{"title":"t","content":"c","sensitivity":"unknown"}]}'
|
||||
assert parse_extraction(raw)[0][0].sensitivity == "low"
|
||||
|
||||
|
||||
def test_invalid_state_type_defaults() -> None:
|
||||
raw = '{"entities":[{"name":"n","state_type":"certain"}]}'
|
||||
assert parse_extraction(raw)[1][0].state_type == "inference"
|
||||
|
||||
|
||||
def test_draft_types() -> None:
|
||||
raw = '{"memories":[{"title":"t","content":"c"}],"entities":[{"name":"n"}]}'
|
||||
memories, entities = parse_extraction(raw)
|
||||
assert isinstance(memories[0], MemoryCandidateDraft)
|
||||
assert isinstance(entities[0], WorldEntityDraft)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""大周天记忆/世界抽取 prompt 构建纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.prompts import EvaluationSnapshot
|
||||
from app.modules.extraction.prompts import build_extraction_messages
|
||||
|
||||
|
||||
def _snapshot() -> EvaluationSnapshot:
|
||||
return EvaluationSnapshot(
|
||||
character_id="character_x",
|
||||
memory_count=15,
|
||||
recent_memories=[("用户偏好简洁", "用户偏好简洁直接的回答")],
|
||||
personality_summary="身份:魂镜",
|
||||
world_entity_count=4,
|
||||
conclusion="近期对话涉及项目X,存在背景缺口",
|
||||
)
|
||||
|
||||
|
||||
def test_messages_return_system_user_pair() -> None:
|
||||
system, user = build_extraction_messages(_snapshot())
|
||||
assert isinstance(system, str) and isinstance(user, str)
|
||||
assert system and user
|
||||
|
||||
|
||||
def test_system_describes_memories_and_entities_structure() -> None:
|
||||
system, _ = build_extraction_messages(_snapshot())
|
||||
for token in ("memories", "entities", "state_type", "sensitivity", "importance"):
|
||||
assert token in system
|
||||
|
||||
|
||||
def test_user_includes_snapshot_signals() -> None:
|
||||
_, user = build_extraction_messages(_snapshot())
|
||||
assert "用户偏好简洁" in user
|
||||
assert "身份:魂镜" in user
|
||||
assert "15" in user
|
||||
@@ -0,0 +1,35 @@
|
||||
"""太一会话目标提取纯函数测试。"""
|
||||
|
||||
from app.modules.goal_orchestrator.extract import (
|
||||
extract_session_goal,
|
||||
format_goal_directive,
|
||||
)
|
||||
|
||||
|
||||
def test_extract_returns_stripped_content() -> None:
|
||||
assert extract_session_goal(" 帮我整理今天的笔记 ") == "帮我整理今天的笔记"
|
||||
|
||||
|
||||
def test_extract_empty_returns_empty_string() -> None:
|
||||
assert extract_session_goal("") == ""
|
||||
assert extract_session_goal(" ") == ""
|
||||
|
||||
|
||||
def test_extract_short_message_unchanged() -> None:
|
||||
assert extract_session_goal("今天天气如何?") == "今天天气如何?"
|
||||
|
||||
|
||||
def test_extract_truncates_over_limit() -> None:
|
||||
# 目标摘要上限 120 字(run.goal 字段容量 300,留余量);超出截断
|
||||
long = "目标描述" * 40 # 160 字
|
||||
goal = extract_session_goal(long)
|
||||
assert len(goal) == 120
|
||||
assert goal == "目标描述" * 30 # 120 字 = 30 个「目标描述」
|
||||
|
||||
|
||||
def test_format_goal_directive_with_goal() -> None:
|
||||
assert format_goal_directive("整理笔记") == "当前会话目标:整理笔记"
|
||||
|
||||
|
||||
def test_format_goal_directive_empty_returns_empty() -> None:
|
||||
assert format_goal_directive("") == ""
|
||||
Whitespace-only changes.
@@ -0,0 +1,104 @@
|
||||
"""角色卡解析器单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
from app.modules.imports.card_parser import parse_character_card
|
||||
|
||||
|
||||
def _make_png_text(keyword: str, text: str) -> bytes:
|
||||
"""构造含一个 tEXt chunk 的最小 PNG。"""
|
||||
|
||||
def chunk(ctype: bytes, data: bytes) -> bytes:
|
||||
body = ctype + data
|
||||
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
||||
|
||||
payload = keyword.encode("latin-1") + b"\x00" + text.encode("latin-1")
|
||||
return b"\x89PNG\r\n\x1a\n" + chunk(b"tEXt", payload) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
def test_parse_v2_json() -> None:
|
||||
card = {"spec": "chara_card_v2", "spec_version": "2.0", "data": {"name": "狂三", "description": "desc", "personality": "腹黑"}}
|
||||
parsed = parse_character_card(json.dumps(card).encode("utf-8"))
|
||||
assert parsed.name == "狂三"
|
||||
assert parsed.personality == "腹黑"
|
||||
assert parsed.spec_version == "2.0"
|
||||
|
||||
|
||||
def test_parse_v1_flat_normalized() -> None:
|
||||
card = {"name": "V1角色", "description": "d", "personality": "p"}
|
||||
parsed = parse_character_card(json.dumps(card).encode("utf-8"))
|
||||
assert parsed.name == "V1角色"
|
||||
assert parsed.spec_version == "1"
|
||||
|
||||
|
||||
def test_parse_png_chara_chunk() -> None:
|
||||
card = {"spec": "chara_card_v2", "spec_version": "2.0", "data": {"name": "PNG卡"}}
|
||||
text = base64.b64encode(json.dumps(card).encode("utf-8")).decode("ascii")
|
||||
parsed = parse_character_card(_make_png_text("chara", text))
|
||||
assert parsed.name == "PNG卡"
|
||||
|
||||
|
||||
def test_parse_png_ccv3_preferred_over_chara() -> None:
|
||||
# 同时含 chara(V2) 和 ccv3(V3),优先 ccv3
|
||||
card_v3 = {"spec_version": "3.0", "data": {"name": "V3卡", "nickname": "三三"}}
|
||||
|
||||
def chunk(ctype: bytes, data: bytes) -> bytes:
|
||||
body = ctype + data
|
||||
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
||||
|
||||
v3_text = base64.b64encode(json.dumps(card_v3).encode()).decode()
|
||||
payload_chara = b"chara\x00" + base64.b64encode(json.dumps({"data": {"name": "旧"}}).encode())
|
||||
payload_ccv3 = b"ccv3\x00" + v3_text.encode("latin-1")
|
||||
png = b"\x89PNG\r\n\x1a\n" + chunk(b"tEXt", payload_chara) + chunk(b"tEXt", payload_ccv3) + chunk(b"IEND", b"")
|
||||
parsed = parse_character_card(png)
|
||||
assert parsed.name == "V3卡"
|
||||
assert parsed.nickname == "三三"
|
||||
|
||||
|
||||
def test_parse_charx_zip() -> None:
|
||||
card = {"spec_version": "3.0", "data": {"name": "CHARX卡"}}
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("card.json", json.dumps(card))
|
||||
parsed = parse_character_card(buf.getvalue())
|
||||
assert parsed.name == "CHARX卡"
|
||||
|
||||
|
||||
def test_character_book_preserved() -> None:
|
||||
card = {
|
||||
"spec": "chara_card_v2",
|
||||
"spec_version": "2.0",
|
||||
"data": {
|
||||
"name": "x",
|
||||
"character_book": {"entries": [{"keys": ["a"], "content": "b"}]},
|
||||
},
|
||||
}
|
||||
parsed = parse_character_card(json.dumps(card).encode())
|
||||
assert parsed.character_book is not None
|
||||
assert parsed.character_book["entries"][0]["keys"] == ["a"]
|
||||
|
||||
|
||||
def test_alternate_greetings_and_tags() -> None:
|
||||
card = {"spec_version": "2.0", "data": {"name": "x", "alternate_greetings": ["hi", "yo"], "tags": ["anime"]}}
|
||||
parsed = parse_character_card(json.dumps(card).encode())
|
||||
assert parsed.alternate_greetings == ["hi", "yo"]
|
||||
assert parsed.tags == ["anime"]
|
||||
|
||||
|
||||
def test_png_without_metadata_raises() -> None:
|
||||
import pytest
|
||||
|
||||
def chunk(ctype: bytes, data: bytes) -> bytes:
|
||||
body = ctype + data
|
||||
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
||||
|
||||
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IEND", b"")
|
||||
with pytest.raises(ValueError):
|
||||
parse_character_card(png)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""角色卡导出映射单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.character import Character
|
||||
from app.models.lorebook import Lorebook, LorebookEntry
|
||||
from app.modules.imports.export_mapping import character_to_card, lorebook_to_st_book
|
||||
from app.repositories.json_utils import dumps
|
||||
|
||||
|
||||
def _character(**kw: object) -> Character:
|
||||
base: dict[str, object] = {
|
||||
"name": "狂三",
|
||||
"profile_json": "{}",
|
||||
}
|
||||
base.update(kw)
|
||||
return Character(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_character_to_card_basic() -> None:
|
||||
char = _character(
|
||||
display_name="三三",
|
||||
personality_summary="腹黑",
|
||||
profile_json=dumps({"background": "desc", "scenario": "sch", "tags": ["a"],
|
||||
"source_meta": {"creator": "X", "character_version": "1"}}),
|
||||
)
|
||||
card = character_to_card(char)
|
||||
assert card["spec"] == "chara_card_v3"
|
||||
assert card["spec_version"] == "3.0"
|
||||
data = card["data"]
|
||||
assert data["name"] == "狂三"
|
||||
assert data["nickname"] == "三三"
|
||||
assert data["personality"] == "腹黑"
|
||||
assert data["description"] == "desc"
|
||||
assert data["creator"] == "X"
|
||||
assert data["character_version"] == "1"
|
||||
|
||||
|
||||
def test_no_nickname_when_same_as_name() -> None:
|
||||
char = _character(name="X", display_name="X")
|
||||
assert "nickname" not in character_to_card(char)["data"]
|
||||
|
||||
|
||||
def test_personality_from_daoti_snapshot() -> None:
|
||||
# 道体 personality_summary 是导出的 personality 来源(演化后的当前快照)
|
||||
char = _character(personality_summary="演化后的人格")
|
||||
assert character_to_card(char)["data"]["personality"] == "演化后的人格"
|
||||
|
||||
|
||||
def test_lorebook_exported_to_st_book() -> None:
|
||||
book = Lorebook(name="谱", scan_depth=4, recursive_scanning=True)
|
||||
entries = [
|
||||
LorebookEntry(keys_json='["猫"]', content="喜欢猫", constant=True, position="after_char"),
|
||||
]
|
||||
st_book = lorebook_to_st_book(book, entries)
|
||||
assert st_book["name"] == "谱"
|
||||
assert st_book["scan_depth"] == 4
|
||||
assert st_book["recursive_scanning"] is True
|
||||
assert st_book["entries"][0]["keys"] == ["猫"]
|
||||
assert st_book["entries"][0]["constant"] is True
|
||||
|
||||
|
||||
def test_no_character_book_when_lorebook_none() -> None:
|
||||
char = _character()
|
||||
assert "character_book" not in character_to_card(char)["data"]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""角色卡字段映射单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.imports.card_parser import CharacterCard
|
||||
from app.modules.imports.mapping import (
|
||||
card_to_daoti,
|
||||
card_to_profile,
|
||||
character_book_to_entries,
|
||||
)
|
||||
|
||||
|
||||
def _card(**kw: object) -> CharacterCard:
|
||||
base: dict[str, object] = {"spec_version": "2.0", "name": "狂三"}
|
||||
base.update(kw)
|
||||
return CharacterCard(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_profile_maps_core_fields() -> None:
|
||||
card = _card(
|
||||
description="desc",
|
||||
scenario="sch",
|
||||
first_mes="hi",
|
||||
tags=["a"],
|
||||
creator="X",
|
||||
character_version="1",
|
||||
)
|
||||
profile = card_to_profile(card)
|
||||
assert profile["background"] == "desc"
|
||||
assert profile["scenario"] == "sch"
|
||||
assert profile["first_mes"] == "hi"
|
||||
assert profile["tags"] == ["a"]
|
||||
assert profile["source_meta"]["creator"] == "X"
|
||||
assert profile["source_meta"]["spec_version"] == "2.0"
|
||||
|
||||
|
||||
def test_profile_preserves_system_prompt_and_extensions() -> None:
|
||||
card = _card(system_prompt="SP", extensions={"foo": {"bar": 1}})
|
||||
profile = card_to_profile(card)
|
||||
assert profile["system_prompt"] == "SP"
|
||||
assert profile["st_extensions"] == {"foo": {"bar": 1}}
|
||||
|
||||
|
||||
def test_daoti_personality_as_initial_summary() -> None:
|
||||
assert card_to_daoti(_card(personality="腹黑优雅"))["personality_summary"] == "腹黑优雅"
|
||||
|
||||
|
||||
def test_daoti_empty_personality_is_none() -> None:
|
||||
assert card_to_daoti(_card())["personality_summary"] is None
|
||||
|
||||
|
||||
def test_book_to_entries_maps_fields() -> None:
|
||||
book = {
|
||||
"entries": [
|
||||
{"keys": ["猫"], "content": "喜欢猫", "position": "after_char", "constant": True},
|
||||
{"keys": ["时间"], "content": "时间能力", "priority": 5},
|
||||
]
|
||||
}
|
||||
entries = character_book_to_entries(book)
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["keys"] == ["猫"]
|
||||
assert entries[0]["constant"] is True
|
||||
assert entries[1]["priority"] == 5
|
||||
assert entries[1]["position"] == "after_char" # 默认
|
||||
|
||||
|
||||
def test_book_none_or_empty() -> None:
|
||||
assert character_book_to_entries(None) == []
|
||||
assert character_book_to_entries({}) == []
|
||||
assert character_book_to_entries({"entries": []}) == []
|
||||
@@ -0,0 +1,195 @@
|
||||
"""叩心枢测试共用 fixtures。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.chat import ChatMessage
|
||||
from app.models.drive import CuriosityQuestion
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
from app.models.memory import Memory
|
||||
from app.models.personality_emotion import CharacterEmotionState
|
||||
from app.models.time_anniversary import TimeAnniversary
|
||||
|
||||
# 用内存 SQLite 做测试数据库
|
||||
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_old_message(db_session: AsyncSession) -> None:
|
||||
"""插入一条 2 小时前的消息,供空闲超时条件触发。"""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
ChatMessage(
|
||||
character_id="char_test",
|
||||
session_id="sess_old",
|
||||
role="assistant",
|
||||
modality="text",
|
||||
content="旧消息",
|
||||
created_at=now - timedelta(hours=2),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_recent_message(db_session: AsyncSession) -> None:
|
||||
"""插入一条 1 分钟前的消息,供空闲超时条件不触发。"""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
ChatMessage(
|
||||
character_id="char_test",
|
||||
session_id="sess_recent",
|
||||
role="assistant",
|
||||
modality="text",
|
||||
content="新消息",
|
||||
created_at=now - timedelta(minutes=1),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_anniversary(db_session: AsyncSession) -> None:
|
||||
"""插入一条 2026-06-27 的生日纪念日。"""
|
||||
db_session.add(
|
||||
TimeAnniversary(
|
||||
character_id="char_test",
|
||||
name="测试生日",
|
||||
calendar="solar",
|
||||
date="2026-06-27",
|
||||
kind="birthday",
|
||||
subject_type="user",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_emotion_state(db_session: AsyncSession) -> None:
|
||||
"""插入一条初始情绪状态(neutral / 0.2)。"""
|
||||
db_session.add(
|
||||
CharacterEmotionState(
|
||||
character_id="char_test",
|
||||
mood="neutral",
|
||||
intensity=0.2,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_curiosity(db_session: AsyncSession) -> None:
|
||||
"""插入一条活跃的好奇心问题。"""
|
||||
db_session.add(
|
||||
CuriosityQuestion(
|
||||
character_id="char_test",
|
||||
cycle_id="cycle_test",
|
||||
question="你为什么总是沉默?",
|
||||
source="drive",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_applied_evolution(db_session: AsyncSession) -> None:
|
||||
"""插入一条已应用的演化候选。"""
|
||||
db_session.add(
|
||||
EvolutionCandidate(
|
||||
character_id="char_test",
|
||||
cycle_id="cycle_test",
|
||||
type="personality",
|
||||
title="语气更柔和",
|
||||
proposal="调整表达风格,减少命令式语气,增加温和感",
|
||||
level="L3",
|
||||
status="applied",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_completed_big_cycle(db_session: AsyncSession) -> None:
|
||||
"""插入一条已完成的大周天循环。"""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
BigCycle(
|
||||
character_id="char_test",
|
||||
trigger="session_turn",
|
||||
status="completed",
|
||||
summary="发现自己最近更倾向于回忆过去,在对话中表现出怀旧倾向",
|
||||
completed_at=now - timedelta(minutes=5),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_new_memory(db_session: AsyncSession) -> None:
|
||||
"""插入一条活跃的新记忆。"""
|
||||
db_session.add(
|
||||
Memory(
|
||||
character_id="char_test",
|
||||
type="experience",
|
||||
title="今天去了海边",
|
||||
content="用户提到今天去了海边,看日落。角色回忆起自己也曾喜欢在海边散步。",
|
||||
status="active",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_many_messages(db_session: AsyncSession) -> None:
|
||||
"""插入多条消息,供关系里程碑(消息数)条件触发。"""
|
||||
now = datetime.now(UTC)
|
||||
for i in range(5):
|
||||
db_session.add(
|
||||
ChatMessage(
|
||||
character_id="char_test",
|
||||
session_id=f"sess_rel_{i}",
|
||||
role="assistant" if i % 2 else "user",
|
||||
modality="text",
|
||||
content=f"第{i + 1}条消息",
|
||||
created_at=now - timedelta(minutes=10 - i),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_old_relationship(db_session: AsyncSession) -> None:
|
||||
"""插入一条 60 天前的消息,供关系里程碑(天数)条件触发。"""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
ChatMessage(
|
||||
character_id="char_test",
|
||||
session_id="sess_old_rel",
|
||||
role="user",
|
||||
modality="text",
|
||||
content="你好",
|
||||
created_at=now - timedelta(days=60),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
@@ -0,0 +1,303 @@
|
||||
"""叩心枢触发条件单元测试。"""
|
||||
|
||||
# ruff: noqa: ARG002 — pytest fixture params are intentionally unused in test signatures
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.modules.knocking.conditions import (
|
||||
AnniversaryCondition,
|
||||
BigCycleCompletedCondition,
|
||||
ConditionResult,
|
||||
CuriosityCondition,
|
||||
EmotionChangeCondition,
|
||||
EvolutionAppliedCondition,
|
||||
MemoryRecallCondition,
|
||||
ProactiveCondition,
|
||||
RelationshipMilestoneCondition,
|
||||
TimeElapsedCondition,
|
||||
TimeOfDayCondition,
|
||||
UserReunionCondition,
|
||||
)
|
||||
|
||||
|
||||
class TestTimeElapsedCondition:
|
||||
"""空闲超时条件测试。"""
|
||||
|
||||
async def test_not_triggered_when_no_history(self, db_session: AsyncSession) -> None:
|
||||
cond = TimeElapsedCondition(min_idle_minutes=360)
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_when_idle_exceeds_threshold(
|
||||
self, db_session: AsyncSession, seed_old_message: None
|
||||
) -> None:
|
||||
cond = TimeElapsedCondition(min_idle_minutes=1)
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "idle"
|
||||
|
||||
async def test_not_triggered_when_recent_message(
|
||||
self, db_session: AsyncSession, seed_recent_message: None
|
||||
) -> None:
|
||||
cond = TimeElapsedCondition(min_idle_minutes=360)
|
||||
now = datetime.now(UTC)
|
||||
result = await cond.check("char_test", db_session, now=now)
|
||||
assert not result.triggered
|
||||
|
||||
|
||||
class TestTimeOfDayCondition:
|
||||
async def test_morning_match(self) -> None:
|
||||
cond = TimeOfDayCondition(windows=["morning"])
|
||||
now = datetime(2026, 6, 27, 7, 0, 0, tzinfo=UTC)
|
||||
result = await cond.check("char_test", None, now=now)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "time_of_day"
|
||||
|
||||
async def test_late_night_match(self) -> None:
|
||||
cond = TimeOfDayCondition(windows=["late_night"])
|
||||
now = datetime(2026, 6, 27, 23, 0, 0, tzinfo=UTC)
|
||||
result = await cond.check("char_test", None, now=now)
|
||||
assert result.triggered
|
||||
assert result.context.get("time_of_day") == "late_night"
|
||||
|
||||
async def test_no_match_outside_window(self) -> None:
|
||||
cond = TimeOfDayCondition(windows=["morning"])
|
||||
now = datetime(2026, 6, 27, 14, 0, 0, tzinfo=UTC)
|
||||
result = await cond.check("char_test", None, now=now)
|
||||
assert not result.triggered
|
||||
|
||||
|
||||
class TestAnniversaryCondition:
|
||||
async def test_not_triggered_on_first_call_no_data(self, db_session: AsyncSession) -> None:
|
||||
cond = AnniversaryCondition()
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_when_anniversary_exists(
|
||||
self, db_session: AsyncSession, seed_anniversary: None
|
||||
) -> None:
|
||||
cond = AnniversaryCondition()
|
||||
now = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC)
|
||||
result = await cond.check("char_test", db_session, now=now)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "anniversary"
|
||||
assert "birthday" in result.context.get("anniversary_kind", "")
|
||||
|
||||
async def test_not_duplicated_same_day(
|
||||
self, db_session: AsyncSession, seed_anniversary: None
|
||||
) -> None:
|
||||
cond = AnniversaryCondition()
|
||||
now = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC)
|
||||
result1 = await cond.check("char_test", db_session, now=now)
|
||||
assert result1.triggered
|
||||
result2 = await cond.check("char_test", db_session, now=now)
|
||||
assert not result2.triggered # 同一天不重复
|
||||
|
||||
|
||||
class TestUserReunionCondition:
|
||||
async def test_not_triggered_when_no_event(self, db_session: AsyncSession) -> None:
|
||||
cond = UserReunionCondition()
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
|
||||
class TestEmotionChangeCondition:
|
||||
async def test_not_triggered_on_first_check(
|
||||
self, db_session: AsyncSession, seed_emotion_state: None
|
||||
) -> None:
|
||||
cond = EmotionChangeCondition(min_delta=0.3)
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert not result.triggered # 首次无基线
|
||||
|
||||
async def test_triggered_when_intensity_changes(
|
||||
self, db_session: AsyncSession, seed_emotion_state: None
|
||||
) -> None:
|
||||
cond = EmotionChangeCondition(min_delta=0.3)
|
||||
await cond.check("char_test", db_session) # 首次建立基线
|
||||
|
||||
# 更新情绪 intensity 到 0.8(变化 0.6 > 0.3)
|
||||
from app.models.personality_emotion import CharacterEmotionState
|
||||
|
||||
result = await db_session.execute(
|
||||
select(CharacterEmotionState).where(CharacterEmotionState.character_id == "char_test")
|
||||
)
|
||||
state = result.scalar_one()
|
||||
state.intensity = 0.8
|
||||
state.mood = "melancholy"
|
||||
await db_session.commit()
|
||||
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "emotion"
|
||||
|
||||
|
||||
class TestCuriosityCondition:
|
||||
async def test_not_triggered_when_no_active_questions(self, db_session: AsyncSession) -> None:
|
||||
cond = CuriosityCondition()
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_when_active_question_exists(
|
||||
self, db_session: AsyncSession, seed_curiosity: None
|
||||
) -> None:
|
||||
cond = CuriosityCondition()
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "curiosity"
|
||||
assert "curiosity_question" in result.context
|
||||
assert result.context["curiosity_question"] == "你为什么总是沉默?"
|
||||
|
||||
|
||||
class TestEvolutionAppliedCondition:
|
||||
"""演化应用条件测试。"""
|
||||
|
||||
async def test_not_triggered_when_no_applied(self, db_session: AsyncSession) -> None:
|
||||
cond = EvolutionAppliedCondition()
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_when_applied_exists(
|
||||
self, db_session: AsyncSession, seed_applied_evolution: None
|
||||
) -> None:
|
||||
cond = EvolutionAppliedCondition()
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "evolution_applied"
|
||||
assert result.context.get("evolution_title") == "语气更柔和"
|
||||
assert result.context.get("evolution_level") == "L3"
|
||||
assert "evolution_proposal" in result.context
|
||||
|
||||
async def test_not_duplicated(
|
||||
self, db_session: AsyncSession, seed_applied_evolution: None
|
||||
) -> None:
|
||||
cond = EvolutionAppliedCondition()
|
||||
result1 = await cond.check("char_test", db_session)
|
||||
assert result1.triggered
|
||||
result2 = await cond.check("char_test", db_session)
|
||||
assert not result2.triggered # 同一条不重复触发
|
||||
|
||||
|
||||
class TestBigCycleCompletedCondition:
|
||||
"""大周天完成条件测试。"""
|
||||
|
||||
async def test_not_triggered_when_no_completed(self, db_session: AsyncSession) -> None:
|
||||
cond = BigCycleCompletedCondition()
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_when_completed_exists(
|
||||
self, db_session: AsyncSession, seed_completed_big_cycle: None
|
||||
) -> None:
|
||||
cond = BigCycleCompletedCondition()
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "big_cycle_completed"
|
||||
assert "big_cycle_summary" in result.context
|
||||
assert "big_cycle_trigger" in result.context
|
||||
|
||||
async def test_not_duplicated(
|
||||
self, db_session: AsyncSession, seed_completed_big_cycle: None
|
||||
) -> None:
|
||||
cond = BigCycleCompletedCondition()
|
||||
result1 = await cond.check("char_test", db_session)
|
||||
assert result1.triggered
|
||||
result2 = await cond.check("char_test", db_session)
|
||||
assert not result2.triggered # 同一条不重复触发
|
||||
|
||||
|
||||
class TestMemoryRecallCondition:
|
||||
"""记忆唤醒条件测试。"""
|
||||
|
||||
async def test_not_triggered_when_no_memory(self, db_session: AsyncSession) -> None:
|
||||
cond = MemoryRecallCondition()
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_when_new_memory_exists(
|
||||
self, db_session: AsyncSession, seed_new_memory: None
|
||||
) -> None:
|
||||
cond = MemoryRecallCondition()
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "memory_recall"
|
||||
assert result.context.get("memory_title") == "今天去了海边"
|
||||
assert "memory_content" in result.context
|
||||
assert "memory_type" in result.context
|
||||
|
||||
async def test_not_duplicated(self, db_session: AsyncSession, seed_new_memory: None) -> None:
|
||||
cond = MemoryRecallCondition()
|
||||
result1 = await cond.check("char_test", db_session)
|
||||
assert result1.triggered
|
||||
result2 = await cond.check("char_test", db_session)
|
||||
assert not result2.triggered # 同一条不重复触发
|
||||
|
||||
|
||||
class TestRelationshipMilestoneCondition:
|
||||
"""关系里程碑条件测试。"""
|
||||
|
||||
async def test_not_triggered_when_no_messages(self, db_session: AsyncSession) -> None:
|
||||
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[30])
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_by_message_count(
|
||||
self, db_session: AsyncSession, seed_many_messages: None
|
||||
) -> None:
|
||||
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[9999])
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "relationship_milestone"
|
||||
assert result.context.get("milestone_type") == "messages"
|
||||
assert result.context.get("milestone_value") == 5
|
||||
assert result.context.get("milestone_threshold") == 5
|
||||
|
||||
async def test_triggered_by_days(
|
||||
self, db_session: AsyncSession, seed_old_relationship: None
|
||||
) -> None:
|
||||
cond = RelationshipMilestoneCondition(message_milestones=[9999], day_milestones=[30])
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "relationship_milestone"
|
||||
assert result.context.get("milestone_type") == "days"
|
||||
assert result.context.get("milestone_value") >= 30
|
||||
assert result.context.get("milestone_threshold") == 30
|
||||
|
||||
async def test_not_duplicated(self, db_session: AsyncSession, seed_many_messages: None) -> None:
|
||||
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[9999])
|
||||
result1 = await cond.check("char_test", db_session)
|
||||
assert result1.triggered
|
||||
result2 = await cond.check("char_test", db_session)
|
||||
assert not result2.triggered # 同一里程碑不重复触发
|
||||
|
||||
async def test_prefers_messages_over_days(
|
||||
self, db_session: AsyncSession, seed_many_messages: None, seed_old_relationship: None
|
||||
) -> None:
|
||||
"""同时满足消息和天数里程碑时,优先触发消息里程碑。"""
|
||||
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[30])
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.context.get("milestone_type") == "messages" # 消息优先
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proactive_condition_protocol() -> None:
|
||||
"""验证 ProactiveCondition 可作为 Protocol 使用。"""
|
||||
|
||||
class DummyCondition(ProactiveCondition):
|
||||
async def check(
|
||||
self,
|
||||
_character_id: str,
|
||||
_session: AsyncSession | None = None,
|
||||
*,
|
||||
_now: datetime | None = None,
|
||||
) -> ConditionResult:
|
||||
return ConditionResult(triggered=True, trigger_reason="测试", condition_name="dummy")
|
||||
|
||||
cond: ProactiveCondition = DummyCondition()
|
||||
result = await cond.check("char_test")
|
||||
assert result.triggered
|
||||
@@ -0,0 +1,214 @@
|
||||
"""叩心枢(KnockingPivot)集成测试。
|
||||
|
||||
测试覆盖:
|
||||
1. Tick 触发 —— 活跃角色条件匹配,执行完整触发链并推送一炁事件
|
||||
2. Tick 空跑 —— 无活跃角色时跳过
|
||||
3. 反 spam 冷却 —— 触发后冷却期内 ``_can_trigger`` 返回 False
|
||||
4. 反 spam 日上限 —— 达到日上限后返回 False
|
||||
5. 静默时段 —— [23:00, 08:00) 返回 True,其余返回 False
|
||||
6. 活跃角色列表 —— DB 查询正确返回 ``status='active'`` 的角色 ID
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
# ── 模型注册 ──────────────────────────────────────────────────────────
|
||||
# 在 DB 表创建前将模型注册到 Base.metadata,确保 create_all 创建对应表。
|
||||
# conftest.py 已注册 ChatMessage / CuriosityQuestion / CharacterEmotionState / TimeAnniversary,
|
||||
# 但未注册 Character 与 ChatSession。
|
||||
from app.models.character import Character # noqa: F401
|
||||
from app.models.chat import ChatSession # noqa: F401
|
||||
from app.modules.knocking.conditions import ConditionResult
|
||||
from app.modules.knocking.opener import OpenerResult
|
||||
from app.modules.knocking.pivot import KnockingPivot
|
||||
from app.services.personality import PersonalitySummary
|
||||
|
||||
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def keeper_session_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession], None]:
|
||||
"""Yield an async_sessionmaker backed by an in-memory SQLite database."""
|
||||
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def dbevent_store() -> AsyncGenerator:
|
||||
"""In-memory event store (no DB needed, supports _events for assertion)."""
|
||||
from app.modules.runtime.events import InMemoryEventStore
|
||||
|
||||
yield InMemoryEventStore()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def keeper(
|
||||
keeper_session_factory: async_sessionmaker[AsyncSession],
|
||||
dbevent_store,
|
||||
) -> AsyncGenerator[KnockingPivot, None]:
|
||||
"""KnockingPivot instance backed by an in-memory SQLite database."""
|
||||
pivot = KnockingPivot(
|
||||
event_store=dbevent_store,
|
||||
session_factory=keeper_session_factory,
|
||||
tick_interval=3600, # 测试期间不真的 tick
|
||||
)
|
||||
yield pivot
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试
|
||||
# ======================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_triggers_for_active_character(
|
||||
keeper: KnockingPivot,
|
||||
keeper_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""活跃角色 + 任意条件触发时,应完成完整触发链:开场 → 消息 → 事件。"""
|
||||
async with keeper_session_factory() as session:
|
||||
char = Character(
|
||||
id="char_test_1",
|
||||
name="测试人物",
|
||||
status="active",
|
||||
owner_scope="test",
|
||||
language="zh-CN",
|
||||
privacy_level="normal",
|
||||
)
|
||||
session.add(char)
|
||||
await session.commit()
|
||||
|
||||
with (
|
||||
patch.object(keeper.conditions[0], "check", AsyncMock(return_value=ConditionResult(triggered=True, trigger_reason="测试触发", condition_name="test"))),
|
||||
patch(
|
||||
"app.modules.knocking.pivot.build_personality_summary",
|
||||
return_value=PersonalitySummary(text="你是一个测试人物。"),
|
||||
),
|
||||
patch(
|
||||
"app.modules.knocking.pivot.generate_opener",
|
||||
return_value=OpenerResult(text="测试开场白。"),
|
||||
),
|
||||
):
|
||||
async with keeper_session_factory() as check_session:
|
||||
processed = await keeper._check_and_trigger(
|
||||
"char_test_1",
|
||||
check_session,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
|
||||
assert processed is True
|
||||
|
||||
# 验证消息已创建
|
||||
async with keeper_session_factory() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(ChatSession).where(ChatSession.character_id == "char_test_1")
|
||||
)
|
||||
assert result.scalar_one_or_none() is not None
|
||||
|
||||
# 验证一炁事件已推送
|
||||
events = keeper._event_store._events
|
||||
assert len(events) > 0
|
||||
last_event = events[-1]
|
||||
assert last_event.source == "knocking_pivot"
|
||||
assert last_event.character_id == "char_test_1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_skips_when_no_active_characters(keeper: KnockingPivot) -> None:
|
||||
"""无活跃角色时 tick 不触发任何操作。"""
|
||||
with (
|
||||
patch("app.modules.knocking.pivot.create_proactive_message") as mock_create,
|
||||
patch("app.modules.knocking.pivot.generate_opener"),
|
||||
):
|
||||
await keeper._tick()
|
||||
|
||||
mock_create.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cooldown_prevents_retrigger(keeper: KnockingPivot) -> None:
|
||||
"""触发后冷却期内 _can_trigger 返回 False。"""
|
||||
now = datetime.now(UTC)
|
||||
assert keeper._can_trigger("char_1", now) is True
|
||||
|
||||
keeper._mark_triggered("char_1", now)
|
||||
assert keeper._can_trigger("char_1", now) is False
|
||||
|
||||
# 冷却结束后应恢复
|
||||
after_cooldown = now + timedelta(minutes=keeper._cooldown_minutes + 1)
|
||||
assert keeper._can_trigger("char_1", after_cooldown) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_daily_limit_prevents_excess(keeper: KnockingPivot) -> None:
|
||||
"""同一天达到上限后 _can_trigger 返回 False。"""
|
||||
now = datetime.now(UTC)
|
||||
assert keeper._can_trigger("char_1", now) is True
|
||||
|
||||
keeper._daily_counts["char_1"] = (now.date().isoformat(), keeper._daily_limit)
|
||||
assert keeper._can_trigger("char_1", now) is False
|
||||
|
||||
# 第二天恢复
|
||||
next_day = now + timedelta(days=1)
|
||||
assert keeper._can_trigger("char_1", next_day) is True
|
||||
|
||||
|
||||
def test_quiet_hours(keeper: KnockingPivot) -> None:
|
||||
"""静默时段 23:00–08:00 _in_quiet_hours 返回 True。"""
|
||||
keeper._quiet_hours_start = 23
|
||||
keeper._quiet_hours_end = 8
|
||||
|
||||
# 静默时段内
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 23, 30, tzinfo=UTC)) is True
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 3, 0, tzinfo=UTC)) is True
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 7, 59, tzinfo=UTC)) is True
|
||||
|
||||
# 静默时段外
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 8, 0, tzinfo=UTC)) is False
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 12, 0, tzinfo=UTC)) is False
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 22, 59, tzinfo=UTC)) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_active_characters(
|
||||
keeper: KnockingPivot,
|
||||
keeper_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""_list_active_characters 只返回 status='active' 的角色。"""
|
||||
async with keeper_session_factory() as session:
|
||||
for i, status in enumerate(["active", "inactive", "paused", "active"]):
|
||||
session.add(
|
||||
Character(
|
||||
id=f"char_{i}",
|
||||
name=f"人物{i}",
|
||||
status=status,
|
||||
owner_scope="test",
|
||||
language="zh-CN",
|
||||
privacy_level="normal",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with keeper_session_factory() as session:
|
||||
result = await keeper._list_active_characters(session)
|
||||
|
||||
assert set(result) == {"char_0", "char_3"}
|
||||
Whitespace-only changes.
@@ -0,0 +1,130 @@
|
||||
"""世谱匹配 / 预算 / 注入分组单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.lorebook import (
|
||||
LorebookBook,
|
||||
LorebookEntry,
|
||||
MatchedEntry,
|
||||
build_injections,
|
||||
match_entries,
|
||||
select_within_budget,
|
||||
)
|
||||
|
||||
|
||||
def _entry(eid: str, **kw) -> LorebookEntry:
|
||||
return LorebookEntry(id=eid, **kw)
|
||||
|
||||
|
||||
def _book(entries: list[LorebookEntry], **kw) -> LorebookBook:
|
||||
return LorebookBook(id="lorebook_x", entries=entries, **kw)
|
||||
|
||||
|
||||
def test_keyword_match() -> None:
|
||||
book = _book([_entry("1", keys=["猫"], content="她喜欢猫。")])
|
||||
assert [m.entry.id for m in match_entries(book, "今天看到一只猫")] == ["1"]
|
||||
|
||||
|
||||
def test_no_match_when_keyword_absent() -> None:
|
||||
book = _book([_entry("1", keys=["猫"], content="她喜欢猫。")])
|
||||
assert match_entries(book, "今天天气不错") == []
|
||||
|
||||
|
||||
def test_case_insensitive_by_default() -> None:
|
||||
book = _book([_entry("1", keys=["cat"], content="cat info")])
|
||||
assert match_entries(book, "I have a CAT") != []
|
||||
|
||||
|
||||
def test_case_sensitive() -> None:
|
||||
book = _book([_entry("1", keys=["Cat"], content="x", case_sensitive=True)])
|
||||
assert match_entries(book, "a cat here") == []
|
||||
assert [m.entry.id for m in match_entries(book, "a Cat here")] == ["1"]
|
||||
|
||||
|
||||
def test_constant_always_matched() -> None:
|
||||
book = _book([_entry("1", keys=["不会命中"], content="恒定设定", constant=True)])
|
||||
matched = match_entries(book, "无关文本")
|
||||
assert [m.entry.id for m in matched] == ["1"]
|
||||
assert matched[0].reason == "constant"
|
||||
|
||||
|
||||
def test_disabled_entry_skipped() -> None:
|
||||
book = _book([_entry("1", keys=["猫"], content="x", enabled=False)])
|
||||
assert match_entries(book, "一只猫") == []
|
||||
|
||||
|
||||
def test_selective_requires_both_keys() -> None:
|
||||
book = _book(
|
||||
[
|
||||
_entry(
|
||||
"1",
|
||||
keys=["猫"],
|
||||
secondary_keys=["黑"],
|
||||
content="黑猫设定",
|
||||
selective=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
assert match_entries(book, "看到一只猫") == []
|
||||
assert [m.entry.id for m in match_entries(book, "看到一只黑猫")] == ["1"]
|
||||
|
||||
|
||||
def test_regex_match() -> None:
|
||||
book = _book([_entry("1", keys=[r"\b\d{4}\b"], content="年份", use_regex=True)])
|
||||
assert [m.entry.id for m in match_entries(book, "生于 1999 年")] == ["1"]
|
||||
assert match_entries(book, "生于 99 年") == []
|
||||
|
||||
|
||||
def test_recursive_scanning() -> None:
|
||||
# 条目 1 命中关键词,其 content 含触发条目 2 的关键词
|
||||
book = _book(
|
||||
[
|
||||
_entry("1", keys=["学校"], content="她在学校认识小明。"),
|
||||
_entry("2", keys=["小明"], content="小明是同班同学。"),
|
||||
],
|
||||
recursive_scanning=True,
|
||||
)
|
||||
matched = match_entries(book, "今天去了学校")
|
||||
ids = {m.entry.id for m in matched}
|
||||
assert ids == {"1", "2"}
|
||||
assert any(m.reason == "recursive" for m in matched if m.entry.id == "2")
|
||||
|
||||
|
||||
def test_budget_keeps_lower_priority() -> None:
|
||||
book = _book(
|
||||
[
|
||||
_entry("a", keys=["k"], content="aaaa", priority=10),
|
||||
_entry("b", keys=["k"], content="bbbb", priority=1),
|
||||
],
|
||||
token_budget=5,
|
||||
)
|
||||
matched = match_entries(book, "k")
|
||||
kept = select_within_budget(matched, token_budget=5)
|
||||
# 仅 b(cost=4)能进预算;a(cost=4,但 priority 低)在 b 之后超预算
|
||||
assert [m.entry.id for m in kept] == ["b"]
|
||||
|
||||
|
||||
def test_budget_none_keeps_all_sorted_by_order() -> None:
|
||||
book = _book(
|
||||
[
|
||||
_entry("a", keys=["k"], content="x", priority=5, insertion_order=20),
|
||||
_entry("b", keys=["k"], content="y", priority=1, insertion_order=10),
|
||||
]
|
||||
)
|
||||
kept = select_within_budget(match_entries(book, "k"))
|
||||
assert [m.entry.id for m in kept] == ["b", "a"]
|
||||
|
||||
|
||||
def test_build_injections_groups_by_position() -> None:
|
||||
matched = [
|
||||
MatchedEntry(entry=_entry("a", keys=["k"], content="前文", position="before_char")),
|
||||
MatchedEntry(entry=_entry("b", keys=["k"], content="后文1", position="after_char")),
|
||||
MatchedEntry(entry=_entry("c", keys=["k"], content="后文2", position="after_char")),
|
||||
]
|
||||
inj = build_injections(matched)
|
||||
assert inj == {"before_char": "前文", "after_char": "后文1\n后文2"}
|
||||
|
||||
|
||||
def test_unknown_position_falls_back_to_after_char() -> None:
|
||||
matched = [MatchedEntry(entry=_entry("a", keys=["k"], content="x", position="weird"))]
|
||||
assert build_injections(matched) == {"after_char": "x"}
|
||||
Whitespace-only changes.
@@ -0,0 +1,76 @@
|
||||
"""符文引擎单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.macros import MacroContext, render
|
||||
|
||||
|
||||
def test_replaces_char_and_user() -> None:
|
||||
ctx = MacroContext(char_name="狂三", user_name="士道")
|
||||
assert render("{{char}}对{{user}}说", ctx) == "狂三对士道说"
|
||||
|
||||
|
||||
def test_bot_is_char_alias() -> None:
|
||||
assert render("{{bot}}", MacroContext(char_name="狂三")) == "狂三"
|
||||
|
||||
|
||||
def test_original_macro() -> None:
|
||||
assert render("[{{original}}]", MacroContext(original="原辞")) == "[原辞]"
|
||||
|
||||
|
||||
def test_case_insensitive() -> None:
|
||||
assert render("{{Char}}", MacroContext(char_name="狂三")) == "狂三"
|
||||
|
||||
|
||||
def test_unknown_macro_preserved() -> None:
|
||||
assert render("{{unknown}}", MacroContext()) == "{{unknown}}"
|
||||
|
||||
|
||||
def test_empty_braces_preserved() -> None:
|
||||
assert render("a{{}}b", MacroContext()) == "a{{}}b"
|
||||
|
||||
|
||||
def test_resolver_exception_preserved() -> None:
|
||||
# {{roll:}} 无有效规格参数 → 解析器走默认 1d6,不应抛错;这里测无参 random 不抛
|
||||
assert render("{{random}}", MacroContext()) == ""
|
||||
|
||||
|
||||
def test_random_picks_from_args() -> None:
|
||||
ctx = MacroContext()
|
||||
for _ in range(20):
|
||||
assert render("{{random:甲,乙,丙}}", ctx) in {"甲", "乙", "丙"}
|
||||
|
||||
|
||||
def test_roll_in_range() -> None:
|
||||
for _ in range(20):
|
||||
result = render("{{roll:2d6}}", MacroContext())
|
||||
assert 2 <= int(result) <= 12
|
||||
|
||||
|
||||
def test_roll_default_and_invalid() -> None:
|
||||
# 默认 1d6
|
||||
assert 1 <= int(render("{{roll}}", MacroContext())) <= 6
|
||||
# 非法规格原样返回
|
||||
assert render("{{roll:xyz}}", MacroContext()) == "xyz"
|
||||
|
||||
|
||||
def test_getvar() -> None:
|
||||
ctx = MacroContext(variables={"mood": "怅然"})
|
||||
assert render("{{getvar:mood}}", ctx) == "怅然"
|
||||
assert render("{{getvar:missing}}", ctx) == ""
|
||||
|
||||
|
||||
def test_recursive_expansion() -> None:
|
||||
# char 值内含 {{user}},递归展开
|
||||
ctx = MacroContext(char_name="{{user}}的影子", user_name="士道")
|
||||
assert render("{{char}}", ctx) == "士道的影子"
|
||||
|
||||
|
||||
def test_time_macro_without_snapshot_is_empty() -> None:
|
||||
ctx = MacroContext()
|
||||
assert render("{{date}}", ctx) == ""
|
||||
assert render("{{shichen}}", ctx) == ""
|
||||
|
||||
|
||||
def test_no_braces_unchanged() -> None:
|
||||
assert render("普通文本无占位符", MacroContext()) == "普通文本无占位符"
|
||||
@@ -0,0 +1,182 @@
|
||||
"""灵核情绪状态机纯计算测试(modules/personality/emotion.py)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.personality.emotion import (
|
||||
MOODS,
|
||||
EmotionState,
|
||||
apply_signal,
|
||||
decay,
|
||||
map_signal_to_mood,
|
||||
parse_baseline,
|
||||
signal_intensity_delta,
|
||||
to_prompt_text,
|
||||
)
|
||||
|
||||
|
||||
def _baseline(mood: str = "neutral", intensity: float = 0.2) -> EmotionState:
|
||||
return EmotionState(mood=mood, intensity=intensity)
|
||||
|
||||
|
||||
# ---- map_signal_to_mood ----
|
||||
|
||||
|
||||
def test_map_reunion_to_touched() -> None:
|
||||
assert map_signal_to_mood("time.reunion", {"reunion_intensity": 0.8}) == "touched"
|
||||
|
||||
|
||||
def test_map_user_care_default_to_concerned() -> None:
|
||||
assert map_signal_to_mood("time.user_care", {"care_kind": "late_night"}) == "concerned"
|
||||
|
||||
|
||||
def test_map_user_care_festival_lonely_to_melancholy() -> None:
|
||||
assert map_signal_to_mood("time.user_care", {"care_kind": "festival_lonely"}) == "melancholy"
|
||||
|
||||
|
||||
def test_map_sentiment_default_to_wistful() -> None:
|
||||
assert map_signal_to_mood("time.sentiment", {"trigger_type": "reunion_memory"}) == "wistful"
|
||||
|
||||
|
||||
def test_map_sentiment_late_night_to_melancholy() -> None:
|
||||
assert map_signal_to_mood("time.sentiment", {"trigger_type": "late_night"}) == "melancholy"
|
||||
|
||||
|
||||
def test_map_unknown_event_returns_none() -> None:
|
||||
assert map_signal_to_mood("tool.started", {}) is None
|
||||
|
||||
|
||||
# ---- signal_intensity_delta ----
|
||||
|
||||
|
||||
def test_delta_reunion_uses_reunion_intensity() -> None:
|
||||
assert signal_intensity_delta("time.reunion", {"reunion_intensity": 0.7}) == 0.7
|
||||
|
||||
|
||||
def test_delta_user_care_uses_care_suggest() -> None:
|
||||
assert signal_intensity_delta("time.user_care", {"care_suggest": 0.6}) == 0.6
|
||||
|
||||
|
||||
def test_delta_sentiment_default_05() -> None:
|
||||
assert signal_intensity_delta("time.sentiment", {}) == 0.5
|
||||
|
||||
|
||||
def test_delta_unknown_event_zero() -> None:
|
||||
assert signal_intensity_delta("tool.started", {}) == 0.0
|
||||
|
||||
|
||||
# ---- apply_signal ----
|
||||
|
||||
|
||||
def test_apply_signal_changes_mood_and_sets_intensity_from_baseline() -> None:
|
||||
state = _baseline() # neutral 0.2
|
||||
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.6}, _baseline())
|
||||
assert new.mood == "touched"
|
||||
assert new.intensity == 0.8 # 0.2 baseline + 0.6 delta
|
||||
|
||||
|
||||
def test_apply_signal_same_mood_accumulates() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.5)
|
||||
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.3}, _baseline())
|
||||
assert new.mood == "touched"
|
||||
assert new.intensity == 0.8 # 0.5 current + 0.3 delta
|
||||
|
||||
|
||||
def test_apply_signal_clamps_to_1() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.9)
|
||||
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.5}, _baseline())
|
||||
assert new.mood == "touched"
|
||||
assert new.intensity == 1.0
|
||||
|
||||
|
||||
def test_apply_signal_unknown_event_returns_unchanged() -> None:
|
||||
state = EmotionState(mood="pleased", intensity=0.5)
|
||||
new = apply_signal(state, "tool.started", {}, _baseline())
|
||||
assert new == state
|
||||
|
||||
|
||||
# ---- decay ----
|
||||
|
||||
|
||||
def test_decay_toward_baseline_half_life() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.8)
|
||||
baseline = _baseline() # neutral 0.2
|
||||
# 半衰期 6 回合,elapsed 6 → intensity 向 baseline 走一半
|
||||
decayed = decay(state, baseline, elapsed_turns=6.0)
|
||||
assert decayed.mood == "touched"
|
||||
assert abs(decayed.intensity - 0.5) < 0.01 # 0.2 + (0.8-0.2)*0.5 = 0.5
|
||||
|
||||
|
||||
def test_decay_below_threshold_returns_baseline_mood() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.3)
|
||||
baseline = _baseline()
|
||||
# elapsed 大 → intensity 衰减到 < 0.15 → mood 回 baseline
|
||||
decayed = decay(state, baseline, elapsed_turns=30.0)
|
||||
assert decayed.mood == baseline.mood
|
||||
assert decayed.intensity == baseline.intensity
|
||||
|
||||
|
||||
def test_decay_zero_elapsed_returns_unchanged() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.8)
|
||||
baseline = _baseline()
|
||||
assert decay(state, baseline, elapsed_turns=0) == state
|
||||
|
||||
|
||||
# ---- parse_baseline ----
|
||||
|
||||
|
||||
def test_parse_baseline_from_dict() -> None:
|
||||
assert parse_baseline({"mood": "neutral", "intensity": 0.3}) == EmotionState(
|
||||
mood="neutral", intensity=0.3
|
||||
)
|
||||
|
||||
|
||||
def test_parse_baseline_from_valid_json() -> None:
|
||||
assert parse_baseline('{"mood": "pleased", "intensity": 0.4}') == EmotionState(
|
||||
mood="pleased", intensity=0.4
|
||||
)
|
||||
|
||||
|
||||
def test_parse_baseline_invalid_json_returns_neutral_default() -> None:
|
||||
assert parse_baseline("not json") == EmotionState()
|
||||
|
||||
|
||||
def test_parse_baseline_none_returns_neutral_default() -> None:
|
||||
assert parse_baseline(None) == EmotionState()
|
||||
|
||||
|
||||
def test_parse_baseline_clamps_intensity() -> None:
|
||||
assert parse_baseline({"mood": "neutral", "intensity": 1.5}).intensity == 1.0
|
||||
assert parse_baseline({"mood": "neutral", "intensity": -0.2}).intensity == 0.0
|
||||
|
||||
|
||||
def test_parse_baseline_unknown_mood_falls_back_neutral() -> None:
|
||||
assert parse_baseline({"mood": "unknown_mood", "intensity": 0.5}).mood == "neutral"
|
||||
|
||||
|
||||
# ---- to_prompt_text ----
|
||||
|
||||
|
||||
def test_to_prompt_text_includes_chinese_label_and_pct() -> None:
|
||||
text = to_prompt_text(EmotionState(mood="touched", intensity=0.7))
|
||||
assert "感动" in text
|
||||
assert "70" in text
|
||||
|
||||
|
||||
def test_to_prompt_text_unknown_mood_uses_raw() -> None:
|
||||
text = to_prompt_text(EmotionState(mood="custom", intensity=0.5))
|
||||
assert "custom" in text
|
||||
|
||||
|
||||
# ---- MOODS ----
|
||||
|
||||
|
||||
def test_moods_contains_seven() -> None:
|
||||
assert set(MOODS) == {
|
||||
"neutral",
|
||||
"pleased",
|
||||
"touched",
|
||||
"wistful",
|
||||
"concerned",
|
||||
"festive",
|
||||
"melancholy",
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"""小周天每轮反思抽取纯函数测试。"""
|
||||
|
||||
from app.modules.extraction.extract import MemoryCandidateDraft
|
||||
from app.modules.reflection.extract import parse_reflection
|
||||
|
||||
|
||||
def test_parse_standard() -> None:
|
||||
raw = (
|
||||
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
|
||||
'"importance":0.8,"sensitivity":"low","confidence":0.7,"reason":"多次提及"}],'
|
||||
'"reflection":"本轮应更主动确认用户意图。"}'
|
||||
)
|
||||
memories, reflection = parse_reflection(raw)
|
||||
assert len(memories) == 1
|
||||
assert memories[0].title == "喜欢简洁"
|
||||
assert isinstance(memories[0], MemoryCandidateDraft)
|
||||
assert reflection == "本轮应更主动确认用户意图。"
|
||||
|
||||
|
||||
def test_parse_markdown_block() -> None:
|
||||
raw = '```json\n{"memories":[],"reflection":"反思文本"}\n```'
|
||||
memories, reflection = parse_reflection(raw)
|
||||
assert memories == []
|
||||
assert reflection == "反思文本"
|
||||
|
||||
|
||||
def test_parse_invalid_returns_empty() -> None:
|
||||
assert parse_reflection("不是JSON") == ([], "")
|
||||
assert parse_reflection("[]") == ([], "")
|
||||
|
||||
|
||||
def test_missing_reflection_returns_empty_string() -> None:
|
||||
memories, reflection = parse_reflection('{"memories":[]}')
|
||||
assert memories == []
|
||||
assert reflection == ""
|
||||
|
||||
|
||||
def test_missing_memories_returns_empty_list() -> None:
|
||||
memories, reflection = parse_reflection('{"reflection":"只有反思"}')
|
||||
assert memories == []
|
||||
assert reflection == "只有反思"
|
||||
|
||||
|
||||
def test_memory_safe_defaults() -> None:
|
||||
raw = '{"memories":[{"title":"t","content":"c"}],"reflection":"r"}'
|
||||
m = parse_reflection(raw)[0][0]
|
||||
assert m.sensitivity == "low"
|
||||
assert m.confidence == 0.5
|
||||
assert m.importance == 0.5
|
||||
|
||||
|
||||
def test_skip_memory_missing_title_or_content() -> None:
|
||||
raw = (
|
||||
'{"memories":[{"title":"t"},{"content":"c"},{"title":"t","content":"c"}],"reflection":"r"}'
|
||||
)
|
||||
assert len(parse_reflection(raw)[0]) == 1
|
||||
|
||||
|
||||
def test_clamp_values() -> None:
|
||||
raw = '{"memories":[{"title":"t","content":"c","importance":1.5}],"reflection":"r"}'
|
||||
assert parse_reflection(raw)[0][0].importance == 1.0
|
||||
|
||||
|
||||
def test_reflection_strips_whitespace() -> None:
|
||||
raw = '{"memories":[],"reflection":" 带空格的反思 "}'
|
||||
assert parse_reflection(raw)[1] == "带空格的反思"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""小周天反思 prompt 构建纯函数测试。"""
|
||||
|
||||
from app.modules.reflection.prompts import build_reflection_messages
|
||||
|
||||
|
||||
def test_messages_return_system_user_pair() -> None:
|
||||
system, user = build_reflection_messages("你好", "你好,我是魂镜")
|
||||
assert isinstance(system, str) and isinstance(user, str)
|
||||
assert system and user
|
||||
|
||||
|
||||
def test_system_describes_memories_and_reflection() -> None:
|
||||
system, _ = build_reflection_messages("u", "a")
|
||||
for token in ("memories", "reflection", "importance", "sensitivity"):
|
||||
assert token in system
|
||||
|
||||
|
||||
def test_user_includes_dialogue() -> None:
|
||||
_, user = build_reflection_messages("用户说的话", "助手的回复")
|
||||
assert "用户说的话" in user
|
||||
assert "助手的回复" in user
|
||||
@@ -0,0 +1,77 @@
|
||||
"""事件存储测试:订阅推送、sequence 单调、run 隔离。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.runtime.events import Event, InMemoryEventStore
|
||||
|
||||
|
||||
async def test_subscribe_receives_appended_event() -> None:
|
||||
store = InMemoryEventStore()
|
||||
queue = store.subscribe("run_1")
|
||||
await store.append(
|
||||
Event(type="tool.started", source="tool", run_id="run_1", payload={"tool": "calc"})
|
||||
)
|
||||
event = await asyncio.wait_for(queue.get(), timeout=1.0)
|
||||
assert event.type == "tool.started"
|
||||
assert event.run_id == "run_1"
|
||||
|
||||
|
||||
async def test_sequence_increments_per_run() -> None:
|
||||
store = InMemoryEventStore()
|
||||
first = await store.append(Event(type="tool.started", source="tool", run_id="r1"))
|
||||
second = await store.append(Event(type="tool.completed", source="tool", run_id="r1"))
|
||||
assert first.sequence == 1
|
||||
assert second.sequence == 2
|
||||
|
||||
|
||||
async def test_subscribe_isolated_by_run() -> None:
|
||||
store = InMemoryEventStore()
|
||||
queue_a = store.subscribe("run_a")
|
||||
await store.append(Event(type="x", source="tool", run_id="run_b"))
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(queue_a.get(), timeout=0.3)
|
||||
|
||||
|
||||
async def test_unsubscribe_stops_delivery() -> None:
|
||||
store = InMemoryEventStore()
|
||||
queue = store.subscribe("run_1")
|
||||
store.unsubscribe("run_1", queue)
|
||||
await store.append(Event(type="x", source="tool", run_id="run_1"))
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(queue.get(), timeout=0.3)
|
||||
|
||||
|
||||
async def test_subscribe_by_pattern_glob_match() -> None:
|
||||
"""subscribe_by_pattern('time.*') 仅收 type 匹配的事件,不收其它 type。"""
|
||||
store = InMemoryEventStore()
|
||||
queue = store.subscribe_by_pattern("time.*")
|
||||
await store.append(Event(type="time.reunion", source="time_sense"))
|
||||
await store.append(Event(type="run.started", source="run"))
|
||||
# time.reunion 命中,立即可取(append 内 put_nowait 同步执行)
|
||||
received = queue.get_nowait()
|
||||
assert received.type == "time.reunion"
|
||||
# run.started 不命中,队列应空
|
||||
assert queue.empty()
|
||||
|
||||
|
||||
async def test_unsubscribe_by_pattern_stops_delivery() -> None:
|
||||
"""unsubscribe_by_pattern 后不再收到事件。"""
|
||||
store = InMemoryEventStore()
|
||||
queue = store.subscribe_by_pattern("time.*")
|
||||
store.unsubscribe_by_pattern("time.*", queue)
|
||||
await store.append(Event(type="time.reunion", source="time_sense"))
|
||||
assert queue.empty()
|
||||
|
||||
|
||||
async def test_subscribe_by_pattern_wildcard_all() -> None:
|
||||
"""subscribe_by_pattern('*') 收所有 type(Dispatcher 用此订阅全量)。"""
|
||||
store = InMemoryEventStore()
|
||||
queue = store.subscribe_by_pattern("*")
|
||||
await store.append(Event(type="time.reunion", source="time_sense"))
|
||||
await store.append(Event(type="run.started", source="run"))
|
||||
assert queue.get_nowait().type == "time.reunion"
|
||||
assert queue.get_nowait().type == "run.started"
|
||||
@@ -0,0 +1,50 @@
|
||||
"""测试目标上下文注入。"""
|
||||
|
||||
from app.modules.runtime.goals import build_goals_context
|
||||
|
||||
|
||||
def test_build_goals_context_with_active_goals():
|
||||
"""生成了 Active Goals 系统提示片段。"""
|
||||
goals = [
|
||||
{
|
||||
"title": "学习 Python",
|
||||
"level": "long_term",
|
||||
"status": "active",
|
||||
"priority": 80,
|
||||
"progress": 0.5,
|
||||
},
|
||||
{
|
||||
"title": "完成项目文档",
|
||||
"level": "task",
|
||||
"status": "active",
|
||||
"priority": 60,
|
||||
"progress": 0.0,
|
||||
"parent_id": "goal_1",
|
||||
},
|
||||
]
|
||||
result = build_goals_context(goals)
|
||||
assert "学习 Python" in result
|
||||
assert "任务目标" in result or "task" in result or "长期目标" in result
|
||||
assert result.startswith("当前活跃目标:") or "当前" in result
|
||||
|
||||
|
||||
def test_build_goals_context_empty():
|
||||
assert build_goals_context([]) == ""
|
||||
|
||||
|
||||
def test_build_goals_context_sorts_by_priority():
|
||||
"""按优先级降序排列输出。"""
|
||||
goals = [
|
||||
{"title": "low", "level": "task", "status": "active", "priority": 10, "progress": 0.0},
|
||||
{
|
||||
"title": "high",
|
||||
"level": "long_term",
|
||||
"status": "active",
|
||||
"priority": 90,
|
||||
"progress": 0.5,
|
||||
},
|
||||
]
|
||||
result = build_goals_context(goals)
|
||||
high_idx = result.index("high")
|
||||
low_idx = result.index("low")
|
||||
assert high_idx < low_idx # high priority first
|
||||
@@ -0,0 +1,120 @@
|
||||
"""DbConfirmationStore 单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db.base import Base
|
||||
from app.modules.safety.confirmation import (
|
||||
DbConfirmationStore,
|
||||
PendingConfirmation,
|
||||
new_confirmation_id,
|
||||
)
|
||||
|
||||
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store(db_session: AsyncSession) -> DbConfirmationStore:
|
||||
"""创建绑定 db_session 的 DbConfirmationStore。"""
|
||||
session_factory = sessionmaker(db_session.bind, class_=AsyncSession, expire_on_commit=False)
|
||||
return DbConfirmationStore(session_factory)
|
||||
|
||||
|
||||
class TestDbConfirmationStore:
|
||||
"""数据库确认存储 CRUD 测试。"""
|
||||
|
||||
async def test_create_and_get(self, store: DbConfirmationStore) -> None:
|
||||
pending = PendingConfirmation(
|
||||
confirmation_id=new_confirmation_id(),
|
||||
tool="fetch_url",
|
||||
input={"q": "test"},
|
||||
risk_level="L2",
|
||||
reason="测试确认",
|
||||
)
|
||||
created = await store.create(pending)
|
||||
assert created.confirmation_id == pending.confirmation_id
|
||||
|
||||
fetched = await store.get(pending.confirmation_id)
|
||||
assert fetched is not None
|
||||
assert fetched.tool == "fetch_url"
|
||||
assert fetched.input == {"q": "test"}
|
||||
|
||||
async def test_get_non_existent(self, store: DbConfirmationStore) -> None:
|
||||
result = await store.get("confirm_nonexistent")
|
||||
assert result is None
|
||||
|
||||
async def test_pop_removes_and_returns(self, store: DbConfirmationStore) -> None:
|
||||
pending = PendingConfirmation(
|
||||
confirmation_id=new_confirmation_id(),
|
||||
tool="shell",
|
||||
input={"cmd": "ls"},
|
||||
risk_level="L3",
|
||||
reason="高风险操作",
|
||||
)
|
||||
await store.create(pending)
|
||||
|
||||
popped = await store.pop(pending.confirmation_id)
|
||||
assert popped is not None
|
||||
assert popped.tool == "shell"
|
||||
|
||||
# pop 后不应再可取到
|
||||
fetched = await store.get(pending.confirmation_id)
|
||||
assert fetched is None
|
||||
|
||||
async def test_pop_non_existent(self, store: DbConfirmationStore) -> None:
|
||||
result = await store.pop("confirm_nonexistent")
|
||||
assert result is None
|
||||
|
||||
async def test_list_pending(self, store: DbConfirmationStore) -> None:
|
||||
# 插入两条
|
||||
id1 = new_confirmation_id()
|
||||
id2 = new_confirmation_id()
|
||||
await store.create(
|
||||
PendingConfirmation(
|
||||
confirmation_id=id1, tool="fetch_url", input={}, risk_level="L1", reason="a"
|
||||
)
|
||||
)
|
||||
await store.create(
|
||||
PendingConfirmation(
|
||||
confirmation_id=id2, tool="shell", input={}, risk_level="L3", reason="b"
|
||||
)
|
||||
)
|
||||
items = await store.list_pending()
|
||||
assert len(items) == 2
|
||||
# 按创建时间升序
|
||||
assert items[0].confirmation_id == id1
|
||||
assert items[1].confirmation_id == id2
|
||||
|
||||
async def test_list_pending_empty(self, store: DbConfirmationStore) -> None:
|
||||
items = await store.list_pending()
|
||||
assert items == []
|
||||
|
||||
async def test_popped_not_in_list_pending(self, store: DbConfirmationStore) -> None:
|
||||
pending = PendingConfirmation(
|
||||
confirmation_id=new_confirmation_id(),
|
||||
tool="fetch_url",
|
||||
input={},
|
||||
risk_level="L2",
|
||||
reason="测试",
|
||||
)
|
||||
await store.create(pending)
|
||||
await store.pop(pending.confirmation_id)
|
||||
items = await store.list_pending()
|
||||
ids = [i.confirmation_id for i in items]
|
||||
assert pending.confirmation_id not in ids
|
||||
@@ -0,0 +1,66 @@
|
||||
"""司命决策器测试:风险等级 × 权限策略 × 是否确认 的决策矩阵,以及策略字典加载。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.safety.governor import (
|
||||
PermissionPolicy,
|
||||
SafetyDecision,
|
||||
SafetyGovernor,
|
||||
ToolPolicy,
|
||||
)
|
||||
|
||||
|
||||
def test_low_risk_defaults_to_allow() -> None:
|
||||
governor = SafetyGovernor()
|
||||
result = governor.evaluate(tool="calc", risk_level="L1")
|
||||
assert result.decision is SafetyDecision.allow
|
||||
|
||||
|
||||
def test_medium_risk_unconfirmed_asks() -> None:
|
||||
governor = SafetyGovernor()
|
||||
result = governor.evaluate(tool="x", risk_level="L2")
|
||||
assert result.decision is SafetyDecision.ask
|
||||
|
||||
|
||||
def test_confirmed_becomes_allow() -> None:
|
||||
governor = SafetyGovernor()
|
||||
result = governor.evaluate(tool="x", risk_level="L3", confirmed=True)
|
||||
assert result.decision is SafetyDecision.allow
|
||||
|
||||
|
||||
def test_l4_always_denied() -> None:
|
||||
governor = SafetyGovernor()
|
||||
result = governor.evaluate(tool="x", risk_level="L4")
|
||||
assert result.decision is SafetyDecision.deny
|
||||
|
||||
|
||||
def test_forbidden_policy_denied() -> None:
|
||||
governor = SafetyGovernor(
|
||||
{"pay": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
|
||||
)
|
||||
result = governor.evaluate(tool="pay", risk_level="L3")
|
||||
assert result.decision is SafetyDecision.deny
|
||||
assert result.policy is PermissionPolicy.forbidden
|
||||
|
||||
|
||||
def test_explicit_tool_policy_overrides_default() -> None:
|
||||
governor = SafetyGovernor(
|
||||
{"read": ToolPolicy(risk_level="L2", permission_policy=PermissionPolicy.allow)}
|
||||
)
|
||||
result = governor.evaluate(tool="read")
|
||||
assert result.decision is SafetyDecision.allow
|
||||
|
||||
|
||||
def test_from_policy_dict_loads_policies() -> None:
|
||||
governor = SafetyGovernor.from_policy_dict(
|
||||
{"tools": {"custom": {"risk_level": "L2", "permission_policy": "allow"}}}
|
||||
)
|
||||
result = governor.evaluate(tool="custom")
|
||||
assert result.decision is SafetyDecision.allow
|
||||
|
||||
|
||||
def test_from_policy_dict_empty_returns_default() -> None:
|
||||
governor = SafetyGovernor.from_policy_dict({})
|
||||
# 空 dict → 空 Governor,未列工具按 risk 推导:L2 → ask
|
||||
result = governor.evaluate(tool="x", risk_level="L2")
|
||||
assert result.decision is SafetyDecision.ask
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Skill 关键词触发匹配纯函数测试。"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.modules.skills.matcher import match_skills
|
||||
|
||||
|
||||
def _skill(name: str, trigger: str, status: str = "enabled") -> SimpleNamespace:
|
||||
return SimpleNamespace(name=name, trigger=trigger, status=status)
|
||||
|
||||
|
||||
def test_match_by_keyword() -> None:
|
||||
skills = [_skill("coding", "代码,编程")]
|
||||
assert len(match_skills("帮我写代码", skills)) == 1
|
||||
|
||||
|
||||
def test_no_match() -> None:
|
||||
skills = [_skill("coding", "代码,编程")]
|
||||
assert match_skills("今天天气不错", skills) == []
|
||||
|
||||
|
||||
def test_case_insensitive() -> None:
|
||||
skills = [_skill("en", "Python")]
|
||||
assert len(match_skills("用 python 写", skills)) == 1
|
||||
|
||||
|
||||
def test_multiple_keywords_any_match() -> None:
|
||||
skills = [_skill("dev", "代码,debug,编程")]
|
||||
assert len(match_skills("帮我 debug", skills)) == 1
|
||||
|
||||
|
||||
def test_disabled_not_matched() -> None:
|
||||
skills = [_skill("coding", "代码", status="disabled")]
|
||||
assert match_skills("写代码", skills) == []
|
||||
|
||||
|
||||
def test_empty_trigger_not_matched() -> None:
|
||||
skills = [_skill("empty", "")]
|
||||
assert match_skills("任意文本", skills) == []
|
||||
|
||||
|
||||
def test_multiple_skills_order_preserved() -> None:
|
||||
skills = [_skill("a", "代码"), _skill("b", "天气"), _skill("c", "编程")]
|
||||
matched = match_skills("写代码和编程", skills)
|
||||
assert [s.name for s in matched] == ["a", "c"]
|
||||
|
||||
|
||||
def test_empty_context() -> None:
|
||||
skills = [_skill("a", "代码")]
|
||||
assert match_skills("", skills) == []
|
||||
|
||||
|
||||
def test_whitespace_only_trigger_ignored() -> None:
|
||||
skills = [_skill("ws", " , , ")]
|
||||
assert match_skills("任意文本", skills) == []
|
||||
Whitespace-only changes.
@@ -0,0 +1,39 @@
|
||||
"""temporal_context 文本组装测试(辰正 §8.1)。"""
|
||||
|
||||
from app.modules.time_sense.context_text import render_temporal_context
|
||||
|
||||
|
||||
def test_context_contains_shichen_and_period():
|
||||
from app.modules.time_sense.modulation import ModulationInput, compute_modulation
|
||||
from tests.modules.time_sense.test_modulation import _snapshot
|
||||
|
||||
snap = _snapshot(day_period="深夜", local_hour=23)
|
||||
factors = compute_modulation(snap, ModulationInput())
|
||||
text = render_temporal_context(snap, factors)
|
||||
assert "深夜" in text
|
||||
assert "周" in text
|
||||
|
||||
|
||||
def test_context_reunion_line():
|
||||
from app.modules.time_sense.duration import OfflineDuration
|
||||
from app.modules.time_sense.modulation import ModulationInput, compute_modulation
|
||||
from tests.modules.time_sense.test_modulation import _snapshot
|
||||
|
||||
snap = _snapshot(
|
||||
offline=OfflineDuration(minutes=4320 * 2, band="reunion_impact", reunion_intensity=0.7)
|
||||
)
|
||||
factors = compute_modulation(snap, ModulationInput())
|
||||
text = render_temporal_context(snap, factors)
|
||||
assert "久别重逢" in text
|
||||
|
||||
|
||||
def test_context_no_lunar_does_not_crash():
|
||||
"""lunar=None 时第二行跳过,不抛异常。"""
|
||||
from app.modules.time_sense.modulation import ModulationInput, compute_modulation
|
||||
from tests.modules.time_sense.test_modulation import _snapshot
|
||||
|
||||
snap = _snapshot(lunar=None)
|
||||
factors = compute_modulation(snap, ModulationInput())
|
||||
text = render_temporal_context(snap, factors)
|
||||
assert isinstance(text, str)
|
||||
assert len(text) > 0
|
||||
Loaded 100 of 963 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user