mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-06 08:16:00 +00:00
Some checks failed
Tests ReMe / Unit Tests - py3.12 (push) Has been cancelled
Tests ReMe / Unit Tests - py3.13 (push) Has been cancelled
Windows Smoke / CLI smoke - py3.11 (push) Has been cancelled
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Tests ReMe / Unit Tests - py3.11 (push) Has been cancelled
* refactor: rebuild auto-fin and daily-paper cookbooks on structured-output agents Rework the auto-fin and daily-paper cookbooks to run on structured-output LLM agents instead of Claude Code agent wrappers, replace the SSH proxy with data-source mirrors, and rewrite the affected unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(auto_fin): unify JSON output serialization and writing - Extracted _write_output static method to serialize and write Pydantic models as compact JSON - Replaced inline JSON dump and write calls with _write_output usage across auto_fin steps - Added _report_path and _current_report for managing intra-day reports in AutoFinMergeStep - Updated auto_fin merge step to write output via new _write_output method - Enhanced news reading with caching in AutoFinHistoryStep - Refined returns calculation to handle events before close on non-trading days correctly feat(daily_paper): improve note path resolution and metadata handling - Introduced iter_note_metadata generator for safe Markdown frontmatter iteration - Added resolve_unique_note_path to avoid note filename conflicts on disk and in used titles - Updated analyze, collect, digest, and select steps to use centralized constants and helpers - Used utc_now_iso for consistent timestamping in metadata - Replaced direct frontmatter loads with iter_note_metadata in collect and analyze steps - Replaced hardcoded paper selection count with PAPER_COUNT constant in all relevant places - Added _MAX_SELECT_ATTEMPTS constant in select step for attempt management - Improved error messages for filename validation in daily paper title normalization feat(auto_fin): add multi-run cron schedules for intraday refinement - Defined three auto_fin cron jobs at 09:30, 11:30, and 18:00 Shanghai time for gradual report updates - Each intraday run adds evidence cumulatively instead of replacing prior output wholly - Updated daily_cookbook.yaml to register new cron schedules and remove legacy 12:00 cron refactor(auto_fin_data): clean ETF code handling and page limits - Replaced hardcoded DEFAULT_ETF_CODES with required non-empty config value "etf_codes" - Added constants for major news and fund page limits to control pagination - Improved ETF name extraction logic to handle missing fields consistently fix(auto_fin_merge): fix report retrieval and merging logic - Added support for getting current intra-day report in addition to previous day's report - Modified merge template to include prior and current report sections for better context - Adjusted report path handling to consistently use Path objects test(auto_fin): add coverage for returns calculation and report retrieval - Added test for returns when event occurs before close on non-trading day, checking next session entry - Added test for previous and current report retrieval feeding merge context with disk files - Extended test asserts for auto_fin cron schedule changes in config style(daily_paper): reorder and cleanup imports - Reorganized imports in _common.py for clarity and added missing collections.abc.Iterator import - Cleaned up commented and unused imports across daily_paper steps * feat: add configurable upstream mirror proxy * style: format auto-fin data step * fix: align cookbook mirrors and contracts --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
320 lines
10 KiB
Python
320 lines
10 KiB
Python
"""Focused tests for the DingTalk background agent bridge."""
|
|
|
|
# pylint: disable=missing-function-docstring,protected-access
|
|
|
|
import asyncio
|
|
import json
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from reme.components import ApplicationContext, R
|
|
from reme.components.agent_wrapper.base_agent_wrapper import BaseAgentWrapper
|
|
from reme.config.config_parser import _load_config
|
|
from reme.enumeration import ComponentEnum
|
|
from reme.steps.cookbook.dingtalk.wait import DingTalkWaitStep, _session_key
|
|
|
|
|
|
class _AgentWrapper(BaseAgentWrapper):
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.reply_calls = []
|
|
self.compact_calls = []
|
|
self.result_text = "回答"
|
|
self.is_error = False
|
|
|
|
async def compact_session(self, session_id):
|
|
self.compact_calls.append(session_id)
|
|
|
|
async def reply(self, inputs, **kwargs):
|
|
self.reply_calls.append((inputs, kwargs))
|
|
session_id = kwargs.get("resume") or "session-1"
|
|
return {
|
|
"session_id": session_id,
|
|
"last_message": {"is_error": self.is_error},
|
|
"result": self.result_text,
|
|
}
|
|
|
|
|
|
class _Handler:
|
|
def __init__(self):
|
|
self.replies = []
|
|
self.markdown_replies = []
|
|
self.markdown_result = {"errcode": 0}
|
|
|
|
def reply_text(self, text, _message):
|
|
self.replies.append(text)
|
|
|
|
def reply_markdown(self, title, text, _message):
|
|
self.markdown_replies.append((title, text))
|
|
return self.markdown_result
|
|
|
|
|
|
class _WebSocket:
|
|
def __init__(self, messages=(), wait_when_empty=True):
|
|
self.messages = list(messages)
|
|
self.wait_when_empty = wait_when_empty
|
|
self.closed = asyncio.Event()
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_args):
|
|
await self.close()
|
|
|
|
def __aiter__(self):
|
|
return self
|
|
|
|
async def __anext__(self):
|
|
if self.messages:
|
|
return self.messages.pop(0)
|
|
if not self.wait_when_empty:
|
|
raise StopAsyncIteration
|
|
await self.closed.wait()
|
|
raise StopAsyncIteration
|
|
|
|
async def close(self):
|
|
self.closed.set()
|
|
|
|
|
|
class _StreamClient:
|
|
TAG_DISCONNECT = "disconnect"
|
|
|
|
def __init__(self, route_result=""):
|
|
self.route_result = route_result
|
|
self.websocket = None
|
|
|
|
def pre_start(self):
|
|
return None
|
|
|
|
def open_connection(self):
|
|
return {"endpoint": "wss://example.test/connect", "ticket": "ticket"}
|
|
|
|
async def keepalive(self, _websocket):
|
|
await asyncio.Event().wait()
|
|
|
|
async def route_message(self, _message):
|
|
return self.route_result
|
|
|
|
|
|
def _message(text="hello", sender="user-1", conversation="cid-1", conversation_type="1"):
|
|
return SimpleNamespace(
|
|
text=SimpleNamespace(content=text),
|
|
sender_staff_id=sender,
|
|
conversation_id=conversation,
|
|
conversation_type=conversation_type,
|
|
)
|
|
|
|
|
|
def test_session_key_uses_conversation_type_id_and_sender():
|
|
assert _session_key(_message()) == "1:cid-1:user-1"
|
|
assert _session_key(_message(sender="user-2")) == "1:cid-1:user-2"
|
|
assert _session_key(_message(conversation="cid-2", conversation_type="2")) == "2:cid-2:user-1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_final_reply_resumes_session_and_clear_only_removes_combined_key(
|
|
tmp_path,
|
|
):
|
|
app_context = ApplicationContext(workspace_dir=str(tmp_path))
|
|
wrapper = _AgentWrapper(app_context=app_context)
|
|
step = DingTalkWaitStep(app_context=app_context, agent_wrapper=wrapper)
|
|
step.logger = MagicMock()
|
|
handler = _Handler()
|
|
sessions = {}
|
|
message = _message()
|
|
key = _session_key(message)
|
|
|
|
await step._handle_message(message, key, sessions, handler)
|
|
await step._handle_message(message, key, sessions, handler)
|
|
|
|
assert sessions == {key: "session-1"}
|
|
tool_kwargs = {"builtin_tools": False, "job_tools": []}
|
|
assert wrapper.reply_calls == [
|
|
("hello", tool_kwargs),
|
|
("hello", {"resume": "session-1", **tool_kwargs}),
|
|
]
|
|
assert handler.markdown_replies == [("ReMe Agent", "回答"), ("ReMe Agent", "回答")]
|
|
|
|
await step._handle_message(_message(text="/compact"), key, sessions, handler)
|
|
assert wrapper.compact_calls == ["session-1"]
|
|
assert sessions[key] == "session-1"
|
|
assert handler.replies[-1] == "✅ Conversation compaction requested."
|
|
|
|
other_key = _session_key(_message(sender="user-2"))
|
|
sessions[other_key] = "session-2"
|
|
await step._handle_message(_message(text="/clear"), key, sessions, handler)
|
|
assert sessions == {other_key: "session-2"}
|
|
assert handler.replies[-1] == "✅ Conversation cleared. The next message will start a new session."
|
|
|
|
logs = "\n".join(call.args[0] for call in step.logger.info.call_args_list)
|
|
assert "received DingTalk text" in logs
|
|
assert "completed DingTalk reply" in logs
|
|
assert "handled session command" in logs
|
|
assert "conversation_type='1' conversation_id='cid-1' sender_staff_id='user-1'" in logs
|
|
assert all(value not in logs for value in ("hello", "session-1"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_final_reply_rejects_empty_agent_reply_and_dingtalk_send_failure(
|
|
tmp_path,
|
|
):
|
|
app_context = ApplicationContext(workspace_dir=str(tmp_path))
|
|
wrapper = _AgentWrapper(app_context=app_context)
|
|
step = DingTalkWaitStep(app_context=app_context, agent_wrapper=wrapper)
|
|
step.logger = MagicMock()
|
|
handler = _Handler()
|
|
message = _message()
|
|
key = _session_key(message)
|
|
|
|
wrapper.result_text = " "
|
|
with pytest.raises(ValueError, match="空回复"):
|
|
await step._handle_message(message, key, {}, handler)
|
|
|
|
wrapper.result_text = "回答"
|
|
handler.markdown_result = None
|
|
with pytest.raises(RuntimeError, match="发送钉钉 Markdown 回复失败"):
|
|
await step._handle_message(message, key, {}, handler)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_final_reply_injects_only_configured_tools(tmp_path):
|
|
app_context = ApplicationContext(workspace_dir=str(tmp_path))
|
|
wrapper = _AgentWrapper(app_context=app_context)
|
|
step = DingTalkWaitStep(
|
|
app_context=app_context,
|
|
agent_wrapper=wrapper,
|
|
builtin_tools=["bash"],
|
|
job_tools=["read", "write", "edit"],
|
|
)
|
|
|
|
message = _message()
|
|
await step._handle_message(message, _session_key(message), {}, _Handler())
|
|
|
|
assert wrapper.reply_calls == [
|
|
(
|
|
"hello",
|
|
{
|
|
"builtin_tools": ["bash"],
|
|
"job_tools": ["read", "write", "edit"],
|
|
},
|
|
),
|
|
]
|
|
|
|
|
|
def test_daily_cookbook_registers_one_step_background_wait_job(monkeypatch):
|
|
for name in ("DINGTALK_APP_KEY", "DINGTALK_APP_SECRET", "DINGTALK_ROBOT_CODE"):
|
|
monkeypatch.delenv(name, raising=False)
|
|
config = _load_config("daily_cookbook")
|
|
job = config["jobs"]["dingtalk_wait"]
|
|
assert job["backend"] == "background"
|
|
assert job["steps"] == [
|
|
{
|
|
"backend": "dingtalk_wait_step",
|
|
"app_key": "",
|
|
"app_secret": "",
|
|
"robot_code": "",
|
|
"worker_count": 4,
|
|
"builtin_tools": ["bash"],
|
|
"job_tools": [
|
|
"memory_search",
|
|
"read",
|
|
"write",
|
|
"edit",
|
|
"daily_list",
|
|
"daily_write",
|
|
"frontmatter_read",
|
|
"frontmatter_update",
|
|
],
|
|
},
|
|
]
|
|
assert config["components"]["agent_wrapper"] == {
|
|
"default": {
|
|
"backend": "agentscope",
|
|
"as_llm": "default",
|
|
"builtin_tools": False,
|
|
},
|
|
}
|
|
assert R.get(ComponentEnum.STEP, "dingtalk_wait_step") is DingTalkWaitStep
|
|
|
|
|
|
def test_daily_cookbook_passes_dingtalk_environment_to_step(monkeypatch):
|
|
monkeypatch.setenv("DINGTALK_APP_KEY", "app-key")
|
|
monkeypatch.setenv("DINGTALK_APP_SECRET", "app-secret")
|
|
monkeypatch.setenv("DINGTALK_ROBOT_CODE", "robot-code")
|
|
step = _load_config("daily_cookbook")["jobs"]["dingtalk_wait"]["steps"][0]
|
|
assert (step["app_key"], step["app_secret"], step["robot_code"]) == (
|
|
"app-key",
|
|
"app-secret",
|
|
"robot-code",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_client_closes_when_background_stop_is_set(monkeypatch):
|
|
websocket = _WebSocket()
|
|
monkeypatch.setattr("websockets.connect", lambda _uri: websocket)
|
|
stop_event = asyncio.Event()
|
|
task = asyncio.create_task(DingTalkWaitStep._run_client(_StreamClient(), stop_event))
|
|
stop_event.set()
|
|
await asyncio.wait_for(task, timeout=1)
|
|
assert websocket.closed.is_set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_client_restarts_after_server_disconnect(monkeypatch):
|
|
websocket = _WebSocket(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"type": "SYSTEM",
|
|
"headers": {"topic": "disconnect"},
|
|
"data": json.dumps({"reason": "connection is expired"}),
|
|
},
|
|
),
|
|
],
|
|
)
|
|
monkeypatch.setattr("websockets.connect", lambda _uri: websocket)
|
|
reason = await DingTalkWaitStep._run_client(_StreamClient("disconnect"), asyncio.Event())
|
|
assert reason == "connection is expired"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_client_raises_when_websocket_closes_unexpectedly(monkeypatch):
|
|
websocket = _WebSocket(wait_when_empty=False)
|
|
monkeypatch.setattr("websockets.connect", lambda _uri: websocket)
|
|
with pytest.raises(ConnectionError, match="closed unexpectedly"):
|
|
await DingTalkWaitStep._run_client(_StreamClient(), asyncio.Event())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_client_reconnects_after_server_request(monkeypatch, tmp_path):
|
|
app_context = ApplicationContext(workspace_dir=str(tmp_path))
|
|
step = DingTalkWaitStep(app_context=app_context)
|
|
step.logger = MagicMock()
|
|
stop_event = asyncio.Event()
|
|
calls = 0
|
|
|
|
async def run_client(_client, _stop_event):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 1:
|
|
return "connection is expired"
|
|
stop_event.set()
|
|
return None
|
|
|
|
async def timeout(awaitable, *, timeout):
|
|
del timeout
|
|
awaitable.close()
|
|
raise asyncio.TimeoutError
|
|
|
|
monkeypatch.setattr(step, "_run_client", run_client)
|
|
monkeypatch.setattr(asyncio, "wait_for", timeout)
|
|
|
|
await step._run_with_reconnect(_StreamClient(), stop_event)
|
|
|
|
assert calls == 2
|
|
step.logger.info.assert_called_once_with(
|
|
"[DingTalkWaitStep] DingTalk server requested reconnect reason='connection is expired'; reconnecting in 1.0s",
|
|
)
|