from io import BytesIO
from types import SimpleNamespace

import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from starlette.datastructures import UploadFile

import app.models  # noqa: F401
from app.database import Base
from app.models.brain import BrainEvent, BrainMemory
from app.models.conversation import Conversation, Message
from app.models.memory import MemorySummary, UserMemory
from app.models.user import User
from app.services import agent_service, memory_service
from app.services.agent_service import AgentService, _build_continuity_snapshot, _extract_continuity_snapshot
from app.services.auth_service import get_password_hash
from app.services.document_service import DocumentService


class FakeGraph:
    async def ainvoke(self, state):
        return {"final_response": "已记录你的请求。"}


def test_continuity_snapshot_roundtrip_preserves_phase_and_checkpoint():
    payload = {
        "current_agent": "master",
        "current_phase": "phase_4_visibility_and_verification",
        "phase_history": [
            {"phase": "phase_0_bootstrap", "reason": "initial_state_created"},
            {"phase": "phase_4_visibility_and_verification", "reason": "verification_started"},
        ],
        "current_checkpoint": "collaboration.completed",
        "checkpoint_history": [
            {"checkpoint": "bootstrap.initialized", "phase": "phase_0_bootstrap", "reason": "initial_state_created"},
            {"checkpoint": "collaboration.completed", "phase": "phase_4_visibility_and_verification", "reason": "collaboration_flow_finished"},
        ],
    }

    snapshot = _build_continuity_snapshot(payload)

    assert snapshot is not None
    restored = _extract_continuity_snapshot({"kind": "agent_continuity_state", **snapshot})
    assert restored is not None
    assert restored["current_phase"] == "phase_4_visibility_and_verification"
    assert restored["current_checkpoint"] == "collaboration.completed"
    assert restored["phase_history"][-1]["phase"] == "phase_4_visibility_and_verification"
    assert restored["checkpoint_history"][-1]["checkpoint"] == "collaboration.completed"


class FakeStreamingGraph:
    async def astream_events(self, state, version="v2"):
        yield {
            "event": "on_chat_model_stream",
            "name": "master",
            "data": {"chunk": SimpleNamespace(content="这是流式回复。")},
        }


class FakeStreamingTwoChunkGraph:
    async def astream_events(self, state, version="v2"):
        yield {
            "event": "on_chat_model_stream",
            "name": "master",
            "data": {"chunk": SimpleNamespace(content="前半段")},
        }
        yield {
            "event": "on_chat_model_stream",
            "name": "master",
            "data": {"chunk": SimpleNamespace(content="后半段")},
        }


class FakeStreamingFinalResponseGraph:
    async def astream_events(self, state, version="v2"):
        yield {
            "event": "on_chain_end",
            "name": "master",
            "data": {"output": {"final_response": "这是最终回答。"}},
        }


class FakeStreamingBadRequestError(Exception):
    pass


class FakeStreamingBadRequestError2(Exception):
    pass


class FakeOpenAIBadRequestError(Exception):
    pass


class FakeStreamingOpenAIBadRequestGraph:
    def __init__(self):
        self.astream_calls = 0
        self.ainvoke_calls = 0

    async def astream_events(self, state, version="v2"):
        self.astream_calls += 1
        raise FakeOpenAIBadRequestError('invalid_request_error: tool arguments failed validation')
        yield

    async def ainvoke(self, state):
        self.ainvoke_calls += 1
        return {"final_response": "不应触发同步回退。"}


class FakeStreamingFallbackGraph:
    def __init__(self):
        self.astream_calls = 0
        self.ainvoke_calls = 0

    async def astream_events(self, state, version="v2"):
        self.astream_calls += 1
        raise FakeStreamingBadRequestError('invalid params, invalid chat setting (2013)')
        yield

    async def ainvoke(self, state):
        self.ainvoke_calls += 1
        return {"final_response": "这是回退后的同步回答。"}


class FakeStreamingFallbackGraphGenericError:
    def __init__(self):
        self.astream_calls = 0
        self.ainvoke_calls = 0

    async def astream_events(self, state, version="v2"):
        self.astream_calls += 1
        raise FakeStreamingBadRequestError2("Error code: 400 - {'type': 'error', 'error': {'type': 'bad_request_error', 'message': 'invalid params, invalid chat setting (2013)', 'http_code': '400'}}")
        yield

    async def ainvoke(self, state):
        self.ainvoke_calls += 1
        return {"final_response": "这是通用异常回退后的同步回答。"}


class FakeStreamingFallbackWithContinuityGraph:
    def __init__(self):
        self.astream_calls = 0
        self.ainvoke_calls = 0

    async def astream_events(self, state, version="v2"):
        self.astream_calls += 1
        raise FakeStreamingBadRequestError('invalid params, invalid chat setting (2013)')
        yield

    async def ainvoke(self, state):
        self.ainvoke_calls += 1
        return {
            'final_response': '这是回退后的同步回答。',
            'continuity_state': {
                'status': 'fresh',
                'mode': 'resume_after_clarification',
            },
            'pending_action': {
                'type': 'create_task',
                'owner_agent': 'executor',
                'owner_sub_commander': 'create_task',
                'status': 'pending',
            },
        }


class FakeStreamingDelegationThenFinalResponseGraph:
    async def astream_events(self, state, version="v2"):
        yield {
            "event": "on_chat_model_stream",
            "name": "master",
            "data": {"chunk": SimpleNamespace(content="现在显示收到，3月28日的任务是完成对话系统。\n\n我将这部分转给schedule_planner，他会根据这个目标结合你当前的进度和资源，给出具体的安排建议。")},
        }
        yield {
            "event": "on_chain_end",
            "name": "schedule_planner",
            "data": {"output": {"final_response": "今天先把完成对话系统拆成三步：先回顾问题，再补测试，最后验证交互链路。"}},
        }


class FakeStreamingDelegationThenModelEndGraph:
    async def astream_events(self, state, version="v2"):
        yield {
            "event": "on_chat_model_stream",
            "name": "master",
            "data": {"chunk": SimpleNamespace(content="我将这部分转给schedule_planner。")},
        }
        yield {
            "event": "on_chat_model_end",
            "name": "schedule_planner",
            "data": {"output": SimpleNamespace(content="最终建议：先完成对话系统，再回归验证。")},
        }


class FakeStreamingChunkThenDuplicateFinalGraph:
    async def astream_events(self, state, version="v2"):
        yield {
            "event": "on_chat_model_stream",
            "name": "master",
            "data": {"chunk": SimpleNamespace(content="完整回答")},
        }
        yield {
            "event": "on_chat_model_end",
            "name": "master",
            "data": {"output": SimpleNamespace(content="完整回答")},
        }


class FakeStreamingListContentGraph:
    async def astream_events(self, state, version="v2"):
        yield {
            "event": "on_chat_model_stream",
            "name": "master",
            "data": {"chunk": SimpleNamespace(content=[{"text": "第一段"}, {"text": "第二段"}])},
        }
        yield {
            "event": "on_chat_model_end",
            "name": "master",
            "data": {"output": SimpleNamespace(content="第一段第二段")},
        }


class CapturingStateGraph:
    def __init__(self, final_response: str = '已记录你的请求。'):
        self.final_response = final_response
        self.captured_state = None

    async def ainvoke(self, state):
        self.captured_state = state
        return {"final_response": self.final_response}


class CapturingStreamingStateGraph:
    def __init__(self, final_response: str = '这是流式回复。', output_state: dict | None = None):
        self.final_response = final_response
        self.output_state = output_state or {}
        self.captured_state = None

    async def astream_events(self, state, version='v2'):
        self.captured_state = state
        yield {
            'event': 'on_chain_end',
            'name': 'master',
            'data': {'output': {'final_response': self.final_response, **self.output_state}},
        }


