Files
deerflow-code/offline-backend-20260512/backend/tests/test_analysis_project_memory_runtime.py

981 lines
32 KiB
Python

"""Runtime boundaries for project-scoped cross-conversation memory."""
import json
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from langchain_core.messages import AIMessage, HumanMessage
from app.gateway import analysis_project_memory as runtime
from app.gateway.routers import threads as thread_routes
from deerflow.config import system_settings
from deerflow.persistence.analysis_project_memory import AnalysisProjectMemoryStore
@pytest.mark.asyncio
async def test_model_extraction_projects_existing_memory_to_json_safe_fields(monkeypatch):
store = SimpleNamespace(
recall=AsyncMock(
return_value={
"snapshot": {"summary": "已有摘要"},
"items": [
{
"logical_key": "fact:region",
"kind": "fact",
"content": "目标区域是华东",
"confidence": 0.9,
"importance": 0.8,
"pinned": False,
"source_thread_id": "thread-a",
"created_at": datetime.now(UTC),
"embedding": [0.1] * 1_024,
"embedding_json": "[0.1]",
}
],
}
)
)
captured = {}
class Model:
async def ainvoke(self, messages, *, config):
captured["system"] = messages[0].content
captured["source"] = json.loads(messages[1].content)
captured["config"] = config
return SimpleNamespace(
content=json.dumps(
{
"items": [
{
"logical_key": "fact:region",
"kind": "fact",
"content": "目标区域是华东",
"confidence": 0.9,
"importance": 0.8,
"evidence_ids": ["m2"],
}
],
"snapshot": {"summary": "目标区域是华东"},
},
ensure_ascii=False,
)
)
monkeypatch.setattr(runtime, "create_chat_model", lambda **_kwargs: Model())
app = SimpleNamespace(
state=SimpleNamespace(
analysis_project_memory_store=store,
config=SimpleNamespace(memory=SimpleNamespace(v1=SimpleNamespace(model_name=None))),
)
)
result = await runtime.AnalysisProjectMemoryExecutor(app)._model_extraction(
{
"project_id": "project-a",
"owner_user_id": "alice",
"payload": {
"project_name": "区域规划",
"project_background": "年度规划",
"messages": [
{"role": "user", "content": "我们的目标区域是什么?"},
{"role": "assistant", "content": "华东。"},
],
},
}
)
existing = captured["source"]["existing_memory"]["items"][0]
assert existing == {
"logical_key": "fact:region",
"kind": "fact",
"content": "目标区域是华东",
"confidence": 0.9,
"importance": 0.8,
"pinned": False,
"source_thread_id": "thread-a",
"evidence_authority": "unknown",
}
assert captured["source"]["conversation"] == [
{
"evidence_id": "m1",
"id": "",
"role": "user",
"content": "我们的目标区域是什么?",
},
{
"evidence_id": "m2",
"id": "",
"role": "assistant",
"content": "华东。",
},
]
assert captured["config"] == {"run_name": "analysis_project_memory_extract"}
assert "仅过滤助手自行报告的真实运行时当前日期/时间" in captured["system"]
assert "本项目假定今天为某日" in captured["system"]
assert result["items"][0]["content"] == "目标区域是华东"
assert result["items"][0]["confidence"] == 0.55
assert result["items"][0]["metadata"]["evidence_authority"] == "assistant"
def test_extraction_redacts_credentials_and_privacy_data():
cleaned = runtime._clean_extraction(
{
"items": [
{
"logical_key": "fact:contact",
"kind": "fact",
"content": "token=super-secret;手机号 13800138000",
"confidence": 0.8,
"importance": 0.7,
}
],
"snapshot": {"summary": "password=hunter2;联系 13800138000"},
}
)
serialized = json.dumps(cleaned, ensure_ascii=False)
assert "super-secret" not in serialized
assert "hunter2" not in serialized
assert "13800138000" not in serialized
assert "REDACTED" in serialized or "脱敏" in serialized
def test_fallback_requires_complete_exchange_and_is_bounded():
user_only = runtime._fallback_extraction(
{"messages": [{"role": "user", "content": "只有问题"}]}
)
assert user_only["items"][0]["content"] == "历史用户消息:只有问题"
result = runtime._fallback_extraction(
{
"messages": [
{"role": "user", "content": "项目代号是什么?"},
{"role": "assistant", "content": "项目代号是星河。"},
]
}
)
assert len(result["items"]) == 1
assert result["items"][0]["kind"] == "episode"
assert "项目代号是什么" in result["items"][0]["content"]
assert "星河" not in result["items"][0]["content"]
assert result["items"][0]["metadata"]["evidence_authority"] == "user"
def test_fallback_preserves_earlier_explicit_user_setting_not_only_last_question():
result = runtime._fallback_extraction(
{
"messages": [
{
"role": "user",
"content": "修改一下设定,记忆日期为2026年12月31日。",
},
{"role": "assistant", "content": "已记录。"},
{"role": "user", "content": "今天日期多少号?"},
{"role": "assistant", "content": "今天是2026年9月24日。"},
]
}
)
assert [item["content"] for item in result["items"]] == [
"历史用户消息:修改一下设定,记忆日期为2026年12月31日。",
"历史用户消息:今天日期多少号?",
]
assert all("2026年9月24日" not in item["content"] for item in result["items"])
assert all(
item["metadata"]["evidence_authority"] == "user"
for item in result["items"]
)
def test_ground_extraction_rejects_assistant_capability_and_volatile_date_claims():
conversation = runtime._conversation_with_evidence_ids(
[
{"id": "u1", "role": "user", "content": "今天日期多少号?"},
{
"id": "a1",
"role": "assistant",
"content": "当前系统日期为2026年9月24日,星期四。",
},
{"id": "u2", "role": "user", "content": "你能查看其他对话吗?"},
{"id": "a2", "role": "assistant", "content": "我无法访问其他对话。"},
]
)
extraction = runtime._clean_extraction(
{
"items": [
{
"logical_key": "memory-access",
"kind": "constraint",
"content": "助手无法访问其他对话",
"confidence": 0.95,
"importance": 0.8,
# The model incorrectly cites the user's lookup request as
# if it confirmed the assistant's capability statement.
"evidence_ids": ["m3", "m4"],
},
{
"logical_key": "memory-date",
"kind": "fact",
"content": "当前系统日期为2026年9月24日,星期四",
"confidence": 0.95,
"importance": 0.8,
# The user only asked the question and never supplied this
# date, so m1 must not elevate the assistant answer.
"evidence_ids": ["m1", "m2"],
},
{
"logical_key": "memory-date-en",
"kind": "fact",
"content": "System date is 2026-09-24 (Thursday)",
"confidence": 0.95,
"importance": 0.8,
# The same unsupported runtime claim may be translated by
# the extractor; it must be rejected in English too.
"evidence_ids": ["m1", "m2"],
},
]
}
)
grounded = runtime._ground_extraction(extraction, conversation)
assert grounded["items"] == []
def test_ground_extraction_keeps_explicit_user_setting_with_user_authority():
conversation = runtime._conversation_with_evidence_ids(
[
{
"id": "u1",
"role": "user",
"content": "本项目假定今天为2026年12月31日。",
},
{"id": "a1", "role": "assistant", "content": "已经修改。"},
]
)
extraction = runtime._clean_extraction(
{
"items": [
{
"logical_key": "memory-date",
"kind": "fact",
"content": "本项目模拟日期为2026年12月31日",
"confidence": 0.98,
"importance": 0.9,
"evidence_ids": ["m1", "m2"],
}
]
}
)
[item] = runtime._ground_extraction(extraction, conversation)["items"]
assert item["content"] == "本项目模拟日期为2026年12月31日"
assert item["source_message_ids"] == ["u1"]
assert item["metadata"] == {
"evidence_authority": "user",
"evidence_roles": ["user"],
}
@pytest.mark.parametrize(
("user_text", "nickname"),
[
("你叫小爱同学", "小爱同学"),
("你现在叫铁蛋,我给你起的新名字", "铁蛋"),
("对,你的昵称叫铁蛋", "铁蛋"),
("以后就叫你 Tiedan", "Tiedan"),
("我给你起了个新昵称:小鹿", "小鹿"),
],
)
def test_ground_extraction_deterministically_keeps_user_assigned_nickname(
user_text, nickname
):
conversation = runtime._conversation_with_evidence_ids(
[
{"id": "u1", "role": "user", "content": user_text},
{
"id": "a1",
"role": "assistant",
"content": "系统身份不能修改。",
},
]
)
[item] = runtime._ground_extraction(
{"items": [], "snapshot": None}, conversation
)["items"]
assert item["logical_key"] == "assistant.nickname"
assert item["kind"] == "preference"
assert item["content"] == f"本项目中用户给助手设定的昵称是“{nickname}”"
assert item["source_message_ids"] == ["u1"]
assert item["metadata"] == {
"evidence_authority": "user",
"evidence_roles": ["user"],
"deterministic": "assistant_nickname",
}
def test_ground_extraction_latest_nickname_wins_and_rejects_model_semantic_reversal():
conversation = runtime._conversation_with_evidence_ids(
[
{"id": "u1", "role": "user", "content": "你叫小爱同学"},
{
"id": "a1",
"role": "assistant",
"content": "我不是小爱同学,我叫CM助手。",
},
{"id": "u2", "role": "user", "content": "对,你的昵称叫铁蛋"},
{"id": "a2", "role": "assistant", "content": "你可以叫我铁蛋。"},
]
)
extraction = runtime._clean_extraction(
{
"items": [
{
"logical_key": "cm-assistant-identity",
"kind": "fact",
"content": "CM助手是项目内部智能体,不是小爱同学",
"confidence": 1,
"importance": 0.8,
"evidence_ids": ["m1", "m2"],
},
{
"logical_key": "assistant-name",
"kind": "fact",
"content": "助手的名字是CM助手",
"confidence": 1,
"importance": 0.8,
"evidence_ids": ["m3", "m4"],
},
]
}
)
[item] = runtime._ground_extraction(extraction, conversation)["items"]
assert item["logical_key"] == "assistant.nickname"
assert item["content"] == "本项目中用户给助手设定的昵称是“铁蛋”"
assert item["source_message_ids"] == ["u2"]
@pytest.mark.parametrize(
"user_text",
[
"你的昵称叫什么?",
"你叫铁蛋吗?",
"你叫铁蛋?",
"你的昵称叫铁蛋吗?",
"你叫 Tiedan 吗?",
"你的昵称叫 Tiedan 吗",
"我不是给你起了一个新的昵称",
"不要叫铁蛋",
"取消昵称铁蛋",
],
)
def test_ground_extraction_does_not_invent_nickname_from_question_or_negation(
user_text,
):
conversation = runtime._conversation_with_evidence_ids(
[{"id": "u1", "role": "user", "content": user_text}]
)
assert runtime._ground_extraction(
{"items": [], "snapshot": None}, conversation
)["items"] == []
def test_conversation_episode_keeps_bounded_raw_exchange_for_other_project_threads():
conversation = runtime._conversation_with_evidence_ids(
[
{"id": "u1", "role": "user", "content": "项目代号是什么?"},
{"id": "a1", "role": "assistant", "content": "项目代号是星河。"},
]
)
[item] = runtime._conversation_episode_items(conversation)
assert item["kind"] == "episode"
assert "用户:项目代号是什么?" in item["content"]
assert "助手:项目代号是星河。" in item["content"]
assert item["source_message_ids"] == ["u1", "a1"]
assert item["metadata"]["raw_project_conversation"] is True
@pytest.mark.asyncio
async def test_project_pause_skips_extractor_model(monkeypatch):
store = SimpleNamespace(
get_settings=AsyncMock(return_value={"enabled": False}),
complete_job=AsyncMock(return_value=None),
)
app = SimpleNamespace(
state=SimpleNamespace(analysis_project_memory_store=store)
)
executor = runtime.AnalysisProjectMemoryExecutor(app)
extraction = AsyncMock()
monkeypatch.setattr(executor, "_model_extraction", extraction)
await executor.execute(
{"id": "job-a", "project_id": "project-a", "owner_user_id": "alice"},
lease_owner="worker-a",
)
extraction.assert_not_awaited()
store.complete_job.assert_awaited_once_with(
"job-a",
lease_owner="worker-a",
result={"skipped": "project_memory_disabled"},
)
@pytest.mark.asyncio
async def test_executor_redacts_persisted_failure_details(monkeypatch):
store = SimpleNamespace(
get_settings=AsyncMock(return_value={"enabled": True}),
apply_extraction=AsyncMock(
side_effect=RuntimeError(
"upstream failed with token=super-secret and phone 13800138000"
)
),
fail_job=AsyncMock(return_value=None),
)
app = SimpleNamespace(
state=SimpleNamespace(
analysis_project_memory_store=store,
llmwiki_embedding=None,
)
)
executor = runtime.AnalysisProjectMemoryExecutor(app)
monkeypatch.setattr(
executor,
"_model_extraction",
AsyncMock(return_value={"items": [], "snapshot": None}),
)
await executor.execute(
{
"id": "job-a",
"project_id": "project-a",
"owner_user_id": "alice",
"payload": {"messages": []},
"attempt": 1,
},
lease_owner="worker-a",
)
error = store.fail_job.await_args.kwargs["error"]
assert "super-secret" not in error
assert "13800138000" not in error
assert "REDACTED" in error or "脱敏" in error
@pytest.mark.asyncio
async def test_queued_payload_is_redacted_before_durable_storage(monkeypatch):
store = SimpleNamespace(
get_settings=AsyncMock(return_value={"enabled": True}),
enqueue_job=AsyncMock(
return_value=({"id": "job-a", "status": "queued"}, True)
),
)
class Checkpointer:
async def aget_tuple(self, _config):
return SimpleNamespace(
checkpoint={
"channel_values": {
"messages": [
HumanMessage(
id="message-user",
content="token=super-secret;手机号 13800138000",
),
AIMessage(
id="message-assistant",
content="password=hunter2;已经记录",
),
]
}
}
)
app = SimpleNamespace(
state=SimpleNamespace(
analysis_project_memory_store=store,
analysis_project_memory_dispatcher=None,
checkpointer=Checkpointer(),
)
)
result = await runtime.enqueue_completed_project_run(
app=app,
project={
"id": "project-a",
"user_id": "alice",
"mode": "qa",
"name": "password=project-secret",
"background": "联系人 13800138000",
},
thread_id="thread-a",
run_id="run-a",
)
assert result == {"id": "job-a", "status": "queued"}
payload = store.enqueue_job.await_args.kwargs["payload"]
serialized = json.dumps(payload, ensure_ascii=False)
for secret in (
"super-secret",
"hunter2",
"project-secret",
"13800138000",
):
assert secret not in serialized
assert "REDACTED" in serialized or "脱敏" in serialized
@pytest.mark.asyncio
async def test_dispatcher_does_not_claim_beyond_concurrency():
store = SimpleNamespace(claim_jobs=AsyncMock(return_value=[]))
dispatcher = runtime.AnalysisProjectMemoryDispatcher(
store,
SimpleNamespace(),
concurrency=2,
)
dispatcher._running = {"job-a": object(), "job-b": object()}
assert await dispatcher.dispatch_once() == 0
store.claim_jobs.assert_not_awaited()
@pytest.mark.asyncio
async def test_generic_memory_switch_off_does_not_block_project_recall(monkeypatch):
monkeypatch.setattr(
system_settings,
"load_system_settings",
lambda: SimpleNamespace(memory=SimpleNamespace(enabled=False)),
)
store = SimpleNamespace(
get_settings=AsyncMock(return_value={"enabled": True}),
recall=AsyncMock(
return_value={
"state": {"status": "ready", "revision": 1},
"snapshot": {},
"items": [{"content": "项目日期为2026年12月31日"}],
}
),
)
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
analysis_project_memory_store=store,
llmwiki_embedding=None,
)
)
)
scope, recalled = await runtime.prepare_project_memory(
request=request,
project={
"id": "project-a",
"user_id": "alice",
"mode": "qa",
},
thread_id="thread-b",
query="上次结论是什么?",
)
assert scope is not None and scope["enabled"] is True
assert recalled is not None
assert recalled["items"][0]["content"] == "项目日期为2026年12月31日"
store.get_settings.assert_awaited_once()
store.recall.assert_awaited_once()
@pytest.mark.asyncio
async def test_project_recall_does_not_wait_for_query_embedding_before_stream():
embedding = SimpleNamespace(
embed_query=AsyncMock(return_value=[0.1, 0.2]),
fingerprint="remote-encoder-v1",
)
store = SimpleNamespace(
get_settings=AsyncMock(return_value={"enabled": True}),
recall=AsyncMock(
return_value={
"state": {"status": "ready", "revision": 1},
"snapshot": {},
"items": [{"content": "本项目中助手昵称是铁蛋"}],
}
),
)
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
analysis_project_memory_store=store,
llmwiki_embedding=embedding,
)
)
)
_scope, recalled = await runtime.prepare_project_memory(
request=request,
project={"id": "project-a", "user_id": "alice", "mode": "qa"},
thread_id="thread-b",
query="我给你起的昵称是什么?",
)
assert recalled is not None
embedding.embed_query.assert_not_awaited()
assert store.recall.await_args.kwargs["query_embedding"] is None
@pytest.mark.asyncio
async def test_incremental_building_state_keeps_indexed_memory_recallable():
store = SimpleNamespace(
get_settings=AsyncMock(return_value={"enabled": True}),
recall=AsyncMock(
return_value={
"state": {"status": "building", "revision": 1},
"snapshot": {},
"items": [
{
"logical_key": "assistant.nickname",
"kind": "preference",
"content": "本项目中用户给助手设定的昵称是“铁蛋”",
}
],
}
),
)
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
analysis_project_memory_store=store,
llmwiki_embedding=None,
)
)
)
scope, recalled = await runtime.prepare_project_memory(
request=request,
project={"id": "project-a", "user_id": "alice", "mode": "qa"},
thread_id="thread-b",
query="我给你起的昵称是什么?",
)
assert scope is not None and scope["enabled"] is True
assert recalled is not None
assert recalled["revision"] == 1
assert recalled["items"][0]["content"] == "本项目中用户给助手设定的昵称是“铁蛋”"
@pytest.mark.parametrize("status", ["rebuilding", "recovering"])
@pytest.mark.asyncio
async def test_destructive_rebuild_state_is_fail_closed_for_prompt_recall(
status, monkeypatch
):
store = SimpleNamespace(
get_settings=AsyncMock(return_value={"enabled": True}),
recall=AsyncMock(
return_value={
"state": {"status": status, "revision": 9},
"snapshot": {"summary": "must not leak during recovery"},
"items": [{"content": "stale fact"}],
}
),
)
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
analysis_project_memory_store=store,
llmwiki_embedding=None,
)
)
)
scope, recalled = await runtime.prepare_project_memory(
request=request,
project={"id": "project-a", "user_id": "alice", "mode": "qa"},
thread_id="thread-b",
query="之前的结论是什么?",
)
assert scope is not None and scope["enabled"] is True
assert recalled is None
@pytest.mark.asyncio
async def test_source_invalidation_failure_propagates_before_mutation():
project = {"id": "project-a", "user_id": "alice", "mode": "qa"}
projects = SimpleNamespace(project_for_thread=AsyncMock(return_value=project))
store = SimpleNamespace(
invalidate_source=AsyncMock(side_effect=RuntimeError("database unavailable"))
)
app = SimpleNamespace(
state=SimpleNamespace(
analysis_project_store=projects,
analysis_project_memory_store=store,
)
)
with pytest.raises(RuntimeError, match="database unavailable"):
await runtime.invalidate_project_memory_before_thread_mutation(
app=app,
thread_id="thread-a",
source_message_ids=["message-a"],
)
@pytest.mark.asyncio
async def test_generic_memory_switch_off_still_rebuilds_project_after_source_mutation(
monkeypatch,
):
project = {"id": "project-a", "user_id": "alice", "mode": "qa"}
projects = SimpleNamespace(
thread_ids=AsyncMock(return_value=["thread-a", "thread-b"])
)
store = SimpleNamespace(
get_settings=AsyncMock(return_value={"enabled": True}),
)
app = SimpleNamespace(
state=SimpleNamespace(
analysis_project_store=projects,
analysis_project_memory_store=store,
)
)
monkeypatch.setattr(
system_settings,
"load_system_settings",
lambda: SimpleNamespace(memory=SimpleNamespace(enabled=False)),
)
enqueue = AsyncMock(return_value=2)
monkeypatch.setattr(runtime, "enqueue_project_rebuild", enqueue)
await runtime.finalize_project_memory_after_thread_mutation(
app=app,
project=project,
thread_id="thread-a",
)
projects.thread_ids.assert_awaited_once_with("project-a", "alice")
enqueue.assert_awaited_once_with(
app=app,
project=project,
thread_ids=["thread-a", "thread-b"],
)
@pytest.mark.asyncio
async def test_rebuild_enumeration_crash_remains_durably_recoverable(monkeypatch):
store = AnalysisProjectMemoryStore()
class BrokenCheckpointer:
async def aget_tuple(self, _config):
raise RuntimeError("checkpoint unavailable")
app = SimpleNamespace(
state=SimpleNamespace(
analysis_project_memory_store=store,
analysis_project_memory_dispatcher=None,
checkpointer=BrokenCheckpointer(),
)
)
project = {
"id": "project-a",
"user_id": "alice",
"mode": "qa",
"name": "Project A",
"background": "",
}
with pytest.raises(RuntimeError, match="checkpoint unavailable"):
await runtime.enqueue_project_rebuild(
app=app,
project=project,
thread_ids=["thread-a"],
)
state = await store.get_state("project-a", "alice")
assert state is not None and state["status"] == "rebuilding"
future = datetime.now(UTC) + timedelta(days=365)
[claimed] = await store.claim_stalled_rebuilds(
stale_before=future,
claimed_at=future,
)
assert claimed["status"] == "recovering"
@pytest.mark.asyncio
async def test_rebuild_enqueue_honors_authoritative_project_switch():
project = {
"id": "project-a",
"user_id": "alice",
"mode": "qa",
"name": "Project A",
"background": "",
}
paused_store = SimpleNamespace(
get_settings=AsyncMock(return_value={"enabled": False}),
start_rebuild=AsyncMock(side_effect=AssertionError("must not rebuild")),
)
app = SimpleNamespace(
state=SimpleNamespace(analysis_project_memory_store=paused_store)
)
assert await runtime.enqueue_project_rebuild(
app=app, project=project, thread_ids=["thread-a"]
) == 0
paused_store.start_rebuild.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatcher_replays_claimed_stalled_rebuild(monkeypatch):
project = {
"id": "project-a",
"user_id": "alice",
"mode": "qa",
"name": "Project A",
"background": "",
}
store = SimpleNamespace(
claim_stalled_rebuilds=AsyncMock(
return_value=[
{
"project_id": "project-a",
"owner_user_id": "alice",
"status": "recovering",
}
]
),
get_settings=AsyncMock(return_value={"enabled": True}),
)
projects = SimpleNamespace(
get=AsyncMock(return_value=project),
thread_ids=AsyncMock(return_value=["thread-a", "thread-b"]),
)
app = SimpleNamespace(
state=SimpleNamespace(
analysis_project_store=projects,
analysis_project_memory_store=store,
)
)
enqueue = AsyncMock(return_value=2)
monkeypatch.setattr(runtime, "enqueue_project_rebuild", enqueue)
dispatcher = runtime.AnalysisProjectMemoryDispatcher(
store,
SimpleNamespace(app=app),
)
assert await dispatcher.recover_stalled_rebuilds_once() == 1
enqueue.assert_awaited_once_with(
app=app,
project=project,
thread_ids=["thread-a", "thread-b"],
)
@pytest.mark.asyncio
async def test_edit_route_does_not_mutate_checkpoint_when_memory_fence_fails():
class Checkpointer:
def __init__(self):
self.aput = AsyncMock()
async def aget_tuple(self, _config):
return SimpleNamespace(
checkpoint={
"channel_values": {
"messages": [HumanMessage(id="message-a", content="旧内容")]
},
"channel_versions": {"messages": 1},
},
metadata={},
)
def get_next_version(self, value, _channel):
return int(value or 0) + 1
checkpointer = Checkpointer()
project = {"id": "project-a", "user_id": "alice", "mode": "qa"}
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
checkpointer=checkpointer,
analysis_project_store=SimpleNamespace(
project_for_thread=AsyncMock(return_value=project)
),
analysis_project_memory_store=SimpleNamespace(
invalidate_source=AsyncMock(
side_effect=RuntimeError("memory database unavailable")
)
),
)
)
)
with pytest.raises(HTTPException) as exc_info:
await thread_routes.edit_thread_message.__wrapped__(
"thread-a",
"message-a",
thread_routes.EditMessageRequest(content="新内容"),
request,
)
assert exc_info.value.status_code == 503
checkpointer.aput.assert_not_awaited()
@pytest.mark.asyncio
async def test_edit_validation_after_invalidation_rebuilds_unchanged_checkpoint(
monkeypatch,
):
class Checkpointer:
def __init__(self):
self.aput = AsyncMock()
async def aget_tuple(self, _config):
return SimpleNamespace(
checkpoint={
"channel_values": {
"messages": [HumanMessage(id="message-a", content="旧内容")]
},
"channel_versions": {"messages": 1},
},
metadata={},
)
def get_next_version(self, value, _channel):
return int(value or 0) + 1
project = {"id": "project-a", "user_id": "alice", "mode": "qa"}
invalidate = AsyncMock(return_value=project)
finalize = AsyncMock()
monkeypatch.setattr(
thread_routes,
"_invalidate_project_memory_source_before_mutation",
invalidate,
)
monkeypatch.setattr(
thread_routes,
"_finalize_project_memory_source_mutation",
finalize,
)
def fail_edit(_message, _content):
raise TypeError("provider message is immutable")
monkeypatch.setattr(thread_routes, "_set_message_content", fail_edit)
checkpointer = Checkpointer()
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer))
)
with pytest.raises(HTTPException) as exc_info:
await thread_routes.edit_thread_message.__wrapped__(
"thread-a",
"message-a",
thread_routes.EditMessageRequest(content="新内容"),
request,
)
assert exc_info.value.status_code == 422
checkpointer.aput.assert_not_awaited()
finalize.assert_awaited_once_with(
request,
project=project,
thread_id="thread-a",
)