Skip to content

Commit 6acfbc4

Browse files
committed
fix(tests): resolve CI mock server issues and improve tool call handling
Several critical fixes were implemented to resolve test failures in CI environments: 1. Updated MockLLMServer to properly use respx_mock fixture instead of global respx, ensuring HTTP mocks work consistently across local and CI environments 2. Modified tool call argument handling to check tc_args is not None rather than truthy 3. Improved scenario matching logic to check all user messages instead of just the last 4. Added proper async handling for Google ADK integration tests 5. Fixed tool call argument formatting to use "{}" instead of empty strings These changes resolve infinite loop issues with Google ADK tests and ensure consistent behavior across different test environments. fix(integration): 修复 CI mock 服务器问题并改进工具调用处理 实施了几项关键修复以解决 CI 环境中的测试失败: 1. 更新 MockLLMServer 以正确使用 respx_mock fixture 而不是全局 respx, 确保 HTTP mocks 在本地和 CI 环境中一致工作 2. 修改工具调用参数处理以检查 tc_args 不为 None 而不是真值 3. 改进场景匹配逻辑以检查所有用户消息而不是仅最后一条 4. 为 Google ADK 集成测试添加了适当的异步处理 5. 修复工具调用参数格式为使用"{}"而不是空字符串 这些更改解决了 Google ADK 测试的无限循环问题并确保 跨不同测试环境的一致行为。 Change-Id: I46c8b4faedb357f8a4a2149756f4cab121c2fb8e Signed-off-by: OhYee <oyohyee@oyohyee.com>
1 parent 3f5dcfd commit 6acfbc4

File tree

12 files changed

+194
-47
lines changed

12 files changed

+194
-47
lines changed

