Skip to content

Commit 3755e4a

Browse files
committed
Cover reasoning protocol behavior with UT and E2E
Add deterministic protocol-level tests for MODEL_PARAMETER_RULES.thinking so reasoning_content exposure is guarded in OpenAI and AG-UI outputs. Constraint: Keep changes limited to test coverage and current e2e expectations. Rejected: Rely only on the smoke script | it does not make the regression part of the test suite. Confidence: high Scope-risk: narrow Directive: Keep reasoning_content coverage in both unit and e2e layers when changing protocol output. Tested: uv run --extra server ruff check tests/unittests/server/test_reasoning.py tests/unittests/server/test_openai_protocol.py tests/unittests/server/test_agui_protocol.py tests/e2e/test_reasoning_protocol.py tests/e2e/integration/langchain/test_agent_invoke_methods.py Tested: uv run --extra server pytest tests/unittests/server -q Tested: uv run --extra server --extra langchain pytest tests/e2e/test_reasoning_protocol.py tests/e2e/integration/langchain/test_agent_invoke_methods.py -q Tested: uv run --extra server --extra langchain pytest tests/unittests/integration/langchain tests/unittests/conversation_service/test_langchain_adapter.py -q Tested: MODEL_PARAMETER_RULES='{"thinking": true}' AGENTRUN_SMOKE_INSECURE_SSL=true uv run --extra server python scripts/smoke_reasoning_protocol.py --env-file /Users/congxiao/workspace/agent-quickstart-langchain/.env --model qwen3-235b-a22b-thinking-2507 --protocol both --response-mode stream --expect-reasoning --expect-content Tested: MODEL_PARAMETER_RULES='{"thinking": false}' AGENTRUN_SMOKE_INSECURE_SSL=true uv run --extra server python scripts/smoke_reasoning_protocol.py --env-file /Users/congxiao/workspace/agent-quickstart-langchain/.env --model qwen3-235b-a22b-thinking-2507 --protocol both --response-mode stream --expect-no-reasoning --expect-content Signed-off-by: congxiao.wxx <congxiao.wxx@alibaba-inc.com> Change-Id: Id273ba4afeb70c63aedcdba01bc31d46f2db3bd1
1 parent 8a7d86b commit 3755e4a

5 files changed

Lines changed: 321 additions & 4 deletions

File tree

tests/e2e/integration/langchain/test_agent_invoke_methods.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,39 @@ def _normalize_agui_event(event: Dict[str, Any]) -> Dict[str, Any]:
430430
},
431431
{"type": "TEXT_MESSAGE_END", "hasMessageId": True},
432432
{"type": "RUN_FINISHED", "hasThreadId": True, "hasRunId": True},
433-
]
433+
],
434+
[
435+
{"type": "RUN_STARTED", "hasThreadId": True, "hasRunId": True},
436+
{
437+
"type": "TOOL_CALL_START",
438+
"toolCallName": "get_time",
439+
"hasToolCallId": True,
440+
},
441+
{
442+
"type": "TOOL_CALL_ARGS",
443+
"delta": "{}",
444+
"hasToolCallId": True,
445+
},
446+
{"type": "TOOL_CALL_END", "hasToolCallId": True},
447+
{
448+
"type": "TOOL_CALL_RESULT",
449+
"role": "tool",
450+
"hasToolCallId": True,
451+
"hasMessageId": True,
452+
},
453+
{
454+
"type": "TEXT_MESSAGE_START",
455+
"role": "assistant",
456+
"hasMessageId": True,
457+
},
458+
{
459+
"type": "TEXT_MESSAGE_CONTENT",
460+
"delta": "工具结果已收到: 2024-01-01 12:00:00",
461+
"hasMessageId": True,
462+
},
463+
{"type": "TEXT_MESSAGE_END", "hasMessageId": True},
464+
{"type": "RUN_FINISHED", "hasThreadId": True, "hasRunId": True},
465+
],
434466
],
435467
}
436468

