Merge pull request #6409 from InfinityPacer/codex/feat/agentchat-async-persistence

This commit is contained in:
jxxghp
2026-08-23 12:50:34 +08:00
committed by GitHub
23 changed files with 1571 additions and 116 deletions
+61
View File
@@ -10,6 +10,10 @@ from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
from app.application.agentdata import AgentChatPort as AgentChatOper
from app.application.messaging.chat import (
get_configured_agent_chat_persistence,
get_configured_agent_chat_service,
)
from app.runtime.log import logger
from app.schemas.agent import ConversationMemory
@@ -105,6 +109,42 @@ class MemoryManager:
self.save_memory(memory)
return memory.messages
async def async_get_agent_messages(
self, session_id: str, user_id: str
) -> List[BaseMessage]:
"""异步恢复 Agent 消息,查询与会话应用服务保持同一异步端口。"""
memory = self.get_memory(session_id, user_id)
if memory:
return memory.messages
try:
service = get_configured_agent_chat_service()
chat = await service.get(
session_id=session_id,
user_id=user_id,
)
if not chat:
chat = await service.get(session_id=session_id)
except Exception as e:
logger.debug(f"读取持久化Agent会话失败: {e}")
return []
if not chat or not chat.agent_messages:
return []
try:
messages = messages_from_dict(chat.agent_messages)
except Exception as e:
logger.debug(f"恢复持久化Agent消息失败: {e}")
return []
memory = ConversationMemory(
session_id=session_id,
user_id=user_id,
messages=messages,
)
self.save_memory(memory)
return memory.messages
def save_agent_messages(
self, session_id: str, user_id: str, messages: List[BaseMessage]
):
@@ -129,6 +169,27 @@ class MemoryManager:
except Exception as e:
logger.debug(f"持久化Agent消息失败: {e}")
async def async_save_agent_messages(
self, session_id: str, user_id: str, messages: List[BaseMessage]
) -> None:
"""异步保存 Agent 消息,持久化写入经有界数据库 worker 承接。"""
memory = self.get_memory(session_id, user_id)
if not memory:
memory = ConversationMemory(session_id=session_id, user_id=user_id)
memory.messages = messages
memory.updated_at = datetime.now()
self.save_memory(memory)
try:
persistence = get_configured_agent_chat_persistence()
await persistence.async_save_agent_messages(
session_id=session_id,
user_id=user_id,
messages=messages_to_dict(messages),
)
except Exception as e:
logger.debug(f"持久化Agent消息失败: {e}")
def save_memory(self, memory: ConversationMemory):
"""
保存记忆到内存缓存
+23 -18
View File
@@ -79,9 +79,13 @@ from app.application.plugin.runtime import get_plugin_manager
def _get_plugin_tools_revision() -> int:
"""读取插件工具目录修订号,避免 Agent 编排依赖具体管理器类型。"""
return get_plugin_manager().get_plugin_agent_tools_revision()
from app.application.agentdata import AgentChatPort as AgentChatOper
from app.application.agentdata import AgentTaskPort as AgentTaskOper
from app.application.agentdata import UserPort as UserOper
from app.application.messaging.chat import (
get_configured_agent_chat_service,
get_configured_agent_chat_persistence,
has_custom_agent_chat_title,
)
from app.runtime.log import logger
from app.schemas.event import AgentLLMProviderEventData
from app.schemas.event import AgentTokensUsageEventData
@@ -471,14 +475,14 @@ class MoviePilotAgent:
"""
return bool(self.channel and self.source)
def _save_display_history_messages(self, messages: List[dict]) -> None:
async def _save_display_history_messages(self, messages: List[dict]) -> None:
"""
将一组可见消息追加到 Agent 会话历史表。
"""
if not messages or not self._should_save_display_history():
return
try:
AgentChatOper().append_display_messages(
await get_configured_agent_chat_persistence().async_append_display_messages(
session_id=self.session_id,
user_id=self.user_id,
username=self.username,
@@ -490,13 +494,13 @@ class MoviePilotAgent:
except Exception as e:
logger.debug(f"写入Agent展示历史失败: {e}")
def _save_assistant_display_message_once(self, message: str) -> None:
async def _save_assistant_display_message_once(self, message: str) -> None:
"""
保存一条助手回复展示记录,并标记本轮已写入。
"""
if not message or self._tool_context.get("assistant_display_saved"):
return
self._save_display_history_messages(
await self._save_display_history_messages(
[self.build_display_message(role="assistant", content=message)]
)
self._tool_context["assistant_display_saved"] = True
@@ -576,18 +580,16 @@ class MoviePilotAgent:
return
self._tool_context["chat_title_prepared"] = True
try:
chat = await run_in_threadpool(
AgentChatOper().get,
chat = await get_configured_agent_chat_service().get(
session_id=self.session_id,
user_id=self.user_id,
)
if chat and AgentChatOper.has_custom_title(chat.title):
if chat and has_custom_agent_chat_title(chat.title):
return
title = await self._generate_chat_title(message)
if not title:
return
await run_in_threadpool(
AgentChatOper().update_title_if_empty,
await get_configured_agent_chat_persistence().async_update_title_if_empty(
session_id=self.session_id,
user_id=self.user_id,
title=title,
@@ -2167,9 +2169,12 @@ class MoviePilotAgent:
return confirmation_result
# 获取历史消息
messages = list(memory_manager.get_agent_messages(
session_id=self.session_id, user_id=self.user_id
))
messages = list(
await memory_manager.async_get_agent_messages(
session_id=self.session_id,
user_id=self.user_id,
)
)
# 构建结构化用户消息内容
request_payload = {
@@ -2194,7 +2199,7 @@ class MoviePilotAgent:
content.append({"type": "image_url", "image_url": {"url": img}})
messages.append(HumanMessage(content=content))
await self.prepare_chat_title(message)
self._save_display_history_messages(
await self._save_display_history_messages(
[
self.build_display_message(
role="user",
@@ -2219,7 +2224,7 @@ class MoviePilotAgent:
error_message = f"处理消息时发生错误: {str(e)}"
logger.error(error_message)
if not user_display_saved:
self._save_display_history_messages(
await self._save_display_history_messages(
[self.build_display_message(role="user", content=message)]
)
if not self.should_dispatch_reply:
@@ -2460,10 +2465,10 @@ class MoviePilotAgent:
if hasattr(msg, "type") and msg.type == "ai" and msg.content:
display_text = LLMHelper.extract_text_content(msg.content).strip()
break
self._save_assistant_display_message_once(display_text)
await self._save_assistant_display_message_once(display_text)
if self._should_persist_agent_chat():
memory_manager.save_agent_messages(
await memory_manager.async_save_agent_messages(
session_id=self.session_id,
user_id=self.user_id,
messages=agent.get_state(agent_config).values.get("messages", []),
@@ -2517,7 +2522,7 @@ class MoviePilotAgent:
and self.channel == NotificationChannel.Telegram.value
else None
)
self._save_assistant_display_message_once(message)
await self._save_assistant_display_message_once(message)
await AgentChain().async_post_message(
Message(
channel=None if broadcast else self.channel,
+10 -1
View File
@@ -4,6 +4,7 @@ from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.context import (
get_agent_chat_runtime,
get_agent_chat_repository,
get_agent_chat_transaction,
get_async_session,
@@ -11,11 +12,12 @@ from app.api.context import (
)
from app.application.messaging.chat import (
AgentChatService,
AgentChatPersistenceService,
AsyncAgentChatRepository,
AsyncUnitOfWork,
)
from app.application.messaging.message import MessageQueryService
from app.startup.context import HostRuntime
from app.startup.context import AgentChatRuntime, HostRuntime
def get_agent_chat_service(
@@ -26,6 +28,13 @@ def get_agent_chat_service(
return AgentChatService(chat_repository, unit_of_work)
def get_agent_chat_persistence(
runtime: AgentChatRuntime = Depends(get_agent_chat_runtime),
) -> AgentChatPersistenceService:
"""从类型化 Agent 运行时获取有界会话写入端口。"""
return runtime.persistence
def get_message_query_service(
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
+131 -56
View File
@@ -47,16 +47,25 @@ from app.command import Command
from app.runtime.config import global_vars
from app.runtime.events import Event, EventManager
from app.api.principal import ApiPrincipal
from app.api.dependencies.agent import get_agent_chat_service
from app.api.dependencies.agent import (
get_agent_chat_persistence,
get_agent_chat_service,
)
from app.api.dependencies.auth import get_current_active_user
from app.application.messaging.chat import (
AgentChatRecord,
AgentChatPersistenceService,
AgentChatService,
get_configured_agent_chat_service,
get_configured_agent_chat_persistence,
)
from app.application.security.user import get_configured_user_id_lookup
from app.application.configuration import get_api_runtime_config_snapshot
from app.application.messaging.agent import attach_web_agent_edit_queue, detach_web_agent_edit_queue
from app.application.messaging.agent import (
attach_web_agent_edit_queue,
create_web_agent_background_task,
detach_web_agent_edit_queue,
)
from app.application.messaging.agent import agent_interaction_manager
from app.application.messaging.agent import (
build_agent_choice_button_rows,
@@ -87,7 +96,6 @@ _WEB_AGENT_FILE_REGISTRY: dict[str, dict[str, Any]] = {}
_WEB_AGENT_MESSAGE_QUEUES: dict[str, list[Queue[_SchemaMessage]]] = {}
_WEB_AGENT_MESSAGE_LOCK = Lock()
_WEB_AGENT_MESSAGE_LISTENER_REGISTERED = False
_WEB_AGENT_BACKGROUND_TASKS: set[asyncio.Task] = set()
class _WebAgentEventPublisher:
@@ -513,6 +521,28 @@ def _build_web_agent_session_id(user: ApiPrincipal, session_id: Optional[str]) -
return f"{WEB_AGENT_SESSION_PREFIX}{digest[:32]}"
async def _build_web_agent_session_id_async(
user: ApiPrincipal,
session_id: Optional[str],
service: Optional[AgentChatService] = None,
) -> str:
"""异步解析 Web Agent 会话 ID,并复用异步会话查询端口。"""
seed = str(session_id or "").strip() or uuid.uuid4().hex
if seed.startswith(WEB_AGENT_SESSION_PREFIX):
return seed
try:
if service is None:
service = get_configured_agent_chat_service()
existing_chat = await service.get(seed)
if existing_chat and AgentChatService.can_access(existing_chat, user):
return seed
except Exception as e:
logger.debug(f"读取WebAgent历史会话失败: {e}")
user_part = user.name or str(user.id)
digest = hashlib.sha256(f"{user_part}:{seed}".encode("utf-8")).hexdigest()
return f"{WEB_AGENT_SESSION_PREFIX}{digest[:32]}"
def _can_access_agent_chat(chat: Any, user: ApiPrincipal) -> bool:
"""
判断当前登录用户是否可以访问指定 Agent 会话。
@@ -632,43 +662,47 @@ def _apply_web_agent_display_event(event: dict, assistant_message: dict) -> None
tool["status"] = "done"
def _save_web_agent_display_snapshot(
async def _save_web_agent_display_snapshot(
*,
session_id: str,
current_user: ApiPrincipal,
messages: list[dict],
client_session_id: Optional[str] = None,
service: Optional[AgentChatService] = None,
persistence: Optional[AgentChatPersistenceService] = None,
) -> None:
"""
保存 WebAgent 当前展示消息快照。
"""
try:
if service is None:
# 直接调用该内部 helper 时没有 FastAPI 依赖注入上下文。
service = get_configured_agent_chat_service()
existing_chat = service.get_sync(session_id)
service.save_display_sync(
session_id=session_id,
user_id=(existing_chat.user_id if existing_chat else str(current_user.id)),
username=(existing_chat.username if existing_chat else current_user.name),
channel=(
existing_chat.channel
if existing_chat and existing_chat.channel
else NotificationChannel.WebAgent
),
source=(
existing_chat.source
if existing_chat and existing_chat.source
else WEB_AGENT_SOURCE
),
original_chat_id=existing_chat.original_chat_id if existing_chat else None,
client_session_id=(
existing_chat.client_session_id
if existing_chat and existing_chat.client_session_id
else client_session_id
),
messages=messages,
)
except Exception as e:
logger.debug(f"保存WebAgent展示历史失败: {e}")
existing_chat = await service.get(session_id)
if persistence is None:
# 直接调用该内部 helper 时没有 FastAPI 依赖注入上下文。
persistence = get_configured_agent_chat_persistence()
await persistence.async_save_display_messages(
session_id=session_id,
user_id=(existing_chat.user_id if existing_chat else str(current_user.id)),
username=(existing_chat.username if existing_chat else current_user.name),
channel=(
existing_chat.channel
if existing_chat and existing_chat.channel
else NotificationChannel.WebAgent
),
source=(
existing_chat.source
if existing_chat and existing_chat.source
else WEB_AGENT_SOURCE
),
original_chat_id=existing_chat.original_chat_id if existing_chat else None,
client_session_id=(
existing_chat.client_session_id
if existing_chat and existing_chat.client_session_id
else client_session_id
),
messages=messages,
)
def _build_web_agent_sse(
@@ -735,7 +769,11 @@ def _sanitize_web_agent_upload_name(
return safe_name
def _get_web_agent_upload_dir(user: ApiPrincipal, session_id: Optional[str]) -> Path:
async def _get_web_agent_upload_dir(
user: ApiPrincipal,
session_id: Optional[str],
service: Optional[AgentChatService] = None,
) -> Path:
"""
计算当前 Web Agent 会话的临时附件目录。
@@ -743,7 +781,11 @@ def _get_web_agent_upload_dir(user: ApiPrincipal, session_id: Optional[str]) ->
:param session_id: 前端会话标识
:return: 已创建的临时附件目录
"""
server_session_id = _build_web_agent_session_id(user, session_id)
server_session_id = await _build_web_agent_session_id_async(
user,
session_id,
service,
)
safe_session_id = server_session_id.replace(":", "_")
upload_dir = get_api_runtime_config_snapshot().temp_path / "agent_uploads" / safe_session_id
upload_dir.mkdir(parents=True, exist_ok=True)
@@ -1679,6 +1721,7 @@ async def upload_web_agent_file(
file: UploadFile = File(...),
session_id: Optional[str] = Form(None),
current_user: ApiPrincipal = Depends(get_current_active_user),
service: AgentChatService = Depends(get_agent_chat_service),
) -> _SchemaResponse:
"""
上传 Web 智能助手对话附件。
@@ -1690,7 +1733,7 @@ async def upload_web_agent_file(
"""
mime_type = file.content_type or mimetypes.guess_type(file.filename or "")[0]
safe_name = _sanitize_web_agent_upload_name(file.filename, mime_type)
upload_dir = _get_web_agent_upload_dir(current_user, session_id)
upload_dir = await _get_web_agent_upload_dir(current_user, session_id, service)
target_path = upload_dir / f"{uuid.uuid4().hex[:8]}_{safe_name}"
size = await _save_web_agent_upload(file, target_path)
attachment = _register_web_agent_file(
@@ -1821,7 +1864,11 @@ async def get_agent_chat_session(
chat = await _get_accessible_agent_chat(service, session_id, current_user)
server_session_id = session_id
if not chat:
server_session_id = _build_web_agent_session_id(current_user, session_id)
server_session_id = await _build_web_agent_session_id_async(
current_user,
session_id,
service,
)
if server_session_id != session_id:
chat = await _get_accessible_agent_chat(
service,
@@ -1859,6 +1906,7 @@ async def save_agent_chat_display(
payload: _SchemaAgentChatDisplaySaveRequest,
current_user: ApiPrincipal = Depends(get_current_active_user),
service: AgentChatService = Depends(get_agent_chat_service),
persistence: AgentChatPersistenceService = Depends(get_agent_chat_persistence),
) -> _SchemaResponse:
"""
保存前端聚合后的 Agent 展示消息。
@@ -1881,17 +1929,21 @@ async def save_agent_chat_display(
message.model_dump(exclude_none=True)
for message in payload.messages
]
await run_in_threadpool(
_save_web_agent_display_snapshot,
await _save_web_agent_display_snapshot(
session_id=session_id,
current_user=current_user,
messages=messages,
client_session_id=existing_chat.client_session_id if existing_chat else session_id,
service=service,
persistence=persistence,
)
chat = await service.get_accessible(session_id, current_user)
# 写入由独立 worker 事务完成,使用组合根登记的短会话服务复读,避免请求会话
# 的 identity map 返回写入前的 ORM 快照。
chat_service = get_configured_agent_chat_service()
chat = await chat_service.get_accessible(session_id, current_user)
if not chat:
return _SchemaResponse(success=False, message="会话保存失败")
return _SchemaResponse(success=True, data=service.to_summary(chat))
return _SchemaResponse(success=True, data=chat_service.to_summary(chat))
@router.delete(
@@ -1937,7 +1989,11 @@ async def stop_web_agent_session_task(
:param service: Agent 会话应用服务
:return: 停止结果
"""
server_session_id = _build_web_agent_session_id(current_user, session_id)
server_session_id = await _build_web_agent_session_id_async(
current_user,
session_id,
service,
)
chat = await _get_accessible_agent_chat(
service,
server_session_id,
@@ -1961,6 +2017,8 @@ async def _web_agent_stream_impl(
payload: _SchemaAgentWebChatRequest,
request: Request,
current_user: ApiPrincipal = Depends(get_current_active_user),
service: Optional[AgentChatService] = None,
persistence: Optional[AgentChatPersistenceService] = None,
) -> StreamingResponse:
"""
Web 智能助手流式对话。
@@ -1971,9 +2029,19 @@ async def _web_agent_stream_impl(
:return: SSE 流式响应
"""
prompt = payload.text.strip()
if not isinstance(service, AgentChatService):
# SSE 后台任务可能在请求依赖释放后继续运行,查询服务必须自行取得短会话。
service = get_configured_agent_chat_service()
if not isinstance(persistence, AgentChatPersistenceService):
# 直接调用公开函数时不经过 FastAPI 依赖解析;生产路由总是传入运行时端口。
persistence = get_configured_agent_chat_persistence()
locale = LocaleHelper.get_locale_from_request(request)
display_prompt = (payload.display_text or payload.text).strip()
session_id = _build_web_agent_session_id(current_user, payload.session_id)
session_id = await _build_web_agent_session_id_async(
current_user,
payload.session_id,
service,
)
is_secret_confirmation_candidate = (
prompt in {"确认", "取消"}
and not payload.images
@@ -2075,19 +2143,18 @@ async def _web_agent_stream_impl(
async def save_display_snapshot() -> None:
"""后台保存传统消息展示快照,不阻塞 SSE 终态。"""
try:
await run_in_threadpool(
_save_web_agent_display_snapshot,
await _save_web_agent_display_snapshot(
session_id=session_id,
current_user=current_user,
messages=display_messages,
client_session_id=payload.session_id or session_id,
service=service,
persistence=persistence,
)
except Exception as err:
logger.error(f"保存WebAgent传统消息快照失败: {str(err)}")
snapshot_task = asyncio.create_task(save_display_snapshot())
_WEB_AGENT_BACKGROUND_TASKS.add(snapshot_task)
snapshot_task.add_done_callback(_WEB_AGENT_BACKGROUND_TASKS.discard)
snapshot_task = create_web_agent_background_task(save_display_snapshot())
await asyncio.sleep(0)
for event in events:
event_payload = copy.deepcopy(event)
@@ -2239,17 +2306,19 @@ async def _web_agent_stream_impl(
# 终态先进入 SSE 队列,避免展示快照落库延迟前端结束动画。
event_publisher.publish(done_event)
if not is_secret_confirmation_control:
await run_in_threadpool(
_save_web_agent_display_snapshot,
session_id=session_id,
current_user=current_user,
messages=display_messages,
client_session_id=payload.session_id or session_id,
)
try:
await _save_web_agent_display_snapshot(
session_id=session_id,
current_user=current_user,
messages=display_messages,
client_session_id=payload.session_id or session_id,
service=service,
persistence=persistence,
)
except Exception as err:
logger.error(f"保存WebAgent展示历史失败:{err}")
task = asyncio.create_task(run_agent())
_WEB_AGENT_BACKGROUND_TASKS.add(task)
task.add_done_callback(_WEB_AGENT_BACKGROUND_TASKS.discard)
task = create_web_agent_background_task(run_agent())
disconnected = False
terminal_sent = False
try:
@@ -2321,6 +2390,12 @@ async def web_agent_stream(
payload: _SchemaAgentWebChatRequest,
request: Request,
current_user: ApiPrincipal = Depends(get_current_active_user),
persistence: AgentChatPersistenceService = Depends(get_agent_chat_persistence),
) -> StreamingResponse:
"""Web 智能助手流式对话的稳定公开路由入口。"""
return await _web_agent_stream_impl(payload, request, current_user)
return await _web_agent_stream_impl(
payload,
request,
current_user,
persistence=persistence,
)
+31 -1
View File
@@ -1,9 +1,10 @@
import asyncio
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from queue import Queue
from threading import Lock
from typing import Callable, Dict, Iterable, List, Optional, Tuple, Union
from typing import Awaitable, Callable, Dict, Iterable, List, Optional, Tuple, Union
from app.schemas.types import NotificationChannel
@@ -177,6 +178,35 @@ _WEB_AGENT_EDIT_QUEUES: dict[str, list[Queue[dict]]] = {}
_WEB_AGENT_EDIT_LOCK = Lock()
_ChannelAdminResolver = Callable[[Optional[dict]], Iterable[Union[str, int]]]
_CHANNEL_ADMIN_RESOLVERS: dict[str, _ChannelAdminResolver] = {}
_WEB_AGENT_BACKGROUND_TASKS: set[asyncio.Task[object]] = set()
def create_web_agent_background_task(
coroutine: Awaitable[object],
) -> asyncio.Task[object]:
"""登记 Web Agent 后台任务,使应用关闭时可以统一收口。"""
task = asyncio.create_task(coroutine)
_WEB_AGENT_BACKGROUND_TASKS.add(task)
task.add_done_callback(_WEB_AGENT_BACKGROUND_TASKS.discard)
return task
async def shutdown_web_agent_background_tasks() -> None:
"""取消并等待 Web Agent 后台任务,避免关闭数据库后仍提交快照。"""
tasks = tuple(_WEB_AGENT_BACKGROUND_TASKS)
for task in tasks:
task.cancel()
if tasks:
# asyncio.wait 不会因关闭阶段自身被取消而再次取消这些任务;仍在收尾的
# Agent 任务会保留在注册表中,直到自己的数据库操作取得确定终态。
await asyncio.wait(tasks)
async def wait_web_agent_background_tasks() -> None:
"""等待已登记的 Web Agent 任务完成取消后的最终收尾。"""
tasks = tuple(_WEB_AGENT_BACKGROUND_TASKS)
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
def register_channel_admin_resolver(
+302 -2
View File
@@ -2,10 +2,28 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from collections.abc import Callable
from typing import Any, Optional, Protocol
from weakref import WeakValueDictionary
from app.application.database import (
AsyncDatabaseExecutor,
DatabaseWorkerClosedError,
DatabaseWorkerOverloadedError,
)
from app.schemas.agent import AgentChatSessionDetail, AgentChatSessionSummary
from app.runtime.observability import record_metric
DEFAULT_AGENT_CHAT_WRITE_CAPACITY = 32
DEFAULT_AGENT_CHAT_SESSION_CAPACITY = 4
def has_custom_agent_chat_title(value: Optional[str]) -> bool:
"""判断会话标题是否已经脱离默认占位标题。"""
return bool(value and value.strip() and value.strip() != "未命名会话")
class AgentChatPrincipal(Protocol):
@@ -72,6 +90,65 @@ class AsyncAgentChatRepository(Protocol):
...
class SyncAgentChatRepository(Protocol):
"""仅包含 Agent 编排所需同步持久化方法的适配器端口。"""
def append_display_messages(
self,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict]] = None,
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> Optional[Any]:
"""追加用户可见消息。"""
...
def save_display_messages(
self,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict]] = None,
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> Optional[Any]:
"""保存用户可见消息快照。"""
...
def save_agent_messages(
self,
session_id: str,
user_id: Optional[str],
messages: list[dict],
) -> None:
"""保存可恢复的原始 Agent 消息。"""
...
def update_title_if_empty(
self,
session_id: str,
user_id: Optional[str],
title: Optional[str],
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> None:
"""在会话尚无标题时写入标题。"""
...
SyncAgentChatRepositoryFactory = Callable[[object], SyncAgentChatRepository]
SyncAgentChatTransaction = Callable[[Callable[[object], object]], object]
@dataclass(frozen=True, slots=True)
class AgentChatRecord:
"""脱离 ORM 会话的 Agent 会话持久化投影。"""
@@ -89,6 +166,7 @@ class AgentChatRecord:
created_at: Any
updated_at: Any
messages: list[dict]
agent_messages: list[dict]
class AsyncUnitOfWork(Protocol):
@@ -146,9 +224,16 @@ class AgentChatService:
return None
return projected
async def get(self, session_id: str) -> Optional[AgentChatRecord]:
async def get(
self,
session_id: str,
user_id: Optional[str] = None,
) -> Optional[AgentChatRecord]:
"""读取不附带授权判断的会话投影。"""
record = await self._repository.async_get(session_id=session_id)
record = await self._repository.async_get(
session_id=session_id,
user_id=user_id,
)
if record is None:
return None
return self._project(record)
@@ -262,10 +347,210 @@ class AgentChatService:
created_at=record.created_at,
updated_at=record.updated_at,
messages=list(record.display_messages or []),
agent_messages=list(record.agent_messages or []),
)
class AgentChatPersistenceService:
"""把 Agent 编排所需的同步持久化操作委托给有界数据库 worker。"""
def __init__(
self,
repository: SyncAgentChatRepositoryFactory,
async_executor: AsyncDatabaseExecutor,
sync_transaction: SyncAgentChatTransaction,
capacity: int = DEFAULT_AGENT_CHAT_WRITE_CAPACITY,
session_capacity: int = DEFAULT_AGENT_CHAT_SESSION_CAPACITY,
) -> None:
"""保存同步仓储工厂、事务端口和两级写入容量。"""
if capacity < 1:
raise ValueError("AgentChat 写入容量必须大于 0")
if session_capacity < 1:
raise ValueError("AgentChat 单会话写入容量必须大于 0")
self._repository = repository
self._async_executor = async_executor
self._sync_transaction = sync_transaction
self._capacity = capacity
self._session_capacity = session_capacity
self._pending_writes = 0
self._pending_by_session: dict[str, int] = {}
self._active_tasks: set[asyncio.Task[object]] = set()
self._closing = False
# append_display_messages 属于读取旧快照后整列写回的复合操作;按会话串行化,
# 才能在 worker 并发下保持首次建行和既有会话追加的完整性。弱引用避免长期运行
# 中为一次性会话永久保留锁对象,不限制不同会话之间的 worker 并行度。
self._session_locks: WeakValueDictionary[str, asyncio.Lock] = WeakValueDictionary()
def _session_lock(self, session_id: str) -> asyncio.Lock:
"""返回当前进程内指定会话的写锁。"""
lock = self._session_locks.get(session_id)
if lock is None:
lock = asyncio.Lock()
self._session_locks[session_id] = lock
return lock
def begin_shutdown(self) -> None:
"""停止接受新的 AgentChat 持久化任务。"""
self._closing = True
async def _run_write(
self,
session_id: str,
operation: Callable[[SyncAgentChatRepository], object],
) -> None:
"""在线程 worker 内完成同步写入并丢弃仓储对象返回值。"""
# 同时限制全局和单会话等待量,避免一个热点会话占满总 admission 后饿死其他会话。
if self._closing:
raise DatabaseWorkerClosedError("AgentChat 持久化服务当前不可接收任务")
session_pending = self._pending_by_session.get(session_id, 0)
if (
self._pending_writes >= self._capacity
or session_pending >= self._session_capacity
):
record_metric("agent.chat.persistence.rejected")
raise DatabaseWorkerOverloadedError(
f"AgentChat 写入容量已用尽(全局上限 {self._capacity}"
f"单会话上限 {self._session_capacity}"
)
self._pending_writes += 1
self._pending_by_session[session_id] = session_pending + 1
current = asyncio.current_task()
if current is not None:
self._active_tasks.add(current)
record_metric("agent.chat.persistence.pending", 1)
try:
async with self._session_lock(session_id):
def execute() -> None:
"""在单一同步事务中执行写入,不让 ORM 对象越过 worker 边界。"""
self._sync_transaction(
lambda session: operation(self._repository(session))
)
await self._async_executor.run(execute)
finally:
self._pending_writes -= 1
remaining = self._pending_by_session.get(session_id, 1) - 1
if remaining:
self._pending_by_session[session_id] = remaining
else:
self._pending_by_session.pop(session_id, None)
if current is not None:
self._active_tasks.discard(current)
record_metric("agent.chat.persistence.pending", -1)
async def shutdown(self) -> None:
"""拒绝新写入并等待当前会话锁和 worker 操作取得终态。"""
self.begin_shutdown()
current = asyncio.current_task()
tasks = tuple(task for task in self._active_tasks if task is not current)
if tasks:
# wait 不会在生命周期超时时取消实际写入;外层可及时返回并保留
# 数据库 worker owner,已开始的事务继续由 worker 收口。
await asyncio.wait(tasks)
async def async_append_display_messages(
self,
*,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict]] = None,
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> None:
"""异步追加展示消息,等待同步事务取得确定终态。"""
await self._run_write(
session_id,
lambda repository: repository.append_display_messages(
session_id=session_id,
user_id=user_id,
messages=messages,
username=username,
channel=channel,
source=source,
original_chat_id=original_chat_id,
client_session_id=client_session_id,
)
)
return None
async def async_save_display_messages(
self,
*,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict]] = None,
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> None:
"""异步保存展示消息快照,实际写入由有界 worker 承接。"""
await self._run_write(
session_id,
lambda repository: repository.save_display_messages(
session_id=session_id,
user_id=user_id,
messages=messages,
username=username,
channel=channel,
source=source,
original_chat_id=original_chat_id,
client_session_id=client_session_id,
)
)
return None
async def async_save_agent_messages(
self,
*,
session_id: str,
user_id: str,
messages: list[dict],
) -> None:
"""异步保存可恢复的原始消息。"""
await self._run_write(
session_id,
lambda repository: repository.save_agent_messages(
session_id=session_id,
user_id=user_id,
messages=messages,
)
)
async def async_update_title_if_empty(
self,
*,
session_id: str,
user_id: Optional[str],
title: Optional[str],
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> None:
"""异步写入首次生成的会话标题。"""
await self._run_write(
session_id,
lambda repository: repository.update_title_if_empty(
session_id=session_id,
user_id=user_id,
title=title,
username=username,
channel=channel,
source=source,
original_chat_id=original_chat_id,
client_session_id=client_session_id,
)
)
_configured_agent_chat_service: AgentChatService | None = None
_configured_agent_chat_persistence: AgentChatPersistenceService | None = None
def configure_agent_chat_service(service: AgentChatService) -> None:
@@ -279,3 +564,18 @@ def get_configured_agent_chat_service() -> AgentChatService:
if _configured_agent_chat_service is None:
raise RuntimeError("Agent 会话服务尚未配置")
return _configured_agent_chat_service
def configure_agent_chat_persistence(
service: AgentChatPersistenceService,
) -> None:
"""由启动组合根登记 Agent 编排所需的同步持久化端口。"""
global _configured_agent_chat_persistence
_configured_agent_chat_persistence = service
def get_configured_agent_chat_persistence() -> AgentChatPersistenceService:
"""返回由启动组合根登记的 AgentChat worker 端口。"""
if _configured_agent_chat_persistence is None:
raise RuntimeError("Agent 会话持久化服务尚未配置")
return _configured_agent_chat_persistence
+2 -1
View File
@@ -269,7 +269,8 @@ class AgentChatOper(DbOper):
)
if not chat:
return None
display_messages = self._normalize_messages(chat.display_messages)
# JSON 列不是 MutableList;必须复制旧列表,原地 extend 会让 SQLAlchemy 误认为字段未变化。
display_messages = list(self._normalize_messages(chat.display_messages))
display_messages.extend(self._normalize_messages(messages))
title = chat.title if self.has_custom_title(chat.title) else None
return self.save_display_messages(
+10 -3
View File
@@ -14,7 +14,10 @@ from app.adapters.observability.otel import build_observation_port
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
from app.adapters.web.health import install_health_routes
from app.application.plugin.routes import configure_plugin_routes
from app.application.database import DatabaseWorkerOverloadedError
from app.application.database import (
DatabaseWorkerClosedError,
DatabaseWorkerOverloadedError,
)
from app.adapters.web.security.access import (
configure_token_codec,
verify_apikey,
@@ -234,9 +237,9 @@ async def localized_http_exception_handler(
async def database_worker_overloaded_handler(
request: Request,
_exc: DatabaseWorkerOverloadedError,
_exc: DatabaseWorkerClosedError | DatabaseWorkerOverloadedError,
) -> JSONResponse:
"""将数据库短事务背压映射为可重试的 503 响应。"""
"""将数据库 worker 暂不可用映射为可重试的 503 响应。"""
return await localized_http_exception_handler(
request,
HTTPException(
@@ -335,6 +338,10 @@ def create_app() -> FastAPI:
DatabaseWorkerOverloadedError,
database_worker_overloaded_handler,
)
_app.add_exception_handler(
DatabaseWorkerClosedError,
database_worker_overloaded_handler,
)
_app.add_exception_handler(
RequestValidationError,
localized_validation_exception_handler,
+2
View File
@@ -60,6 +60,8 @@ METRIC_SPECS = {
MetricSpec("scheduler.job.dead_letter", MetricKind.COUNTER, frozenset({"owner"})),
MetricSpec("plugin.lifecycle.duration", MetricKind.HISTOGRAM, frozenset({"operation", "outcome"})),
MetricSpec("agent.active_tasks", MetricKind.GAUGE, frozenset({"task_type"})),
MetricSpec("agent.chat.persistence.pending", MetricKind.GAUGE, frozenset()),
MetricSpec("agent.chat.persistence.rejected", MetricKind.COUNTER, frozenset()),
MetricSpec("agent.cancel", MetricKind.COUNTER, frozenset({"task_type", "outcome"})),
MetricSpec("agent.provider.duration", MetricKind.HISTOGRAM, frozenset({"provider_type", "outcome"})),
MetricSpec("agent.token_usage", MetricKind.COUNTER, frozenset({"provider_type", "direction"})),
+2
View File
@@ -6,6 +6,7 @@ from typing import Protocol
from app.application.messaging.chat import (
AsyncAgentChatRepository,
AgentChatPersistenceService,
AsyncUnitOfWork,
)
from app.application.outbox import AsyncOutboxTransaction
@@ -113,6 +114,7 @@ class AgentChatRuntime:
async_session: AsyncSessionProvider
repository: AgentChatRepositoryFactory
transaction: AsyncUnitOfWorkFactory
persistence: AgentChatPersistenceService
@dataclass(frozen=True, slots=True)
+21 -3
View File
@@ -99,14 +99,32 @@ async def run_shutdown_step(
callback: Callable[[], object],
timeout_seconds: float | None = None,
) -> None:
"""隔离单个关闭阶段的异常,确保后续资源仍有机会释放"""
"""在有限预算内执行关闭阶段,并保留未收敛任务的资源所有权。"""
try:
result = callback()
if inspect.isawaitable(result):
task = asyncio.ensure_future(result)
def _consume_shutdown_result(done: asyncio.Future) -> None:
"""消费延迟收敛任务的最终异常,避免事件循环产生未取回异常。"""
try:
done.result()
except asyncio.CancelledError:
pass
except Exception as err:
logger.error(f"关闭{name}最终收尾失败:{err}")
task.add_done_callback(_consume_shutdown_result)
if timeout_seconds:
await asyncio.wait_for(result, timeout=timeout_seconds)
try:
await asyncio.wait_for(
asyncio.shield(task), timeout=timeout_seconds
)
except asyncio.TimeoutError:
logger.error("关闭%s超时,已请求取消并保留未收敛任务", name)
task.cancel()
else:
await result
await task
except Exception as err:
logger.error(f"关闭{name}失败:{err}")
+51 -6
View File
@@ -1,3 +1,4 @@
import asyncio
import inspect
import sys
from typing import Callable
@@ -62,7 +63,17 @@ from app.application.database import configure_database_governance
from app.application.service import configure_service_directory
from app.application.plugin.runtime import configure_plugin_runtime
from app.application.module import configure_module_runtime
from app.application.messaging.chat import AgentChatService, configure_agent_chat_service
from app.application.messaging.chat import (
AgentChatPersistenceService,
AgentChatService,
configure_agent_chat_persistence,
configure_agent_chat_service,
get_configured_agent_chat_persistence,
)
from app.application.messaging.agent import (
shutdown_web_agent_background_tasks,
wait_web_agent_background_tasks,
)
from app.application.security.user import configure_user_lookups
from app.application.security.auth import AuthService, configure_auth_service
from app.application.security.passkeys import PasskeyService, configure_passkey_service
@@ -552,14 +563,19 @@ async def stop_modules():
"""
服务关闭
"""
async def run_step(name: str, callback: Callable[[], object]) -> None:
async def run_step(name: str, callback: Callable[[], object]) -> bool:
"""单个模块资源关闭失败时继续执行后续阶段"""
try:
result = callback()
if inspect.isawaitable(result):
await result
return True
except asyncio.CancelledError:
logger.warning("关闭%s时收到取消请求,继续执行资源收口", name)
return False
except Exception as err:
logger.error(f"关闭{name}失败:{err}")
return True
await run_step("AI智能体", stop_agent)
await run_step("模块", lambda: ModuleManager().shutdown())
@@ -571,11 +587,32 @@ async def stop_modules():
await run_step("消息服务", stop_message)
await run_step("Redis缓存连接", lambda: RedisHelper().close())
await run_step("异步Redis缓存连接", lambda: AsyncRedisHelper().close())
await run_step("数据库任务", stop_database_worker)
if _database_worker is None:
await run_step("数据库连接", close_database)
# Web Agent 的取消 finally 可能还要写入最终展示快照,必须先完成任务收尾,再关闭写入准入。
web_agent_drained = await run_step(
"Web Agent后台任务", shutdown_web_agent_background_tasks
)
if not web_agent_drained:
web_agent_drained = await run_step(
"Web Agent后台任务收尾", wait_web_agent_background_tasks
)
if web_agent_drained:
await run_step(
"Agent会话持久化准入",
lambda: get_configured_agent_chat_persistence().begin_shutdown(),
)
persistence_drained = await run_step(
"Agent会话持久化",
lambda: get_configured_agent_chat_persistence().shutdown(),
)
else:
logger.error("数据库任务未收敛,跳过数据库连接关闭以避免运行中事务使用已释放连接")
persistence_drained = False
logger.error("Web Agent任务未完成收尾,跳过持久化和数据库关闭以保护活动事务")
if persistence_drained:
await run_step("数据库任务", stop_database_worker)
if _database_worker is None:
await run_step("数据库连接", close_database)
else:
logger.error("数据库任务未收敛,跳过数据库连接关闭以避免运行中事务使用已释放连接")
await run_step("前端服务", stop_frontend)
await run_step("临时文件", clear_temp)
@@ -637,11 +674,18 @@ async def init_modules() -> HostRuntime:
chain=lambda: build_chain_runtime_config(settings),
)
runtime_settings = _build_runtime_settings_service()
agent_chat_persistence = AgentChatPersistenceService(
repository=lambda session: AgentChatOper(session),
async_executor=database_worker,
sync_transaction=transaction_runner.sync,
capacity=database_worker.snapshot().capacity,
)
host_runtime = HostRuntime(
agent_chat=AgentChatRuntime(
async_session=get_async_db,
repository=AgentChatOper,
transaction=SqlAlchemyAsyncUnitOfWork,
persistence=agent_chat_persistence,
),
persistence=PersistenceRuntime(
sync_session=get_db,
@@ -709,6 +753,7 @@ async def init_modules() -> HostRuntime:
)
configure_database_governance(build_database_governance())
configure_agent_chat_service(AgentChatService(repository=AgentChatOper()))
configure_agent_chat_persistence(agent_chat_persistence)
configure_user_lookups(
by_id=lambda user_id: UserOper().get_by_id(user_id),
by_name=lambda username: UserOper().get_by_name(username),
+14
View File
@@ -118,6 +118,12 @@ def configure_plugin_system_services():
configure_chain_runtime_context_provider,
)
from app.application.messaging.message import MessageHelper, MessageQueueManager
from app.application.messaging.chat import (
AgentChatService,
AgentChatPersistenceService,
configure_agent_chat_service,
configure_agent_chat_persistence,
)
from app.runtime.cache import AsyncFileCache, FileCache
from app.runtime.events import EventManager
from app.runtime.extensions.module_manager import ModuleManager
@@ -255,6 +261,14 @@ def configure_plugin_system_services():
workflow=lambda: WorkflowOper(),
plugin_data=lambda: PluginDataOper(),
)
configure_agent_chat_persistence(
AgentChatPersistenceService(
repository=lambda session: AgentChatOper(session),
async_executor=database_executor,
sync_transaction=transaction_runner.sync,
)
)
configure_agent_chat_service(AgentChatService(repository=AgentChatOper()))
from app.adapters.external.market import (
PluginHelper,
VERSION_BACKWARD_COMPATIBLE_FLAGS,
+11 -2
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6435,
"edge_sha256": "ed079837faf943d744ba7c6dee9172449bc204a2068a89db75e030e0be12fbb2",
"edge_count": 6444,
"edge_sha256": "b5db7b31c7ea4dd7311fcd9e11a49eb32feb6939f896ed85703b3573408752a8",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -237,6 +237,8 @@
"app.agent.mcp -> app.schemas.types",
"app.agent.memory -> app.application",
"app.agent.memory -> app.application.agentdata",
"app.agent.memory -> app.application.messaging",
"app.agent.memory -> app.application.messaging.chat",
"app.agent.memory -> app.runtime",
"app.agent.memory -> app.runtime.log",
"app.agent.memory -> app.runtime.settings",
@@ -345,6 +347,8 @@
"app.agent.orchestrator -> app.agent.tools.impl.query_system_settings",
"app.agent.orchestrator -> app.application",
"app.agent.orchestrator -> app.application.agentdata",
"app.agent.orchestrator -> app.application.messaging",
"app.agent.orchestrator -> app.application.messaging.chat",
"app.agent.orchestrator -> app.application.plugin",
"app.agent.orchestrator -> app.application.plugin.runtime",
"app.agent.orchestrator -> app.chain",
@@ -2553,6 +2557,10 @@
"app.application.mediaserver -> app.schemas.types",
"app.application.messaging.agent -> app.schemas",
"app.application.messaging.agent -> app.schemas.types",
"app.application.messaging.chat -> app.application",
"app.application.messaging.chat -> app.application.database",
"app.application.messaging.chat -> app.runtime",
"app.application.messaging.chat -> app.runtime.observability",
"app.application.messaging.chat -> app.schemas",
"app.application.messaging.chat -> app.schemas.agent",
"app.application.messaging.interaction -> app.schemas",
@@ -6089,6 +6097,7 @@
"app.startup.modules_initializer -> app.application.history",
"app.startup.modules_initializer -> app.application.image",
"app.startup.modules_initializer -> app.application.messaging",
"app.startup.modules_initializer -> app.application.messaging.agent",
"app.startup.modules_initializer -> app.application.messaging.chat",
"app.startup.modules_initializer -> app.application.messaging.message",
"app.startup.modules_initializer -> app.application.module",
+13 -3
View File
@@ -249,7 +249,11 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
)
agent.send_agent_message = AsyncMock()
with patch.object(memory_manager, "save_agent_messages") as save_messages:
with patch.object(
memory_manager,
"async_save_agent_messages",
new=AsyncMock(),
) as save_messages:
await agent._execute_agent([HumanMessage(content="测试")])
save_messages.assert_called_once()
@@ -277,7 +281,9 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
with (
patch.object(
memory_manager, "get_agent_messages", return_value=cached_messages
memory_manager,
"async_get_agent_messages",
new=AsyncMock(return_value=cached_messages),
),
patch.object(agent, "prepare_chat_title", new=AsyncMock()),
patch.object(agent, "_save_display_history_messages"),
@@ -305,7 +311,11 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
)
agent.send_agent_message = AsyncMock()
with patch.object(memory_manager, "save_agent_messages") as save_messages:
with patch.object(
memory_manager,
"async_save_agent_messages",
new=AsyncMock(),
) as save_messages:
await agent._execute_agent([])
agent.send_agent_message.assert_awaited_once_with(
+46
View File
@@ -1,6 +1,7 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
from langchain_core.messages import AIMessage, HumanMessage
@@ -278,3 +279,48 @@ def test_memory_manager_restores_agent_messages_from_database():
assert len(messages) == 1
assert isinstance(messages[0], HumanMessage)
assert messages[0].content == "继续之前的话题"
def test_async_memory_manager_restores_through_native_async_service(monkeypatch):
"""异步记忆恢复只能通过会话应用服务的异步查询端口。"""
session_id = "session-memory-async"
user_id = "3"
memory_manager.clear_memory(session_id, user_id)
service = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
agent_messages=[
{
"type": "human",
"data": {
"content": "异步恢复",
"additional_kwargs": {},
"response_metadata": {},
"type": "human",
"name": None,
"id": None,
"example": False,
},
}
]
)
)
)
monkeypatch.setattr(
"app.agent.memory.get_configured_agent_chat_service",
lambda: service,
)
messages = asyncio.run(
memory_manager.async_get_agent_messages(
session_id=session_id,
user_id=user_id,
)
)
assert len(messages) == 1
assert messages[0].content == "异步恢复"
service.get.assert_awaited_once_with(
session_id=session_id,
user_id=user_id,
)
+546
View File
@@ -0,0 +1,546 @@
"""AgentChat 同步短事务经有界 worker 委托的应用端口测试。"""
from __future__ import annotations
import asyncio
import threading
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, call, patch
from uuid import uuid4
import pytest
from sqlalchemy import delete, select
from app.application.database import (
DatabaseWorkerClosedError,
DatabaseWorkerOverloadedError,
)
from app.application.messaging.chat import AgentChatPersistenceService, AgentChatService
from app.api.endpoints.agent import save_agent_chat_display
from app.db.models.agentchat import AgentChat
from app.db.oper.agentchat import AgentChatOper
from app.db.session import SessionFactory, async_session_scope
from app.db.uow import run_sync_transaction
from app.schemas.agent import AgentChatDisplaySaveRequest
from app.db.worker import DatabaseWorker
class _Executor:
"""用独立线程模拟 G2B worker,验证调用方不会直接执行同步仓储。"""
def __init__(self) -> None:
self.calls = 0
self.worker_thread_id: int | None = None
self.results: list[object] = []
async def run(self, operation):
"""在线程中执行一个完整的同步操作。"""
self.calls += 1
def invoke():
self.worker_thread_id = threading.get_ident()
result = operation()
self.results.append(result)
return result
return await asyncio.to_thread(invoke)
class _Repository:
"""记录 AgentChat 端口调用的同步仓储替身。"""
def __init__(self) -> None:
self.calls: list[tuple[str, dict]] = []
def append_display_messages(self, **kwargs):
self.calls.append(("append_display_messages", kwargs))
return None
def save_display_messages(self, **kwargs):
self.calls.append(("save_display_messages", kwargs))
return None
def save_agent_messages(self, **kwargs):
self.calls.append(("save_agent_messages", kwargs))
def update_title_if_empty(self, **kwargs):
self.calls.append(("update_title_if_empty", kwargs))
@pytest.mark.asyncio
async def test_agent_chat_persistence_runs_sync_repository_inside_worker() -> None:
"""同步 AgentChat 写入必须经过一次 worker admission。"""
executor = _Executor()
repository = _Repository()
service = AgentChatPersistenceService(
repository=lambda _session: repository,
async_executor=executor,
sync_transaction=lambda operation: operation(object()),
)
caller_thread_id = threading.get_ident()
await service.async_append_display_messages(
session_id="session-1",
user_id="1",
messages=[{"role": "user", "content": "hello"}],
)
await service.async_save_display_messages(
session_id="session-1",
user_id="1",
messages=[],
)
await service.async_save_agent_messages(
session_id="session-1",
user_id="1",
messages=[],
)
await service.async_update_title_if_empty(
session_id="session-1",
user_id="1",
title="标题",
)
assert executor.calls == 4
assert executor.results == [None, None, None, None]
assert executor.worker_thread_id != caller_thread_id
assert [name for name, _kwargs in repository.calls] == [
"append_display_messages",
"save_display_messages",
"save_agent_messages",
"update_title_if_empty",
]
@pytest.mark.asyncio
async def test_agent_chat_persistence_propagates_worker_failure() -> None:
"""worker admission 或事务异常必须原样返回给 async 应用调用方。"""
class FailingExecutor:
async def run(self, _operation):
raise RuntimeError("worker failed")
service = AgentChatPersistenceService(
repository=lambda _session: _Repository(),
async_executor=FailingExecutor(),
sync_transaction=lambda operation: operation(object()),
)
with pytest.raises(RuntimeError, match="worker failed"):
await service.async_save_agent_messages(
session_id="session-1",
user_id="1",
messages=[],
)
@pytest.mark.asyncio
async def test_agent_chat_persistence_pending_metric_uses_deltas() -> None:
"""pending 是 UpDownCounter,准入和释放必须分别记录增减量。"""
service = AgentChatPersistenceService(
repository=lambda _session: _Repository(),
async_executor=_Executor(),
sync_transaction=lambda operation: operation(object()),
)
with patch("app.application.messaging.chat.record_metric") as record_metric:
await service.async_save_agent_messages(
session_id="metric-session",
user_id="1",
messages=[],
)
record_metric.assert_has_calls(
[
call("agent.chat.persistence.pending", 1),
call("agent.chat.persistence.pending", -1),
]
)
@pytest.mark.asyncio
async def test_authoritative_display_save_propagates_worker_overload() -> None:
"""权威 PUT 保存不能把 worker 背压吞成成功或普通业务失败。"""
repository = AsyncMock()
repository.async_get.return_value = None
service = AgentChatService(repository=repository)
class OverloadedPersistence:
async def async_save_display_messages(self, **_kwargs):
raise DatabaseWorkerOverloadedError("busy")
with pytest.raises(DatabaseWorkerOverloadedError, match="busy"):
await save_agent_chat_display(
session_id="overloaded-session",
payload=AgentChatDisplaySaveRequest(messages=[]),
current_user=SimpleNamespace(id=1, name="admin", is_superuser=True),
service=service,
persistence=OverloadedPersistence(),
)
@pytest.mark.asyncio
async def test_authoritative_display_save_reads_fresh_projection_after_worker_write(
monkeypatch,
) -> None:
"""权威展示保存的响应必须读取 worker 提交后的最新投影。"""
existing_chat = SimpleNamespace(
user_id="1",
username="admin",
channel="WebAgent",
source="web-agent",
original_chat_id=None,
client_session_id="client-1",
)
updated_chat = SimpleNamespace(
session_id="fresh-session",
message_count=2,
)
request_service = SimpleNamespace(
get_accessible=AsyncMock(return_value=existing_chat),
get=AsyncMock(return_value=existing_chat),
)
canonical_service = SimpleNamespace(
get_accessible=AsyncMock(return_value=updated_chat),
to_summary=MagicMock(return_value="fresh-summary"),
)
persistence = SimpleNamespace(async_save_display_messages=AsyncMock())
current_user = SimpleNamespace(id=1, name="admin", is_superuser=True)
monkeypatch.setattr(
"app.api.endpoints.agent.get_configured_agent_chat_service",
MagicMock(return_value=canonical_service),
)
response = await save_agent_chat_display(
session_id="fresh-session",
payload=AgentChatDisplaySaveRequest(messages=[]),
current_user=current_user,
service=request_service,
persistence=persistence,
)
assert response.success is True
assert response.data == "fresh-summary"
canonical_service.get_accessible.assert_awaited_once_with(
"fresh-session", current_user
)
@pytest.mark.asyncio
async def test_agent_chat_persistence_rolls_back_compound_write(monkeypatch) -> None:
"""复合写入中途失败时,创建或更新不能留下半成品。"""
worker = DatabaseWorker(max_workers=1, capacity=4)
await worker.start()
session_id = f"worker-rollback-{uuid4().hex}"
persistence = AgentChatPersistenceService(
repository=lambda session: AgentChatOper(session),
async_executor=worker,
sync_transaction=run_sync_transaction,
)
original = AgentChatOper.save_display_messages
def fail_after_stage(self, *args, **kwargs):
original(self, *args, **kwargs)
raise RuntimeError("display snapshot failed")
monkeypatch.setattr(AgentChatOper, "save_display_messages", fail_after_stage)
try:
with pytest.raises(RuntimeError, match="display snapshot failed"):
await persistence.async_append_display_messages(
session_id=session_id,
user_id="rollback-user",
messages=[{"role": "user", "content": "not committed"}],
)
async with async_session_scope() as session:
result = await session.execute(
select(AgentChat).where(AgentChat.session_id == session_id)
)
assert result.scalars().first() is None
finally:
await worker.shutdown()
@pytest.mark.asyncio
async def test_agent_chat_persistence_bounds_session_waiters_and_releases_cancelled() -> None:
"""同会话锁等待受总量限制,取消等待不会遗留 admission。"""
class BlockingExecutor:
def __init__(self) -> None:
self.started = asyncio.Event()
self.release = asyncio.Event()
async def run(self, operation):
self.started.set()
await self.release.wait()
return operation()
executor = BlockingExecutor()
service = AgentChatPersistenceService(
repository=lambda _session: _Repository(),
async_executor=executor,
sync_transaction=lambda operation: operation(object()),
capacity=2,
session_capacity=2,
)
first = asyncio.create_task(
service.async_save_agent_messages(
session_id="session-admission",
user_id="1",
messages=[],
)
)
await executor.started.wait()
second = asyncio.create_task(
service.async_save_agent_messages(
session_id="session-admission",
user_id="1",
messages=[],
)
)
await asyncio.sleep(0)
third = asyncio.create_task(
service.async_save_agent_messages(
session_id="session-admission",
user_id="1",
messages=[],
)
)
with pytest.raises(DatabaseWorkerOverloadedError):
await third
second.cancel()
with pytest.raises(asyncio.CancelledError):
await second
assert service._pending_writes == 1
executor.release.set()
await first
assert service._pending_writes == 0
@pytest.mark.asyncio
async def test_agent_chat_persistence_session_admission_is_fair() -> None:
"""热点会话的锁等待不能占满全局容量并拒绝其他会话。"""
class BlockingExecutor:
def __init__(self) -> None:
self.started = asyncio.Event()
self.release = asyncio.Event()
async def run(self, operation):
self.started.set()
await self.release.wait()
return operation()
executor = BlockingExecutor()
service = AgentChatPersistenceService(
repository=lambda _session: _Repository(),
async_executor=executor,
sync_transaction=lambda operation: operation(object()),
capacity=4,
session_capacity=2,
)
first = asyncio.create_task(
service.async_save_agent_messages(
session_id="hot-session", user_id="1", messages=[]
)
)
await executor.started.wait()
second = asyncio.create_task(
service.async_save_agent_messages(
session_id="hot-session", user_id="1", messages=[]
)
)
await asyncio.sleep(0)
with pytest.raises(DatabaseWorkerOverloadedError):
await service.async_save_agent_messages(
session_id="hot-session", user_id="1", messages=[]
)
other = asyncio.create_task(
service.async_save_agent_messages(
session_id="other-session", user_id="1", messages=[]
)
)
await asyncio.sleep(0)
assert not other.done()
executor.release.set()
await first
await second
await other
@pytest.mark.asyncio
async def test_agent_chat_persistence_shutdown_drains_active_writes() -> None:
"""关闭持久化端口时拒绝新写入并等待现有会话写入收口。"""
class BlockingExecutor:
def __init__(self) -> None:
self.started = asyncio.Event()
self.release = asyncio.Event()
async def run(self, operation):
self.started.set()
await self.release.wait()
return operation()
executor = BlockingExecutor()
service = AgentChatPersistenceService(
repository=lambda _session: _Repository(),
async_executor=executor,
sync_transaction=lambda operation: operation(object()),
)
write = asyncio.create_task(
service.async_save_agent_messages(
session_id="shutdown-session", user_id="1", messages=[]
)
)
await executor.started.wait()
shutdown = asyncio.create_task(service.shutdown())
await asyncio.sleep(0)
assert not shutdown.done()
with pytest.raises(DatabaseWorkerClosedError):
await service.async_save_agent_messages(
session_id="new-session", user_id="1", messages=[]
)
executor.release.set()
await write
await shutdown
@pytest.mark.asyncio
async def test_agent_chat_shutdown_timeout_keeps_worker_owner_until_write_finishes() -> None:
"""持久化关闭超时时保留运行中的写入和数据库 worker owner。"""
started = threading.Event()
release = threading.Event()
class BlockingRepository(_Repository):
def save_agent_messages(self, **kwargs):
started.set()
release.wait(1)
super().save_agent_messages(**kwargs)
worker = DatabaseWorker(max_workers=1, capacity=1)
await worker.start()
service = AgentChatPersistenceService(
repository=lambda _session: BlockingRepository(),
async_executor=worker,
sync_transaction=lambda operation: operation(object()),
)
write = asyncio.create_task(
service.async_save_agent_messages(
session_id="shutdown-timeout-session",
user_id="1",
messages=[],
)
)
assert await asyncio.to_thread(started.wait, 1)
shutdown = asyncio.create_task(service.shutdown())
try:
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(shutdown, timeout=0.01)
assert service._closing is True
assert write.done() is False
assert worker._executor is not None
finally:
release.set()
await write
await worker.shutdown()
assert worker._executor is None
@pytest.mark.asyncio
async def test_agent_chat_persistence_uses_real_worker_and_sqlite_transaction() -> None:
"""真实 AgentChat Oper 经 worker 写入后可被 native async 查询恢复。"""
worker = DatabaseWorker(max_workers=1, capacity=4)
await worker.start()
session_id = f"worker-{uuid4().hex}"
persistence = AgentChatPersistenceService(
repository=lambda session: AgentChatOper(session),
async_executor=worker,
sync_transaction=run_sync_transaction,
)
query = AgentChatService(repository=AgentChatOper())
try:
await persistence.async_save_display_messages(
session_id=session_id,
user_id="worker-user",
username="worker-user",
channel="WebAgent",
source="worker-test",
messages=[{"role": "user", "content": "worker"}],
)
chat = await query.get(
session_id,
user_id="worker-user",
)
assert chat is not None
assert chat.message_count == 1
assert chat.messages[0]["content"] == "worker"
finally:
await AgentChatOper().async_delete(
session_id=session_id,
user_id="worker-user",
)
await worker.shutdown()
@pytest.mark.asyncio
async def test_agent_chat_persistence_serializes_same_session_writes() -> None:
"""同一会话的首次创建和既有快照追加都必须串行。"""
worker = DatabaseWorker(max_workers=4, capacity=16)
await worker.start()
session_id = f"worker-race-{uuid4().hex}"
existing_session_id = f"worker-race-existing-{uuid4().hex}"
persistence = AgentChatPersistenceService(
repository=lambda session: AgentChatOper(session),
async_executor=worker,
sync_transaction=run_sync_transaction,
)
async def append(content: str) -> None:
await persistence.async_append_display_messages(
session_id=session_id,
user_id="worker-race-user",
messages=[{"role": "user", "content": content}],
)
async def append_existing(content: str) -> None:
await persistence.async_append_display_messages(
session_id=existing_session_id,
user_id="worker-race-user",
messages=[{"role": "user", "content": content}],
)
try:
await asyncio.gather(*(append(f"message-{index}") for index in range(4)))
await persistence.async_save_display_messages(
session_id=existing_session_id,
user_id="worker-race-user",
messages=[{"role": "user", "content": "seed"}],
)
await asyncio.gather(
*(append_existing(f"existing-{index}") for index in range(4))
)
async with async_session_scope() as session:
result = await session.execute(
select(AgentChat).where(
AgentChat.session_id.in_((session_id, existing_session_id))
)
)
rows = list(result.scalars().all())
assert len(rows) == 2
row_by_session = {row.session_id: row for row in rows}
assert {
message["content"]
for message in row_by_session[session_id].display_messages
} == {f"message-{index}" for index in range(4)}
assert {
message["content"]
for message in row_by_session[existing_session_id].display_messages
} == {"seed"} | {f"existing-{index}" for index in range(4)}
finally:
with SessionFactory() as session:
session.execute(
delete(AgentChat).where(
AgentChat.session_id.in_((session_id, existing_session_id))
)
)
session.commit()
await worker.shutdown()
+1
View File
@@ -29,6 +29,7 @@ def _chat() -> SimpleNamespace:
created_at=None,
updated_at=None,
display_messages=[],
agent_messages=[],
)
+54
View File
@@ -4,6 +4,10 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import app.agent.orchestrator as agent_module
from app.application.messaging.agent import (
create_web_agent_background_task,
shutdown_web_agent_background_tasks,
)
from app.agent.orchestrator import (
AGENT_SESSION_QUEUE_MAX_SIZE,
AgentManager,
@@ -14,6 +18,56 @@ from app.agent.memory import MemoryManager
from app.startup import agent_initializer, modules_initializer
@pytest.mark.anyio
async def test_web_agent_background_tasks_are_cancelled_and_drained() -> None:
"""Web Agent 任务关闭后不得继续占用循环或提交晚到的快照。"""
started = asyncio.Event()
finished = asyncio.Event()
async def blocked_task() -> None:
started.set()
try:
await asyncio.Event().wait()
finally:
finished.set()
task = create_web_agent_background_task(blocked_task())
await started.wait()
await shutdown_web_agent_background_tasks()
assert task.done()
assert task.cancelled()
assert finished.is_set()
@pytest.mark.anyio
async def test_web_agent_shutdown_timeout_does_not_cancel_task_cleanup() -> None:
"""关闭超时时保留仍在执行取消收尾的 Web Agent 任务。"""
started = asyncio.Event()
release = asyncio.Event()
async def task_with_slow_cleanup() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
await release.wait()
raise
task = create_web_agent_background_task(task_with_slow_cleanup())
await started.wait()
shutdown = asyncio.create_task(shutdown_web_agent_background_tasks())
await asyncio.sleep(0)
shutdown.cancel()
with pytest.raises(asyncio.CancelledError):
await shutdown
assert task.done() is False
release.set()
with pytest.raises(asyncio.CancelledError):
await task
@pytest.mark.anyio
async def test_agent_entrypoint_initializes_on_calling_loop(monkeypatch) -> None:
"""Agent 启动入口必须在应用主循环完成初始化。"""
+40 -1
View File
@@ -23,7 +23,10 @@ from app.factory import (
localized_unhandled_exception_handler,
localized_validation_exception_handler,
)
from app.application.database import DatabaseWorkerOverloadedError
from app.application.database import (
DatabaseWorkerClosedError,
DatabaseWorkerOverloadedError,
)
from app.runtime.localization import LocaleHelper
from app.runtime.config import settings
from app.schemas.common import JsonData
@@ -67,6 +70,10 @@ def api_app() -> FastAPI:
DatabaseWorkerOverloadedError,
database_worker_overloaded_handler,
)
app.add_exception_handler(
DatabaseWorkerClosedError,
database_worker_overloaded_handler,
)
from fastapi.exceptions import RequestValidationError
app.add_exception_handler(
@@ -125,6 +132,11 @@ def api_app() -> FastAPI:
"""模拟数据库短事务容量耗尽。"""
raise DatabaseWorkerOverloadedError("worker full")
@app.get("/database-closed")
async def get_database_closed() -> None:
"""模拟数据库 worker 在关闭态拒绝新任务。"""
raise DatabaseWorkerClosedError("worker closed")
@app.get("/native", response_model=None)
async def get_native_response() -> dict[str, bool]:
"""返回显式旁路的原生 JSON 协议。"""
@@ -219,6 +231,33 @@ async def test_database_worker_overload_is_retryable_service_unavailable(
}
async def test_database_worker_closed_is_retryable_service_unavailable(
api_app: FastAPI,
):
"""数据库 worker 关闭态应返回 503,而不是落入通用 500。"""
async with make_client(api_app) as client:
response = await client.get("/database-closed")
assert response.status_code == 503
assert response.headers["retry-after"] == "1"
assert response.json() == {
"success": False,
"message": "服务当前繁忙,请稍后重试",
"data": None,
}
def test_create_app_registers_closed_database_worker_handler() -> None:
"""生产组合根必须为 worker 关闭态登记 503 处理器。"""
from app.factory import create_app
app = create_app()
assert app.exception_handlers[DatabaseWorkerClosedError] is (
database_worker_overloaded_handler
)
@pytest.mark.parametrize(
"path",
[
+12 -2
View File
@@ -13,6 +13,7 @@ from app.api.context import (
get_agent_chat_repository,
get_agent_chat_transaction,
)
from app.api.dependencies.agent import get_agent_chat_persistence
from app.startup import lifecycle
from app.startup.context import (
AgentChatRuntime,
@@ -59,6 +60,10 @@ class _UnitOfWork:
"""模拟回滚。"""
class _AgentChatPersistence:
"""提供 AgentChat 运行时所需的最小写端口。"""
class _SyncUnitOfWork:
"""记录绑定会话的同步事务替身。"""
@@ -119,6 +124,7 @@ def _runtime() -> HostRuntime:
async_session=async_session,
repository=_Repository,
transaction=_UnitOfWork,
persistence=_AgentChatPersistence(),
),
persistence=PersistenceRuntime(
sync_session=sync_session,
@@ -186,15 +192,19 @@ def test_fastapi_dependencies_use_fake_runtime_without_real_services() -> None:
async def probe(
repository=Depends(get_agent_chat_repository),
unit_of_work=Depends(get_agent_chat_transaction),
persistence=Depends(get_agent_chat_persistence),
) -> dict[str, bool]:
"""返回两个类型化能力是否绑定同一请求会话。"""
return {"same_session": repository.session is unit_of_work.session}
return {
"same_session": repository.session is unit_of_work.session,
"has_persistence": persistence is app.state.host_runtime.agent_chat.persistence,
}
with TestClient(app) as client:
response = client.get("/probe")
assert response.status_code == 200
assert response.json() == {"same_session": True}
assert response.json() == {"same_session": True, "has_persistence": True}
def test_official_api_dependencies_do_not_use_string_data_locator() -> None:
+116
View File
@@ -570,6 +570,122 @@ def test_stop_modules_continues_after_internal_owner_failures(monkeypatch):
_assert_completed_once(dependency)
def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch):
"""关闭时先收口 Web Agent,再关闭持久化准入和数据库任务。"""
order = []
monkeypatch.setattr(modules_initializer, "stop_agent", AsyncMock())
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
monkeypatch.setattr(
modules_initializer,
"shutdown_web_agent_background_tasks",
AsyncMock(side_effect=lambda: order.append("web-agent")),
)
persistence = MagicMock()
persistence.begin_shutdown = MagicMock(
side_effect=lambda: order.append("persistence-admission")
)
persistence.shutdown = AsyncMock(side_effect=lambda: order.append("persistence"))
monkeypatch.setattr(
modules_initializer,
"get_configured_agent_chat_persistence",
MagicMock(return_value=persistence),
)
monkeypatch.setattr(
modules_initializer,
"stop_database_worker",
AsyncMock(side_effect=lambda: order.append("database")),
)
monkeypatch.setattr(modules_initializer, "_database_worker", object())
asyncio.run(modules_initializer.stop_modules())
assert order == ["web-agent", "persistence-admission", "persistence", "database"]
@pytest.mark.asyncio
async def test_shutdown_timeout_does_not_skip_database_worker_cleanup(monkeypatch):
"""模块关闭超时取消当前步骤后仍应继续收口数据库 worker。"""
started = asyncio.Event()
async def blocked_web_agent_shutdown():
started.set()
await asyncio.Event().wait()
monkeypatch.setattr(modules_initializer, "stop_agent", AsyncMock())
_patch_module_shutdown_dependencies(monkeypatch)
monkeypatch.setattr(
modules_initializer,
"shutdown_web_agent_background_tasks",
blocked_web_agent_shutdown,
)
monkeypatch.setattr(
modules_initializer,
"wait_web_agent_background_tasks",
AsyncMock(),
)
persistence = MagicMock()
persistence.begin_shutdown = MagicMock()
persistence.shutdown = AsyncMock()
monkeypatch.setattr(
modules_initializer,
"get_configured_agent_chat_persistence",
MagicMock(return_value=persistence),
)
stop_database_worker = AsyncMock()
monkeypatch.setattr(modules_initializer, "stop_database_worker", stop_database_worker)
monkeypatch.setattr(modules_initializer, "_database_worker", object())
shutdown = asyncio.create_task(
lifecycle.run_shutdown_step(
"模块服务",
modules_initializer.stop_modules,
timeout_seconds=0.01,
)
)
await started.wait()
await shutdown
stop_database_worker.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_shutdown_timeout_has_hard_bound_for_nonconverging_cleanup() -> None:
"""关闭收尾不响应取消时,生命周期调用仍必须在预算内返回。"""
started = asyncio.Event()
cancel_requested = asyncio.Event()
release = asyncio.Event()
settled = asyncio.Event()
async def nonconverging_shutdown() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancel_requested.set()
await release.wait()
settled.set()
raise
started_at = asyncio.get_running_loop().time()
shutdown = asyncio.create_task(
lifecycle.run_shutdown_step(
"不可收敛阶段",
nonconverging_shutdown,
timeout_seconds=0.01,
)
)
await started.wait()
await shutdown
elapsed = asyncio.get_running_loop().time() - started_at
assert elapsed < 0.2
await asyncio.wait_for(cancel_requested.wait(), timeout=0.2)
assert not settled.is_set()
release.set()
await asyncio.wait_for(settled.wait(), timeout=0.2)
def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
"""替换 stop_modules 的资源所有者,避免测试启动真实后台服务"""
dependencies = {}
+72 -17
View File
@@ -19,6 +19,7 @@ from app.api.endpoints.agent import (
_build_web_agent_message_events,
_build_web_agent_command_items,
_build_web_agent_session_id,
_build_web_agent_session_id_async,
_build_web_agent_traditional_callback_payload,
_build_web_agent_display_message_from_events,
_collect_web_agent_traditional_events,
@@ -174,6 +175,31 @@ def test_build_web_agent_session_id_reuses_accessible_history():
assert _build_web_agent_session_id(user, "telegram-session") == "telegram-session"
def test_build_web_agent_session_id_async_uses_native_async_persistence():
"""异步 Web 会话解析应通过 native async 会话服务读取历史。"""
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
service = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
user_id="telegram-user",
username="tester",
agent_messages=[],
)
)
)
with patch(
"app.api.endpoints.agent.get_configured_agent_chat_service",
return_value=service,
):
session_id = asyncio.run(
_build_web_agent_session_id_async(user, "telegram-session")
)
assert session_id == "telegram-session"
service.get.assert_awaited_once_with("telegram-session")
def test_apply_web_agent_display_event_updates_snapshot():
"""WebAgent SSE 事件应按到达顺序聚合为服务端展示快照。"""
message = {
@@ -329,6 +355,23 @@ def test_web_agent_stream_returns_error_for_unknown_command():
handle_message.assert_not_called()
def test_web_agent_stream_does_not_bind_request_scoped_chat_service():
"""流式路由不能把请求级 Agent 会话服务捕获到后台任务。"""
from app.api.dependencies.agent import get_agent_chat_service
from app.api.endpoints import agent as agent_endpoint
route = next(
route
for route in agent_endpoint.router.routes
if getattr(route, "name", None) == "web_agent_stream"
)
assert all(
dependency.call is not get_agent_chat_service
for dependency in route.dependant.dependencies
)
def test_build_web_agent_message_update_event_converts_buttons():
"""WebAgent 编辑消息应转换为可原地更新卡片的事件。"""
event = build_web_agent_message_update_event(
@@ -904,6 +947,7 @@ def test_web_agent_stream_emits_secret_result_only_as_protected_event():
return_value=FakeProtectedAgent,
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
) as save_snapshot:
body = asyncio.run(scenario())
@@ -970,6 +1014,7 @@ def test_web_agent_cancel_keeps_existing_display_history():
new=AsyncMock(return_value="已取消敏感设置读取。"),
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
) as save_snapshot:
body = asyncio.run(scenario())
@@ -1113,6 +1158,7 @@ def test_web_agent_stream_drops_secret_result_after_disconnect():
new=AsyncMock(side_effect=finish_after_disconnect),
) as process, patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
) as save_snapshot:
body = asyncio.run(scenario())
@@ -1156,7 +1202,7 @@ def test_web_agent_stream_emits_heartbeat_during_idle_tool_wait():
"app.api.endpoints.agent._has_web_agent_traditional_interaction",
return_value=False,
), patch(
"app.api.endpoints.agent._build_web_agent_session_id",
"app.api.endpoints.agent._build_web_agent_session_id_async",
return_value="web-agent:heartbeat",
), patch.object(
MessageChain,
@@ -1167,6 +1213,7 @@ def test_web_agent_stream_emits_heartbeat_during_idle_tool_wait():
side_effect=slow_process_message,
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
):
body = asyncio.run(scenario())
@@ -1228,7 +1275,7 @@ def test_web_agent_stop_finishes_stream_without_error():
"app.api.endpoints.agent._has_web_agent_traditional_interaction",
return_value=False,
), patch(
"app.api.endpoints.agent._build_web_agent_session_id",
"app.api.endpoints.agent._build_web_agent_session_id_async",
return_value=session_id,
), patch.object(
MessageChain,
@@ -1238,6 +1285,7 @@ def test_web_agent_stop_finishes_stream_without_error():
return_value=BlockingWebAgent,
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
):
body = asyncio.run(scenario())
finally:
@@ -1275,9 +1323,10 @@ def test_web_agent_stream_rechecks_running_service_before_enqueue():
), patch(
"app.api.endpoints.agent.get_running_agent_manager",
side_effect=[stale_manager, None],
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
):
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
):
body = asyncio.run(scenario())
assert '"type": "error"' in body
@@ -1299,11 +1348,11 @@ def test_web_agent_traditional_stream_keeps_alive_and_saves_after_done():
await asyncio.sleep(0.035)
return [{"type": "delta", "content": "状态正常"}]
def slow_snapshot(**_kwargs):
async def slow_snapshot(**_kwargs):
"""阻塞快照写入,便于断言 done 不等待落库。"""
snapshot_started.set()
snapshot_release.wait(timeout=2)
snapshot_finished.set()
await asyncio.to_thread(snapshot_started.set)
await asyncio.to_thread(snapshot_release.wait, 2)
await asyncio.to_thread(snapshot_finished.set)
async def scenario():
response = await web_agent_stream(payload, request, user)
@@ -1324,6 +1373,9 @@ def test_web_agent_traditional_stream_keeps_alive_and_saves_after_done():
assert snapshot_started.is_set()
assert not snapshot_finished.is_set()
await iterator.aclose()
assert not snapshot_finished.is_set()
snapshot_release.set()
await asyncio.to_thread(snapshot_finished.wait, 1)
return "".join(received)
try:
@@ -1340,13 +1392,14 @@ def test_web_agent_traditional_stream_keeps_alive_and_saves_after_done():
"app.api.endpoints.agent._get_web_agent_unknown_command_message",
return_value=None,
), patch(
"app.api.endpoints.agent._build_web_agent_session_id",
"app.api.endpoints.agent._build_web_agent_session_id_async",
return_value="web-agent:traditional-heartbeat",
), patch(
"app.api.endpoints.agent._collect_web_agent_traditional_events",
side_effect=slow_collect,
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
side_effect=slow_snapshot,
):
body = asyncio.run(scenario())
@@ -1354,7 +1407,6 @@ def test_web_agent_traditional_stream_keeps_alive_and_saves_after_done():
assert ": heartbeat\n\n" in body
assert '"type": "delta"' in body
assert '"type": "done"' in body
assert not snapshot_finished.is_set()
finally:
snapshot_release.set()
@@ -1374,11 +1426,11 @@ def test_web_agent_stream_sends_done_before_snapshot_persistence_finishes():
"""立即生成一段文本,随后进入终态。"""
kwargs["output_callback"]("检查完成")
def slow_snapshot(**_kwargs):
async def slow_snapshot(**_kwargs):
"""阻塞快照写入,便于验证 done 的发送时机。"""
snapshot_started.set()
snapshot_release.wait(timeout=2)
snapshot_finished.set()
await asyncio.to_thread(snapshot_started.set)
await asyncio.to_thread(snapshot_release.wait, 2)
await asyncio.to_thread(snapshot_finished.set)
async def scenario():
response = await web_agent_stream(payload, request, user)
@@ -1399,6 +1451,9 @@ def test_web_agent_stream_sends_done_before_snapshot_persistence_finishes():
assert not snapshot_finished.is_set()
await iterator.aclose()
assert not snapshot_finished.is_set()
snapshot_release.set()
await asyncio.to_thread(snapshot_finished.wait, 1)
return "".join(received)
try:
@@ -1412,7 +1467,7 @@ def test_web_agent_stream_sends_done_before_snapshot_persistence_finishes():
"app.api.endpoints.agent._has_web_agent_traditional_interaction",
return_value=False,
), patch(
"app.api.endpoints.agent._build_web_agent_session_id",
"app.api.endpoints.agent._build_web_agent_session_id_async",
return_value="web-agent:snapshot",
), patch.object(
MessageChain,
@@ -1423,12 +1478,12 @@ def test_web_agent_stream_sends_done_before_snapshot_persistence_finishes():
side_effect=immediate_process_message,
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
side_effect=slow_snapshot,
):
body = asyncio.run(scenario())
assert '"type": "done"' in body
assert not snapshot_finished.is_set()
finally:
snapshot_release.set()