agentrun/integration/langgraph/agent_converter.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ def _convert_stream_updates_event(
495495
if tc_id:
496496
# 发送带有完整参数的 TOOL_CALL_CHUNK
497497
args_str = ""
498-
if tc_args:
498+
if tc_args is not None:
499499
args_str = (
500500
AgentRunConverter._safe_json_dumps(tc_args)
501501
if isinstance(tc_args, dict)
@@ -570,7 +570,7 @@ def _convert_stream_values_event(
570570
if tc_id:
571571
# 发送带有完整参数的 TOOL_CALL_CHUNK
572572
args_str = ""
573-
if tc_args:
573+
if tc_args is not None:
574574
args_str = (
575575
AgentRunConverter._safe_json_dumps(tc_args)
576576
if isinstance(tc_args, dict)
@@ -694,7 +694,7 @@ def _convert_astream_events_event(
694694
tool_name_to_call_ids[tc_name].append(tc_id)
695695
# 第一个 chunk 包含 id 和 name
696696
args_delta = ""
697-
if tc_args:
697+
if tc_args is not None:
698698
args_delta = (
699699
AgentRunConverter._safe_json_dumps(tc_args)
700700
if isinstance(tc_args, (dict, list))
@@ -708,7 +708,7 @@ def _convert_astream_events_event(
708708
"args_delta": args_delta,
709709
},
710710
)
711-
elif tc_args:
711+
elif tc_args is not None:
712712
# 后续 chunk 只有 args_delta
713713
args_delta = (
714714
AgentRunConverter._safe_json_dumps(tc_args)
@@ -765,7 +765,7 @@ def _convert_astream_events_event(
765765
).append(tc_id)
766766

767767
args_delta = ""
768-
if tc_args:
768+
if tc_args is not None:
769769
args_delta = (
770770
AgentRunConverter._safe_json_dumps(
771771
tc_args

tests/unittests/integration/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,9 +320,12 @@ def shared_mock_server(monkeypatch: Any, respx_mock: Any) -> MockLLMServer:
320320
"""提供共享的 Mock LLM Server
321321
322322
预配置了默认场景。
323+
324+
关键修复:传入 respx_mock fixture 给 MockLLMServer
325+
- 确保 HTTP mock 在所有环境(本地/CI)中一致生效
323326
"""
324327
server = MockLLMServer(expect_tools=True, validate_tools=False)
325-
server.install(monkeypatch)
328+
server.install(monkeypatch, respx_mock)
326329
server.add_default_scenarios()
327330
return server
328331

tests/unittests/integration/langchain/test_agent_invoke_methods.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,9 @@ def _normalize_agui_event(event: Dict[str, Any]) -> Dict[str, Any]:
400400
},
401401
{
402402
"type": "TOOL_CALL_ARGS",
403-
"delta": "",
403+
# 空参数在 LangGraph 中表现为 "{}" (Node.js SDK) 或 根据转换逻辑可能为空字符串
404+
# 但当前 mock server 返回 "{}",转换器保留了它
405+
"delta": "{}",
404406
"hasToolCallId": True,
405407
},
406408
{"type": "TOOL_CALL_END", "hasToolCallId": True},
@@ -551,6 +553,15 @@ def _normalize_openai_stream(
551553
}],
552554
"finish_reason": None,
553555
},
556+
{
557+
"object": "chat.completion.chunk",
558+
"tool_calls": [{
559+
"name": None,
560+
"arguments": "{}",
561+
"has_id": False,
562+
}],
563+
"finish_reason": None,
564+
},
554565
{
555566
"object": "chat.completion.chunk",
556567
"delta_role": "assistant",
@@ -612,7 +623,7 @@ def _normalize_openai_nonstream(resp: Dict[str, Any]) -> Dict[str, Any]:
612623
"content": "工具结果已收到: 2024-01-01 12:00:00",
613624
"tool_calls": [{
614625
"name": "get_time",
615-
"arguments": "",
626+
"arguments": "{}",
616627
"has_id": True,
617628
}],
618629
"finish_reason": "tool_calls",

tests/unittests/integration/mock_llm_server.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class MockLLMServer:
4343
使用方式:
4444
# 基本用法
4545
server = MockLLMServer()
46-
server.install(monkeypatch)
46+
server.install(monkeypatch, respx_mock) # 需要传入 respx_mock
4747
4848
# 添加自定义场景
4949
server.add_scenario(Scenarios.simple_chat("你好", "你好!"))
@@ -67,15 +67,22 @@ class MockLLMServer:
6767
validate_tools: bool = True
6868
"""是否验证工具格式(默认 True)"""
6969

70-
def install(self, monkeypatch: Any) -> "MockLLMServer":
70+
_respx_router: Any = field(default=None, init=False, repr=False)
71+
"""内部使用的 respx router 实例"""
72+
73+
def install(
74+
self, monkeypatch: Any, respx_mock: Any = None
75+
) -> "MockLLMServer":
7176
"""安装所有 mock
7277
7378
Args:
7479
monkeypatch: pytest monkeypatch fixture
80+
respx_mock: pytest respx_mock fixture(必须传入以确保 mock 生效)
7581
7682
Returns:
7783
self: 返回自身以便链式调用
7884
"""
85+
self._respx_router = respx_mock
7986
self._patch_model_info(monkeypatch)
8087
self._patch_litellm(monkeypatch)
8188
self._setup_respx()
@@ -240,7 +247,20 @@ async def fake_acompletion(*args: Any, **kwargs: Any) -> ModelResponse:
240247
pass # google.adk not installed
241248

242249
def _setup_respx(self):
243-
"""设置 respx HTTP mock"""
250+
"""设置 respx HTTP mock
251+
252+
关键修复:使用 pytest-respx fixture 提供的 router 而不是全局 respx
253+
254+
问题背景:
255+
- 之前直接使用全局 respx.route() 在 CI 环境中不生效
256+
- 全局 respx router 在某些环境中可能没有正确初始化
257+
- 导致 HTTP 请求没有被拦截,Google ADK 发送真实请求
258+
259+
解决方案:
260+
- 使用 pytest-respx 提供的 respx_mock fixture
261+
- 通过 install() 方法传入 respx_mock
262+
- 确保 mock 在所有环境中一致生效
263+
"""
244264

245265
def extract_payload(request: Any) -> Dict[str, Any]:
246266
try:
@@ -274,7 +294,10 @@ def build_response(request: Any, route: Any) -> respx.MockResponse:
274294
)
275295
return respx.MockResponse(status_code=200, json=response_json)
276296

277-
respx.route(url__startswith=self.base_url).mock(
297+
# 关键修复:使用传入的 respx_router 而不是全局 respx
298+
# 如果没有传入 respx_router,回退到全局 respx(向后兼容)
299+
router = self._respx_router if self._respx_router is not None else respx
300+
router.route(url__startswith=self.base_url).mock(
278301
side_effect=build_response
279302
)
280303

@@ -304,6 +327,27 @@ def _build_response(
304327
tools_payload is not None,
305328
)
306329

330+
# 添加详细的消息日志,帮助调试框架的消息格式
331+
for i, msg in enumerate(messages):
332+
role = msg.get("role", "unknown")
333+
content_preview = str(msg.get("content", ""))[:100]
334+
logger.debug(
335+
"Message[%d] role=%s, content_preview=%s",
336+
i,
337+
role,
338+
content_preview,
339+
)
340+
if "tool_calls" in msg:
341+
logger.debug(
342+
"Message[%d] has tool_calls: %s", i, msg.get("tool_calls")
343+
)
344+
if "tool_call_id" in msg:
345+
logger.debug(
346+
"Message[%d] has tool_call_id: %s",
347+
i,
348+
msg.get("tool_call_id"),
349+
)
350+
307351
# 验证工具格式
308352
if self.validate_tools and self.expect_tools and tools_payload:
309353
self._assert_tools(tools_payload)
@@ -319,16 +363,19 @@ def _build_response(
319363
turn = scenario.get_response(messages)
320364
return turn.to_response()
321365

322-
# 默认逻辑:根据最后一条消息决定响应
366+
# 默认逻辑:未匹配场景时使用
323367
return self._build_default_response(messages, tools_payload)
324368

325369
def _build_default_response(
326370
self, messages: List[Dict], tools_payload: Optional[List]
327371
) -> Dict[str, Any]:
328372
"""构建默认响应(无场景匹配时使用)"""
329-
last_role = messages[-1].get("role")
373+
# 检查消息历史中是否已经有 tool 结果
374+
# 这是关键修复:不只检查最后一条消息,而是检查整个历史
375+
has_tool_results = any(msg.get("role") == "tool" for msg in messages)
330376

331-
if last_role == "tool":
377+
if has_tool_results:
378+
# 已经有 tool 结果,应该返回最终答案而不是再次调用工具
332379
return {
333380
"id": "chatcmpl-mock-final",
334381
"object": "chat.completion",
@@ -349,7 +396,7 @@ def _build_default_response(
349396
},
350397
}
351398

352-
# 如果有工具,返回工具调用
399+
# 如果有工具且未调用过,返回工具调用
353400
if tools_payload:
354401
return {
355402
"id": "chatcmpl-mock-tools",

tests/unittests/integration/scenarios.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,31 @@ def get_response(self, messages: List[Dict]) -> MockTurn:
113113
- 如果最后一条消息是 tool 类型,说明工具已执行,进入下一轮
114114
- 否则返回当前轮次
115115
"""
116+
import logging
117+
118+
logger = logging.getLogger(__name__)
119+
116120
# 计算当前应该返回哪一轮
117121
tool_rounds = sum(1 for msg in messages if msg.get("role") == "tool")
118122

123+
logger.debug(
124+
"Scenario '%s': Found %d tool messages, total turns: %d",
125+
self.name,
126+
tool_rounds,
127+
len(self.turns),
128+
)
129+
119130
# 根据工具消息数量确定当前轮次
120131
# 每个工具响应对应一个轮次的推进
121132
current_idx = min(tool_rounds, len(self.turns) - 1)
133+
134+
logger.debug(
135+
"Scenario '%s': Returning turn %d, has_tool_calls=%s",
136+
self.name,
137+
current_idx,
138+
self.turns[current_idx].has_tool_calls(),
139+
)
140+
122141
return self.turns[current_idx]
123142

124143
def reset(self):
@@ -145,12 +164,14 @@ def simple_chat(trigger: str, response: str) -> MockScenario:
145164
"""
146165

147166
def trigger_fn(messages: List[Dict]) -> bool:
148-
# 查找最后一条用户消息
149-
for msg in reversed(messages):
167+
# 检查所有用户消息(任意一条包含trigger即匹配)
168+
# 修复:不只检查最后一条,避免框架插入的额外消息干扰匹配
169+
for msg in messages:
150170
if msg.get("role") == "user":
151171
content = msg.get("content", "")
152172
if isinstance(content, str):
153-
return trigger in content
173+
if trigger in content:
174+
return True
154175
elif isinstance(content, list):
155176
# 处理 content 是列表的情况
156177
for item in content:
@@ -188,11 +209,13 @@ def single_tool_call(
188209
"""
189210

190211
def trigger_fn(messages: List[Dict]) -> bool:
191-
for msg in reversed(messages):
212+
# 检查所有用户消息(任意一条包含trigger即匹配)
213+
# 修复:避免框架插入的额外消息干扰匹配
214+
for msg in messages:
192215
if msg.get("role") == "user":
193216
content = msg.get("content", "")
194-
if isinstance(content, str):
195-
return trigger in content
217+
if isinstance(content, str) and trigger in content:
218+
return True
196219
return False
197220

198221
return MockScenario(
@@ -230,11 +253,13 @@ def multi_tool_calls(
230253
"""
231254

232255
def trigger_fn(messages: List[Dict]) -> bool:
233-
for msg in reversed(messages):
256+
# 检查所有用户消息(任意一条包含trigger即匹配)
257+
# 修复:避免框架插入的额外消息干扰匹配
258+
for msg in messages:
234259
if msg.get("role") == "user":
235260
content = msg.get("content", "")
236-
if isinstance(content, str):
237-
return trigger in content
261+
if isinstance(content, str) and trigger in content:
262+
return True
238263
return False
239264

240265
return MockScenario(
@@ -273,11 +298,13 @@ def multi_round_tools(
273298
"""
274299

275300
def trigger_fn(messages: List[Dict]) -> bool:
276-
for msg in reversed(messages):
301+
# 检查所有用户消息(任意一条包含trigger即匹配)
302+
# 修复:避免框架插入的额外消息干扰匹配
303+
for msg in messages:
277304
if msg.get("role") == "user":
278305
content = msg.get("content", "")
279-
if isinstance(content, str):
280-
return trigger in content
306+
if isinstance(content, str) and trigger in content:
307+
return True
281308
return False
282309

283310
turns = []

tests/unittests/integration/test_agentscope.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,13 @@ class TestAgentScopeIntegration(AgentScopeTestMixin):
124124

125125
@pytest.fixture
126126
def mock_server(self, monkeypatch: Any, respx_mock: Any) -> MockLLMServer:
127-
"""创建并安装 Mock LLM Server"""
127+
"""创建并安装 Mock LLM Server
128+
129+
关键修复:传入 respx_mock fixture 给 MockLLMServer
130+
- 确保 HTTP mock 在所有环境(本地/CI)中一致生效
131+
"""
128132
server = MockLLMServer(expect_tools=True, validate_tools=False)
129-
server.install(monkeypatch)
133+
server.install(monkeypatch, respx_mock)
130134
server.add_default_scenarios()
131135
return server
132136

tests/unittests/integration/test_crewai.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,13 @@ class TestCrewAIIntegration(CrewAITestMixin):
123123

124124
@pytest.fixture
125125
def mock_server(self, monkeypatch: Any, respx_mock: Any) -> MockLLMServer:
126-
"""创建并安装 Mock LLM Server"""
126+
"""创建并安装 Mock LLM Server
127+
128+
关键修复:传入 respx_mock fixture 给 MockLLMServer
129+
- 确保 HTTP mock 在所有环境(本地/CI)中一致生效
130+
"""
127131
server = MockLLMServer(expect_tools=True, validate_tools=False)
128-
server.install(monkeypatch)
132+
server.install(monkeypatch, respx_mock)
129133
server.add_default_scenarios()
130134
return server
131135

0 commit comments

Comments
 (0)