@@ -562,6 +594,15 @@ def _normalize_openai_stream(
562594
}],
563595
"finish_reason": None,
564596
},
597+
{
598+
"object": "chat.completion.chunk",
599+
"tool_calls": [{
600+
"name": None,
601+
"arguments": "{}",
602+
"has_id": False,
603+
}],
604+
"finish_reason": None,
605+
},
565606
{
566607
"object": "chat.completion.chunk",
567608
"delta_role": "assistant",
@@ -623,7 +664,7 @@ def _normalize_openai_nonstream(resp: Dict[str, Any]) -> Dict[str, Any]:
623664
"content": "工具结果已收到: 2024-01-01 12:00:00",
624665
"tool_calls": [{
625666
"name": "get_time",
626-
"arguments": "",
667+
"arguments": "{}",
627668
"has_id": True,
628669
}],
629670
"finish_reason": "tool_calls",
@@ -810,9 +851,7 @@ async def test_astream_events(
810851
async def test_convert_python_3_10(self):
811852
from langchain.messages import (
812853
AIMessage,
813-
AIMessageChunk,
814854
HumanMessage,
815-
SystemMessage,
816855
)
817856

818857
events = [
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""E2E coverage for reasoning_content protocol output."""
2+
3+
import json
4+
from types import SimpleNamespace
5+
from typing import Any, Dict, List
6+
7+
import httpx
8+
import pytest
9+
10+
from agentrun.server import AgentRequest, AgentRunServer
11+
12+
13+
def _parse_sse_events(content: str) -> List[Dict[str, Any]]:
14+
events = []
15+
for line in content.splitlines():
16+
if not line.startswith("data: "):
17+
continue
18+
payload = line[6:]
19+
if payload == "[DONE]":
20+
continue
21+
events.append(json.loads(payload))
22+
return events
23+
24+
25+
@pytest.fixture
26+
def reasoning_app():
27+
async def invoke_agent(request: AgentRequest):
28+
yield SimpleNamespace(
29+
content="",
30+
additional_kwargs={"reasoning_content": "thinking"},
31+
)
32+
yield SimpleNamespace(content="answer", additional_kwargs={})
33+
34+
return AgentRunServer(invoke_agent=invoke_agent).as_fastapi_app()
35+
36+
37+
async def _post_json(app, path: str, payload: Dict[str, Any]) -> httpx.Response:
38+
async with httpx.AsyncClient(
39+
transport=httpx.ASGITransport(app=app),
40+
base_url="http://test",
41+
) as client:
42+
return await client.post(path, json=payload, timeout=60.0)
43+
44+
45+
def _set_thinking(monkeypatch, enabled: bool) -> None:
46+
monkeypatch.setenv(
47+
"MODEL_PARAMETER_RULES",
48+
json.dumps({"thinking": enabled}),
49+
)
50+
51+
52+
@pytest.mark.parametrize("thinking_enabled", [True, False])
53+
@pytest.mark.asyncio
54+
async def test_openai_stream_reasoning_content_gate(
55+
reasoning_app,
56+
monkeypatch,
57+
thinking_enabled: bool,
58+
):
59+
_set_thinking(monkeypatch, thinking_enabled)
60+
61+
response = await _post_json(
62+
reasoning_app,
63+
"/openai/v1/chat/completions",
64+
{
65+
"model": "mock-model",
66+
"messages": [{"role": "user", "content": "Hi"}],
67+
"stream": True,
68+
},
69+
)
70+
71+
assert response.status_code == 200
72+
events = _parse_sse_events(response.text)
73+
deltas = [
74+
(event.get("choices") or [{}])[0].get("delta") or {}
75+
for event in events
76+
]
77+
reasoning = "".join(delta.get("reasoning_content", "") for delta in deltas)
78+
content = "".join(delta.get("content", "") for delta in deltas)
79+
80+
assert content == "answer"
81+
assert reasoning == ("thinking" if thinking_enabled else "")
82+
assert all("additional_kwargs" not in delta for delta in deltas)
83+
84+
85+
@pytest.mark.parametrize("thinking_enabled", [True, False])
86+
@pytest.mark.asyncio
87+
async def test_openai_non_stream_reasoning_content_gate(
88+
reasoning_app,
89+
monkeypatch,
90+
thinking_enabled: bool,
91+
):
92+
_set_thinking(monkeypatch, thinking_enabled)
93+
94+
response = await _post_json(
95+
reasoning_app,
96+
"/openai/v1/chat/completions",
97+
{
98+
"model": "mock-model",
99+
"messages": [{"role": "user", "content": "Hi"}],
100+
"stream": False,
101+
},
102+
)
103+
104+
assert response.status_code == 200
105+
message = response.json()["choices"][0]["message"]
106+
assert message["content"] == "answer"
107+
if thinking_enabled:
108+
assert message["reasoning_content"] == "thinking"
109+
else:
110+
assert "reasoning_content" not in message
111+
112+
113+
@pytest.mark.parametrize("thinking_enabled", [True, False])
114+
@pytest.mark.asyncio
115+
async def test_agui_reasoning_events_gate(
116+
reasoning_app,
117+
monkeypatch,
118+
thinking_enabled: bool,
119+
):
120+
_set_thinking(monkeypatch, thinking_enabled)
121+
122+
response = await _post_json(
123+
reasoning_app,
124+
"/ag-ui/agent",
125+
{"messages": [{"role": "user", "content": "Hi"}]},
126+
)
127+
128+
assert response.status_code == 200
129+
events = _parse_sse_events(response.text)
130+
event_types = [event["type"] for event in events]
131+
reasoning = "".join(
132+
event.get("delta", "")
133+
for event in events
134+
if event["type"] == "REASONING_MESSAGE_CONTENT"
135+
)
136+
content = "".join(
137+
event.get("delta", "")
138+
for event in events
139+
if event["type"] == "TEXT_MESSAGE_CONTENT"
140+
)
141+
142+
assert content == "answer"
143+
if thinking_enabled:
144+
assert reasoning == "thinking"
145+
assert event_types.index("REASONING_MESSAGE_CONTENT") < event_types.index(
146+
"TEXT_MESSAGE_START"
147+
)
148+
else:
149+
assert reasoning == ""
150+
assert all(
151+
not event_type.startswith("REASONING")
152+
for event_type in event_types
153+
)

tests/unittests/server/test_agui_protocol.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,3 +1281,63 @@ async def invoke_agent(request: AgentRequest):
12811281
)
12821282
assert reasoning_event["delta"] == "thinking"
12831283
assert text_event["delta"] == "answer"
1284+
1285+
def test_text_addition_reasoning_is_emitted_before_text(
1286+
self, monkeypatch
1287+
):
1288+
monkeypatch.setenv("MODEL_PARAMETER_RULES", '{"thinking": true}')
1289+
1290+
async def invoke_agent(request: AgentRequest):
1291+
yield AgentEvent(
1292+
event=EventType.TEXT,
1293+
data={"delta": "answer"},
1294+
addition={
1295+
"additional_kwargs": {"reasoning_content": "thinking"}
1296+
},
1297+
)
1298+
1299+
response = self.get_client(invoke_agent).post(
1300+
"/ag-ui/agent",
1301+
json={"messages": [{"role": "user", "content": "Hi"}]},
1302+
)
1303+
1304+
events = _agui_sse_events(response)
1305+
types = [event["type"] for event in events]
1306+
assert types.index("REASONING_MESSAGE_CONTENT") < types.index(
1307+
"TEXT_MESSAGE_START"
1308+
)
1309+
assert "REASONING_MESSAGE_END" in types
1310+
assert "REASONING_END" in types
1311+
text_event = next(
1312+
event for event in events if event["type"] == "TEXT_MESSAGE_CONTENT"
1313+
)
1314+
assert text_event["delta"] == "answer"
1315+
assert "additional_kwargs" not in text_event
1316+
1317+
def test_text_addition_reasoning_is_stripped_when_thinking_disabled(
1318+
self, monkeypatch
1319+
):
1320+
monkeypatch.setenv("MODEL_PARAMETER_RULES", '{"thinking": false}')
1321+
1322+
async def invoke_agent(request: AgentRequest):
1323+
yield AgentEvent(
1324+
event=EventType.TEXT,
1325+
data={"delta": "answer"},
1326+
addition={
1327+
"additional_kwargs": {"reasoning_content": "thinking"}
1328+
},
1329+
)
1330+
1331+
response = self.get_client(invoke_agent).post(
1332+
"/ag-ui/agent",
1333+
json={"messages": [{"role": "user", "content": "Hi"}]},
1334+
)
1335+
1336+
events = _agui_sse_events(response)
1337+
types = [event["type"] for event in events]
1338+
assert all(not event_type.startswith("REASONING") for event_type in types)
1339+
text_event = next(
1340+
event for event in events if event["type"] == "TEXT_MESSAGE_CONTENT"
1341+
)
1342+
assert text_event["delta"] == "answer"
1343+
assert "additional_kwargs" not in text_event

tests/unittests/server/test_openai_protocol.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,32 @@ def invoke_agent(request: AgentRequest):
10981098
assert message["content"] == "answer"
10991099
assert message["reasoning_content"] == "thinking"
11001100

1101+
def test_non_stream_suppresses_reasoning_when_thinking_disabled(
1102+
self, monkeypatch
1103+
):
1104+
monkeypatch.setenv("MODEL_PARAMETER_RULES", '{"thinking": false}')
1105+
1106+
def invoke_agent(request: AgentRequest):
1107+
return [
1108+
AgentEvent(
1109+
event=EventType.REASONING,
1110+
data={"delta": "thinking"},
1111+
),
1112+
AgentEvent(event=EventType.TEXT, data={"delta": "answer"}),
1113+
]
1114+
1115+
response = self.get_client(invoke_agent).post(
1116+
"/openai/v1/chat/completions",
1117+
json={
1118+
"messages": [{"role": "user", "content": "Hi"}],
1119+
"stream": False,
1120+
},
1121+
)
1122+
1123+
message = response.json()["choices"][0]["message"]
1124+
assert message["content"] == "answer"
1125+
assert "reasoning_content" not in message
1126+
11011127
def test_stream_promotes_chunk_additional_kwargs_reasoning(
11021128
self, monkeypatch
11031129
):
@@ -1120,3 +1146,25 @@ async def invoke_agent(request: AgentRequest):
11201146
events = _openai_sse_events(response)
11211147
assert events[0]["choices"][0]["delta"]["reasoning_content"] == "thinking"
11221148
assert events[1]["choices"][0]["delta"]["content"] == "answer"
1149+
1150+
def test_parses_request_message_reasoning_content(self):
1151+
captured_request = {}
1152+
1153+
def invoke_agent(request: AgentRequest):
1154+
captured_request["messages"] = request.messages
1155+
return "Done"
1156+
1157+
response = self.get_client(invoke_agent).post(
1158+
"/openai/v1/chat/completions",
1159+
json={
1160+
"messages": [{
1161+
"role": "assistant",
1162+
"content": "answer",
1163+
"reasoning_content": "thinking",
1164+
}],
1165+
"stream": False,
1166+
},
1167+
)
1168+
1169+
assert response.status_code == 200
1170+
assert captured_request["messages"][0].reasoning_content == "thinking"

tests/unittests/server/test_reasoning.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,29 @@ def test_model_parameter_rules_list_enables_thinking():
3030
assert get_thinking_value_from_env(env) is True
3131

3232

33+
def test_model_parameter_rules_nested_parameters_disables_thinking():
34+
env = {
35+
"MODEL_PARAMETER_RULES": (
36+
'{"parameters": [{"name": "thinking", "default": "false"}]}'
37+
)
38+
}
39+
40+
assert is_thinking_enabled_from_env(env) is False
41+
assert get_thinking_value_from_env(env) is False
42+
43+
3344
def test_model_parameter_rules_invalid_json_disables_thinking():
3445
env = {"MODEL_PARAMETER_RULES": "not json"}
3546

3647
assert is_thinking_enabled_from_env(env) is False
3748

3849

50+
def test_get_reasoning_content_from_attribute():
51+
chunk = SimpleNamespace(reasoning_content="thinking")
52+
53+
assert get_reasoning_content(chunk) == "thinking"
54+
55+
3956
def test_get_reasoning_content_from_additional_kwargs():
4057
chunk = {"additional_kwargs": {"reasoning_content": "thinking"}}
4158

0 commit comments

Comments
 (0)