@pytest.fixture
async def brain_ingestion_env(tmp_path, monkeypatch):
    db_path = tmp_path / 'test_brain_ingestion.db'
    engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
    session_factory = async_sessionmaker(engine, expire_on_commit=False)

    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

    async with session_factory() as session:
        user = User(
            username='brain-ingestion-tester',
            email='brain-ingestion@example.com',
            hashed_password=get_password_hash('secret123'),
            full_name='Brain Ingestion Tester',
        )
        session.add(user)
        await session.commit()
        await session.refresh(user)

    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: FakeGraph())
    monkeypatch.setattr(agent_service, 'set_current_user', lambda user_id: None)
    monkeypatch.setattr(agent_service, 'clear_current_user', lambda: None)
    monkeypatch.setattr('app.services.document_service.settings.UPLOAD_DIR', str(tmp_path / 'uploads'))

    async with session_factory() as session:
        attached_user = await session.get(User, user.id)
        yield session, attached_user

    await engine.dispose()


@pytest.mark.asyncio
async def test_chat_simple_creates_brain_event_for_user_message(brain_ingestion_env):
    session, user = brain_ingestion_env
    service = AgentService(session)

    conversation_id, _message_id, _response, _model_name = await service.chat_simple(
        user.id,
        '请记住我这周要完成知识大脑第一阶段。',
    )

    result = await session.execute(
        select(BrainEvent)
        .where(BrainEvent.user_id == user.id, BrainEvent.source_type == 'conversation')
        .order_by(BrainEvent.created_at.asc())
    )
    events = list(result.scalars().all())
    user_events = [event for event in events if event.metadata_ == {'role': 'user'}]

    assert len(user_events) == 1
    assert user_events[0].source_id == conversation_id
    assert user_events[0].event_type == 'message_created'
    assert user_events[0].title == 'User message'
    assert '知识大脑第一阶段' in (user_events[0].content_summary or '')
    assert user_events[0].status == 'pending'


@pytest.mark.asyncio
async def test_upload_document_creates_brain_event_for_document_flow(brain_ingestion_env):
    session, user = brain_ingestion_env
    service = DocumentService(session)
    upload = UploadFile(
        filename='brain-notes.md',
        file=BytesIO('# Brain\n\nCapture important product knowledge.'.encode('utf-8')),
    )

    document = await service.upload_document(user.id, upload)

    result = await session.execute(
        select(BrainEvent)
        .where(
            BrainEvent.user_id == user.id,
            BrainEvent.source_type == 'document',
            BrainEvent.source_id == document.id,
        )
    )
    event = result.scalar_one_or_none()

    assert event is not None
    assert event.event_type == 'document_uploaded'
    assert event.title == 'brain-notes.md'
    assert 'Capture important product knowledge.' in (event.content_summary or '')
    assert event.metadata_ == {
        'document_id': document.id,
        'file_type': 'md',
        'ingestion_status': 'uploaded',
    }
    assert event.status == 'pending'


@pytest.mark.asyncio
async def test_chat_simple_rejects_access_to_other_users_conversation(brain_ingestion_env):
    session, user = brain_ingestion_env
    other_user = User(
        username='other-user',
        email='other-user@example.com',
        hashed_password=get_password_hash('secret123'),
        full_name='Other User',
    )
    session.add(other_user)
    await session.flush()

    foreign_conversation = Conversation(user_id=other_user.id, title='foreign')
    session.add(foreign_conversation)
    await session.commit()

    service = AgentService(session)

    with pytest.raises(ValueError, match='会话不存在或无权访问'):
        await service.chat_simple(
            user.id,
            '不能访问别人的会话。',
            conversation_id=foreign_conversation.id,
        )


@pytest.mark.asyncio
async def test_streaming_chat_rejects_foreign_conversation_ownership(brain_ingestion_env):
    session, user = brain_ingestion_env

    other_user = User(
        username='stream-foreign-user',
        email='stream-foreign-user@example.com',
        hashed_password=get_password_hash('secret123'),
        full_name='Stream Foreign User',
    )
    session.add(other_user)
    await session.flush()

    foreign_conversation = Conversation(user_id=other_user.id, title='foreign stream')
    session.add(foreign_conversation)
    await session.commit()

    service = AgentService(session)

    with pytest.raises(ValueError, match='会话不存在或无权访问'):
        await service.chat(
            user.id,
            '不能访问别人的流式会话。',
            conversation_id=foreign_conversation.id,
        )


@pytest.mark.asyncio
async def test_chat_simple_creates_brain_event_for_assistant_message(brain_ingestion_env):
    session, user = brain_ingestion_env
    service = AgentService(session)

    conversation_id, _message_id, response, _model_name = await service.chat_simple(
        user.id,
        '帮我总结今天知识大脑的进展。',
    )

    result = await session.execute(
        select(BrainEvent)
        .where(BrainEvent.user_id == user.id, BrainEvent.source_type == 'conversation')
        .order_by(BrainEvent.created_at.asc())
    )
    events = list(result.scalars().all())

    assert len(events) == 2
    assert events[1].source_id == conversation_id
    assert events[1].event_type == 'message_created'
    assert events[1].title == 'Assistant message'
    assert events[1].content_summary == response
    assert events[1].metadata_ == {'role': 'assistant'}


@pytest.mark.asyncio
async def test_streaming_chat_creates_brain_event_for_assistant_message(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: FakeStreamingGraph())
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '用流式回复告诉我今天知识大脑学到了什么。',
    )

    chunks = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])

    result = await session.execute(
        select(BrainEvent)
        .where(BrainEvent.user_id == user.id, BrainEvent.source_type == 'conversation')
        .order_by(BrainEvent.created_at.asc())
    )
    events = list(result.scalars().all())

    assert ''.join(chunks) == '这是流式回复。'
    assert len(events) == 2
    assert events[1].source_id == conversation_id
    assert events[1].event_type == 'message_created'
    assert events[1].title == 'Assistant message'
    assert events[1].content_summary == '这是流式回复。'
    assert events[1].metadata_ == {'role': 'assistant'}


@pytest.mark.asyncio
async def test_streaming_chat_rejects_access_to_other_users_conversation(brain_ingestion_env):
    session, user = brain_ingestion_env
    other_user = User(
        username='other-user-stream',
        email='other-user-stream@example.com',
        hashed_password=get_password_hash('secret123'),
        full_name='Other User Stream',
    )
    session.add(other_user)
    await session.flush()

    foreign_conversation = Conversation(user_id=other_user.id, title='foreign-stream')
    session.add(foreign_conversation)
    await session.commit()

    service = AgentService(session)

    with pytest.raises(ValueError, match='会话不存在或无权访问'):
        await service.chat(
            user.id,
            '不能访问别人的流式会话。',
            conversation_id=foreign_conversation.id,
        )


@pytest.mark.asyncio
async def test_streaming_chat_emits_final_response_from_chain_end_when_no_model_chunks_exist(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: FakeStreamingFinalResponseGraph())
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '直接给我最终回答。',
    )

    chunks = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])

    result = await session.execute(
        select(BrainEvent)
        .where(BrainEvent.user_id == user.id, BrainEvent.source_type == 'conversation')
        .order_by(BrainEvent.created_at.asc())
    )
    events = list(result.scalars().all())

    assert ''.join(chunks) == '这是最终回答。'
    assert len(events) == 2
    assert events[1].source_id == conversation_id
    assert events[1].event_type == 'message_created'
    assert events[1].title == 'Assistant message'
    assert events[1].content_summary == '这是最终回答。'
    assert events[1].metadata_ == {'role': 'assistant'}




