Merge origin/main into fix/discord-allow-channel-threads
Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"""Tests for Feishu reaction add/remove and auto-cleanup on stream end."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -160,19 +160,38 @@ class TestRemoveReactionAsync:
|
||||
|
||||
|
||||
class TestStreamEndReactionCleanup:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_buffers_are_scoped_by_message_id(self):
|
||||
ch = _make_channel()
|
||||
ch._create_streaming_card_sync = MagicMock(return_value=None)
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1", "first",
|
||||
metadata={"message_id": "om_first"},
|
||||
)
|
||||
await ch.send_delta(
|
||||
"oc_chat1", "second",
|
||||
metadata={"message_id": "om_second"},
|
||||
)
|
||||
|
||||
assert ch._stream_bufs["om_first"].text == "first"
|
||||
assert ch._stream_bufs["om_second"].text == "second"
|
||||
assert "oc_chat1" not in ch._stream_bufs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_reaction_on_stream_end(self):
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
|
||||
)
|
||||
ch._reaction_ids["om_001"] = "rx_42"
|
||||
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._remove_reaction = AsyncMock()
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1", "",
|
||||
metadata={"_stream_end": True, "message_id": "om_001", "reaction_id": "rx_42"},
|
||||
metadata={"_stream_end": True, "message_id": "om_001"},
|
||||
)
|
||||
|
||||
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
|
||||
@@ -189,7 +208,7 @@ class TestStreamEndReactionCleanup:
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1", "",
|
||||
metadata={"_stream_end": True, "reaction_id": "rx_42"},
|
||||
metadata={"_stream_end": True},
|
||||
)
|
||||
|
||||
ch._remove_reaction.assert_not_called()
|
||||
|
||||
@@ -3,7 +3,7 @@ import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -21,18 +21,18 @@ from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_feishu_channel(reply_to_message: bool = False) -> FeishuChannel:
|
||||
def _make_feishu_channel(reply_to_message: bool = False, group_policy: str = "mention") -> FeishuChannel:
|
||||
config = FeishuConfig(
|
||||
enabled=True,
|
||||
app_id="cli_test",
|
||||
app_secret="secret",
|
||||
allow_from=["*"],
|
||||
reply_to_message=reply_to_message,
|
||||
group_policy=group_policy,
|
||||
)
|
||||
channel = FeishuChannel(config, MessageBus())
|
||||
channel._client = MagicMock()
|
||||
@@ -202,7 +202,7 @@ def test_reply_message_sync_returns_false_on_exception() -> None:
|
||||
("filename", "expected_msg_type"),
|
||||
[
|
||||
("voice.opus", "audio"),
|
||||
("clip.mp4", "video"),
|
||||
("clip.mp4", "media"),
|
||||
("report.pdf", "file"),
|
||||
],
|
||||
)
|
||||
@@ -443,3 +443,288 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None:
|
||||
|
||||
channel._client.im.v1.message.get.assert_not_called()
|
||||
assert len(captured) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session key derivation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_group_with_root_id_is_thread_scoped() -> None:
|
||||
"""Group message with root_id gets a thread-scoped session key."""
|
||||
channel = _make_feishu_channel(group_policy="open")
|
||||
bus_spy = []
|
||||
original_publish = channel.bus.publish_inbound
|
||||
|
||||
async def capture(msg):
|
||||
bus_spy.append(msg)
|
||||
await original_publish(msg)
|
||||
|
||||
channel.bus.publish_inbound = capture
|
||||
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
|
||||
channel.transcribe_audio = AsyncMock(return_value="")
|
||||
channel._add_reaction = AsyncMock(return_value=None)
|
||||
|
||||
event = _make_feishu_event(
|
||||
chat_type="group",
|
||||
content='{"text": "hello"}',
|
||||
root_id="om_root123",
|
||||
message_id="om_child456",
|
||||
)
|
||||
await channel._on_message(event)
|
||||
|
||||
assert len(bus_spy) == 1
|
||||
assert bus_spy[0].session_key == "feishu:oc_abc:om_root123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_group_no_root_id_uses_message_id() -> None:
|
||||
"""Group message without root_id gets session keyed by message_id (per-message session)."""
|
||||
channel = _make_feishu_channel(group_policy="open")
|
||||
bus_spy = []
|
||||
original_publish = channel.bus.publish_inbound
|
||||
|
||||
async def capture(msg):
|
||||
bus_spy.append(msg)
|
||||
await original_publish(msg)
|
||||
|
||||
channel.bus.publish_inbound = capture
|
||||
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
|
||||
channel.transcribe_audio = AsyncMock(return_value="")
|
||||
channel._add_reaction = AsyncMock(return_value=None)
|
||||
|
||||
event = _make_feishu_event(
|
||||
chat_type="group",
|
||||
content='{"text": "hello"}',
|
||||
root_id=None,
|
||||
message_id="om_001",
|
||||
)
|
||||
await channel._on_message(event)
|
||||
|
||||
assert len(bus_spy) == 1
|
||||
assert bus_spy[0].session_key == "feishu:oc_abc:om_001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_private_chat_no_override() -> None:
|
||||
"""Private chat never overrides session key (consistent with Telegram/Slack)."""
|
||||
channel = _make_feishu_channel()
|
||||
bus_spy = []
|
||||
original_publish = channel.bus.publish_inbound
|
||||
|
||||
async def capture(msg):
|
||||
bus_spy.append(msg)
|
||||
await original_publish(msg)
|
||||
|
||||
channel.bus.publish_inbound = capture
|
||||
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
|
||||
channel.transcribe_audio = AsyncMock(return_value="")
|
||||
channel._add_reaction = AsyncMock(return_value=None)
|
||||
|
||||
event = _make_feishu_event(
|
||||
chat_type="p2p",
|
||||
content='{"text": "hello"}',
|
||||
root_id=None,
|
||||
message_id="om_001",
|
||||
)
|
||||
await channel._on_message(event)
|
||||
|
||||
assert len(bus_spy) == 1
|
||||
assert bus_spy[0].session_key_override is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reply_in_thread tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_uses_reply_in_thread_when_enabled() -> None:
|
||||
"""When reply_to_message is True, reply includes reply_in_thread=True."""
|
||||
channel = _make_feishu_channel(reply_to_message=True)
|
||||
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_abc",
|
||||
content="hello",
|
||||
metadata={"message_id": "om_001"},
|
||||
))
|
||||
|
||||
channel._client.im.v1.message.reply.assert_called_once()
|
||||
call_args = channel._client.im.v1.message.reply.call_args
|
||||
request = call_args[0][0]
|
||||
assert request.request_body.reply_in_thread is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_without_reply_in_thread_when_disabled() -> None:
|
||||
"""When reply_to_message is False, reply does NOT use reply_in_thread."""
|
||||
channel = _make_feishu_channel(reply_to_message=False)
|
||||
|
||||
create_resp = MagicMock()
|
||||
create_resp.success.return_value = True
|
||||
channel._client.im.v1.message.create.return_value = create_resp
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_abc",
|
||||
content="hello",
|
||||
))
|
||||
|
||||
# No message_id in metadata → no reply attempt, direct create
|
||||
channel._client.im.v1.message.create.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_keeps_fallback_when_reply_fails() -> None:
|
||||
"""Even with reply_to_message=True, fallback to create on reply failure."""
|
||||
channel = _make_feishu_channel(reply_to_message=True)
|
||||
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = False
|
||||
reply_resp.code = 99991400
|
||||
reply_resp.msg = "rate limited"
|
||||
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
create_resp = MagicMock()
|
||||
create_resp.success.return_value = True
|
||||
channel._client.im.v1.message.create.return_value = create_resp
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_abc",
|
||||
content="hello",
|
||||
metadata={"message_id": "om_001"},
|
||||
))
|
||||
|
||||
channel._client.im.v1.message.reply.assert_called()
|
||||
channel._client.im.v1.message.create.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_no_reply_in_thread_for_p2p_chat() -> None:
|
||||
"""reply_in_thread should NOT be set for p2p chats (identified by chat_type)."""
|
||||
channel = _make_feishu_channel(reply_to_message=True)
|
||||
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_abc", # p2p chats also use oc_ prefix
|
||||
content="hello",
|
||||
metadata={"message_id": "om_001", "chat_type": "p2p"},
|
||||
))
|
||||
|
||||
channel._client.im.v1.message.reply.assert_called_once()
|
||||
call_args = channel._client.im.v1.message.reply.call_args
|
||||
request = call_args[0][0]
|
||||
assert request.request_body.reply_in_thread is not True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_uses_reply_in_thread_for_group_chat() -> None:
|
||||
"""reply_in_thread should be True for group chats (identified by chat_type)."""
|
||||
channel = _make_feishu_channel(reply_to_message=True)
|
||||
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_abc",
|
||||
content="hello",
|
||||
metadata={"message_id": "om_001", "chat_type": "group"},
|
||||
))
|
||||
|
||||
channel._client.im.v1.message.reply.assert_called_once()
|
||||
call_args = channel._client.im.v1.message.reply.call_args
|
||||
request = call_args[0][0]
|
||||
assert request.request_body.reply_in_thread is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_targets_message_id_when_in_topic() -> None:
|
||||
"""When inbound message is inside a topic (root_id != message_id),
|
||||
the reply should target the inbound message_id (not root_id).
|
||||
The Feishu Reply API keeps the response in the same topic
|
||||
automatically when the target message is already inside a topic."""
|
||||
channel = _make_feishu_channel(reply_to_message=True)
|
||||
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_abc",
|
||||
content="hello",
|
||||
metadata={
|
||||
"message_id": "om_child456",
|
||||
"chat_type": "group",
|
||||
"root_id": "om_root123",
|
||||
},
|
||||
))
|
||||
|
||||
channel._client.im.v1.message.reply.assert_called_once()
|
||||
call_args = channel._client.im.v1.message.reply.call_args
|
||||
request = call_args[0][0]
|
||||
# Should reply to the inbound message_id, not the root
|
||||
assert request.message_id == "om_child456"
|
||||
assert request.request_body.reply_in_thread is True
|
||||
|
||||
|
||||
def test_on_reaction_added_stores_reaction_id() -> None:
|
||||
"""_on_reaction_added stores the returned reaction_id in _reaction_ids."""
|
||||
channel = _make_feishu_channel()
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
task = loop.create_task(asyncio.sleep(0, result="reaction_abc"))
|
||||
loop.run_until_complete(task)
|
||||
channel._on_reaction_added("om_001", task)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
assert channel._reaction_ids["om_001"] == "reaction_abc"
|
||||
|
||||
|
||||
def test_on_reaction_added_skips_none_result() -> None:
|
||||
"""_on_reaction_added does not store None results."""
|
||||
channel = _make_feishu_channel()
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
task = loop.create_task(asyncio.sleep(0, result=None))
|
||||
loop.run_until_complete(task)
|
||||
channel._on_reaction_added("om_001", task)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
assert "om_001" not in channel._reaction_ids
|
||||
|
||||
|
||||
def test_on_background_task_done_removes_from_set() -> None:
|
||||
"""_on_background_task_done removes task from tracking set."""
|
||||
channel = _make_feishu_channel()
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
async def _fail():
|
||||
raise RuntimeError("test failure")
|
||||
|
||||
task = loop.create_task(_fail())
|
||||
channel._background_tasks.add(task)
|
||||
try:
|
||||
loop.run_until_complete(task)
|
||||
except RuntimeError:
|
||||
pass # expected
|
||||
channel._on_background_task_done(task)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
assert task not in channel._background_tasks
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# Check optional Slack dependencies before running tests
|
||||
@@ -10,7 +14,7 @@ except ImportError:
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.slack import SlackChannel, SlackConfig
|
||||
from nanobot.channels.slack import SLACK_MAX_MESSAGE_LEN, SlackChannel, SlackConfig
|
||||
|
||||
|
||||
class _FakeAsyncWebClient:
|
||||
@@ -20,26 +24,30 @@ class _FakeAsyncWebClient:
|
||||
self.reactions_add_calls: list[dict[str, object | None]] = []
|
||||
self.reactions_remove_calls: list[dict[str, object | None]] = []
|
||||
self.conversations_list_calls: list[dict[str, object | None]] = []
|
||||
self.conversations_replies_calls: list[dict[str, object | None]] = []
|
||||
self.users_list_calls: list[dict[str, object | None]] = []
|
||||
self.conversations_open_calls: list[dict[str, object | None]] = []
|
||||
self._conversations_pages: list[dict[str, object]] = []
|
||||
self._conversations_replies_response: dict[str, object] = {"messages": []}
|
||||
self._users_pages: list[dict[str, object]] = []
|
||||
self._open_dm_response: dict[str, object] = {"channel": {"id": "D_OPENED"}}
|
||||
|
||||
async def chat_postMessage(
|
||||
async def chat_postMessage( # noqa: N802 - mirrors Slack SDK method name
|
||||
self,
|
||||
*,
|
||||
channel: str,
|
||||
text: str,
|
||||
thread_ts: str | None = None,
|
||||
blocks: list[dict[str, object]] | None = None,
|
||||
) -> None:
|
||||
self.chat_post_calls.append(
|
||||
{
|
||||
"channel": channel,
|
||||
"text": text,
|
||||
"thread_ts": thread_ts,
|
||||
}
|
||||
)
|
||||
call: dict[str, object | None] = {
|
||||
"channel": channel,
|
||||
"text": text,
|
||||
"thread_ts": thread_ts,
|
||||
}
|
||||
if blocks is not None:
|
||||
call["blocks"] = blocks
|
||||
self.chat_post_calls.append(call)
|
||||
|
||||
async def files_upload_v2(
|
||||
self,
|
||||
@@ -92,6 +100,10 @@ class _FakeAsyncWebClient:
|
||||
return self._conversations_pages.pop(0)
|
||||
return {"channels": [], "response_metadata": {"next_cursor": ""}}
|
||||
|
||||
async def conversations_replies(self, **kwargs):
|
||||
self.conversations_replies_calls.append(kwargs)
|
||||
return self._conversations_replies_response
|
||||
|
||||
async def users_list(self, **kwargs):
|
||||
self.users_list_calls.append(kwargs)
|
||||
if self._users_pages:
|
||||
@@ -120,14 +132,15 @@ async def test_send_uses_thread_for_channel_messages() -> None:
|
||||
)
|
||||
|
||||
assert len(fake_web.chat_post_calls) == 1
|
||||
assert fake_web.chat_post_calls[0]["text"] == "hello\n"
|
||||
assert fake_web.chat_post_calls[0]["text"] == "hello"
|
||||
assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100"
|
||||
assert len(fake_web.file_upload_calls) == 1
|
||||
assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_omits_thread_for_dm_messages() -> None:
|
||||
async def test_send_omits_thread_for_dm_root_messages() -> None:
|
||||
"""DM root replies should not be threaded; metadata carries thread_ts=None."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
fake_web = _FakeAsyncWebClient()
|
||||
channel._web_client = fake_web
|
||||
@@ -138,17 +151,101 @@ async def test_send_omits_thread_for_dm_messages() -> None:
|
||||
chat_id="D123",
|
||||
content="hello",
|
||||
media=["/tmp/demo.txt"],
|
||||
metadata={"slack": {"thread_ts": "1700000000.000100", "channel_type": "im"}},
|
||||
metadata={"slack": {"thread_ts": None, "channel_type": "im"}},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(fake_web.chat_post_calls) == 1
|
||||
assert fake_web.chat_post_calls[0]["text"] == "hello\n"
|
||||
assert fake_web.chat_post_calls[0]["text"] == "hello"
|
||||
assert fake_web.chat_post_calls[0]["thread_ts"] is None
|
||||
assert len(fake_web.file_upload_calls) == 1
|
||||
assert fake_web.file_upload_calls[0]["thread_ts"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_keeps_thread_for_dm_thread_messages() -> None:
|
||||
"""When the user replies inside a DM thread, bot replies stay in the same thread."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
fake_web = _FakeAsyncWebClient()
|
||||
channel._web_client = fake_web
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="slack",
|
||||
chat_id="D123",
|
||||
content="hello",
|
||||
media=["/tmp/demo.txt"],
|
||||
metadata={
|
||||
"slack": {
|
||||
"thread_ts": "1700000000.000100",
|
||||
"channel_type": "im",
|
||||
"event": {"channel": "D123"},
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(fake_web.chat_post_calls) == 1
|
||||
assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100"
|
||||
assert len(fake_web.file_upload_calls) == 1
|
||||
assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_splits_long_messages() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
fake_web = _FakeAsyncWebClient()
|
||||
channel._web_client = fake_web
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
content="x" * (SLACK_MAX_MESSAGE_LEN + 10),
|
||||
)
|
||||
)
|
||||
|
||||
assert len(fake_web.chat_post_calls) == 2
|
||||
assert all(len(str(call["text"])) <= SLACK_MAX_MESSAGE_LEN for call in fake_web.chat_post_calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_renders_buttons_on_last_message_chunk() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
fake_web = _FakeAsyncWebClient()
|
||||
channel._web_client = fake_web
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
content="Choose one",
|
||||
buttons=[["Yes", "No"]],
|
||||
)
|
||||
)
|
||||
|
||||
assert len(fake_web.chat_post_calls) == 1
|
||||
blocks = fake_web.chat_post_calls[0]["blocks"]
|
||||
assert isinstance(blocks, list)
|
||||
assert blocks[-1] == {
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Yes"},
|
||||
"value": "Yes",
|
||||
"action_id": "ask_user_Yes",
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "No"},
|
||||
"value": "No",
|
||||
"action_id": "ask_user_No",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_updates_reaction_when_final_response_sent() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True, react_emoji="eyes"), MessageBus())
|
||||
@@ -195,7 +292,7 @@ async def test_send_resolves_channel_name_to_channel_id() -> None:
|
||||
)
|
||||
|
||||
assert fake_web.chat_post_calls == [
|
||||
{"channel": "C999", "text": "hello\n", "thread_ts": None}
|
||||
{"channel": "C999", "text": "hello", "thread_ts": None}
|
||||
]
|
||||
assert len(fake_web.conversations_list_calls) == 1
|
||||
|
||||
@@ -229,7 +326,7 @@ async def test_send_resolves_user_handle_to_dm_channel() -> None:
|
||||
|
||||
assert fake_web.conversations_open_calls == [{"users": "U234"}]
|
||||
assert fake_web.chat_post_calls == [
|
||||
{"channel": "D234", "text": "hello\n", "thread_ts": None}
|
||||
{"channel": "D234", "text": "hello", "thread_ts": None}
|
||||
]
|
||||
|
||||
|
||||
@@ -260,7 +357,7 @@ async def test_send_updates_reaction_on_origin_channel_for_cross_channel_send()
|
||||
)
|
||||
|
||||
assert fake_web.chat_post_calls == [
|
||||
{"channel": "C999", "text": "done\n", "thread_ts": None}
|
||||
{"channel": "C999", "text": "done", "thread_ts": None}
|
||||
]
|
||||
assert fake_web.reactions_remove_calls == [
|
||||
{"channel": "D_ORIGIN", "name": "eyes", "timestamp": "1700000000.000100"}
|
||||
@@ -298,7 +395,7 @@ async def test_send_does_not_reuse_origin_thread_ts_for_cross_channel_send() ->
|
||||
)
|
||||
|
||||
assert fake_web.chat_post_calls == [
|
||||
{"channel": "C999", "text": "done\n", "thread_ts": None}
|
||||
{"channel": "C999", "text": "done", "thread_ts": None}
|
||||
]
|
||||
|
||||
|
||||
@@ -316,3 +413,237 @@ async def test_send_raises_when_named_target_cannot_be_resolved() -> None:
|
||||
content="hello",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_thread_context_fetches_root_once() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
fake_web = _FakeAsyncWebClient()
|
||||
fake_web._conversations_replies_response = {
|
||||
"messages": [
|
||||
{"ts": "111.000", "user": "UROOT", "text": "drink water"},
|
||||
{"ts": "112.000", "user": "U2", "text": "good idea"},
|
||||
{"ts": "112.500", "user": "UBOT", "text": "I'll remind you."},
|
||||
{"ts": "113.000", "user": "U3", "text": "<@UBOT> what did you see?"},
|
||||
]
|
||||
}
|
||||
channel._web_client = fake_web
|
||||
|
||||
content = await channel._with_thread_context(
|
||||
"what did you see?",
|
||||
chat_id="C123",
|
||||
channel_type="channel",
|
||||
thread_ts="111.000",
|
||||
raw_thread_ts="111.000",
|
||||
current_ts="113.000",
|
||||
)
|
||||
|
||||
assert fake_web.conversations_replies_calls == [
|
||||
{"channel": "C123", "ts": "111.000", "limit": 20}
|
||||
]
|
||||
assert "Slack thread context before this mention:" in content
|
||||
assert "- <@UROOT>: drink water" in content
|
||||
assert "- <@U2>: good idea" in content
|
||||
assert "- bot: I'll remind you." in content
|
||||
assert "U3" not in content
|
||||
assert content.endswith("Current message:\nwhat did you see?")
|
||||
|
||||
second = await channel._with_thread_context(
|
||||
"again",
|
||||
chat_id="C123",
|
||||
channel_type="channel",
|
||||
thread_ts="111.000",
|
||||
raw_thread_ts="111.000",
|
||||
current_ts="114.000",
|
||||
)
|
||||
assert second == "again"
|
||||
assert len(fake_web.conversations_replies_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_thread_context_fetches_replies_in_dm_thread() -> None:
|
||||
"""DM threads should also pull thread history (not only channel threads)."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
fake_web = _FakeAsyncWebClient()
|
||||
fake_web._conversations_replies_response = {
|
||||
"messages": [
|
||||
{"ts": "211.000", "user": "UA", "text": "here is the file"},
|
||||
{"ts": "212.000", "user": "UA", "text": "please read it"},
|
||||
]
|
||||
}
|
||||
channel._web_client = fake_web
|
||||
|
||||
content = await channel._with_thread_context(
|
||||
"what did you see?",
|
||||
chat_id="D123",
|
||||
channel_type="im",
|
||||
thread_ts="211.000",
|
||||
raw_thread_ts="211.000",
|
||||
current_ts="213.000",
|
||||
)
|
||||
|
||||
assert fake_web.conversations_replies_calls == [
|
||||
{"channel": "D123", "ts": "211.000", "limit": 20}
|
||||
]
|
||||
assert "Slack thread context before this mention:" in content
|
||||
assert "- <@UA>: here is the file" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_root_message_has_no_thread_ts_and_no_thread_session() -> None:
|
||||
"""A top-level DM should not synthesize a thread_ts and uses the default session."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
req = SimpleNamespace(
|
||||
type="events_api",
|
||||
envelope_id="env-dm-root",
|
||||
payload={
|
||||
"event": {
|
||||
"type": "message",
|
||||
"user": "U1",
|
||||
"channel": "D123",
|
||||
"channel_type": "im",
|
||||
"text": "hello",
|
||||
"ts": "1700000000.000100",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
kwargs = channel._handle_message.await_args.kwargs
|
||||
assert kwargs["session_key"] is None
|
||||
assert kwargs["metadata"]["slack"]["thread_ts"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_thread_message_keeps_thread_ts_and_threaded_session() -> None:
|
||||
"""A DM message inside a real thread should preserve thread_ts and isolate the session."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
channel._with_thread_context = AsyncMock(return_value="hello") # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
req = SimpleNamespace(
|
||||
type="events_api",
|
||||
envelope_id="env-dm-thread",
|
||||
payload={
|
||||
"event": {
|
||||
"type": "message",
|
||||
"user": "U1",
|
||||
"channel": "D123",
|
||||
"channel_type": "im",
|
||||
"text": "hello",
|
||||
"ts": "1700000000.000200",
|
||||
"thread_ts": "1700000000.000100",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
kwargs = channel._handle_message.await_args.kwargs
|
||||
assert kwargs["session_key"] == "slack:D123:1700000000.000100"
|
||||
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_slash_command_skips_thread_context() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._with_thread_context = AsyncMock(return_value="wrapped") # type: ignore[method-assign]
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
req = SimpleNamespace(
|
||||
type="events_api",
|
||||
envelope_id="env-1",
|
||||
payload={
|
||||
"event": {
|
||||
"type": "app_mention",
|
||||
"user": "U1",
|
||||
"channel": "C123",
|
||||
"text": "<@UBOT> /restart",
|
||||
"thread_ts": "111.000",
|
||||
"ts": "112.000",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._with_thread_context.assert_not_awaited()
|
||||
channel._handle_message.assert_awaited_once()
|
||||
assert channel._handle_message.await_args.kwargs["content"] == "/restart"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_file_share_downloads_media_and_reaches_agent() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True, bot_token="xoxb-test"), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
channel._download_slack_file = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=("/tmp/report.pdf", "[file: report.pdf]")
|
||||
)
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
req = SimpleNamespace(
|
||||
type="events_api",
|
||||
envelope_id="env-file",
|
||||
payload={
|
||||
"event": {
|
||||
"type": "message",
|
||||
"subtype": "file_share",
|
||||
"user": "U1",
|
||||
"channel": "D123",
|
||||
"channel_type": "im",
|
||||
"text": "please read this",
|
||||
"ts": "1700000000.000100",
|
||||
"files": [
|
||||
{
|
||||
"id": "F123",
|
||||
"name": "report.pdf",
|
||||
"mimetype": "application/pdf",
|
||||
"url_private_download": "https://files.slack.com/report.pdf",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._download_slack_file.assert_awaited_once()
|
||||
channel._handle_message.assert_awaited_once()
|
||||
kwargs = channel._handle_message.await_args.kwargs
|
||||
assert kwargs["content"] == "please read this\n[file: report.pdf]"
|
||||
assert kwargs["media"] == ["/tmp/report.pdf"]
|
||||
|
||||
|
||||
def test_slack_download_rejects_login_html() -> None:
|
||||
html_response = httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/html; charset=utf-8"},
|
||||
content=b"<!doctype html><html><title>Sign in to Slack</title>",
|
||||
)
|
||||
markdown_response = httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/markdown"},
|
||||
content=b"# PR Extraction Guide\n",
|
||||
)
|
||||
|
||||
assert SlackChannel._looks_like_html_download(html_response) is True
|
||||
assert SlackChannel._looks_like_html_download(markdown_response) is False
|
||||
|
||||
|
||||
def test_slack_channel_uses_channel_aware_allow_policy() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
||||
assert channel.is_allowed("U1") is True
|
||||
assert channel._is_allowed("U1", "C123", "channel") is True
|
||||
|
||||
@@ -59,6 +59,9 @@ class _FakeBot:
|
||||
async def send_photo(self, **kwargs) -> None:
|
||||
self.sent_media.append({"kind": "photo", **kwargs})
|
||||
|
||||
async def send_video(self, **kwargs) -> None:
|
||||
self.sent_media.append({"kind": "video", **kwargs})
|
||||
|
||||
async def send_voice(self, **kwargs) -> None:
|
||||
self.sent_media.append({"kind": "voice", **kwargs})
|
||||
|
||||
@@ -1591,3 +1594,125 @@ async def test_send_delta_mid_stream_strips_markdown() -> None:
|
||||
assert "**" not in edited_text
|
||||
assert "Title" in edited_text
|
||||
assert "1. step" in edited_text
|
||||
|
||||
|
||||
def test_build_keyboard_respects_inline_keyboards_flag() -> None:
|
||||
"""``_build_keyboard`` returns ``None`` whenever the feature flag is off,
|
||||
regardless of whether buttons are provided; returns a proper Markup only
|
||||
when the flag is explicitly enabled. Pins the kill-switch so accidentally
|
||||
flipping the default doesn't silently expose callback handlers."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
off = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", inline_keyboards=False),
|
||||
MessageBus(),
|
||||
)
|
||||
assert off._build_keyboard([["A", "B"]]) is None
|
||||
|
||||
on = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", inline_keyboards=True),
|
||||
MessageBus(),
|
||||
)
|
||||
assert on._build_keyboard([]) is None # empty still no-op
|
||||
markup = on._build_keyboard([["Yes", "No"], ["Cancel"]])
|
||||
assert isinstance(markup, InlineKeyboardMarkup)
|
||||
rows = markup.inline_keyboard
|
||||
assert [[b.text for b in row] for row in rows] == [["Yes", "No"], ["Cancel"]]
|
||||
# callback_data mirrors label so _on_callback_query can echo the tap back.
|
||||
assert rows[0][0].callback_data == "Yes"
|
||||
|
||||
|
||||
def test_safe_callback_data_truncates_at_utf8_boundary() -> None:
|
||||
# Telegram's 64-byte callback_data cap is a hard API limit; silent 400s were the bug.
|
||||
short = "Yes"
|
||||
assert TelegramChannel._safe_callback_data(short) == short
|
||||
|
||||
long_ascii = "a" * 100
|
||||
out = TelegramChannel._safe_callback_data(long_ascii)
|
||||
assert len(out.encode("utf-8")) <= 64
|
||||
assert long_ascii.startswith(out)
|
||||
|
||||
# Multibyte labels must not split a codepoint mid-byte.
|
||||
long_cjk = "同意并继续下一步,我已阅读并同意了服务条款以及隐私政策"
|
||||
assert len(long_cjk.encode("utf-8")) > 64
|
||||
out = TelegramChannel._safe_callback_data(long_cjk)
|
||||
assert len(out.encode("utf-8")) <= 64
|
||||
assert long_cjk.startswith(out)
|
||||
out.encode("utf-8").decode("utf-8") # must round-trip cleanly
|
||||
|
||||
|
||||
def test_build_keyboard_uses_safe_callback_data_for_long_labels() -> None:
|
||||
# Pins the integration so a long-label payload survives ``send_message`` instead of 400ing.
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", inline_keyboards=True),
|
||||
MessageBus(),
|
||||
)
|
||||
long_label = "Approve and continue to the next step with the updated terms of service"
|
||||
assert len(long_label.encode("utf-8")) > 64
|
||||
|
||||
markup = channel._build_keyboard([[long_label]])
|
||||
btn = markup.inline_keyboard[0][0]
|
||||
assert btn.text == long_label # display preserved
|
||||
assert len(btn.callback_data.encode("utf-8")) <= 64
|
||||
assert long_label.startswith(btn.callback_data)
|
||||
|
||||
|
||||
def test_buttons_as_text_format_preserves_rows_and_labels() -> None:
|
||||
# Canonical shape: one row per line, labels bracketed. Layout survives the fallback.
|
||||
assert TelegramChannel._buttons_as_text([["Yes", "No"], ["Cancel"]]) == "[Yes] [No]\n[Cancel]"
|
||||
assert TelegramChannel._buttons_as_text([["Only"]]) == "[Only]"
|
||||
assert TelegramChannel._buttons_as_text([[], ["A"]]) == "[A]" # empty rows skipped
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_falls_back_buttons_to_inline_text_when_flag_off() -> None:
|
||||
"""Buttons are semantic options; with ``inline_keyboards=False`` we must
|
||||
splice labels into the text so users still see the choices. Silent-drop
|
||||
was the pre-fallback bug — the agent got a success reply while the user
|
||||
saw a question with no options."""
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], inline_keyboards=False),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="123",
|
||||
content="Proceed?",
|
||||
buttons=[["Yes", "No"], ["Cancel"]],
|
||||
)
|
||||
)
|
||||
|
||||
assert len(channel._app.bot.sent_messages) == 1
|
||||
sent = channel._app.bot.sent_messages[0]
|
||||
assert sent.get("reply_markup") is None
|
||||
assert "Proceed?" in sent["text"]
|
||||
assert "[Yes] [No]" in sent["text"]
|
||||
assert "[Cancel]" in sent["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_native_keyboard_when_flag_on() -> None:
|
||||
"""With the flag on, the content stays clean and buttons ride in ``reply_markup``."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], inline_keyboards=True),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="123",
|
||||
content="Proceed?",
|
||||
buttons=[["Yes", "No"]],
|
||||
)
|
||||
)
|
||||
|
||||
sent = channel._app.bot.sent_messages[0]
|
||||
assert isinstance(sent.get("reply_markup"), InlineKeyboardMarkup)
|
||||
assert "[Yes]" not in sent["text"] # native keyboard owns the rendering
|
||||
|
||||
@@ -26,6 +26,8 @@ from nanobot.channels.websocket import (
|
||||
_parse_query,
|
||||
_parse_request_path,
|
||||
)
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
|
||||
|
||||
@@ -178,6 +180,7 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None:
|
||||
content="hello",
|
||||
reply_to="m1",
|
||||
media=["/tmp/a.png"],
|
||||
buttons=[["Yes", "No"]],
|
||||
)
|
||||
await channel.send(msg)
|
||||
|
||||
@@ -185,9 +188,44 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None:
|
||||
payload = json.loads(mock_ws.send.call_args[0][0])
|
||||
assert payload["event"] == "message"
|
||||
assert payload["chat_id"] == "chat-1"
|
||||
assert payload["text"] == "hello"
|
||||
assert payload["text"] == "hello\n\n1. Yes\n2. No"
|
||||
assert payload["button_prompt"] == "hello"
|
||||
assert payload["reply_to"] == "m1"
|
||||
assert payload["media"] == ["/tmp/a.png"]
|
||||
assert payload["buttons"] == [["Yes", "No"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
|
||||
bus = MagicMock()
|
||||
media_root = tmp_path / "media"
|
||||
ws_media = media_root / "websocket"
|
||||
ws_media.mkdir(parents=True)
|
||||
external = tmp_path / "clip.mp4"
|
||||
external.write_bytes(b"video")
|
||||
|
||||
def fake_media_dir(channel: str | None = None):
|
||||
return ws_media if channel == "websocket" else media_root
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="video",
|
||||
media=[str(external)],
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(mock_ws.send.call_args[0][0])
|
||||
assert payload["media"] == [str(external)]
|
||||
assert payload["media_urls"][0]["name"] == "clip.mp4"
|
||||
assert payload["media_urls"][0]["url"].startswith("/api/media/")
|
||||
assert any(p.name.endswith("-clip.mp4") for p in ws_media.iterdir())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -403,6 +441,72 @@ async def test_http_route_issues_token_then_websocket_requires_it(bus: MagicMock
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
bus: MagicMock,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
port = 29891
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.agents.defaults.model = "openai/gpt-4o"
|
||||
config.providers.openai.api_key = "secret-key"
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
channel = _ch(bus, port=port)
|
||||
channel._api_tokens["tok"] = time.monotonic() + 300
|
||||
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
try:
|
||||
settings = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert settings.status_code == 200
|
||||
body = settings.json()
|
||||
assert body["agent"]["model"] == "openai/gpt-4o"
|
||||
assert body["agent"]["provider"] == "openai"
|
||||
assert {"name": "auto", "label": "Auto"} in body["providers"]
|
||||
assert body["agent"]["has_api_key"] is True
|
||||
assert "secret-key" not in settings.text
|
||||
|
||||
updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/update?model=openrouter/test"
|
||||
"&provider=openrouter",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["requires_restart"] is True
|
||||
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.model == "openrouter/test"
|
||||
assert saved.agents.defaults.provider == "openrouter"
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
def test_settings_payload_normalizes_camel_case_provider(
|
||||
bus: MagicMock,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.agents.defaults.provider = "minimaxAnthropic"
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
body = _ch(bus)._settings_payload()
|
||||
|
||||
assert body["agent"]["provider"] == "minimax_anthropic"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMock) -> None:
|
||||
port = 29880
|
||||
|
||||
Reference in New Issue
Block a user