"""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", )