feat(engine): 优化智能体循环中的助手消息处理逻辑 - 在没有工具调用时才添加助手消息到上下文 - 确保工具调用响应正确添加到消息上下文中 - 修复了消息构建的条件逻辑 fix(cron): 改进定时任务调度的时间解析功能 - 添加正则表达式导入用于时间显示解析 - 实现从显示文本中提取毫秒间隔的功能 - 增强整数转换的安全性,避免类型错误 - 优化定时任务配置的解析逻辑 feat(outlook): 增强Outlook集成的功能和稳定性 - 将默认超时时间从10秒增加到180秒 - 为状态检查函数添加可选的验证参数 - 串行执行邮件概览获取操作而非并行 - 改进连接状态验证逻辑 feat(channel): 添加设备名称作为会话标识的选项 - 为终端WebSocket适配器添加新的配置选项 - 实现基于设备名称生成会话对等ID的功能 - 记录原始对等ID和设备名称的元数据 - 支持从设备名称创建会话对等ID feat(skills): 完善技能学习评估系统和进度跟踪 - 在应用启动时自动调度待评估的技能草稿 - 为技能评估工作创建独立的循环工厂 - 实现异步技能评估任务的取消和清理机制 - 添加技能评估进度报告和状态跟踪功能 - 扩展会话列表API以包含更多详细信息 - 防止对不存在的会话进行操作 - 优化技能草稿提交和评估的业务逻辑 perf(skills): 提升技能评估的并发性能 - 实现并行技能案例评估以提高效率 - 添加最大并行案例数的环境变量控制 - 实现实时评估进度更新和回调机制 - 优化评估过程中的资源管理和同步 refactor(services): 创建隔离的智能体循环实例 - 添加创建独立智能体循环的工厂方法 - 确保新循环继承运行时服务配置 - 支持技能评估等需要隔离环境的场景 ```
344 lines
14 KiB
Python
344 lines
14 KiB
Python
import asyncio
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from beaver.foundation.events import InboundMessage, OutboundMessage
|
|
from beaver.interfaces.web.app import create_app
|
|
from beaver.services.agent_service import AgentService
|
|
|
|
|
|
class TerminalFakeAgentService(AgentService):
|
|
def __init__(self, *, config_path: Path, delay_seconds: float = 0.0) -> None:
|
|
super().__init__(config_path=config_path)
|
|
self.delay_seconds = delay_seconds
|
|
self.inbound_calls: list[InboundMessage] = []
|
|
|
|
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
|
|
self.inbound_calls.append(inbound)
|
|
if self.delay_seconds:
|
|
await asyncio.sleep(self.delay_seconds)
|
|
return OutboundMessage(
|
|
message_id=inbound.message_id,
|
|
channel=inbound.channel,
|
|
content=f"echo:{inbound.content}",
|
|
session_id=inbound.session_id,
|
|
finish_reason="stop",
|
|
run_id="run-1",
|
|
channel_identity=inbound.channel_identity,
|
|
)
|
|
|
|
|
|
def write_terminal_config(tmp_path: Path) -> Path:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}},
|
|
"providers": {},
|
|
"channels": {
|
|
"terminal-dev": {
|
|
"enabled": True,
|
|
"kind": "terminal",
|
|
"mode": "websocket",
|
|
"accountId": "local",
|
|
"displayName": "Terminal Dev",
|
|
"config": {"heartbeatSeconds": 30, "maxMessageChars": 20000},
|
|
}
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return config_path
|
|
|
|
|
|
def write_terminal_config_with_device_session(tmp_path: Path) -> Path:
|
|
config_path = write_terminal_config(tmp_path)
|
|
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
payload["channels"]["terminal-dev"]["config"]["sessionPeerFromDeviceName"] = True
|
|
config_path.write_text(json.dumps(payload), encoding="utf-8")
|
|
return config_path
|
|
|
|
|
|
def test_terminal_websocket_connect_ping_and_message_roundtrip(tmp_path: Path) -> None:
|
|
config_path = write_terminal_config(tmp_path)
|
|
service = TerminalFakeAgentService(config_path=config_path)
|
|
app = create_app(service=service, manage_service_lifecycle=False)
|
|
|
|
with TestClient(app) as client:
|
|
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
|
websocket.send_json(
|
|
{
|
|
"type": "connect",
|
|
"peer_id": "device-001",
|
|
"device_name": "desk-terminal",
|
|
"capabilities": ["text"],
|
|
}
|
|
)
|
|
assert websocket.receive_json() == {
|
|
"type": "connected",
|
|
"channel_id": "terminal-dev",
|
|
"session_id": "terminal-dev:local:device-001",
|
|
}
|
|
|
|
websocket.send_json({"type": "ping"})
|
|
assert websocket.receive_json() == {"type": "pong"}
|
|
|
|
websocket.send_json(
|
|
{
|
|
"type": "message",
|
|
"message_id": "device-001-000001",
|
|
"text": "hello",
|
|
}
|
|
)
|
|
assert websocket.receive_json() == {
|
|
"type": "ack",
|
|
"message_id": "device-001-000001",
|
|
"session_id": "terminal-dev:local:device-001",
|
|
"accepted": True,
|
|
}
|
|
reply = websocket.receive_json()
|
|
|
|
service.close()
|
|
assert reply == {
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"message_id": "device-001-000001",
|
|
"run_id": "run-1",
|
|
"text": "echo:hello",
|
|
"finish_reason": "stop",
|
|
}
|
|
assert len(service.inbound_calls) == 1
|
|
inbound = service.inbound_calls[0]
|
|
assert inbound.channel == "terminal-dev"
|
|
assert inbound.content == "hello"
|
|
assert inbound.content_type == "text"
|
|
assert inbound.session_id == "terminal-dev:local:device-001"
|
|
assert inbound.channel_identity is not None
|
|
assert inbound.channel_identity.peer_id == "device-001"
|
|
assert inbound.channel_identity.peer_type == "terminal"
|
|
assert inbound.channel_identity.message_id == "device-001-000001"
|
|
|
|
|
|
def test_terminal_websocket_can_use_device_name_as_stable_session_peer(tmp_path: Path) -> None:
|
|
config_path = write_terminal_config_with_device_session(tmp_path)
|
|
service = TerminalFakeAgentService(config_path=config_path)
|
|
app = create_app(service=service, manage_service_lifecycle=False)
|
|
|
|
with TestClient(app) as client:
|
|
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
|
websocket.send_json(
|
|
{
|
|
"type": "connect",
|
|
"peer_id": "livekit-test-livekit-07291699",
|
|
"device_name": "desk-terminal",
|
|
}
|
|
)
|
|
first = websocket.receive_json()
|
|
|
|
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
|
websocket.send_json(
|
|
{
|
|
"type": "connect",
|
|
"peer_id": "livekit-test-livekit-3fb03fff",
|
|
"device_name": "desk-terminal",
|
|
}
|
|
)
|
|
second = websocket.receive_json()
|
|
websocket.send_json(
|
|
{
|
|
"type": "message",
|
|
"message_id": "livekit-test-livekit-3fb03fff-000001",
|
|
"text": "hello",
|
|
}
|
|
)
|
|
ack = websocket.receive_json()
|
|
reply = websocket.receive_json()
|
|
|
|
service.close()
|
|
assert first["session_id"] == "terminal-dev:local:device-desk-terminal"
|
|
assert second["session_id"] == first["session_id"]
|
|
assert ack["session_id"] == first["session_id"]
|
|
assert reply["text"] == "echo:hello"
|
|
assert service.inbound_calls[0].session_id == first["session_id"]
|
|
assert service.inbound_calls[0].channel_identity is not None
|
|
assert service.inbound_calls[0].channel_identity.peer_id == "device-desk-terminal"
|
|
|
|
|
|
def test_terminal_websocket_reconnect_delivers_pending_reply_to_latest_device_connection(tmp_path: Path) -> None:
|
|
config_path = write_terminal_config_with_device_session(tmp_path)
|
|
service = TerminalFakeAgentService(config_path=config_path, delay_seconds=0.05)
|
|
app = create_app(service=service, manage_service_lifecycle=False)
|
|
|
|
with TestClient(app) as client:
|
|
with client.websocket_connect("/api/channels/terminal-dev/ws") as first_websocket:
|
|
first_websocket.send_json(
|
|
{
|
|
"type": "connect",
|
|
"peer_id": "livekit-test-livekit-old",
|
|
"device_name": "desk-terminal",
|
|
}
|
|
)
|
|
first = first_websocket.receive_json()
|
|
first_websocket.send_json(
|
|
{
|
|
"type": "message",
|
|
"message_id": "livekit-test-livekit-old-000001",
|
|
"text": "slow",
|
|
}
|
|
)
|
|
assert first_websocket.receive_json()["accepted"] is True
|
|
|
|
with client.websocket_connect("/api/channels/terminal-dev/ws") as latest_websocket:
|
|
latest_websocket.send_json(
|
|
{
|
|
"type": "connect",
|
|
"peer_id": "livekit-test-livekit-new",
|
|
"device_name": "desk-terminal",
|
|
}
|
|
)
|
|
latest = latest_websocket.receive_json()
|
|
reply = latest_websocket.receive_json()
|
|
|
|
service.close()
|
|
assert latest["session_id"] == first["session_id"]
|
|
assert reply == {
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"message_id": "livekit-test-livekit-old-000001",
|
|
"run_id": "run-1",
|
|
"text": "echo:slow",
|
|
"finish_reason": "stop",
|
|
}
|
|
|
|
|
|
def test_terminal_websocket_rejects_message_before_connect(tmp_path: Path) -> None:
|
|
config_path = write_terminal_config(tmp_path)
|
|
service = TerminalFakeAgentService(config_path=config_path)
|
|
app = create_app(service=service, manage_service_lifecycle=False)
|
|
|
|
with TestClient(app) as client:
|
|
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
|
websocket.send_json({"type": "message", "message_id": "m1", "text": "hello"})
|
|
assert websocket.receive_json() == {
|
|
"type": "error",
|
|
"error": "connect is required before message",
|
|
}
|
|
websocket.send_json({"type": "ping"})
|
|
assert websocket.receive_json() == {"type": "pong"}
|
|
|
|
service.close()
|
|
assert service.inbound_calls == []
|
|
|
|
|
|
def test_terminal_websocket_unknown_frame_keeps_connection_open(tmp_path: Path) -> None:
|
|
config_path = write_terminal_config(tmp_path)
|
|
service = TerminalFakeAgentService(config_path=config_path)
|
|
app = create_app(service=service, manage_service_lifecycle=False)
|
|
|
|
with TestClient(app) as client:
|
|
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
|
websocket.send_json({"type": "example"})
|
|
assert websocket.receive_json() == {
|
|
"type": "error",
|
|
"error": "Unsupported websocket frame type: example",
|
|
}
|
|
websocket.send_json({"type": "ping"})
|
|
assert websocket.receive_json() == {"type": "pong"}
|
|
|
|
service.close()
|
|
|
|
|
|
def test_terminal_websocket_validates_message_fields(tmp_path: Path) -> None:
|
|
config_path = write_terminal_config(tmp_path)
|
|
service = TerminalFakeAgentService(config_path=config_path)
|
|
app = create_app(service=service, manage_service_lifecycle=False)
|
|
|
|
with TestClient(app) as client:
|
|
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
|
websocket.send_json({"type": "connect", "peer_id": "device-001"})
|
|
assert websocket.receive_json()["type"] == "connected"
|
|
|
|
websocket.send_json({"type": "message", "text": "hello"})
|
|
assert websocket.receive_json() == {"type": "error", "error": "message_id is required"}
|
|
|
|
websocket.send_json({"type": "message", "message_id": "m1", "text": " "})
|
|
assert websocket.receive_json() == {"type": "error", "error": "text is required"}
|
|
|
|
service.close()
|
|
assert service.inbound_calls == []
|
|
|
|
|
|
def test_terminal_websocket_duplicate_message_returns_cached_reply(tmp_path: Path) -> None:
|
|
config_path = write_terminal_config(tmp_path)
|
|
service = TerminalFakeAgentService(config_path=config_path)
|
|
app = create_app(service=service, manage_service_lifecycle=False)
|
|
|
|
with TestClient(app) as client:
|
|
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
|
websocket.send_json({"type": "connect", "peer_id": "device-001"})
|
|
assert websocket.receive_json()["type"] == "connected"
|
|
|
|
frame = {"type": "message", "message_id": "device-001-000001", "text": "hello"}
|
|
websocket.send_json(frame)
|
|
assert websocket.receive_json()["accepted"] is True
|
|
assert websocket.receive_json()["text"] == "echo:hello"
|
|
|
|
websocket.send_json(frame)
|
|
duplicate = websocket.receive_json()
|
|
|
|
service.close()
|
|
assert duplicate["type"] == "ack"
|
|
assert duplicate["accepted"] is False
|
|
assert duplicate["duplicate"] is True
|
|
assert duplicate["pending"] is False
|
|
assert duplicate["reply"] == "echo:hello"
|
|
assert len(service.inbound_calls) == 1
|
|
|
|
|
|
def test_terminal_websocket_disconnect_before_reply_records_unclaimed(tmp_path: Path) -> None:
|
|
config_path = write_terminal_config(tmp_path)
|
|
service = TerminalFakeAgentService(config_path=config_path, delay_seconds=0.05)
|
|
app = create_app(service=service, manage_service_lifecycle=False)
|
|
|
|
with TestClient(app) as client:
|
|
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
|
websocket.send_json({"type": "connect", "peer_id": "device-001"})
|
|
assert websocket.receive_json()["type"] == "connected"
|
|
websocket.send_json({"type": "message", "message_id": "device-001-000001", "text": "slow"})
|
|
assert websocket.receive_json()["accepted"] is True
|
|
|
|
time.sleep(0.15)
|
|
events = client.get("/api/channels/terminal-dev/events").json()
|
|
|
|
service.close()
|
|
kinds = [event["kind"] for event in events]
|
|
assert "terminal_disconnected" in kinds
|
|
assert "outbound_unclaimed" in kinds
|
|
|
|
|
|
def test_terminal_channel_status_exposes_websocket_url_and_peer_count(tmp_path: Path) -> None:
|
|
config_path = write_terminal_config(tmp_path)
|
|
service = TerminalFakeAgentService(config_path=config_path)
|
|
app = create_app(service=service, manage_service_lifecycle=False)
|
|
|
|
with TestClient(app) as client:
|
|
initial = client.get("/api/status").json()["channels"][0]
|
|
assert initial["channel_id"] == "terminal-dev"
|
|
assert initial["websocket_url"] == "/api/channels/terminal-dev/ws"
|
|
assert initial["connected_peers"] == 0
|
|
assert "persistent_connection" in initial["capabilities"]
|
|
|
|
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
|
websocket.send_json({"type": "connect", "peer_id": "device-001"})
|
|
assert websocket.receive_json()["type"] == "connected"
|
|
connected = client.get("/api/status").json()["channels"][0]
|
|
assert connected["connected_peers"] == 1
|
|
|
|
service.close()
|