@pytest.mark.asyncio
async def test_streaming_chat_prefers_chain_end_final_response_over_delegation_chunk(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: FakeStreamingDelegationThenFinalResponseGraph())
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '帮我安排今天先做什么。',
    )

    chunks = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])

    message_result = await session.execute(
        select(Message)
        .where(Message.conversation_id == conversation_id, Message.role == 'assistant')
        .order_by(Message.created_at.desc())
    )
    assistant_message = message_result.scalars().first()

    assert '今天先把完成对话系统拆成三步：先回顾问题，再补测试，最后验证交互链路。' in chunks
    assert chunks[-1] == '今天先把完成对话系统拆成三步：先回顾问题，再补测试，最后验证交互链路。'
    assert assistant_message is not None
    assert assistant_message.content == '今天先把完成对话系统拆成三步：先回顾问题，再补测试，最后验证交互链路。'


@pytest.mark.asyncio
async def test_streaming_chat_prefers_model_end_final_content_over_delegation_chunk(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: FakeStreamingDelegationThenModelEndGraph())
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '帮我安排今天先做什么。',
    )

    chunks = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])

    message_result = await session.execute(
        select(Message)
        .where(Message.conversation_id == conversation_id, Message.role == 'assistant')
        .order_by(Message.created_at.desc())
    )
    assistant_message = message_result.scalars().first()

    assert '最终建议：先完成对话系统，再回归验证。' in chunks
    assert chunks[-1] == '最终建议：先完成对话系统，再回归验证。'
    assert assistant_message is not None
    assert assistant_message.content == '最终建议：先完成对话系统，再回归验证。'


@pytest.mark.asyncio
async def test_streaming_chat_does_not_duplicate_terminal_content_after_same_stream_output(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: FakeStreamingChunkThenDuplicateFinalGraph())
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '测试终态事件不应重复输出。',
    )

    chunks = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])

    message_result = await session.execute(
        select(Message)
        .where(Message.conversation_id == conversation_id, Message.role == 'assistant')
        .order_by(Message.created_at.desc())
    )
    assistant_message = message_result.scalars().first()

    assert chunks == ['完整回答']
    assert assistant_message is not None
    assert assistant_message.content == '完整回答'


@pytest.mark.asyncio
async def test_streaming_chat_coerces_list_content_blocks_without_duplicate_terminal_output(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: FakeStreamingListContentGraph())
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '测试 list content 流式输出。',
    )

    chunks = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])

    message_result = await session.execute(
        select(Message)
        .where(Message.conversation_id == conversation_id, Message.role == 'assistant')
        .order_by(Message.created_at.desc())
    )
    assistant_message = message_result.scalars().first()

    assert chunks == ['第一段第二段']
    assert assistant_message is not None
    assert assistant_message.content == '第一段第二段'


@pytest.mark.asyncio
async def test_streaming_chat_does_not_fall_back_for_official_openai_bad_request_without_output(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    graph = FakeStreamingOpenAIBadRequestGraph()
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: graph)
    monkeypatch.setattr(agent_service, 'BadRequestError', FakeOpenAIBadRequestError)

    original_get_user_llm_config = AgentService._get_user_llm_config

    async def fake_get_user_llm_config(self, user_id, model_name=None):
        return {
            'name': 'Official OpenAI',
            'provider': 'openai',
            'model': 'gpt-4o',
            'base_url': 'https://api.openai.com/v1',
            'enabled': True,
        }

    monkeypatch.setattr(AgentService, '_get_user_llm_config', fake_get_user_llm_config)
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '测试官方 OpenAI bad request 不应回退。',
    )

    chunks = []
    errors = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])
        if event.get('type') == 'error':
            errors.append(event['error'])

    message_result = await session.execute(
        select(Message)
        .where(Message.conversation_id == conversation_id, Message.role == 'assistant')
        .order_by(Message.created_at.desc())
    )
    assistant_message = message_result.scalars().first()

    assert graph.astream_calls == 1
    assert graph.ainvoke_calls == 0
    assert errors == ['模型服务暂不可用，请稍后再试。']
    assert chunks == ['抱歉，发生错误: 模型服务暂不可用，请稍后再试。']
    assert assistant_message is not None
    assert assistant_message.content == '抱歉，发生错误: 模型服务暂不可用，请稍后再试。'


@pytest.mark.asyncio
async def test_streaming_chat_falls_back_for_generic_400_streaming_error(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    fallback_graph = FakeStreamingFallbackGraphGenericError()
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: fallback_graph)
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '帮我制定一下明天的计划。',
    )

    chunks = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])

    result = await session.execute(
        select(BrainEvent)
        .where(BrainEvent.user_id == user.id, BrainEvent.source_type == 'conversation')
        .order_by(BrainEvent.created_at.asc())
    )
    events = list(result.scalars().all())

    assert fallback_graph.astream_calls == 1
    assert fallback_graph.ainvoke_calls == 1
    assert ''.join(chunks) == '这是通用异常回退后的同步回答。'
    assert len(events) == 2
    assert events[1].source_id == conversation_id
    assert events[1].content_summary == '这是通用异常回退后的同步回答。'


@pytest.mark.asyncio
async def test_streaming_chat_fallback_reuses_rehydrated_continuity_snapshot(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env

    conversation = Conversation(user_id=user.id, title='Fallback continuity merge')
    session.add(conversation)
    await session.flush()

    previous_snapshot = {
        'turn_context': {
            'user_turn_type': 'continuation',
            'user_turn_signal': 'clarification_answer',
            'active_agent': 'executor',
            'active_sub_commander': 'create_reminder',
        },
        'current_agent': 'executor',
        'clarification_context': {
            'owning_agent': 'executor',
            'owning_sub_commander': 'create_reminder',
            'target_action': 'create_reminder',
            'question': '你想提醒几点？',
            'status': 'pending',
        },
        'pending_action': {
            'type': 'clarification',
            'owner_agent': 'executor',
            'owner_sub_commander': 'create_reminder',
            'status': 'blocked_on_clarification',
        },
        'continuity_state': {
            'status': 'fresh',
            'mode': 'resume_after_clarification',
        },
    }
    conversation.agent_state = {
        'kind': 'agent_continuity_state',
        'version': 1,
        'state': previous_snapshot,
    }
    await session.commit()

    class FallbackCapturingGraph:
        def __init__(self):
            self.astream_calls = 0
            self.ainvoke_calls = 0
            self.fallback_state = None

        async def astream_events(self, state, version='v2'):
            self.astream_calls += 1
            raise FakeStreamingBadRequestError2("Error code: 400 - {'type': 'error', 'error': {'type': 'bad_request_error', 'message': 'invalid params, invalid chat setting (2013)', 'http_code': '400'}}")
            yield

        async def ainvoke(self, state):
            self.ainvoke_calls += 1
            self.fallback_state = state
            return {'final_response': '这是回退后的延续回答。'}

    graph = FallbackCapturingGraph()
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: graph)
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '明天下午三点提醒我',
        conversation_id=conversation.id,
    )

    chunks = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])

    assert conversation_id == conversation.id
    assert graph.astream_calls == 1
    assert graph.ainvoke_calls == 1
    assert chunks == ['这是回退后的延续回答。']
    assert graph.fallback_state is not None
    assert graph.fallback_state['turn_context'] == previous_snapshot['turn_context']
    assert graph.fallback_state['clarification_context'] == previous_snapshot['clarification_context']
    assert graph.fallback_state['pending_action'] == previous_snapshot['pending_action']
    assert graph.fallback_state['continuity_state'] == previous_snapshot['continuity_state']


