- engine.generate_response 不再下发 tools schema(TUI/CLI 分支均 tools=None) - 系统提示改为纯 JSON 调用格式说明,保留 tool_search 自查用法 - 新增 _loads_json 容错解析,容忍模型写出的 Windows 反斜杠路径(如 C:/Users) - 新增 tests/test_pure_json.py 覆盖解析容错/引擎无 schema 下发/端到端按名分发 Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
146 lines
5.9 KiB
Python
146 lines
5.9 KiB
Python
"""
|
||
纯 JSON 工具调用模式 测试
|
||
|
||
取代 FC:引擎不再收到 tools schema,模型按系统提示里的
|
||
{"action":"use_tool","tool":名,"args":参数} 文本输出,由 _parse_mixed_response
|
||
解析、按名分发。重点验证:
|
||
1. _loads_json / _parse_mixed_response 对 Windows 反斜杠路径健壮
|
||
2. 引擎在 tools=None 时返回纯文本(不产 _fc / tool_calls)
|
||
3. 端到端:真实 AICLI + FakeEngine,use_tool 被按名分发执行,且无 FC schema 下发
|
||
"""
|
||
import os
|
||
import sys
|
||
import json
|
||
import tempfile
|
||
|
||
import pytest
|
||
|
||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
if PROJECT_ROOT not in sys.path:
|
||
sys.path.insert(0, PROJECT_ROOT)
|
||
|
||
|
||
# ════════════════════════════════════════════════
|
||
# 1. 解析器:Windows 反斜杠路径容错
|
||
# ════════════════════════════════════════════════
|
||
|
||
@pytest.fixture
|
||
def cli():
|
||
from xcli_core.cli_core import AICLI
|
||
return AICLI()
|
||
|
||
|
||
class TestParseRobustness:
|
||
def test_loads_json_tolerates_backslash_path(self, cli):
|
||
# 模型常把 Windows 路径写成单反斜杠 → 非法 JSON 转义
|
||
raw = r'{"action": "use_tool", "tool": "code_editor", "args": "create C:\Users\me\demo.py print(1)"}'
|
||
data = cli._loads_json(raw)
|
||
assert data["action"] == "use_tool"
|
||
assert data["tool"] == "code_editor"
|
||
# 修复后应是合法的单反斜杠路径
|
||
assert data["args"] == r"create C:\Users\me\demo.py print(1)"
|
||
|
||
def test_parse_mixed_extracts_use_tool_with_backslash(self, cli):
|
||
resp = (
|
||
'好的,我来创建文件。\n'
|
||
r'{"action": "use_tool", "tool": "code_editor", "args": "create C:\Users\me\demo.py print(1)"}'
|
||
)
|
||
text, data = cli._parse_mixed_response(resp)
|
||
assert isinstance(data, dict)
|
||
assert data["action"] == "use_tool"
|
||
assert data["tool"] == "code_editor"
|
||
assert r"C:\Users\me\demo.py" in data["args"]
|
||
|
||
def test_parse_mixed_plain_text_no_action(self, cli):
|
||
text, data = cli._parse_mixed_response("今天天气不错")
|
||
assert data is None
|
||
assert "今天天气不错" in text
|
||
|
||
|
||
# ════════════════════════════════════════════════
|
||
# 2. 引擎:tools=None 返回纯文本,不产 tool_calls
|
||
# ════════════════════════════════════════════════
|
||
|
||
class TestEngineToolsNone:
|
||
def test_openai_engine_no_tools_sent(self, monkeypatch):
|
||
import types
|
||
import ai_engines.openai_engine as oe
|
||
|
||
calls = []
|
||
|
||
def fake_post(url=None, json=None, headers=None, timeout=None, **kw):
|
||
calls.append(dict(json))
|
||
return types.SimpleNamespace(
|
||
status_code=200,
|
||
json=lambda: {"choices": [{"message": {"content": "好"}}],
|
||
"usage": {"prompt_tokens": 5}},
|
||
text="{}",
|
||
)
|
||
|
||
monkeypatch.setattr(oe, "requests",
|
||
types.SimpleNamespace(post=fake_post, exceptions=oe.requests.exceptions))
|
||
|
||
eng = oe.OpenaiAI.__new__(oe.OpenaiAI)
|
||
eng.name = "openai"
|
||
eng.api_key = "k"
|
||
eng.base_url = "https://example.com/v1"
|
||
eng.model = "gpt-4o"
|
||
eng.max_history = 10
|
||
eng.conversation_history = []
|
||
eng.shared_conversation_history = None
|
||
eng.last_prompt_tokens = None
|
||
eng.thinking_start_marker = "<think>"
|
||
eng.thinking_end_marker = "</think>"
|
||
|
||
reply = eng.generate_response("hi", tools=None)
|
||
assert reply == "好"
|
||
assert "tools" not in calls[0], "tools=None 时不应下发 FC schema"
|
||
assert "tool_choice" not in calls[0]
|
||
|
||
|
||
# ════════════════════════════════════════════════
|
||
# 3. 端到端:真实 AICLI + FakeEngine,use_tool 按名分发
|
||
# ════════════════════════════════════════════════
|
||
|
||
class FakeEngine:
|
||
def __init__(self):
|
||
self.calls = 0
|
||
self.tools_seen = []
|
||
|
||
@property
|
||
def name(self):
|
||
return "fake"
|
||
|
||
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None):
|
||
self.calls += 1
|
||
self.tools_seen.append(tools)
|
||
assert tools is None, f"纯 JSON 模式不应下发 FC tools,实际 {tools!r}"
|
||
if self.calls == 1:
|
||
return ('好的,我来创建文件。\n'
|
||
'{"action": "use_tool", "tool": "code_editor", '
|
||
'"args": "create %s print(1)"}' % self._fp)
|
||
return "已完成。"
|
||
|
||
|
||
class TestEndToEndDispatch:
|
||
def test_use_tool_dispatched_without_fc(self):
|
||
from xcli_core.cli_core import AICLI
|
||
from xcli_core.safety import get_safety, MODE_UNRESTRICTED
|
||
|
||
cli = AICLI()
|
||
engine = FakeEngine()
|
||
cli.current_engine = engine
|
||
cli.tui_output_callback = lambda *a, **k: None # 同步 TUI 分支,免动画线程
|
||
get_safety().set_mode(MODE_UNRESTRICTED) # 跳过交互确认,验证分发本身
|
||
|
||
d = tempfile.mkdtemp(prefix="pj_")
|
||
FakeEngine._fp = os.path.join(d, "demo.py") # 原始反斜杠路径
|
||
fp = FakeEngine._fp
|
||
|
||
cli.process_conversation("帮我创建一个 python 文件")
|
||
|
||
assert all(t is None for t in engine.tools_seen), "引擎不应收到任何 FC tools"
|
||
assert engine.calls >= 2, "至少应有:首轮JSON + 工具结果续轮"
|
||
assert os.path.exists(fp), "code_editor 应被按名分发执行并创建文件"
|
||
assert "print(1)" in open(fp, encoding="utf-8").read()
|