refactor(channels): make built-in channels self-contained (#4908)
* refactor(channels): own setup and instance contracts * refactor(channels): isolate management contracts * refactor(channels): normalize activation contracts * fix(channels): enforce management contracts * refactor(channels): finish setup ownership migration * fix(channels): harden management contracts * fix(channels): enforce lazy loading and runtime ownership * fix(feishu): make multi-instance startup idempotent * fix(webui): render channel setup contracts cleanly * fix(feishu): stop websocket clients cleanly * fix(channels): enforce persistence and activation gates * fix(channels): preserve global feature action scope * fix(channels): apply defaults for single plugins * fix(channels): enforce management contract boundaries * refactor(feishu): remove identity helper indirection * fix(channels): preserve management setup contracts * refactor(channels): generalize instance settings UI * refactor(channels): package channel plugins with web UI metadata * refactor(channels): make built-ins self-contained packages * test(channels): colocate tests with channel packages * fix(dingtalk): use official brand icon * feat(channels): colocate webui translations * docs(channels): clarify plugin ownership * test(exec): remove output wait race * refactor(channels): unify plugin descriptors * fix(channels): enforce descriptor-owned contracts * refactor(channels): finish package-owned plugin setup * refactor(channels): use repository-owned packages only * fix(channels): self-describe dependencies and runtime state * fix(channels): warn about legacy entry points
This commit is contained in:
@@ -0,0 +1,644 @@
|
||||
# ruff: noqa: E402
|
||||
|
||||
"""Tests for Feishu streaming (send_delta) via CardKit streaming API."""
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("lark_oapi")
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel, FeishuConfig, _FeishuStreamBuf
|
||||
|
||||
|
||||
def _make_channel(streaming: bool = True, reply_to_message: bool = False) -> FeishuChannel:
|
||||
config = FeishuConfig(
|
||||
enabled=True,
|
||||
app_id="cli_test",
|
||||
app_secret="secret",
|
||||
allow_from=["*"],
|
||||
streaming=streaming,
|
||||
reply_to_message=reply_to_message,
|
||||
)
|
||||
ch = FeishuChannel(config, MessageBus())
|
||||
ch._client = MagicMock()
|
||||
ch._loop = None
|
||||
return ch
|
||||
|
||||
|
||||
def _mock_create_card_response(card_id: str = "card_stream_001"):
|
||||
resp = MagicMock()
|
||||
resp.success.return_value = True
|
||||
resp.data = SimpleNamespace(card_id=card_id)
|
||||
return resp
|
||||
|
||||
|
||||
def _mock_send_response(message_id: str = "om_stream_001"):
|
||||
resp = MagicMock()
|
||||
resp.success.return_value = True
|
||||
resp.data = SimpleNamespace(message_id=message_id)
|
||||
return resp
|
||||
|
||||
|
||||
def _mock_content_response(success: bool = True):
|
||||
resp = MagicMock()
|
||||
resp.success.return_value = success
|
||||
resp.code = 0 if success else 99999
|
||||
resp.msg = "ok" if success else "error"
|
||||
return resp
|
||||
|
||||
|
||||
class TestFeishuStreamingConfig:
|
||||
def test_streaming_default_true(self):
|
||||
assert FeishuConfig().streaming is True
|
||||
|
||||
def test_supports_streaming_when_enabled(self):
|
||||
ch = _make_channel(streaming=True)
|
||||
assert ch.supports_streaming is True
|
||||
|
||||
def test_supports_streaming_disabled(self):
|
||||
ch = _make_channel(streaming=False)
|
||||
assert ch.supports_streaming is False
|
||||
|
||||
|
||||
class TestCreateStreamingCard:
|
||||
def test_returns_card_id_on_success(self):
|
||||
ch = _make_channel()
|
||||
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_123")
|
||||
ch._client.im.v1.message.create.return_value = _mock_send_response()
|
||||
result = ch._create_streaming_card_sync("chat_id", "oc_chat1")
|
||||
assert result == "card_123"
|
||||
ch._client.cardkit.v1.card.create.assert_called_once()
|
||||
ch._client.im.v1.message.create.assert_called_once()
|
||||
|
||||
def test_returns_none_on_failure(self):
|
||||
ch = _make_channel()
|
||||
resp = MagicMock()
|
||||
resp.success.return_value = False
|
||||
resp.code = 99999
|
||||
resp.msg = "error"
|
||||
ch._client.cardkit.v1.card.create.return_value = resp
|
||||
assert ch._create_streaming_card_sync("chat_id", "oc_chat1") is None
|
||||
|
||||
def test_returns_none_on_exception(self):
|
||||
ch = _make_channel()
|
||||
ch._client.cardkit.v1.card.create.side_effect = RuntimeError("network")
|
||||
assert ch._create_streaming_card_sync("chat_id", "oc_chat1") is None
|
||||
|
||||
def test_returns_none_when_card_send_fails(self):
|
||||
ch = _make_channel()
|
||||
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_123")
|
||||
resp = MagicMock()
|
||||
resp.success.return_value = False
|
||||
resp.code = 99999
|
||||
resp.msg = "error"
|
||||
resp.get_log_id.return_value = "log1"
|
||||
ch._client.im.v1.message.create.return_value = resp
|
||||
assert ch._create_streaming_card_sync("chat_id", "oc_chat1") is None
|
||||
|
||||
|
||||
class TestCloseStreamingMode:
|
||||
def test_returns_true_on_success(self):
|
||||
ch = _make_channel()
|
||||
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
|
||||
assert ch._close_streaming_mode_sync("card_1", 10) is True
|
||||
|
||||
def test_returns_false_on_failure(self):
|
||||
ch = _make_channel()
|
||||
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(False)
|
||||
assert ch._close_streaming_mode_sync("card_1", 10) is False
|
||||
|
||||
def test_returns_false_on_exception(self):
|
||||
ch = _make_channel()
|
||||
ch._client.cardkit.v1.card.settings.side_effect = RuntimeError("err")
|
||||
assert ch._close_streaming_mode_sync("card_1", 10) is False
|
||||
|
||||
|
||||
class TestStreamUpdateWithReopen:
|
||||
def test_reopens_streaming_mode_and_retries_update(self):
|
||||
ch = _make_channel()
|
||||
ch._client.cardkit.v1.card_element.content.side_effect = [
|
||||
_mock_content_response(False),
|
||||
_mock_content_response(True),
|
||||
]
|
||||
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
|
||||
|
||||
assert ch._stream_update_text_with_reopen_sync("card_1", "hello", 4) == (True, 6)
|
||||
assert ch._client.cardkit.v1.card_element.content.call_count == 2
|
||||
settings_call = ch._client.cardkit.v1.card.settings.call_args[0][0]
|
||||
assert settings_call.body.sequence == 5
|
||||
assert '"streaming_mode": true' in settings_call.body.settings
|
||||
|
||||
|
||||
class TestStreamUpdateText:
|
||||
def test_returns_true_on_success(self):
|
||||
ch = _make_channel()
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(True)
|
||||
assert ch._stream_update_text_sync("card_1", "hello", 1) is True
|
||||
|
||||
def test_returns_false_on_failure(self):
|
||||
ch = _make_channel()
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(False)
|
||||
assert ch._stream_update_text_sync("card_1", "hello", 1) is False
|
||||
|
||||
def test_returns_false_on_exception(self):
|
||||
ch = _make_channel()
|
||||
ch._client.cardkit.v1.card_element.content.side_effect = RuntimeError("err")
|
||||
assert ch._stream_update_text_sync("card_1", "hello", 1) is False
|
||||
|
||||
|
||||
class TestSendDelta:
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_delta_creates_card_and_sends(self):
|
||||
ch = _make_channel()
|
||||
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
|
||||
ch._client.im.v1.message.create.return_value = _mock_send_response("om_new")
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||
|
||||
await ch.send_delta("oc_chat1", "Hello ")
|
||||
|
||||
assert "oc_chat1" in ch._stream_bufs
|
||||
buf = ch._stream_bufs["oc_chat1"]
|
||||
assert buf.text == "Hello "
|
||||
assert buf.card_id == "card_new"
|
||||
assert buf.sequence == 1
|
||||
ch._client.cardkit.v1.card.create.assert_called_once()
|
||||
ch._client.im.v1.message.create.assert_called_once()
|
||||
ch._client.cardkit.v1.card_element.content.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_delta_closes_blank_card_when_initial_update_fails(self):
|
||||
ch = _make_channel()
|
||||
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
|
||||
ch._client.im.v1.message.create.return_value = _mock_send_response("om_new")
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(False)
|
||||
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
|
||||
|
||||
await ch.send_delta("oc_chat1", "Hello ")
|
||||
|
||||
buf = ch._stream_bufs["oc_chat1"]
|
||||
assert buf.text == "Hello "
|
||||
assert buf.card_id is None
|
||||
assert ch._client.cardkit.v1.card_element.content.call_count == 2
|
||||
assert ch._client.cardkit.v1.card.settings.call_count == 2
|
||||
close_call = ch._client.cardkit.v1.card.settings.call_args_list[-1][0][0]
|
||||
assert '"streaming_mode": false' in close_call.body.settings
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_delta_uses_create_when_reply_disabled(self):
|
||||
ch = _make_channel(reply_to_message=False)
|
||||
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
|
||||
ch._client.im.v1.message.create.return_value = _mock_send_response("om_new")
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1",
|
||||
"Hello ",
|
||||
metadata={"message_id": "om_001", "chat_type": "group"},
|
||||
)
|
||||
|
||||
ch._client.im.v1.message.create.assert_called_once()
|
||||
ch._client.im.v1.message.reply.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_delta_keeps_existing_topic_when_reply_disabled(self):
|
||||
ch = _make_channel(reply_to_message=False)
|
||||
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
ch._client.im.v1.message.reply.return_value = reply_resp
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1",
|
||||
"Hello ",
|
||||
metadata={"message_id": "om_001", "chat_type": "group", "thread_id": "ot_001"},
|
||||
)
|
||||
|
||||
ch._client.im.v1.message.reply.assert_called_once()
|
||||
ch._client.im.v1.message.create.assert_not_called()
|
||||
request = ch._client.im.v1.message.reply.call_args[0][0]
|
||||
assert request.request_body.reply_in_thread is not True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_delta_replies_in_thread_when_reply_enabled(self):
|
||||
ch = _make_channel(reply_to_message=True)
|
||||
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
ch._client.im.v1.message.reply.return_value = reply_resp
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1",
|
||||
"Hello ",
|
||||
metadata={"message_id": "om_001", "chat_type": "group"},
|
||||
)
|
||||
|
||||
ch._client.im.v1.message.reply.assert_called_once()
|
||||
ch._client.im.v1.message.create.assert_not_called()
|
||||
request = ch._client.im.v1.message.reply.call_args[0][0]
|
||||
assert request.request_body.reply_in_thread is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_delta_within_interval_skips_update(self):
|
||||
ch = _make_channel()
|
||||
buf = _FeishuStreamBuf(text="Hello ", card_id="card_1", sequence=1, last_edit=time.monotonic())
|
||||
ch._stream_bufs["oc_chat1"] = buf
|
||||
|
||||
await ch.send_delta("oc_chat1", "world")
|
||||
|
||||
assert buf.text == "Hello world"
|
||||
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delta_after_interval_updates_text(self):
|
||||
ch = _make_channel()
|
||||
buf = _FeishuStreamBuf(text="Hello ", card_id="card_1", sequence=1, last_edit=time.monotonic() - 1.0)
|
||||
ch._stream_bufs["oc_chat1"] = buf
|
||||
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||
await ch.send_delta("oc_chat1", "world")
|
||||
|
||||
assert buf.text == "Hello world"
|
||||
assert buf.sequence == 2
|
||||
ch._client.cardkit.v1.card_element.content.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_end_sends_final_update(self):
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Final content", card_id="card_1", sequence=3, last_edit=0.0,
|
||||
)
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
|
||||
|
||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
||||
|
||||
assert "oc_chat1" not in ch._stream_bufs
|
||||
ch._client.cardkit.v1.card_element.content.assert_called_once()
|
||||
ch._client.cardkit.v1.card.settings.assert_called_once()
|
||||
settings_call = ch._client.cardkit.v1.card.settings.call_args[0][0]
|
||||
assert settings_call.body.sequence == 5 # after final content seq 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_end_fallback_when_no_card_id(self):
|
||||
"""If card creation failed, stream_end falls back to a plain card message."""
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Fallback content", card_id=None, sequence=0, last_edit=0.0,
|
||||
)
|
||||
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
|
||||
|
||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
||||
|
||||
assert "oc_chat1" not in ch._stream_bufs
|
||||
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
||||
ch._client.im.v1.message.create.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_end_fallback_group_uses_create_when_reply_disabled(self):
|
||||
ch = _make_channel(reply_to_message=False)
|
||||
ch._stream_bufs["om_001"] = _FeishuStreamBuf(
|
||||
text="Fallback content", card_id=None, sequence=0, last_edit=0.0,
|
||||
)
|
||||
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1",
|
||||
"",
|
||||
metadata={"message_id": "om_001", "chat_type": "group"},
|
||||
stream_end=True,
|
||||
)
|
||||
|
||||
ch._client.im.v1.message.create.assert_called_once()
|
||||
ch._client.im.v1.message.reply.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_end_fallback_keeps_existing_topic_when_reply_disabled(self):
|
||||
ch = _make_channel(reply_to_message=False)
|
||||
ch._stream_bufs["om_001"] = _FeishuStreamBuf(
|
||||
text="Fallback content", card_id=None, sequence=0, last_edit=0.0,
|
||||
)
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
ch._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1",
|
||||
"",
|
||||
metadata={
|
||||
"message_id": "om_001",
|
||||
"chat_type": "group",
|
||||
"thread_id": "ot_001",
|
||||
},
|
||||
stream_end=True,
|
||||
)
|
||||
|
||||
ch._client.im.v1.message.reply.assert_called_once()
|
||||
ch._client.im.v1.message.create.assert_not_called()
|
||||
request = ch._client.im.v1.message.reply.call_args[0][0]
|
||||
assert request.request_body.reply_in_thread is not True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_end_fallback_group_replies_when_reply_enabled(self):
|
||||
ch = _make_channel(reply_to_message=True)
|
||||
ch._stream_bufs["om_001"] = _FeishuStreamBuf(
|
||||
text="Fallback content", card_id=None, sequence=0, last_edit=0.0,
|
||||
)
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
ch._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1",
|
||||
"",
|
||||
metadata={"message_id": "om_001", "chat_type": "group"},
|
||||
stream_end=True,
|
||||
)
|
||||
|
||||
ch._client.im.v1.message.reply.assert_called_once()
|
||||
ch._client.im.v1.message.create.assert_not_called()
|
||||
request = ch._client.im.v1.message.reply.call_args[0][0]
|
||||
assert request.request_body.reply_in_thread is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_end_fallback_when_final_update_fails(self):
|
||||
"""If streaming mode was closed (e.g. Feishu timeout), fall back to a regular card."""
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Lost content", card_id="card_1", sequence=3, last_edit=0.0,
|
||||
)
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(success=False)
|
||||
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
|
||||
|
||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
||||
|
||||
assert "oc_chat1" not in ch._stream_bufs
|
||||
assert ch._client.cardkit.v1.card.settings.call_count == 2
|
||||
# Should fall back to sending a regular interactive card
|
||||
ch._client.im.v1.message.create.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_end_reopens_streaming_card_before_fallback(self):
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Recovered content", card_id="card_1", sequence=3, last_edit=0.0,
|
||||
)
|
||||
ch._client.cardkit.v1.card_element.content.side_effect = [
|
||||
_mock_content_response(False),
|
||||
_mock_content_response(True),
|
||||
]
|
||||
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
|
||||
|
||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
||||
|
||||
assert "oc_chat1" not in ch._stream_bufs
|
||||
assert ch._client.cardkit.v1.card_element.content.call_count == 2
|
||||
assert ch._client.cardkit.v1.card.settings.call_count == 2
|
||||
ch._client.im.v1.message.create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_end_without_buf_is_noop(self):
|
||||
ch = _make_channel()
|
||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
||||
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_delta_skips_send(self):
|
||||
ch = _make_channel()
|
||||
await ch.send_delta("oc_chat1", " ")
|
||||
|
||||
assert "oc_chat1" in ch._stream_bufs
|
||||
ch._client.cardkit.v1.card.create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_client_returns_early(self):
|
||||
ch = _make_channel()
|
||||
ch._client = None
|
||||
await ch.send_delta("oc_chat1", "text")
|
||||
assert "oc_chat1" not in ch._stream_bufs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sequence_increments_correctly(self):
|
||||
ch = _make_channel()
|
||||
buf = _FeishuStreamBuf(text="a", card_id="card_1", sequence=5, last_edit=0.0)
|
||||
ch._stream_bufs["oc_chat1"] = buf
|
||||
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||
await ch.send_delta("oc_chat1", "b")
|
||||
assert buf.sequence == 6
|
||||
|
||||
buf.last_edit = 0.0 # reset to bypass throttle
|
||||
await ch.send_delta("oc_chat1", "c")
|
||||
assert buf.sequence == 7
|
||||
|
||||
|
||||
class TestToolHintInlineStreaming:
|
||||
"""Tool hint messages should be inlined into active streaming cards."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_hint_inlined_when_stream_active(self):
|
||||
"""With an active streaming buffer, tool hint appends to the card."""
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0,
|
||||
)
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||
|
||||
msg = OutboundMessage(
|
||||
channel="feishu", chat_id="oc_chat1",
|
||||
content='web_fetch("https://example.com")',
|
||||
event=ProgressEvent(content='web_fetch("https://example.com")', tool_hint=True),
|
||||
)
|
||||
await ch.send(msg)
|
||||
|
||||
buf = ch._stream_bufs["oc_chat1"]
|
||||
assert '🔧 web_fetch("https://example.com")' in buf.text
|
||||
assert buf.sequence == 3
|
||||
ch._client.cardkit.v1.card_element.content.assert_called_once()
|
||||
ch._client.im.v1.message.create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_hint_preserved_on_next_delta(self):
|
||||
"""When new delta arrives, the tool hint is kept as permanent content and delta appends after it."""
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Partial answer\n\n🔧 web_fetch(\"url\")\n\n",
|
||||
card_id="card_1", sequence=3, last_edit=0.0,
|
||||
)
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||
|
||||
await ch.send_delta("oc_chat1", " continued")
|
||||
|
||||
buf = ch._stream_bufs["oc_chat1"]
|
||||
assert "Partial answer" in buf.text
|
||||
assert "🔧 web_fetch" in buf.text
|
||||
assert buf.text.endswith(" continued")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_hint_fallback_when_no_stream(self):
|
||||
"""Without an active buffer, tool hint falls back to a standalone card."""
|
||||
ch = _make_channel()
|
||||
ch._client.im.v1.message.create.return_value = _mock_send_response("om_hint")
|
||||
|
||||
msg = OutboundMessage(
|
||||
channel="feishu", chat_id="oc_chat1",
|
||||
content='read_file("path")',
|
||||
event=ProgressEvent(content='read_file("path")', tool_hint=True),
|
||||
)
|
||||
await ch.send(msg)
|
||||
|
||||
assert "oc_chat1" not in ch._stream_bufs
|
||||
ch._client.im.v1.message.create.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_hint_group_uses_create_when_reply_disabled(self):
|
||||
ch = _make_channel(reply_to_message=False)
|
||||
ch._client.im.v1.message.create.return_value = _mock_send_response("om_hint")
|
||||
|
||||
msg = OutboundMessage(
|
||||
channel="feishu", chat_id="oc_chat1",
|
||||
content='read_file("path")',
|
||||
event=ProgressEvent(content='read_file("path")', tool_hint=True),
|
||||
metadata={"message_id": "om_001", "chat_type": "group"},
|
||||
)
|
||||
await ch.send(msg)
|
||||
|
||||
ch._client.im.v1.message.create.assert_called_once()
|
||||
ch._client.im.v1.message.reply.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_hint_keeps_existing_topic_when_reply_disabled(self):
|
||||
ch = _make_channel(reply_to_message=False)
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
ch._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
msg = OutboundMessage(
|
||||
channel="feishu", chat_id="oc_chat1",
|
||||
content='read_file("path")',
|
||||
event=ProgressEvent(content='read_file("path")', tool_hint=True),
|
||||
metadata={
|
||||
"message_id": "om_001",
|
||||
"chat_type": "group",
|
||||
"thread_id": "ot_001",
|
||||
},
|
||||
)
|
||||
await ch.send(msg)
|
||||
|
||||
ch._client.im.v1.message.reply.assert_called_once()
|
||||
ch._client.im.v1.message.create.assert_not_called()
|
||||
request = ch._client.im.v1.message.reply.call_args[0][0]
|
||||
assert request.request_body.reply_in_thread is not True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_hint_group_replies_when_reply_enabled(self):
|
||||
ch = _make_channel(reply_to_message=True)
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
ch._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
msg = OutboundMessage(
|
||||
channel="feishu", chat_id="oc_chat1",
|
||||
content='read_file("path")',
|
||||
event=ProgressEvent(content='read_file("path")', tool_hint=True),
|
||||
metadata={"message_id": "om_001", "chat_type": "group"},
|
||||
)
|
||||
await ch.send(msg)
|
||||
|
||||
ch._client.im.v1.message.reply.assert_called_once()
|
||||
ch._client.im.v1.message.create.assert_not_called()
|
||||
request = ch._client.im.v1.message.reply.call_args[0][0]
|
||||
assert request.request_body.reply_in_thread is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consecutive_tool_hints_append(self):
|
||||
"""When multiple tool hints arrive consecutively, each appends to the card."""
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0,
|
||||
)
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||
|
||||
msg1 = OutboundMessage(
|
||||
channel="feishu", chat_id="oc_chat1",
|
||||
content='$ cd /project',
|
||||
event=ProgressEvent(content='$ cd /project', tool_hint=True),
|
||||
)
|
||||
await ch.send(msg1)
|
||||
|
||||
msg2 = OutboundMessage(
|
||||
channel="feishu", chat_id="oc_chat1",
|
||||
content='$ git status',
|
||||
event=ProgressEvent(content='$ git status', tool_hint=True),
|
||||
)
|
||||
await ch.send(msg2)
|
||||
|
||||
buf = ch._stream_bufs["oc_chat1"]
|
||||
assert "$ cd /project" in buf.text
|
||||
assert "$ git status" in buf.text
|
||||
assert buf.text.startswith("Partial answer")
|
||||
assert "🔧 $ cd /project" in buf.text
|
||||
assert "🔧 $ git status" in buf.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_hint_preserved_on_final_stream_end(self):
|
||||
"""When stream end closes the card, tool hint is kept in the final text."""
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Final content\n\n🔧 web_fetch(\"url\")\n\n",
|
||||
card_id="card_1", sequence=3, last_edit=0.0,
|
||||
)
|
||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
|
||||
|
||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
||||
|
||||
assert "oc_chat1" not in ch._stream_bufs
|
||||
update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0]
|
||||
assert "🔧" in update_call.body.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_tool_hint_is_noop(self):
|
||||
"""Empty or whitespace-only tool hint content is silently ignored."""
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0,
|
||||
)
|
||||
|
||||
for content in ("", " ", "\t\n"):
|
||||
msg = OutboundMessage(
|
||||
channel="feishu", chat_id="oc_chat1",
|
||||
content=content,
|
||||
event=ProgressEvent(content=content, tool_hint=True),
|
||||
)
|
||||
await ch.send(msg)
|
||||
|
||||
buf = ch._stream_bufs["oc_chat1"]
|
||||
assert buf.text == "Partial answer"
|
||||
assert buf.sequence == 2
|
||||
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
||||
|
||||
|
||||
class TestSendMessageReturnsId:
|
||||
def test_returns_message_id_on_success(self):
|
||||
ch = _make_channel()
|
||||
ch._client.im.v1.message.create.return_value = _mock_send_response("om_abc")
|
||||
result = ch._send_message_sync("chat_id", "oc_chat1", "text", '{"text":"hi"}')
|
||||
assert result == "om_abc"
|
||||
|
||||
def test_returns_none_on_failure(self):
|
||||
ch = _make_channel()
|
||||
resp = MagicMock()
|
||||
resp.success.return_value = False
|
||||
resp.code = 99999
|
||||
resp.msg = "error"
|
||||
resp.get_log_id.return_value = "log1"
|
||||
ch._client.im.v1.message.create.return_value = resp
|
||||
result = ch._send_message_sync("chat_id", "oc_chat1", "text", '{"text":"hi"}')
|
||||
assert result is None
|
||||
Reference in New Issue
Block a user