@pytest.mark.asyncio
async def test_streaming_chat_does_not_fall_back_after_partial_stream_output(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env

    class PartialThenFailGraph:
        def __init__(self):
            self.ainvoke_calls = 0

        async def astream_events(self, state, version='v2'):
            yield {
                'event': 'on_chat_model_stream',
                'name': 'master',
                'data': {'chunk': SimpleNamespace(content='前半段')},
            }
            raise FakeStreamingBadRequestError('stream interrupted')

        async def ainvoke(self, state):
            self.ainvoke_calls += 1
            return {'final_response': '不应触发'}

    graph = PartialThenFailGraph()
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: graph)
    monkeypatch.setattr(agent_service, 'BadRequestError', FakeStreamingBadRequestError)
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '测试部分流式输出失败。',
    )

    chunks = []
    errors = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])
        if event.get('type') == 'error':
            errors.append(event['error'])

    message_result = await session.execute(
        select(Message)
        .where(Message.conversation_id == conversation_id, Message.role == 'assistant')
        .order_by(Message.created_at.desc())
    )
    assistant_message = message_result.scalars().first()

    brain_event_result = await session.execute(
        select(BrainEvent)
        .where(BrainEvent.user_id == user.id, BrainEvent.source_type == 'conversation')
        .order_by(BrainEvent.created_at.asc())
    )
    events = list(brain_event_result.scalars().all())

    assert chunks == ['前半段']
    assert graph.ainvoke_calls == 0
    assert errors == ['stream interrupted']
    assert assistant_message is not None
    assert assistant_message.content == '前半段'
    assert events[1].content_summary == '前半段'


@pytest.mark.asyncio
async def test_streaming_chat_persists_partial_output_when_stream_closed_early(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env

    class EarlyCloseGraph:
        async def astream_events(self, state, version='v2'):
            yield {
                'event': 'on_chat_model_stream',
                'name': 'master',
                'data': {'chunk': SimpleNamespace(content='前半段回复')},
            }
            await asyncio.sleep(60)

    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: EarlyCloseGraph())
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '测试提前关闭流式输出。',
    )

    chunks = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])
            await stream.aclose()
            break

    message_result = await session.execute(
        select(Message)
        .where(Message.conversation_id == conversation_id, Message.role == 'assistant')
        .order_by(Message.created_at.desc())
    )
    assistant_message = message_result.scalars().first()

    brain_event_result = await session.execute(
        select(BrainEvent)
        .where(BrainEvent.user_id == user.id, BrainEvent.source_type == 'conversation')
        .order_by(BrainEvent.created_at.asc())
    )
    events = list(brain_event_result.scalars().all())
    assistant_events = [event for event in events if event.metadata_ == {'role': 'assistant'}]

    assert chunks == ['前半段回复']
    assert assistant_message is not None
    assert assistant_message.content == '前半段回复'
    assert len(assistant_events) == 1
    assert assistant_events[0].content_summary == '前半段回复'


@pytest.mark.asyncio
async def test_streaming_chat_persists_error_response_when_stream_disconnects_before_first_chunk(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env

    class DisconnectBeforeFirstChunkGraph:
        async def astream_events(self, state, version='v2'):
            raise RuntimeError('client disconnected before first chunk')
            yield

    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: DisconnectBeforeFirstChunkGraph())
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '测试首个 chunk 前断开。',
    )

    chunks = []
    errors = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])
        if event.get('type') == 'error':
            errors.append(event['error'])

    message_result = await session.execute(
        select(Message)
        .where(Message.conversation_id == conversation_id, Message.role == 'assistant')
        .order_by(Message.created_at.desc())
    )
    assistant_message = message_result.scalars().first()

    brain_event_result = await session.execute(
        select(BrainEvent)
        .where(BrainEvent.user_id == user.id, BrainEvent.source_type == 'conversation')
        .order_by(BrainEvent.created_at.asc())
    )
    events = list(brain_event_result.scalars().all())
    assistant_events = [event for event in events if event.metadata_ == {'role': 'assistant'}]

    assert errors == ['模型服务暂不可用，请稍后再试。']
    assert chunks == ['抱歉，发生错误: 模型服务暂不可用，请稍后再试。']
    assert assistant_message is not None
    assert assistant_message.content == '抱歉，发生错误: 模型服务暂不可用，请稍后再试。'
    assert len(assistant_events) == 1
    assert assistant_events[0].content_summary == '抱歉，发生错误: 模型服务暂不可用，请稍后再试。'


@pytest.mark.asyncio
async def test_chat_simple_persists_continuity_snapshot_on_assistant_message(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env

    class ContinuityGraph:
        async def ainvoke(self, state):
            return {
                'final_response': '需要你确认下一步。',
                'pending_action': {
                    'type': 'create_task',
                    'owner_agent': 'executor',
                    'owner_sub_commander': 'create_task',
                    'status': 'pending',
                },
                'clarification_context': {
                    'owning_agent': 'executor',
                    'owning_sub_commander': 'create_task',
                    'target_action': 'create_task',
                    'question': '要现在创建吗？',
                    'status': 'pending',
                },
                'continuity_state': {
                    'status': 'fresh',
                    'mode': 'resume_after_clarification',
                },
                'last_completed_action': {
                    'tool_name': 'create_task',
                    'args': {'title': '补测试'},
                    'status': 'success',
                    'entity_type': 'task',
                },
            }

    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: ContinuityGraph())
    service = AgentService(session)

    conversation_id, _message_id, _response, _model_name = await service.chat_simple(
        user.id,
        '先帮我准备创建任务。',
    )

    message_result = await session.execute(
        select(Message)
        .where(Message.conversation_id == conversation_id, Message.role == 'assistant')
        .order_by(Message.created_at.desc())
    )
    assistant_message = message_result.scalars().first()

    assert assistant_message is not None
    assert assistant_message.attachments == [{
        'kind': 'agent_continuity_state',
        'version': 1,
        'state': {
            'continuity_state': {
                'status': 'fresh',
                'mode': 'resume_after_clarification',
            },
            'pending_action': {
                'type': 'create_task',
                'owner_agent': 'executor',
                'owner_sub_commander': 'create_task',
                'status': 'pending',
            },
            'last_completed_action': {
                'tool_name': 'create_task',
                'args': {'title': '补测试'},
                'status': 'success',
                'entity_type': 'task',
            },
            'clarification_context': {
                'owning_agent': 'executor',
                'owning_sub_commander': 'create_task',
                'target_action': 'create_task',
                'question': '要现在创建吗？',
                'status': 'pending',
            },
        },
    }]


