Files
Octop/tests/unit/gateway/test_response_mode.py
薄生 8b0793c69a feat(memory): add manual profile context and conflict review (#2103)
* feat(memory): add strong memory editor and surface task failures

* fix(memory): explain session snapshot lifetime in strong memory UI

* feat(memory): add owner profile editor and manual conflict review

* test: verify fault history, turn recovery and memory isolation

* fix: persist terminal failures and release stalled chat turns

* fix(deps): require released memory runtime packages

Require octop-memory 1.0.2, octop-harness 1.0.4 and octop-gateway 1.0.3 for manual document review and terminal stream cleanup. Remove the obsolete harness Git pin and refresh the lockfile so CI installs the matching released APIs.
2026-10-16 20:56:13 +08:00

239 lines
8.0 KiB
Python

"""Tests for external IM response delivery modes."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
import pytest
from octop_gateway.models import (
FileContent,
InboundMessage,
MessageEvent,
MessageEventType,
TextContent,
)
from octop_gateway.utils import close_event_stream
from octop.infra.gateway.process.response_mode import (
collapse_to_invoke_response,
normalize_channel_response_mode,
processor_for_response_mode,
qq_channel_response_mode,
withhold_weixin_stream_preamble,
wrap_weixin_stream_processor,
)
async def _events(*events: MessageEvent) -> AsyncIterator[MessageEvent]:
for event in events:
yield event
@pytest.mark.asyncio
async def test_invoke_discards_progress_before_tool_and_emits_final_once() -> None:
source = _events(
MessageEvent.typing(),
MessageEvent.delta("我先查一下。"),
MessageEvent.flush(),
MessageEvent.tool_start("web_fetch"),
MessageEvent.tool_end("web_fetch"),
MessageEvent.delta("这是"),
MessageEvent.delta("最终答案。"),
MessageEvent.completed(),
)
result = [event async for event in collapse_to_invoke_response(source)]
assert [event.type for event in result] == [
MessageEventType.MESSAGE,
MessageEventType.COMPLETED,
]
text = result[0].content[0]
assert isinstance(text, TextContent)
assert text.text == "这是最终答案。"
@pytest.mark.asyncio
async def test_invoke_strips_orphan_thinking_prefix_from_final_text() -> None:
source = _events(
MessageEvent.delta("Let me inspect another source. "),
MessageEvent.delta("This is internal reasoning."),
MessageEvent.delta("</think>"),
MessageEvent.delta("【每日指南学习】最终内容"),
MessageEvent.completed(),
)
result = [event async for event in collapse_to_invoke_response(source)]
assert [event.type for event in result] == [
MessageEventType.MESSAGE,
MessageEventType.COMPLETED,
]
text = result[0].content[0]
assert isinstance(text, TextContent)
assert text.text == "【每日指南学习】最终内容"
@pytest.mark.asyncio
async def test_invoke_preserves_tool_media_with_final_text() -> None:
attachment = FileContent(filename="report.pdf", data="cGRm")
source = _events(
MessageEvent.tool_start("write_file"),
MessageEvent.tool_end("write_file"),
MessageEvent(type=MessageEventType.MESSAGE, content=[attachment]),
MessageEvent.delta("报告已生成。"),
MessageEvent.completed(),
)
result = [event async for event in collapse_to_invoke_response(source)]
assert result[0].type == MessageEventType.MESSAGE
assert len(result[0].content) == 2
assert isinstance(result[0].content[0], TextContent)
assert result[0].content[1] is attachment
@pytest.mark.asyncio
async def test_invoke_forwards_error_without_partial_text() -> None:
error = MessageEvent.error_event("upstream failed")
source = _events(
MessageEvent.delta("partial"),
error,
MessageEvent.completed(),
)
result = [event async for event in collapse_to_invoke_response(source)]
assert result == [error]
@pytest.mark.parametrize("mode", ["invoke", "weixin-stream"])
async def test_terminal_error_closes_underlying_processor_through_response_wrapper(mode):
closed = False
async def source(_message):
nonlocal closed
try:
yield MessageEvent.error_event("simulated failure")
await asyncio.Event().wait()
finally:
closed = True
processor = (
processor_for_response_mode(source, "invoke")
if mode == "invoke"
else wrap_weixin_stream_processor(source)
)
stream = processor(InboundMessage(channel_id="test", content=[TextContent(text="test")]))
assert (await anext(stream)).type == MessageEventType.ERROR
await close_event_stream(stream)
assert closed
@pytest.mark.asyncio
async def test_invoke_keeps_ask_agent_dispatch_narration() -> None:
"""Team hosts often narrate before ask_agent — keep that line on invoke IM."""
source = _events(
MessageEvent.delta("我先请临床助手看一下。"),
MessageEvent.tool_start("咨询专家", tool_key="ask_agent"),
MessageEvent.tool_end("咨询专家"),
MessageEvent.completed(),
)
result = [event async for event in collapse_to_invoke_response(source)]
assert [event.type for event in result] == [
MessageEventType.MESSAGE,
MessageEventType.COMPLETED,
]
text = result[0].content[0]
assert isinstance(text, TextContent)
assert text.text == "我先请临床助手看一下。"
@pytest.mark.asyncio
async def test_invoke_still_discards_progress_before_other_tools() -> None:
source = _events(
MessageEvent.delta("我先查一下。"),
MessageEvent.tool_start("web_fetch", tool_key="web_fetch"),
MessageEvent.tool_end("web_fetch"),
MessageEvent.delta("最终答案。"),
MessageEvent.completed(),
)
result = [event async for event in collapse_to_invoke_response(source)]
text = result[0].content[0]
assert isinstance(text, TextContent)
assert text.text == "最终答案。"
def test_response_mode_defaults_to_invoke_and_accepts_stream() -> None:
assert normalize_channel_response_mode(None) == "invoke"
assert normalize_channel_response_mode("unknown") == "invoke"
assert normalize_channel_response_mode(" STREAM ") == "stream"
def test_stream_mode_uses_original_processor() -> None:
async def processor(_message: InboundMessage) -> AsyncIterator[MessageEvent]:
yield MessageEvent.completed()
assert processor_for_response_mode(processor, "stream") is processor
assert processor_for_response_mode(processor, "invoke") is not processor
@pytest.mark.asyncio
async def test_weixin_stream_drops_preamble_but_keeps_ask_agent() -> None:
source = _events(
MessageEvent.delta("我先查一下。"),
MessageEvent.flush(),
MessageEvent.tool_start("web_fetch", tool_key="web_fetch"),
MessageEvent.delta("结果"),
MessageEvent.tool_start("ask_agent", tool_key="ask_agent"),
MessageEvent.completed(),
)
result = [event async for event in withhold_weixin_stream_preamble(source)]
texts = [
part.text
for event in result
if event.type == MessageEventType.DELTA
for part in event.content
if isinstance(part, TextContent)
]
assert "我先查一下。" not in texts
assert "结果" in texts
assert any(
event.type == MessageEventType.TOOL_START and event.metadata.get("tool_key") == "ask_agent"
for event in result
)
def test_newer_weixin_token_is_kept() -> None:
from octop.infra.utils.weixin_context import (
WEIXIN_CONTEXT_TOKEN_KEY,
WEIXIN_CONTEXT_TOKEN_MS_KEY,
merge_weixin_context_token,
)
kept = merge_weixin_context_token(
{WEIXIN_CONTEXT_TOKEN_KEY: "new", WEIXIN_CONTEXT_TOKEN_MS_KEY: 20, "account_id": "a"},
{WEIXIN_CONTEXT_TOKEN_KEY: "old", WEIXIN_CONTEXT_TOKEN_MS_KEY: 10, "account_id": "b"},
)
assert kept[WEIXIN_CONTEXT_TOKEN_KEY] == "new"
assert kept["account_id"] == "b"
empty = merge_weixin_context_token(
{WEIXIN_CONTEXT_TOKEN_KEY: "keep", WEIXIN_CONTEXT_TOKEN_MS_KEY: 5, "account_id": "a"},
{"account_id": "a"},
)
assert empty[WEIXIN_CONTEXT_TOKEN_KEY] == "keep"
def test_qq_channel_streams_by_default() -> None:
assert qq_channel_response_mode({}) == "stream"
assert qq_channel_response_mode({"response_mode": "invoke"}) == "stream"
assert qq_channel_response_mode({"streaming": False}) == "stream"
assert qq_channel_response_mode({"c2c_streaming": True}) == "stream"
assert qq_channel_response_mode({"c2c_streaming": False}) == "invoke"
assert qq_channel_response_mode({"c2c_streaming": "false"}) == "invoke"