diff --git a/backend/agenteval/web/routers/exploration.py b/backend/agenteval/web/routers/exploration.py index dec9e9a..f8e4ab7 100644 --- a/backend/agenteval/web/routers/exploration.py +++ b/backend/agenteval/web/routers/exploration.py @@ -10,6 +10,7 @@ with 409 plus a readable reason, so the rejection itself is feedback to the resident agent. """ +import json from datetime import datetime from typing import Any @@ -61,6 +62,20 @@ class CloseSessionRequest(BaseModel): experience: dict[str, Any] +def _coerce_reply_text(content: Any) -> str: + """Flatten a reply payload to text; tutu returns msgBody as a parsed object, and str(dict) would leak a Python repr into the view.""" + if isinstance(content, str): + return content + if isinstance(content, dict): + for key in ("content", "text", "message"): + value = content.get(key) + if isinstance(value, str) and value: + return value + if content is None: + return "" + return json.dumps(content, ensure_ascii=False) + + def _check_creation_guardrails( campaign: Campaign, triggered_by: ExplorationTrigger, @@ -283,7 +298,7 @@ async def send_session_message( received_at = utc_now() latency_ms = int((received_at - sent_at).total_seconds() * 1000) - reply_text = str(reply.content) + reply_text = _coerce_reply_text(reply.content) message_repo.save_message( ExplorationMessage( session_id=session_obj.id, diff --git a/tests/integration/test_exploration_api.py b/tests/integration/test_exploration_api.py index cdcc240..4eaba49 100644 --- a/tests/integration/test_exploration_api.py +++ b/tests/integration/test_exploration_api.py @@ -175,6 +175,37 @@ async def test_full_lifecycle_create_message_close(seeded_db, mock_channel, clie assert closed["closed_at"] is not None +async def test_dict_reply_content_is_flattened_to_text(seeded_db, monkeypatch, client): + """通道回复 content 为对象(如 tutu msgBody)时应提取文本而非存 str(dict)。""" + from agenteval.channels.base import Reply + from tests.unit.mock_channel import MockChannel + + class _DictReplyChannel(MockChannel): + async def poll_reply(self, question_msg_id, timeout=30.0, poll_interval=1.0): + return Reply( + question_msg_id=question_msg_id, + content={"content": "您好,我是客服"}, + raw_message={}, + ) + + _stub_channel_factory(monkeypatch, _DictReplyChannel(reply_text="unused")) + + resp = await _create_session(client) + assert resp.status_code == 200, resp.text + session_id = resp.json()["id"] + + resp = await client.post( + f"/api/exploration/sessions/{session_id}/messages", + json={"content": "你好"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["reply"] == "您好,我是客服" + + messages_resp = await client.get(f"/api/exploration/sessions/{session_id}/messages") + assistant = [m for m in messages_resp.json()["messages"] if m["role"] == "assistant"] + assert assistant[0]["content"] == "您好,我是客服" + + async def test_create_requires_existing_running_campaign(seeded_db, client): resp = await _create_session(client, campaign_id="nope") assert resp.status_code == 404