@pytest.mark.asyncio
async def test_streaming_chat_persists_continuity_snapshot_in_assistant_message_attachments(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    graph = CapturingStreamingStateGraph(
        final_response='继续处理。',
        output_state={
            'continuity_state': {
                'status': 'fresh',
                'mode': 'resume_after_clarification',
            },
            'pending_action': {
                'type': 'create_task',
                'owner_agent': 'executor',
                'owner_sub_commander': 'create_task',
                'status': 'pending',
            },
            'clarification_context': {
                'owning_agent': 'executor',
                'owning_sub_commander': 'create_task',
                'target_action': 'create_task',
                'question': '要现在创建吗？',
                'status': 'pending',
            },
        },
    )
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: graph)
    service = AgentService(session)

    conversation_id, _assistant_message_id, stream = await service.chat(
        user.id,
        '继续创建任务。',
    )

    async for _event in stream:
        pass

    message_result = await session.execute(
        select(Message)
        .where(Message.conversation_id == conversation_id, Message.role == 'assistant')
        .order_by(Message.created_at.desc())
    )
    assistant_message = message_result.scalars().first()
    conversation = await session.get(Conversation, conversation_id)

    expected_state_fields = {
        'continuity_state': {
            'status': 'fresh',
            'mode': 'resume_after_clarification',
        },
        'pending_action': {
            'type': 'create_task',
            'owner_agent': 'executor',
            'owner_sub_commander': 'create_task',
            'status': 'pending',
        },
        'clarification_context': {
            'owning_agent': 'executor',
            'owning_sub_commander': 'create_task',
            'target_action': 'create_task',
            'question': '要现在创建吗？',
            'status': 'pending',
        },
    }

    assert assistant_message is not None
    assert assistant_message.attachments is not None
    persisted_snapshot = assistant_message.attachments[0]
    assert persisted_snapshot['kind'] == 'agent_continuity_state'
    assert persisted_snapshot['version'] == 1
    for key, value in expected_state_fields.items():
        assert persisted_snapshot['state'][key] == value
    assert conversation is not None
    assert conversation.agent_state == {
        'kind': 'agent_continuity_state',
        'version': persisted_snapshot['version'],
        'state': persisted_snapshot['state'],
    }


@pytest.mark.asyncio
async def test_streaming_chat_rehydrates_previous_continuity_snapshot(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    seeded_graph = CapturingStateGraph(final_response='第一轮完成。')
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: seeded_graph)
    service = AgentService(session)

    conversation_id, _message_id, _response, _model_name = await service.chat_simple(
        user.id,
        '先记录一个待确认动作。',
    )

    message_result = await session.execute(
        select(Message)
        .where(Message.conversation_id == conversation_id, Message.role == 'assistant')
        .order_by(Message.created_at.desc())
    )
    assistant_message = message_result.scalars().first()
    assistant_message.attachments = [{
        'kind': 'agent_continuity_state',
        'version': 1,
        'state': {
            'pending_action': {
                'type': 'create_task',
                'owner_agent': 'executor',
                'owner_sub_commander': 'create_task',
                'status': 'pending',
            },
            'clarification_context': {
                'owning_agent': 'executor',
                'owning_sub_commander': 'create_task',
                'target_action': 'create_task',
                'question': '要现在创建吗？',
                'status': 'pending',
            },
            'continuity_state': {
                'status': 'fresh',
                'mode': 'resume_after_clarification',
            },
            'last_completed_action': {
                'tool_name': 'create_task',
                'args': {'title': '补测试'},
                'status': 'success',
                'entity_type': 'task',
            },
        },
    }]
    await session.commit()

    streaming_graph = CapturingStreamingStateGraph(final_response='继续处理。')
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: streaming_graph)

    _conversation_id, _assistant_message_id, stream = await service.chat(
        user.id,
        '好的，继续。',
        conversation_id=conversation_id,
    )

    async for _event in stream:
        pass

    assert streaming_graph.captured_state is not None
    assert streaming_graph.captured_state['pending_action'] == {
        'type': 'create_task',
        'owner_agent': 'executor',
        'owner_sub_commander': 'create_task',
        'status': 'pending',
    }
    assert streaming_graph.captured_state['clarification_context'] == {
        'owning_agent': 'executor',
        'owning_sub_commander': 'create_task',
        'target_action': 'create_task',
        'question': '要现在创建吗？',
        'status': 'pending',
    }
    assert streaming_graph.captured_state['continuity_state'] == {
        'status': 'fresh',
        'mode': 'resume_after_clarification',
    }
    assert streaming_graph.captured_state['last_completed_action'] == {
        'tool_name': 'create_task',
        'args': {'title': '补测试'},
        'status': 'success',
        'entity_type': 'task',
    }


@pytest.mark.asyncio
async def test_streaming_chat_persists_partial_response_when_stream_is_closed_early(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: FakeStreamingTwoChunkGraph())
    service = AgentService(session)

    conversation_id, _message_id, stream = await service.chat(
        user.id,
        '测试提前关闭流式输出。',
    )

    first_chunk = None
    async for event in stream:
        if event.get('type') == 'chunk':
            first_chunk = event['content']
            await stream.aclose()
            break

    message_result = await session.execute(
        select(Message)
        .where(Message.conversation_id == conversation_id, Message.role == 'assistant')
        .order_by(Message.created_at.desc())
    )
    assistant_message = message_result.scalars().first()

    brain_event_result = await session.execute(
        select(BrainEvent)
        .where(BrainEvent.user_id == user.id, BrainEvent.source_type == 'conversation')
        .order_by(BrainEvent.created_at.asc())
    )
    events = list(brain_event_result.scalars().all())

    assert first_chunk == '前半段'
    assert assistant_message is not None
    assert assistant_message.content == '前半段'
    assert events[1].content_summary == '前半段'


@pytest.mark.asyncio
async def test_chat_simple_passes_current_datetime_context_into_langgraph_state(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    graph = CapturingStateGraph()
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: graph)
    service = AgentService(session)

    await service.chat_simple(
        user.id,
        '3月29日，对话系统交付节点',
    )

    assert graph.captured_state is not None
    current_context = graph.captured_state.get('current_datetime_context')
    assert isinstance(current_context, str)
    assert current_context
    assert '当前时间' in current_context

    current_reference = graph.captured_state.get('current_datetime_reference')
    assert isinstance(current_reference, dict)
    assert 'current_time_iso' in current_reference
    assert 'current_date_iso' in current_reference
    assert current_reference['current_time_iso'] in current_context
    assert current_reference['current_date_iso'] in current_context


@pytest.mark.asyncio
async def test_get_user_llm_config_defaults_to_enabled_chat_model_not_vlm(brain_ingestion_env):
    session, user = brain_ingestion_env
    user.llm_config = {
        'chat': [
            {'name': 'Disabled Chat', 'provider': 'openai', 'model': 'disabled-chat', 'enabled': False},
            {'name': 'Enabled Chat', 'provider': 'openai', 'model': 'enabled-chat', 'enabled': True},
        ],
        'vlm': [
            {'name': 'Enabled Vision', 'provider': 'openai', 'model': 'enabled-vision', 'enabled': True},
        ],
    }
    await session.commit()

    service = AgentService(session)

    config = await service._get_user_llm_config(user.id)

    assert config is not None
    assert config['name'] == 'Enabled Chat'


@pytest.mark.asyncio
async def test_get_user_llm_config_returns_none_when_only_vlm_is_enabled(brain_ingestion_env):
    session, user = brain_ingestion_env
    user.llm_config = {
        'chat': [
            {'name': 'Disabled Chat', 'provider': 'openai', 'model': 'disabled-chat', 'enabled': False},
        ],
        'vlm': [
            {'name': 'Enabled Vision', 'provider': 'openai', 'model': 'enabled-vision', 'enabled': True},
        ],
    }
    await session.commit()

    service = AgentService(session)

    config = await service._get_user_llm_config(user.id)

    assert config is None


@pytest.mark.asyncio
async def test_chat_simple_rejects_vlm_model_without_persisting_conversation_state(brain_ingestion_env):
    session, user = brain_ingestion_env
    user.llm_config = {
        'chat': [
            {'name': 'Enabled Chat', 'provider': 'openai', 'model': 'enabled-chat', 'enabled': True},
        ],
        'vlm': [
            {'name': 'Vision Only', 'provider': 'openai', 'model': 'vision-only', 'enabled': True},
        ],
    }
    await session.commit()

    service = AgentService(session)

    with pytest.raises(ValueError, match='所选模型不可用于聊天，请切换到聊天模型'):
        await service.chat_simple(user.id, '测试聊天模型选择', model_name='Vision Only')

    conversation_result = await session.execute(select(Conversation).where(Conversation.user_id == user.id))
    message_result = await session.execute(select(Message))
    brain_event_result = await session.execute(select(BrainEvent).where(BrainEvent.user_id == user.id))

    assert conversation_result.scalars().all() == []
    assert message_result.scalars().all() == []
    assert brain_event_result.scalars().all() == []


@pytest.mark.asyncio
async def test_streaming_chat_rejects_vlm_model_without_persisting_conversation_state(brain_ingestion_env):
    session, user = brain_ingestion_env
    user.llm_config = {
        'chat': [
            {'name': 'Enabled Chat', 'provider': 'openai', 'model': 'enabled-chat', 'enabled': True},
        ],
        'vlm': [
            {'name': 'Vision Only', 'provider': 'openai', 'model': 'vision-only', 'enabled': True},
        ],
    }
    await session.commit()

    service = AgentService(session)

    with pytest.raises(ValueError, match='所选模型不可用于聊天，请切换到聊天模型'):
        await service.chat(user.id, '测试流式聊天模型选择', model_name='Vision Only')

    conversation_result = await session.execute(select(Conversation).where(Conversation.user_id == user.id))
    message_result = await session.execute(select(Message))
    brain_event_result = await session.execute(select(BrainEvent).where(BrainEvent.user_id == user.id))

    assert conversation_result.scalars().all() == []
    assert message_result.scalars().all() == []
    assert brain_event_result.scalars().all() == []


@pytest.mark.asyncio
async def test_build_memory_context_includes_brain_memory_section(brain_ingestion_env):
    session, user = brain_ingestion_env
    conversation = Conversation(user_id=user.id, title='Brain context test')
    session.add(conversation)
    await session.flush()

    session.add(UserMemory(
        user_id=user.id,
        memory_type='preference',
        content='用户偏好结构化输出。',
        importance=6,
        source_conversation_id=conversation.id,
    ))
    session.add(MemorySummary(
        user_id=user.id,
        conversation_id=conversation.id,
        summary_text='之前讨论了知识大脑的整体设计。',
        turn_count=8,
    ))
    session.add(BrainMemory(
        user_id=user.id,
        memory_type='project_fact',
        title='Knowledge brain phase 1',
        content='Jarvis should learn from conversation and document events first.',
        importance=9,
        confidence=0.93,
        status='active',
        origin_source_types=['conversation', 'document'],
        metadata_={'source_count': 2},
    ))
    session.add(BrainMemory(
        user_id=user.id,
        memory_type='project_fact',
        title='Forum moderation policy',
        content='Forum moderation escalation stays separate from the current task.',
        importance=10,
        confidence=0.95,
        status='active',
        origin_source_types=['forum'],
        metadata_={'source_count': 1},
    ))
    await session.commit()

    context = await memory_service.build_memory_context(
        session,
        user.id,
        conversation.id,
        'Jarvis 接下来应该优先做什么？',
    )

    assert '【之前对话摘要】' in context
    assert '【知识大脑】' in context
    assert 'Knowledge brain phase 1' in context
    assert 'Jarvis should learn from conversation and document events first.' in context
    assert '【用户记忆】' not in context
    assert 'Forum moderation policy' not in context


@pytest.mark.asyncio
async def test_chat_simple_rehydrates_clarification_follow_up_state_into_langgraph_state(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    graph = CapturingStateGraph()
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: graph)

    conversation = Conversation(user_id=user.id, title='Reminder follow-up')
    session.add(conversation)
    await session.flush()

    previous_snapshot = {
        'turn_context': {
            'user_turn_type': 'continuation',
            'user_turn_signal': 'clarification_answer',
            'active_agent': 'executor',
            'active_sub_commander': 'create_reminder',
        },
        'current_agent': 'executor',
        'clarification_context': {
            'owning_agent': 'executor',
            'owning_sub_commander': 'create_reminder',
            'target_action': 'create_reminder',
            'question': '你想提醒几点？',
            'status': 'pending',
        },
        'pending_action': {
            'type': 'clarification',
            'owner_agent': 'executor',
            'owner_sub_commander': 'create_reminder',
            'status': 'blocked_on_clarification',
        },
        'continuity_state': {
            'status': 'fresh',
            'mode': 'resume_after_clarification',
        },
    }
    session.add(Message(
        conversation_id=conversation.id,
        role='assistant',
        content='你想提醒几点？',
        attachments=[{
            'kind': 'agent_continuity_state',
            'version': 1,
            'state': previous_snapshot,
        }],
    ))
    await session.commit()

    async def fake_build_memory_context(db, user_id, conversation_id, current_query):
        assert conversation_id == conversation.id
        assert current_query == '明天下午三点提醒我'
        return '【延续处理】\n- reminder clarification is still pending.'

    monkeypatch.setattr(memory_service, 'build_memory_context', fake_build_memory_context)
    service = AgentService(session)

    await service.chat_simple(user.id, '明天下午三点提醒我', conversation_id=conversation.id)

    assert graph.captured_state is not None
    assert graph.captured_state['messages'][0].content == '明天下午三点提醒我'
    assert graph.captured_state['memory_context'] == '【延续处理】\n- reminder clarification is still pending.'
    assert graph.captured_state['turn_context'] == previous_snapshot['turn_context']
    assert graph.captured_state['clarification_context'] == previous_snapshot['clarification_context']
    assert graph.captured_state['pending_action'] == previous_snapshot['pending_action']
    assert graph.captured_state['continuity_state'] == previous_snapshot['continuity_state']
    assert graph.captured_state['current_agent'] == 'executor'


@pytest.mark.asyncio
async def test_chat_simple_preserves_stale_continuity_state_for_fresh_request_override(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    graph = CapturingStateGraph()
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: graph)

    conversation = Conversation(user_id=user.id, title='Topic switch')
    session.add(conversation)
    await session.flush()

    previous_snapshot = {
        'turn_context': {
            'user_turn_type': 'continuation',
            'user_turn_signal': 'clarification_answer',
            'active_agent': 'executor',
            'active_sub_commander': 'create_reminder',
        },
        'current_agent': 'executor',
        'clarification_context': {
            'owning_agent': 'executor',
            'owning_sub_commander': 'create_reminder',
            'target_action': 'create_reminder',
            'question': '你想提醒几点？',
            'status': 'pending',
        },
        'pending_action': {
            'type': 'clarification',
            'owner_agent': 'executor',
            'owner_sub_commander': 'create_reminder',
            'status': 'blocked_on_clarification',
        },
        'continuity_state': {
            'status': 'fresh',
            'mode': 'resume_after_clarification',
        },
        'last_completed_action': {
            'tool_name': 'create_reminder',
            'args': {'title': '开会', 'reminder_at': '2026-04-03T09:00:00'},
            'status': 'success',
            'entity_type': 'reminder',
        },
    }
    session.add(Message(
        conversation_id=conversation.id,
        role='assistant',
        content='你想提醒几点？',
        attachments=[{
            'kind': 'agent_continuity_state',
            'version': 1,
            'state': previous_snapshot,
        }],
    ))
    await session.commit()

    expected_memory_context = (
        '【用户记忆】\n'
        '  - 用户近期在跟进 ACME 资料。\n\n'
        '【延续处理】\n'
        '- stale reminder continuity should remain available for fresh-request override routing.'
    )

    async def fake_build_memory_context(db, user_id, conversation_id, current_query):
        assert conversation_id == conversation.id
        assert current_query == '另外，帮我查一下 ACME 2025 年报'
        return expected_memory_context

    monkeypatch.setattr(memory_service, 'build_memory_context', fake_build_memory_context)
    service = AgentService(session)

    await service.chat_simple(user.id, '另外，帮我查一下 ACME 2025 年报', conversation_id=conversation.id)

    assert graph.captured_state is not None
    assert graph.captured_state['messages'][0].content == '另外，帮我查一下 ACME 2025 年报'
    assert graph.captured_state['memory_context'] == expected_memory_context
    assert graph.captured_state['turn_context'] == previous_snapshot['turn_context']
    assert graph.captured_state['clarification_context'] == previous_snapshot['clarification_context']
    assert graph.captured_state['pending_action'] == previous_snapshot['pending_action']
    assert graph.captured_state['continuity_state'] == previous_snapshot['continuity_state']
    assert graph.captured_state['last_completed_action'] == previous_snapshot['last_completed_action']


@pytest.mark.asyncio
async def test_streaming_chat_rehydrates_continuation_state_and_memory_context_into_langgraph_state(brain_ingestion_env, monkeypatch):
    session, user = brain_ingestion_env
    graph = CapturingStreamingStateGraph()
    monkeypatch.setattr(agent_service, 'get_agent_graph', lambda: graph)

    conversation = Conversation(user_id=user.id, title='Plan revision')
    session.add(conversation)
    await session.flush()

    previous_snapshot = {
        'turn_context': {
            'user_turn_type': 'continuation',
            'user_turn_signal': 'clarification_answer',
            'active_agent': 'schedule_planner',
            'active_sub_commander': 'plan_revision',
        },
        'current_agent': 'schedule_planner',
        'clarification_context': {
            'owning_agent': 'schedule_planner',
            'owning_sub_commander': 'plan_revision',
            'question': '你想优先看总结版还是完整计划？',
            'status': 'pending',
        },
        'pending_action': {
            'type': 'clarification',
            'owner_agent': 'schedule_planner',
            'owner_sub_commander': 'plan_revision',
            'status': 'blocked_on_clarification',
        },
        'continuity_state': {
            'status': 'fresh',
            'mode': 'resume_after_clarification',
        },
    }
    session.add(Message(
        conversation_id=conversation.id,
        role='assistant',
        content='你想优先看总结版还是完整计划？',
        attachments=[{
            'kind': 'agent_continuity_state',
            'version': 1,
            'state': previous_snapshot,
        }],
    ))
    await session.commit()

    expected_memory_context = (
        '【用户记忆】\n'
        '  - 用户偏好先看总结再看细节。\n\n'
        '【延续处理】\n'
        '- continuation context: this user turn continues an existing workflow.\n'
        '- active_agent: schedule_planner\n'
        '- active_sub_commander: plan_revision\n'
        '- user_turn_signal: clarification_answer'
    )

    async def fake_build_memory_context(db, user_id, conversation_id, current_query):
        assert conversation_id == conversation.id
        assert current_query == '改成周五下午，先给我总结版'
        return expected_memory_context

    monkeypatch.setattr(memory_service, 'build_memory_context', fake_build_memory_context)
    service = AgentService(session)

    _conversation_id, _message_id, stream = await service.chat(
        user.id,
        '改成周五下午，先给我总结版',
        conversation_id=conversation.id,
    )

    chunks = []
    async for event in stream:
        if event.get('type') == 'chunk':
            chunks.append(event['content'])

    assert chunks == ['这是流式回复。']
    assert graph.captured_state is not None
    assert graph.captured_state['messages'][0].content == '改成周五下午，先给我总结版'
    assert graph.captured_state['memory_context'] == expected_memory_context
    assert graph.captured_state['turn_context'] == previous_snapshot['turn_context']
    assert graph.captured_state['clarification_context'] == previous_snapshot['clarification_context']
    assert graph.captured_state['pending_action'] == previous_snapshot['pending_action']
    assert graph.captured_state['continuity_state'] == previous_snapshot['continuity_state']
    assert graph.captured_state['current_agent'] == 'schedule_planner'
async def test_build_memory_context_suppresses_summary_for_memory_query(brain_ingestion_env):
    session, user = brain_ingestion_env
    conversation = Conversation(user_id=user.id, title='Memory-only query test')
    session.add(conversation)
    await session.flush()

    session.add(UserMemory(
        user_id=user.id,
        memory_type='preference',
        content='用户喜欢燕麦拿铁。',
        importance=8,
        source_conversation_id=conversation.id,
    ))
    session.add(MemorySummary(
        user_id=user.id,
        conversation_id=conversation.id,
        summary_text='之前讨论了知识大脑迁移和文档入库流程。',
        turn_count=10,
    ))
    await session.commit()

    context = await memory_service.build_memory_context(
        session,
        user.id,
        conversation.id,
        '记住我喜欢燕麦拿铁，以后推荐咖啡时参考这个偏好。',
    )

    assert '【用户记忆】' in context
    assert '用户喜欢燕麦拿铁。' in context
    assert '【之前对话摘要】' not in context


@pytest.mark.asyncio
async def test_build_memory_context_keeps_summary_for_ambiguous_like_word_query(brain_ingestion_env):
    session, user = brain_ingestion_env
    conversation = Conversation(user_id=user.id, title='Ambiguous preference word test')
    session.add(conversation)
    await session.flush()

    session.add(UserMemory(
        user_id=user.id,
        memory_type='preference',
        content='用户喜欢结构化输出。',
        importance=7,
        source_conversation_id=conversation.id,
    ))
    session.add(MemorySummary(
        user_id=user.id,
        conversation_id=conversation.id,
        summary_text='之前已经总结过知识大脑迁移计划。',
        turn_count=6,
    ))
    await session.commit()

    context = await memory_service.build_memory_context(
        session,
        user.id,
        conversation.id,
        '你觉得用户会喜欢这个知识大脑迁移方案吗？顺便总结一下之前聊过的重点。',
    )

    assert '【用户记忆】' not in context
    assert '【之前对话摘要】' in context
    assert '之前已经总结过知识大脑迁移计划。' in context


@pytest.mark.asyncio
async def test_build_memory_context_keeps_summary_for_document_reference_query(brain_ingestion_env):
    session, user = brain_ingestion_env
    conversation = Conversation(user_id=user.id, title='Document reference query test')
    session.add(conversation)
    await session.flush()

    session.add(UserMemory(
        user_id=user.id,
        memory_type='preference',
        content='用户偏好带示例的说明。',
        importance=7,
        source_conversation_id=conversation.id,
    ))
    session.add(MemorySummary(
        user_id=user.id,
        conversation_id=conversation.id,
        summary_text='之前总结了文档入库和知识大脑联动流程。',
        turn_count=7,
    ))
    await session.commit()

    context = await memory_service.build_memory_context(
        session,
        user.id,
        conversation.id,
        '这个 document ingestion 方案会有什么影响？也请总结一下之前聊过的重点。',
    )

    assert '【用户记忆】' not in context
    assert '【之前对话摘要】' in context
    assert '之前总结了文档入库和知识大脑联动流程。' in context


@pytest.mark.asyncio
async def test_build_memory_context_suppresses_user_memory_for_grounded_query(brain_ingestion_env):
    session, user = brain_ingestion_env
    conversation = Conversation(user_id=user.id, title='Grounded query test')
    session.add(conversation)
    await session.flush()

    session.add(UserMemory(
        user_id=user.id,
        memory_type='preference',
        content='用户偏好轻松随意的语气。',
        importance=9,
        source_conversation_id=conversation.id,
    ))
    session.add(MemorySummary(
        user_id=user.id,
        conversation_id=conversation.id,
        summary_text='之前聊过论坛审核策略。',
        turn_count=8,
    ))
    session.add(BrainMemory(
        user_id=user.id,
        memory_type='project_fact',
        title='Document ingestion flow',
        content='Document uploads are chunked before vector indexing.',
        importance=7,
        confidence=0.9,
        status='active',
        origin_source_types=['document'],
        metadata_={'source_count': 1},
    ))
    await session.commit()

    context = await memory_service.build_memory_context(
        session,
        user.id,
        conversation.id,
        '请严格根据文档内容说明 document ingestion flow，不要结合我的个人偏好。',
    )

    assert '【知识大脑】' in context
    assert 'Document ingestion flow' in context
    assert '【用户记忆】' not in context
    assert '用户偏好轻松随意的语气。' not in context
    assert '【之前对话摘要】' not in context


@pytest.mark.asyncio
async def test_build_memory_context_keeps_partial_context_when_user_memory_recall_fails(
    brain_ingestion_env,
    monkeypatch,
    caplog,
):
    session, user = brain_ingestion_env
    conversation = Conversation(user_id=user.id, title='Partial context test')
    session.add(conversation)
    await session.flush()

    session.add(MemorySummary(
        user_id=user.id,
        conversation_id=conversation.id,
        summary_text='之前总结了知识大脑的激活记忆策略。',
        turn_count=9,
    ))
    session.add(BrainMemory(
        user_id=user.id,
        memory_type='project_fact',
        title='Active memory filter',
        content='Only active Brain memories should enter default prompt context.',
        importance=8,
        confidence=0.96,
        status='active',
        origin_source_types=['conversation'],
        metadata_={'source_count': 1},
    ))
    await session.commit()

    original_execute = session.execute
    recall_selects = 0

    async def fail_recall_user_memories(*args, **kwargs):
        nonlocal recall_selects
        recall_selects += 1
        await original_execute(select(UserMemory).where(UserMemory.user_id == user.id))
        raise RuntimeError('mem0 unavailable')

    monkeypatch.setattr(memory_service, 'recall_user_memories', fail_recall_user_memories)
    caplog.set_level('WARNING')

    context = await memory_service.build_memory_context(
        session,
        user.id,
        conversation.id,
        'active memory filter',
    )

    assert recall_selects == 1
    assert '【之前对话摘要】' in context
    assert '之前总结了知识大脑的激活记忆策略。' in context
    assert '【知识大脑】' in context
    assert 'Active memory filter' in context
    assert '【用户记忆】' not in context
    assert any('用户记忆召回失败' in record.message for record in caplog.records)
    assert any(record.exc_info for record in caplog.records if '用户记忆召回失败' in record.message)


@pytest.mark.asyncio
async def test_build_memory_context_does_not_rollback_caller_pending_message_on_tolerated_failure(
    brain_ingestion_env,
    monkeypatch,
):
    session, user = brain_ingestion_env
    conversation = Conversation(user_id=user.id, title='Pending message preservation test')
    session.add(conversation)
    await session.flush()

    pending_message = Message(
        conversation_id=conversation.id,
        role='user',
        content='这条消息不应因记忆召回失败而丢失。',
    )
    session.add(pending_message)

    async def fail_recall_user_memories(*args, **kwargs):
        raise RuntimeError('mem0 unavailable')

    monkeypatch.setattr(memory_service, 'recall_user_memories', fail_recall_user_memories)

    context = await memory_service.build_memory_context(
        session,
        user.id,
        conversation.id,
        'active memory filter',
    )

    await session.commit()

    persisted_message = await session.get(Message, pending_message.id)

    assert context == ''
    assert persisted_message is not None
    assert persisted_message.content == '这条消息不应因记忆召回失败而丢失。'


@pytest.mark.asyncio
async def test_build_memory_context_skips_unrelated_user_memory_when_fallback_has_no_query_match(brain_ingestion_env):
    session, user = brain_ingestion_env
    conversation = Conversation(user_id=user.id, title='Irrelevant fallback memory test')
    session.add(conversation)
    await session.flush()

    session.add(UserMemory(
        user_id=user.id,
        memory_type='preference',
        content='用户喜欢燕麦拿铁。',
        importance=8,
        source_conversation_id=conversation.id,
    ))
    await session.commit()

    context = await memory_service.build_memory_context(
        session,
        user.id,
        conversation.id,
        '讨论数据库迁移回滚策略。',
    )

    assert '【用户记忆】' not in context
    assert '用户喜欢燕麦拿铁。' not in context


@pytest.mark.asyncio
async def test_build_memory_context_marks_recalled_memories_in_single_commit(
    brain_ingestion_env,
    monkeypatch,
):
    session, user = brain_ingestion_env
    conversation = Conversation(user_id=user.id, title='Recall batching test')
    session.add(conversation)
    await session.flush()

    memories = [
        UserMemory(
            user_id=user.id,
            memory_type='preference',
            content='用户偏好简洁回答。',
            importance=7,
            source_conversation_id=conversation.id,
        ),
        UserMemory(
            user_id=user.id,
            memory_type='goal',
            content='用户想推进知识大脑上线。',
            importance=6,
            source_conversation_id=conversation.id,
        ),
    ]
    session.add_all(memories)
    await session.commit()

    original_commit = session.commit
    commit_calls = 0

    async def counting_commit():
        nonlocal commit_calls
        commit_calls += 1
        await original_commit()

    monkeypatch.setattr(session, 'commit', counting_commit)

    context = await memory_service.build_memory_context(
        session,
        user.id,
        conversation.id,
        '请结合我的历史偏好给我建议。',
    )

    assert '【用户记忆】' in context
    assert '用户偏好简洁回答。' in context
    assert '用户想推进知识大脑上线。' in context
    assert commit_calls == 1


@pytest.mark.asyncio
async def test_build_memory_context_excludes_non_active_brain_memories(brain_ingestion_env):
    session, user = brain_ingestion_env
    conversation = Conversation(user_id=user.id, title='Brain status filter test')
    session.add(conversation)
    await session.flush()

    session.add(BrainMemory(
        user_id=user.id,
        memory_type='project_fact',
        title='Active rollout note',
        content='Use only active Brain memories in the default prompt.',
        importance=9,
        confidence=0.97,
        status='active',
        origin_source_types=['conversation'],
        metadata_={'source_count': 1},
    ))
    session.add(BrainMemory(
        user_id=user.id,
        memory_type='project_fact',
        title='Archived rollout note',
        content='This archived memory should stay out of the prompt.',
        importance=10,
        confidence=0.99,
        status='archived',
        origin_source_types=['conversation'],
        metadata_={'source_count': 1},
    ))
    session.add(BrainMemory(
        user_id=user.id,
        memory_type='project_fact',
        title='Superseded rollout note',
        content='This superseded memory should stay out of the prompt.',
        importance=10,
        confidence=0.99,
        status='superseded',
        origin_source_types=['conversation'],
        metadata_={'source_count': 1},
    ))
    await session.commit()

    context = await memory_service.build_memory_context(
        session,
        user.id,
        conversation.id,
        'rollout note',
    )

    assert '【知识大脑】' in context
    assert 'Active rollout note' in context
    assert 'Archived rollout note' not in context
    assert 'Superseded rollout note' not in context
