Files
xiaoli-cli/tests/test_conversation.py
T
Xiaoli Dev ff53a89754 feat: 重写沙箱 + 完整测试套件 (192 tests)
沙箱 v2 - 多层隔离:
- AST 静态分析: 30+ 危险模式检测
- 子进程隔离: 独立进程执行 + 资源限制
- 网络阻断: socket 模块替换
- 模块白名单: 30 个安全模块
- 文件系统限制: 临时目录隔离
- SandboxResult 数据类 + 执行历史

测试套件 (8 个模块, 192 个用例):
- test_sandbox.py:     AST检查(47) + 沙箱执行(15) + 安全拒绝(20) + 超时(2) + 模式对比(3)
- test_conversation.py: 响应解析(13) + 思考标记(5) + 工具提取(10)
- test_config.py:       配置加载/保存/缓存/损坏处理(8)
- test_history.py:      历史CRUD/持久化/边界(12)
- test_engines.py:      引擎管理/加载/切换(14)
- test_plugins.py:      code_editor(10) + code_search(7) + git_tools(4) + tool_manager(7)
- test_integration.py:  沙箱+对话(2) + 历史+对话(2) + 安全链(3) + 提示词(1)
2026-04-25 11:43:47 +08:00

227 lines
8.4 KiB
Python

"""对话响应解析器测试"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xiaoli.conversation import parse_response, process_thinking, process_code_blocks, extract_tool_calls
# ═══════════════════════════════════════════════
# 1. parse_response 测试
# ═══════════════════════════════════════════════
class TestParseResponse:
"""响应解析测试"""
def test_pure_text(self):
text, data = parse_response("hello world")
assert text == "hello world"
assert data is None
def test_empty_response(self):
text, data = parse_response("")
assert text == ""
assert data is None
def test_pure_json_dict(self):
resp = '{"action": "use_tool", "tool": "code_editor", "args": "read foo.py"}'
text, data = parse_response(resp)
assert text == ""
assert isinstance(data, dict)
assert data['action'] == 'use_tool'
assert data['tool'] == 'code_editor'
def test_pure_json_list(self):
resp = '[{"action": "use_tool", "tool": "t1", "args": "a1"}]'
text, data = parse_response(resp)
assert text == ""
assert isinstance(data, list)
def test_json_in_code_block(self):
resp = 'Here is the command:\n```json\n{"action": "use_tool", "tool": "git_tools", "args": "status"}\n```'
text, data = parse_response(resp)
assert 'command' in text.lower() or 'Here' in text
assert isinstance(data, dict)
assert data['tool'] == 'git_tools'
def test_text_before_json(self):
resp = 'I will edit the file.\n{"action": "use_tool", "tool": "code_editor", "args": "edit foo.py"}'
text, data = parse_response(resp)
assert 'edit' in text.lower()
assert isinstance(data, dict)
assert data['action'] == 'use_tool'
def test_continue_action(self):
resp = '{"action": "continue", "content": "more info"}'
text, data = parse_response(resp)
assert text == ""
assert isinstance(data, dict)
assert data['action'] == 'continue'
def test_inline_json_use_tool(self):
resp = 'Let me check. {"action":"use_tool","tool":"code_search","args":"find . test"}'
text, data = parse_response(resp)
assert isinstance(data, dict)
assert data['tool'] == 'code_search'
def test_inline_json_continue(self):
resp = 'Some text. {"action":"continue","content":"extra"}'
text, data = parse_response(resp)
assert isinstance(data, dict)
assert data['action'] == 'continue'
def test_multiple_json_per_line(self):
resp = '{"action":"use_tool","tool":"t1","args":"a1"}\n{"action":"use_tool","tool":"t2","args":"a2"}'
text, data = parse_response(resp)
# Should pick up at least one
assert data is not None
def test_invalid_json_ignored(self):
resp = 'This is not json {broken'
text, data = parse_response(resp)
assert text == resp
assert data is None
def test_tool_call_tag_format(self):
resp = '<tool_call>{"action":"use_tool","tool":"test","args":"arg"}'
text, data = parse_response(resp)
assert isinstance(data, dict)
assert data['tool'] == 'test'
def test_message_field(self):
resp = '{"message": "done processing"}'
text, data = parse_response(resp)
assert isinstance(data, dict)
assert data['message'] == 'done processing'
# ═══════════════════════════════════════════════
# 2. process_thinking 测试
# ═══════════════════════════════════════════════
class TestProcessThinking:
"""思考标记处理测试"""
def test_think_tag(self):
resp = '<think/>This is the reply'
result = process_thinking(resp)
assert result == 'This is the reply'
def test_think_tag_with_thinking(self):
resp = 'I am thinking deeply<think/>Here is my answer'
result = process_thinking(resp)
assert 'answer' in result
def test_no_think_tag(self):
resp = 'Just a normal response'
result = process_thinking(resp)
assert result == resp
def test_empty_after_think(self):
resp = '<think/>'
result = process_thinking(resp)
assert result == '' or result is not None
def test_custom_markers(self):
class FakeEngine:
thinking_start_marker = '<thinking>'
thinking_end_marker = '</thinking>'
resp = '<thinking>deep thought</thinking>The answer is 42'
result = process_thinking(resp, FakeEngine())
assert '42' in result
# ═══════════════════════════════════════════════
# 3. process_code_blocks 测试
# ═══════════════════════════════════════════════
class TestProcessCodeBlocks:
"""代码块处理测试"""
def test_triple_quote_block(self):
text = 'before\n"""\ncode here\n"""\nafter'
result = process_code_blocks(text)
assert 'before' in result
assert 'after' in result
def test_no_code_blocks(self):
text = 'plain text no blocks'
result = process_code_blocks(text)
assert result == text
# ═══════════════════════════════════════════════
# 4. extract_tool_calls 测试
# ═══════════════════════════════════════════════
class TestExtractToolCalls:
"""工具调用提取测试"""
def test_single_use_tool(self):
data = {"action": "use_tool", "tool": "code_editor", "args": "edit foo.py"}
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['tool'] == 'code_editor'
def test_list_of_use_tool(self):
data = [
{"action": "use_tool", "tool": "t1", "args": "a1"},
{"action": "use_tool", "tool": "t2", "args": "a2"},
]
calls = extract_tool_calls(data)
assert len(calls) == 2
def test_simplified_format(self):
data = {"tool": "git_tools", "args": "status"}
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['tool'] == 'git_tools'
def test_openai_function_call(self):
data = {"function_call": {"name": "code_editor", "arguments": '{"operation": "read"}'}}
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['tool'] == 'code_editor'
def test_mcp_format(self):
data = {"name": "code_search", "arguments": {"operation": "find", "directory": "."}}
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['tool'] == 'code_search'
def test_empty_data(self):
assert extract_tool_calls([]) == []
assert extract_tool_calls({}) == []
assert extract_tool_calls(None) == []
assert extract_tool_calls("string") == []
def test_non_tool_dict(self):
data = {"message": "just text", "continue": True}
calls = extract_tool_calls(data)
# continue action should not be extracted as tool call
assert len(calls) == 0
def test_mixed_list(self):
data = [
{"action": "use_tool", "tool": "t1", "args": "a1"},
{"action": "continue", "content": "more"},
]
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['tool'] == 't1'
def test_function_call_string_args(self):
data = {"function_call": {"name": "test", "arguments": '{"key": "val"}'}}
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['arguments'] == {"key": "val"}
def test_function_call_dict_args(self):
data = {"function_call": {"name": "test", "arguments": {"key": "val"}}}
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['arguments'] == {"key": "val"}