mirror of
https://github.com/FuQuan233/nonebot-plugin-llmchat.git
synced 2026-08-13 10:09:27 +00:00
♻️ 大幅重构,拆分模块,增加一些MCP相关限制
This commit is contained in:
parent
41e6aeacb9
commit
0d6771eca6
17 changed files with 1142 additions and 912 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
11
tests/bootstrap.py
Normal file
11
tests/bootstrap.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""加载子模块而不执行NoneBot插件注册入口。"""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
PACKAGE_NAME = "nonebot_plugin_llmchat"
|
||||
if PACKAGE_NAME not in sys.modules:
|
||||
package = ModuleType(PACKAGE_NAME)
|
||||
package.__path__ = [str(Path(__file__).parents[1] / PACKAGE_NAME)]
|
||||
sys.modules[PACKAGE_NAME] = package
|
||||
31
tests/test_config_and_messages.py
Normal file
31
tests/test_config_and_messages.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# ruff: noqa: I001
|
||||
import unittest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
import tests.bootstrap # noqa: F401
|
||||
from nonebot_plugin_llmchat.config import MCPServerConfig, PresetConfig, ScopedConfig
|
||||
from nonebot_plugin_llmchat.message_utils import pop_reasoning_content
|
||||
|
||||
|
||||
def assert_validation_error(factory):
|
||||
try:
|
||||
factory()
|
||||
except ValidationError:
|
||||
return
|
||||
raise AssertionError("expected ValidationError")
|
||||
|
||||
|
||||
class ConfigurationTests(unittest.TestCase):
|
||||
def test_rejects_duplicate_presets(self):
|
||||
preset = PresetConfig(name="same", api_base="x", api_key="x", model_name="x")
|
||||
assert_validation_error(lambda: ScopedConfig(api_presets=[preset, preset]))
|
||||
|
||||
def test_mcp_requires_exactly_one_transport_target(self):
|
||||
assert_validation_error(MCPServerConfig)
|
||||
assert_validation_error(lambda: MCPServerConfig(command="cmd", url="https://example.invalid"))
|
||||
|
||||
def test_reasoning_tag_is_removed(self):
|
||||
reply, reasoning = pop_reasoning_content("<think>secret</think>answer")
|
||||
assert reply == "answer"
|
||||
assert reasoning == "secret"
|
||||
49
tests/test_dispatcher.py
Normal file
49
tests/test_dispatcher.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import asyncio
|
||||
import unittest
|
||||
|
||||
# ruff: noqa: I001
|
||||
import tests.bootstrap # noqa: F401
|
||||
from nonebot_plugin_llmchat.dispatcher import MessageDispatcher
|
||||
from nonebot_plugin_llmchat.state import ChatState
|
||||
|
||||
|
||||
class DispatcherTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_messages_enqueued_while_worker_runs_are_not_stranded(self):
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
processed = []
|
||||
|
||||
async def process(_context_id, _is_group, _state, event):
|
||||
processed.append(event)
|
||||
if event == "first":
|
||||
started.set()
|
||||
await release.wait()
|
||||
|
||||
state = ChatState("test", 10, 10)
|
||||
dispatcher = MessageDispatcher(process)
|
||||
await dispatcher.enqueue(1, True, state, "first")
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
await dispatcher.enqueue(1, True, state, "second")
|
||||
release.set()
|
||||
await asyncio.wait_for(state.queue.join(), timeout=1)
|
||||
|
||||
assert processed, ["first", "second"]
|
||||
assert state.queue.empty()
|
||||
await dispatcher.shutdown()
|
||||
|
||||
async def test_processor_failure_does_not_stop_the_queue(self):
|
||||
processed = []
|
||||
|
||||
async def process(_context_id, _is_group, _state, event):
|
||||
processed.append(event)
|
||||
if event == "bad":
|
||||
raise RuntimeError("boom")
|
||||
|
||||
state = ChatState("test", 10, 10)
|
||||
dispatcher = MessageDispatcher(process)
|
||||
await dispatcher.enqueue(1, True, state, "bad")
|
||||
await dispatcher.enqueue(1, True, state, "good")
|
||||
await asyncio.wait_for(state.queue.join(), timeout=1)
|
||||
|
||||
assert processed, ["bad", "good"]
|
||||
await dispatcher.shutdown()
|
||||
34
tests/test_persistence.py
Normal file
34
tests/test_persistence.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# ruff: noqa: I001
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
import tests.bootstrap # noqa: F401
|
||||
from nonebot_plugin_llmchat.config import PresetConfig, ScopedConfig
|
||||
from nonebot_plugin_llmchat.persistence import StatePersistence
|
||||
from nonebot_plugin_llmchat.state import StateStore
|
||||
|
||||
|
||||
class PersistenceTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_private_state_loads_even_when_group_file_is_missing(self):
|
||||
preset = PresetConfig(name="test", api_base="x", api_key="x", model_name="x")
|
||||
config = ScopedConfig(
|
||||
api_presets=[preset],
|
||||
default_preset="test",
|
||||
enable_private_chat=True,
|
||||
private_chat_preset="test",
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
group_file = Path(directory) / "groups.json"
|
||||
private_file = Path(directory) / "private.json"
|
||||
source = StateStore(config)
|
||||
source.private_states[42].prompt = "remember me"
|
||||
persistence = StatePersistence(config, source, group_file, private_file)
|
||||
await persistence.save()
|
||||
group_file.unlink()
|
||||
|
||||
restored = StateStore(config)
|
||||
await StatePersistence(config, restored, group_file, private_file).load()
|
||||
|
||||
assert restored.private_states[42].prompt == "remember me"
|
||||
assert not group_file.exists()
|
||||
101
tests/test_tool_loop.py
Normal file
101
tests/test_tool_loop.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
import unittest
|
||||
|
||||
# ruff: noqa: I001
|
||||
import tests.bootstrap # noqa: F401
|
||||
from nonebot_plugin_llmchat.config import PresetConfig, ScopedConfig
|
||||
from nonebot_plugin_llmchat.conversation import ConversationService
|
||||
from nonebot_plugin_llmchat.state import StateStore
|
||||
|
||||
|
||||
class FakeToolCall:
|
||||
def __init__(self):
|
||||
self.id = "call-1"
|
||||
self.function = SimpleNamespace(name="mcp__demo__search", arguments='{"query":"same"}')
|
||||
|
||||
def model_dump(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": "function",
|
||||
"function": {"name": self.function.name, "arguments": self.function.arguments},
|
||||
}
|
||||
|
||||
|
||||
class FakeMessage:
|
||||
def __init__(self, content=None, tool_calls=None):
|
||||
self.content = content
|
||||
self.tool_calls = tool_calls
|
||||
self.reasoning_content = None
|
||||
|
||||
|
||||
class FakeCompletions:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def create(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
if "tools" not in kwargs:
|
||||
message = FakeMessage("final answer")
|
||||
else:
|
||||
message = FakeMessage(tool_calls=[FakeToolCall()])
|
||||
return SimpleNamespace(choices=[SimpleNamespace(message=message)], usage=None)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.chat = SimpleNamespace(completions=FakeCompletions())
|
||||
|
||||
|
||||
class FakeMCPClient:
|
||||
def __init__(self):
|
||||
self.call_count = 0
|
||||
|
||||
def get_friendly_name(self, _name):
|
||||
return "测试工具"
|
||||
|
||||
async def call_tool(self, *_args, **_kwargs):
|
||||
self.call_count += 1
|
||||
return "result"
|
||||
|
||||
|
||||
class ToolLoopTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_repeated_tool_calls_are_bounded_and_forced_to_finish(self):
|
||||
preset = PresetConfig(
|
||||
name="test",
|
||||
api_base="https://example.invalid/v1",
|
||||
api_key="test",
|
||||
model_name="test",
|
||||
support_mcp=True,
|
||||
)
|
||||
config = ScopedConfig(
|
||||
api_presets=[preset],
|
||||
default_preset="test",
|
||||
max_tool_rounds=3,
|
||||
max_repeated_tool_calls=2,
|
||||
)
|
||||
sent = []
|
||||
|
||||
async def sender(message):
|
||||
sent.append(message)
|
||||
|
||||
service = ConversationService(config, StateStore(config), {"bot"}, sender)
|
||||
client = FakeClient()
|
||||
mcp_client = FakeMCPClient()
|
||||
transcript = [{"role": "user", "content": "hello"}]
|
||||
message = await service._run_tool_loop(
|
||||
client=cast(Any, client),
|
||||
preset=preset,
|
||||
mcp_client=cast(Any, mcp_client),
|
||||
request_config={"model": "test", "tools": [{}]},
|
||||
messages=[{"role": "system", "content": "system"}],
|
||||
transcript=transcript,
|
||||
event=cast(Any, SimpleNamespace(self_id=1)),
|
||||
is_group=False,
|
||||
)
|
||||
|
||||
assert message.content == "final answer"
|
||||
assert mcp_client.call_count == 2
|
||||
assert len(client.chat.completions.calls) == 5
|
||||
assert "tools" not in client.chat.completions.calls[-1]
|
||||
assert "达到上限" in transcript[-1]["content"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue