初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
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"
+100
View File
@@ -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"
+61
View File
@@ -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
View File
Whitespace-only changes.
+189
View File
@@ -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"
+126
View File
@@ -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"
+161
View File
@@ -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
+153
View File
@@ -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
+144
View File
@@ -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"
+66
View File
@@ -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"
+176
View File
@@ -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"
+142
View File
@@ -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
+145
View File
@@ -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
+79
View File
@@ -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
+77
View File
@@ -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
+68
View File
@@ -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
+41
View File
@@ -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"
+150
View File
@@ -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
+104
View File
@@ -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
+170
View File
@@ -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}"}
+23
View File
@@ -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
+53
View File
@@ -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"
+21
View File
@@ -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
View File
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": []}) == []
+195
View File
@@ -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