fix(exploration): flatten dict reply payloads instead of storing str(dict)

E2E on t480 showed assistant bubbles rendering {'content': '...'} because
tutu replies carry msgBody as a parsed object and the router stored
str(reply.content). Coerce to the inner text before persisting.
This commit is contained in:
sinohqb 2026-08-04 02:26:24 +08:00
parent 958cefc380
commit 9abf572949
2 changed files with 47 additions and 1 deletions

View File

@ -10,6 +10,7 @@ with 409 plus a readable reason, so the rejection itself is feedback to the
resident agent. resident agent.
""" """
import json
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@ -61,6 +62,20 @@ class CloseSessionRequest(BaseModel):
experience: dict[str, Any] 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( def _check_creation_guardrails(
campaign: Campaign, campaign: Campaign,
triggered_by: ExplorationTrigger, triggered_by: ExplorationTrigger,
@ -283,7 +298,7 @@ async def send_session_message(
received_at = utc_now() received_at = utc_now()
latency_ms = int((received_at - sent_at).total_seconds() * 1000) 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( message_repo.save_message(
ExplorationMessage( ExplorationMessage(
session_id=session_obj.id, session_id=session_obj.id,

View File

@ -175,6 +175,37 @@ async def test_full_lifecycle_create_message_close(seeded_db, mock_channel, clie
assert closed["closed_at"] is not None 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): async def test_create_requires_existing_running_campaign(seeded_db, client):
resp = await _create_session(client, campaign_id="nope") resp = await _create_session(client, campaign_id="nope")
assert resp.status_code == 404 assert resp.status_code == 404