Files
AstrBot/tests/unit/test_group_chat_context_wiring.py
Ruochen Pan 95d80578bf refactor(ltm): redesign long-term memory with context compaction (reopen of #8144) (#8226)
* refactor(ltm): redesign long-term memory with context compaction

- Add raw_records / contexts / summaries data model per group
- Add LLM summary compaction strategy alongside truncation
- Add turn-based (_split_into_rounds) granularity
- Add image caption integration into LTM history
- Add tool_call / tool_result persistence into raw_records
- Add active reply support driven by LTM state
- Improve summary injection prefix with system note and delimiters
- Add info-level logging for summary compaction lifecycle
- Clarify default summary prompt with explicit preserve/drop rules
- Add context_guard for history overflow protection in agent runner
- Add internal agent history compaction in agent_sub_stages
- Add comprehensive LTM unit tests and compaction test suites

* fix(ltm): handle malformed JSON in tool args and clean up lock on session removal

* fix(ltm): guard against duplicate system prompt note injection

* fix(ltm): fall back to user message when internal marker parsing fails

- Treat lines starting with <T:CALL>, <T:RES, or <BOT/ as regular user
  messages when their respective parsers return None, instead of silently
  dropping them. Defensive guard against malformed internal markers.

* fix(ltm): release session lock during LLM summary generation

* fix(ltm): trim raw_records in handle_message to prevent unbounded growth

* perf(ltm): use len(s) instead of len(s.encode()) in trim loop

Avoid allocating a new bytes object for every string when calculating
buffer size in _trim_raw_records. Character count is sufficient for
the approximate memory cap.

* feat(ltm): make user segment truncation limits configurable

* feat(ltm): pre-fill default LTM summary prompt in config and i18n

* refactor(ltm): hardcode internal segment/trim constants

* refactor(ltm): unify compaction strategy with main agent runner

* feat(ltm): add @mention weight marker for group chat messages

* test: fix test failures from LTM compaction unification

* chore(dashboard): remove obsolete LTM compaction i18n metadata

* chore: shrink codebase

* feat(group-chat): implement group chat context management and related functionality

---------

Co-authored-by: Tsukumi <112180165+Tsukumi233@users.noreply.github.com>
Co-authored-by: zenfun <zenfun510@gmail.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-05-30 17:16:36 +08:00

121 lines
3.9 KiB
Python

from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from astrbot.api.message_components import Plain
from astrbot.builtin_stars.astrbot.main import Main
def make_main_with_conversation_manager(conv_mgr):
main = Main.__new__(Main)
main.context = MagicMock()
main.context.conversation_manager = conv_mgr
return main
def make_event(umo: str = "aiocqhttp:GroupMessage:user_123_group_456"):
event = MagicMock()
event.unified_msg_origin = umo
event.get_platform_id.return_value = "aiocqhttp"
event.message_obj = SimpleNamespace(message=[Plain("hello")])
event.message_str = "hello"
event.session_id = "session-1"
return event
@pytest.mark.asyncio
async def test_active_reply_does_not_create_conversation_when_current_missing():
conv_mgr = SimpleNamespace(
get_curr_conversation_id=AsyncMock(return_value=None),
new_conversation=AsyncMock(),
get_conversation=AsyncMock(),
)
main = make_main_with_conversation_manager(conv_mgr)
main.context.get_config.return_value = {
"provider_ltm_settings": {
"group_icl_enable": False,
"active_reply": {"enable": True},
},
}
main.context.get_using_provider.return_value = object()
main.group_chat_context = SimpleNamespace(
need_active_reply=AsyncMock(return_value=True),
handle_message=AsyncMock(),
)
event = make_event()
results = [item async for item in main.on_message(event)]
assert results == []
conv_mgr.get_curr_conversation_id.assert_awaited_once_with(event.unified_msg_origin)
conv_mgr.new_conversation.assert_not_called()
conv_mgr.get_conversation.assert_not_called()
event.request_llm.assert_not_called()
@pytest.mark.asyncio
async def test_active_reply_reuses_current_umo_conversation():
conv = SimpleNamespace(cid="cid-1")
conv_mgr = SimpleNamespace(
get_curr_conversation_id=AsyncMock(return_value="cid-1"),
new_conversation=AsyncMock(),
get_conversation=AsyncMock(return_value=conv),
)
main = make_main_with_conversation_manager(conv_mgr)
main.context.get_config.return_value = {
"provider_ltm_settings": {
"group_icl_enable": False,
"active_reply": {"enable": True},
},
}
main.context.get_using_provider.return_value = object()
main.group_chat_context = SimpleNamespace(
need_active_reply=AsyncMock(return_value=True),
handle_message=AsyncMock(),
)
event = make_event("aiocqhttp:GroupMessage:user_999_group_456")
llm_request = object()
event.request_llm.return_value = llm_request
results = [item async for item in main.on_message(event)]
assert results == [llm_request]
conv_mgr.get_curr_conversation_id.assert_awaited_once_with(event.unified_msg_origin)
conv_mgr.new_conversation.assert_not_called()
conv_mgr.get_conversation.assert_awaited_once_with(
event.unified_msg_origin,
"cid-1",
)
event.request_llm.assert_called_once_with(
prompt="hello",
session_id="session-1",
image_urls=[],
conversation=conv,
)
@pytest.mark.asyncio
async def test_on_message_does_not_clear_group_context_on_first_enabled_message():
main = Main.__new__(Main)
main.context = MagicMock()
main.context.get_config.return_value = {
"provider_ltm_settings": {
"group_icl_enable": True,
"active_reply": {"enable": False},
},
}
main.group_chat_context = SimpleNamespace(
need_active_reply=AsyncMock(return_value=False),
handle_message=AsyncMock(),
remove_session=AsyncMock(),
)
event = make_event()
async for _ in main.on_message(event):
pass
main.group_chat_context.need_active_reply.assert_awaited_once_with(event)
main.group_chat_context.handle_message.assert_awaited_once_with(event)
main.group_chat_context.remove_session.assert_not_called()