sync: 鍚屾灞€鍩熺綉鏈€鏂颁唬鐮?(source master @ b69568e4)
This commit is contained in:
1 parent
4f7f07504a
commit
8bc8b0e082
1651 files changed
+278599
-11416
No files matched your search
@@ -13,14 +13,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import app.gateway.roundtable_model_fallback as mf
|
||||
import app.gateway.routers.parallel_agents as parallel_agents
|
||||
from app.gateway.roundtable_model_fallback import (
|
||||
fallback_model_chain,
|
||||
is_llm_error_message,
|
||||
reason_text,
|
||||
)
|
||||
from app.gateway.routers._strategy_selection_seed import default_strategy_agent_roles
|
||||
from deerflow.agents.middlewares.llm_error_handling_middleware import (
|
||||
LLM_ERROR_MARKER,
|
||||
LLMErrorHandlingMiddleware,
|
||||
@@ -56,6 +62,648 @@ def test_is_llm_error_message_normal_is_none() -> None:
|
||||
assert is_llm_error_message({"type": "ai", "content": ""}) is None
|
||||
|
||||
|
||||
def test_parallel_agent_run_raises_instead_of_completing_llm_fallback(monkeypatch) -> None:
|
||||
fallback_message = {
|
||||
"type": "ai",
|
||||
"content": "大模型本次调用未能成功完成,请稍后再试或更换模型。",
|
||||
}
|
||||
|
||||
async def fake_stream_upstream(*_args, **_kwargs):
|
||||
yield None, [f"data: {json.dumps({'messages': [fallback_message]}, ensure_ascii=False)}"]
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
|
||||
|
||||
async def collect() -> list[object]:
|
||||
return [
|
||||
item
|
||||
async for item in parallel_agents._run_one_agent(
|
||||
None, # type: ignore[arg-type]
|
||||
"http://gateway.test",
|
||||
{"Authorization": "Bearer test"},
|
||||
"strategy-selection-intent",
|
||||
"thread-id",
|
||||
"test task",
|
||||
"",
|
||||
)
|
||||
]
|
||||
|
||||
try:
|
||||
asyncio.run(collect())
|
||||
except RuntimeError as exc:
|
||||
assert "模型调用失败" in str(exc)
|
||||
else:
|
||||
raise AssertionError("LLM fallback message must not be emitted as agent_done")
|
||||
|
||||
|
||||
def test_parallel_agent_run_rejects_punctuation_only_delivery(monkeypatch) -> None:
|
||||
invalid_message = {"type": "ai", "content": "!" * 500}
|
||||
|
||||
async def fake_stream_upstream(*_args, **_kwargs):
|
||||
yield None, [f"data: {json.dumps({'messages': [invalid_message]}, ensure_ascii=False)}"]
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
|
||||
|
||||
async def collect() -> list[object]:
|
||||
return [
|
||||
item
|
||||
async for item in parallel_agents._run_one_agent(
|
||||
None, # type: ignore[arg-type]
|
||||
"http://gateway.test",
|
||||
{"Authorization": "Bearer test"},
|
||||
"strategy-selection-angle-security",
|
||||
"thread-id",
|
||||
"test task",
|
||||
"",
|
||||
)
|
||||
]
|
||||
|
||||
try:
|
||||
asyncio.run(collect())
|
||||
except RuntimeError as exc:
|
||||
assert "未生成有效正文" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Punctuation-only output must not be emitted as agent_done")
|
||||
|
||||
|
||||
def test_parallel_agent_run_allows_empty_content_clarification(monkeypatch) -> None:
|
||||
clarification_message = {
|
||||
"type": "ai",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "clarify-1",
|
||||
"name": "ask_clarification",
|
||||
"args": {
|
||||
"question": "请确认策略时间范围。",
|
||||
"clarification_type": "single_choice",
|
||||
"options": ["短期", "长期"],
|
||||
"allow_custom": True,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def fake_stream_upstream(*_args, **_kwargs):
|
||||
yield None, [f"data: {json.dumps({'messages': [clarification_message]}, ensure_ascii=False)}"]
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
|
||||
|
||||
async def collect() -> list[object]:
|
||||
return [
|
||||
item
|
||||
async for item in parallel_agents._run_one_agent(
|
||||
None, # type: ignore[arg-type]
|
||||
"http://gateway.test",
|
||||
{"Authorization": "Bearer test"},
|
||||
"strategy-selection-intent",
|
||||
"thread-id",
|
||||
"test task",
|
||||
"",
|
||||
)
|
||||
]
|
||||
|
||||
events = asyncio.run(collect())
|
||||
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert isinstance(event, dict)
|
||||
assert event["type"] == "clarification"
|
||||
assert event["content"] == ""
|
||||
assert event["clarification"]["question"] == "请确认策略时间范围。"
|
||||
|
||||
|
||||
def test_parallel_agent_final_values_keep_typed_reasoning_out_of_content(monkeypatch) -> None:
|
||||
final_message = {
|
||||
"type": "ai",
|
||||
"content": [
|
||||
{"type": "thinking", "text": "核对约束与证据"},
|
||||
{"type": "text", "text": "这是最终可见策略正文,包含目标、路径和验收标准。"},
|
||||
],
|
||||
}
|
||||
|
||||
async def fake_stream_upstream(*_args, **_kwargs):
|
||||
yield None, [f"data: {json.dumps({'messages': [final_message]}, ensure_ascii=False)}"]
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
|
||||
|
||||
async def collect() -> list[object]:
|
||||
return [
|
||||
item
|
||||
async for item in parallel_agents._run_one_agent(
|
||||
None, # type: ignore[arg-type]
|
||||
"http://gateway.test",
|
||||
{"Authorization": "Bearer test"},
|
||||
"strategy-selection-angle-security",
|
||||
"thread-id",
|
||||
"test task",
|
||||
"",
|
||||
)
|
||||
]
|
||||
|
||||
events = asyncio.run(collect())
|
||||
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert isinstance(event, dict)
|
||||
assert event["content"] == "这是最终可见策略正文,包含目标、路径和验收标准。"
|
||||
assert event["reasoning"] == "核对约束与证据"
|
||||
|
||||
|
||||
def test_parallel_agent_final_values_recovers_template_injected_reasoning(monkeypatch) -> None:
|
||||
final_message = {
|
||||
"type": "ai",
|
||||
"content": "模板注入的内部推理</think>这是最终可见策略正文,包含目标、路径和验收标准。",
|
||||
}
|
||||
|
||||
async def fake_stream_upstream(*_args, **_kwargs):
|
||||
yield None, [f"data: {json.dumps({'messages': [final_message]}, ensure_ascii=False)}"]
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
|
||||
|
||||
async def collect() -> list[object]:
|
||||
return [
|
||||
item
|
||||
async for item in parallel_agents._run_one_agent(
|
||||
None, # type: ignore[arg-type]
|
||||
"http://gateway.test",
|
||||
{"Authorization": "Bearer test"},
|
||||
"strategy-selection-angle-security",
|
||||
"thread-id",
|
||||
"test task",
|
||||
"",
|
||||
)
|
||||
]
|
||||
|
||||
events = asyncio.run(collect())
|
||||
|
||||
event = events[0]
|
||||
assert isinstance(event, dict)
|
||||
assert event["content"] == "这是最终可见策略正文,包含目标、路径和验收标准。"
|
||||
assert event["reasoning"] == "模板注入的内部推理"
|
||||
|
||||
|
||||
def test_parallel_agent_substantive_content_accepts_chinese_and_english() -> None:
|
||||
assert parallel_agents._has_substantive_content("这是一份包含目标、前置条件、阶段路径和验收标准的完整策略。")
|
||||
assert parallel_agents._has_substantive_content("A complete strategy with objectives, prerequisites, milestones, and measurable outcomes.")
|
||||
assert not parallel_agents._has_substantive_content("<think>internal reasoning only</think>!!!")
|
||||
|
||||
|
||||
def test_parallel_agent_extracts_provider_reasoning_without_mixing_answer() -> None:
|
||||
answer, reasoning = parallel_agents._extract_ai_stream_parts(
|
||||
{
|
||||
"type": "AIMessageChunk",
|
||||
"content": [
|
||||
{"type": "thinking_delta", "text": "先核对边界"},
|
||||
{"type": "text", "text": "可见正文"},
|
||||
],
|
||||
"additional_kwargs": {"reasoning_content": ",再比较路径"},
|
||||
}
|
||||
)
|
||||
|
||||
assert answer == "可见正文"
|
||||
assert reasoning == "先核对边界,再比较路径"
|
||||
|
||||
answer, reasoning = parallel_agents._extract_ai_stream_parts(
|
||||
{
|
||||
"type": "AIMessageChunk",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "结构化思考字段"},
|
||||
{"type": "text", "text": "最终正文"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert answer == "最终正文"
|
||||
assert reasoning == "结构化思考字段"
|
||||
|
||||
|
||||
class _EnabledStrategyRoleStore:
|
||||
async def ensure_defaults(self, _rows) -> int:
|
||||
return 0
|
||||
|
||||
async def list_roles(self, *, include_disabled: bool) -> list[dict[str, object]]:
|
||||
del include_disabled
|
||||
return [dict(row) for row in default_strategy_agent_roles()]
|
||||
|
||||
async def get_role(self, agent_id: str) -> dict[str, object]:
|
||||
return {"id": agent_id, "enabled": True}
|
||||
|
||||
|
||||
class _GlobalStrategyAgentStore:
|
||||
async def get_any(self, agent_id: str) -> dict[str, object]:
|
||||
return {"id": agent_id, "user_id": None}
|
||||
|
||||
|
||||
def _parallel_request() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
strategy_agent_role_store=_EnabledStrategyRoleStore(),
|
||||
agent_store=_GlobalStrategyAgentStore(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _collect_parallel_events(payload: dict[str, object]) -> list[dict[str, object]]:
|
||||
response = await parallel_agents.run_parallel_agents_stream(
|
||||
_parallel_request(),
|
||||
payload,
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
async for chunk in response.body_iterator:
|
||||
text = chunk.decode() if isinstance(chunk, bytes) else chunk
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data:"):
|
||||
events.append(json.loads(line[5:].strip()))
|
||||
return events
|
||||
|
||||
|
||||
def test_parallel_agent_tool_progress_message_is_specific_and_bounded() -> None:
|
||||
assert (
|
||||
parallel_agents._tool_progress_message(
|
||||
"skill_view",
|
||||
{"skill_name": "industry-research"},
|
||||
)
|
||||
== "正在读取技能说明:industry-research"
|
||||
)
|
||||
assert (
|
||||
parallel_agents._tool_progress_message(
|
||||
"llmwiki_search",
|
||||
{"query": " 人工智能 产业趋势 "},
|
||||
)
|
||||
== "正在检索知识库:人工智能 产业趋势"
|
||||
)
|
||||
long_message = parallel_agents._tool_progress_message(
|
||||
"read_file",
|
||||
{"path": "/mnt/user-data/" + ("very-long-segment-" * 8)},
|
||||
)
|
||||
assert long_message.startswith("正在读取资料:/mnt/user-data/")
|
||||
assert long_message.endswith("…")
|
||||
assert len(long_message.removeprefix("正在读取资料:").removesuffix("…")) == 64
|
||||
|
||||
|
||||
def test_parallel_agent_stream_heartbeat_precedes_terminal_event(monkeypatch) -> None:
|
||||
async def fake_run_one_agent(*_args, **_kwargs):
|
||||
await asyncio.sleep(0.035)
|
||||
yield {
|
||||
"status": "done",
|
||||
"content": "这是一份包含目标、执行路径、风险和验收标准的完整策略正文。",
|
||||
"agent_name": "strategy-selection-angle-security",
|
||||
"tool_calls": [],
|
||||
}
|
||||
# Keep the upstream iterator alive briefly after its terminal payload;
|
||||
# this used to allow a heartbeat to arrive after agent_done.
|
||||
await asyncio.sleep(0.025)
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_client(*_args, **_kwargs):
|
||||
yield object()
|
||||
|
||||
async def fake_create_thread(*_args, **_kwargs):
|
||||
return "thread-id"
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
|
||||
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
|
||||
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
|
||||
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
|
||||
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
|
||||
monkeypatch.setattr(parallel_agents, "PROGRESS_HEARTBEAT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(parallel_agents, "AGENT_STREAM_IDLE_TIMEOUT_SECONDS", 1.0)
|
||||
|
||||
events = asyncio.run(
|
||||
_collect_parallel_events(
|
||||
{
|
||||
"task_name": "heartbeat order",
|
||||
"brief": "show progress while a skill is silent",
|
||||
"agent_ids": ["strategy-selection-angle-security"],
|
||||
}
|
||||
)
|
||||
)
|
||||
event_types = [str(event.get("type")) for event in events]
|
||||
done_index = event_types.index("agent_done")
|
||||
progress_indexes = [index for index, event_type in enumerate(event_types) if event_type == "agent_progress"]
|
||||
|
||||
assert event_types[0] == "agent_start"
|
||||
assert progress_indexes
|
||||
assert all(index < done_index for index in progress_indexes)
|
||||
assert event_types[-1] == "all_done"
|
||||
|
||||
|
||||
def test_parallel_agent_stream_idle_timeout_finishes_with_error(monkeypatch) -> None:
|
||||
async def fake_run_one_agent(*_args, **_kwargs):
|
||||
await asyncio.sleep(60)
|
||||
if False: # pragma: no cover - keep this function an async generator
|
||||
yield None
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_client(*_args, **_kwargs):
|
||||
yield object()
|
||||
|
||||
async def fake_create_thread(*_args, **_kwargs):
|
||||
return "thread-id"
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
|
||||
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
|
||||
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
|
||||
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
|
||||
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
|
||||
monkeypatch.setattr(parallel_agents, "PROGRESS_HEARTBEAT_SECONDS", 0.005)
|
||||
monkeypatch.setattr(parallel_agents, "AGENT_STREAM_IDLE_TIMEOUT_SECONDS", 0.025)
|
||||
|
||||
async def collect_with_deadline() -> list[dict[str, object]]:
|
||||
return await asyncio.wait_for(
|
||||
_collect_parallel_events(
|
||||
{
|
||||
"task_name": "timeout",
|
||||
"brief": "a stalled skill must not leave the page pending",
|
||||
"agent_ids": ["strategy-selection-angle-security"],
|
||||
}
|
||||
),
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
events = asyncio.run(collect_with_deadline())
|
||||
errors = [event for event in events if event.get("type") == "agent_error"]
|
||||
|
||||
assert any(event.get("type") == "agent_progress" for event in events)
|
||||
assert len(errors) == 1
|
||||
assert "没有返回进度" in str(errors[0].get("message"))
|
||||
assert not any(event.get("type") == "agent_done" for event in events)
|
||||
assert events[-1]["type"] == "all_done"
|
||||
|
||||
|
||||
def test_parallel_agent_stream_separates_inline_and_provider_reasoning(monkeypatch) -> None:
|
||||
async def fake_run_one_agent(*_args, **_kwargs):
|
||||
yield "event: messages"
|
||||
yield f"data: {json.dumps([{'type': 'ai', 'content': '<thi'}], ensure_ascii=False)}"
|
||||
yield "event: messages"
|
||||
yield f"data: {json.dumps([{'type': 'ai', 'content': 'nk>内部分析</think>方案正文'}], ensure_ascii=False)}"
|
||||
yield "event: messages"
|
||||
yield f"data: {json.dumps([{'type': 'ai', 'content': '', 'additional_kwargs': {'reasoning_content': '补充推理'}}], ensure_ascii=False)}"
|
||||
yield {
|
||||
"status": "done",
|
||||
"content": "<think>内部分析</think>方案正文",
|
||||
"agent_name": "strategy-selection-angle-security",
|
||||
"tool_calls": [],
|
||||
}
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_client(*_args, **_kwargs):
|
||||
yield object()
|
||||
|
||||
async def fake_create_thread(*_args, **_kwargs):
|
||||
return "thread-id"
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
|
||||
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
|
||||
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
|
||||
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
|
||||
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
|
||||
|
||||
async def collect() -> list[dict[str, object]]:
|
||||
response = await parallel_agents.run_parallel_agents_stream(
|
||||
_parallel_request(),
|
||||
{
|
||||
"task_name": "test task",
|
||||
"brief": "test brief",
|
||||
"agent_ids": ["strategy-selection-angle-security"],
|
||||
},
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
async for chunk in response.body_iterator:
|
||||
text = chunk.decode() if isinstance(chunk, bytes) else chunk
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data:"):
|
||||
events.append(json.loads(line[5:].strip()))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect())
|
||||
visible = "".join(str(event.get("text") or "") for event in events if event.get("type") == "delta")
|
||||
thinking = "".join(str(event.get("text") or "") for event in events if event.get("type") == "reasoning_delta")
|
||||
done = next(event for event in events if event.get("type") == "agent_done")
|
||||
|
||||
assert visible == "方案正文"
|
||||
assert thinking == "内部分析补充推理"
|
||||
assert done["content"] == "方案正文"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("buffered_content", "expected_error"),
|
||||
[
|
||||
("大模型本次调用未能成功完成,请稍后再试或更换模型。", "模型调用失败"),
|
||||
("", "未生成有效正文"),
|
||||
("<think>internal reasoning only</think>", "未生成有效正文"),
|
||||
("!" * 500, "未生成有效正文"),
|
||||
],
|
||||
)
|
||||
def test_parallel_agent_truncated_stream_rejects_invalid_buffer(
|
||||
monkeypatch,
|
||||
buffered_content: str,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
async def fake_run_one_agent(*_args, **_kwargs):
|
||||
yield "event: messages"
|
||||
yield f"data: {json.dumps([{'type': 'ai', 'content': buffered_content}], ensure_ascii=False)}"
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_client(*_args, **_kwargs):
|
||||
yield object()
|
||||
|
||||
async def fake_create_thread(*_args, **_kwargs):
|
||||
return "thread-id"
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
|
||||
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
|
||||
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
|
||||
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
|
||||
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
|
||||
|
||||
async def collect() -> list[dict[str, object]]:
|
||||
response = await parallel_agents.run_parallel_agents_stream(
|
||||
_parallel_request(),
|
||||
{
|
||||
"task_name": "test task",
|
||||
"brief": "test brief",
|
||||
"agent_ids": ["strategy-selection-intent"],
|
||||
},
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
async for chunk in response.body_iterator:
|
||||
text = chunk.decode() if isinstance(chunk, bytes) else chunk
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data:"):
|
||||
events.append(json.loads(line[5:].strip()))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect())
|
||||
assert not any(event.get("type") == "agent_done" for event in events)
|
||||
errors = [event for event in events if event.get("type") == "agent_error"]
|
||||
assert len(errors) == 1
|
||||
assert expected_error in str(errors[0].get("message"))
|
||||
|
||||
|
||||
def test_parallel_agent_plain_text_streams_each_chunk_without_probe_delay(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
chunks = [
|
||||
"This visible strategy ",
|
||||
"includes objectives and execution paths, ",
|
||||
"plus measurable acceptance criteria.",
|
||||
]
|
||||
|
||||
async def fake_run_one_agent(*_args, **_kwargs):
|
||||
for content in chunks:
|
||||
yield "event: messages"
|
||||
yield f"data: {json.dumps([{'type': 'ai', 'content': content}], ensure_ascii=False)}"
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_client(*_args, **_kwargs):
|
||||
yield object()
|
||||
|
||||
async def fake_create_thread(*_args, **_kwargs):
|
||||
return "thread-id"
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
|
||||
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
|
||||
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
|
||||
monkeypatch.setattr(
|
||||
parallel_agents,
|
||||
"_loopback_base",
|
||||
lambda _request: "http://gateway.test",
|
||||
)
|
||||
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
|
||||
monkeypatch.setattr(
|
||||
parallel_agents,
|
||||
"_should_probe_implicit_reasoning",
|
||||
lambda *_args: False,
|
||||
)
|
||||
|
||||
async def collect() -> list[dict[str, object]]:
|
||||
response = await parallel_agents.run_parallel_agents_stream(
|
||||
_parallel_request(),
|
||||
{
|
||||
"task_name": "plain stream",
|
||||
"brief": "plain answer chunks must stay live",
|
||||
"agent_ids": ["strategy-selection-intent"],
|
||||
},
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
async for chunk in response.body_iterator:
|
||||
text = chunk.decode() if isinstance(chunk, bytes) else chunk
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data:"):
|
||||
events.append(json.loads(line[5:].strip()))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect())
|
||||
assert [event["text"] for event in events if event.get("type") == "delta"] == chunks
|
||||
done = next(event for event in events if event.get("type") == "agent_done")
|
||||
assert done["content"] == "".join(chunks)
|
||||
|
||||
|
||||
def test_parallel_agent_typed_reasoning_unlocks_live_answer_channel(monkeypatch) -> None:
|
||||
chunks = [
|
||||
"This visible strategy ",
|
||||
"streams after typed reasoning, ",
|
||||
"with objectives and acceptance criteria.",
|
||||
]
|
||||
|
||||
async def fake_run_one_agent(*_args, **_kwargs):
|
||||
yield "event: messages"
|
||||
yield f"data: {json.dumps([{'type': 'ai', 'content': '', 'additional_kwargs': {'reasoning_content': 'live reasoning'}}])}"
|
||||
for content in chunks:
|
||||
yield "event: messages"
|
||||
yield f"data: {json.dumps([{'type': 'ai', 'content': content}])}"
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_client(*_args, **_kwargs):
|
||||
yield object()
|
||||
|
||||
async def fake_create_thread(*_args, **_kwargs):
|
||||
return "thread-id"
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
|
||||
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
|
||||
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
|
||||
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
|
||||
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
|
||||
monkeypatch.setattr(parallel_agents, "_should_probe_implicit_reasoning", lambda *_args: True)
|
||||
|
||||
async def collect() -> list[dict[str, object]]:
|
||||
response = await parallel_agents.run_parallel_agents_stream(
|
||||
_parallel_request(),
|
||||
{
|
||||
"task_name": "typed reasoning stream",
|
||||
"brief": "answer chunks must stay live",
|
||||
"agent_ids": ["strategy-selection-intent"],
|
||||
},
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
async for chunk in response.body_iterator:
|
||||
text = chunk.decode() if isinstance(chunk, bytes) else chunk
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data:"):
|
||||
events.append(json.loads(line[5:].strip()))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect())
|
||||
assert [event["text"] for event in events if event.get("type") == "delta"] == chunks
|
||||
assert "".join(str(event.get("text") or "") for event in events if event.get("type") == "reasoning_delta") == "live reasoning"
|
||||
|
||||
|
||||
def test_parallel_agent_truncated_close_only_stream_separates_reasoning(monkeypatch) -> None:
|
||||
async def fake_run_one_agent(*_args, **_kwargs):
|
||||
yield "event: messages"
|
||||
yield f"data: {json.dumps([{'type': 'ai', 'content': 'internal '}], ensure_ascii=False)}"
|
||||
yield "event: messages"
|
||||
yield f"data: {json.dumps([{'type': 'ai', 'content': 'reasoning</thi'}], ensure_ascii=False)}"
|
||||
yield "event: messages"
|
||||
yield f"data: {json.dumps([{'type': 'ai', 'content': 'nk>This visible strategy includes objectives, execution paths, and acceptance criteria.'}], ensure_ascii=False)}"
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_client(*_args, **_kwargs):
|
||||
yield object()
|
||||
|
||||
async def fake_create_thread(*_args, **_kwargs):
|
||||
return "thread-id"
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
|
||||
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
|
||||
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
|
||||
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
|
||||
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
|
||||
monkeypatch.setattr(
|
||||
parallel_agents,
|
||||
"_should_probe_implicit_reasoning",
|
||||
lambda *_args: True,
|
||||
)
|
||||
|
||||
async def collect() -> list[dict[str, object]]:
|
||||
response = await parallel_agents.run_parallel_agents_stream(
|
||||
_parallel_request(),
|
||||
{
|
||||
"task_name": "test task",
|
||||
"brief": "test brief",
|
||||
"agent_ids": ["strategy-selection-intent"],
|
||||
},
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
async for chunk in response.body_iterator:
|
||||
text = chunk.decode() if isinstance(chunk, bytes) else chunk
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data:"):
|
||||
events.append(json.loads(line[5:].strip()))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect())
|
||||
done = next(event for event in events if event.get("type") == "agent_done")
|
||||
reasoning = "".join(str(event.get("text") or "") for event in events if event.get("type") == "reasoning_delta")
|
||||
visible = "".join(str(event.get("text") or "") for event in events if event.get("type") == "delta")
|
||||
assert done["content"] == ("This visible strategy includes objectives, execution paths, and acceptance criteria.")
|
||||
assert visible == done["content"]
|
||||
assert reasoning == "internal reasoning"
|
||||
|
||||
|
||||
# ── 中间件给兜底 AIMessage 打标记 ────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user