Merge origin/main into fix/cron-contract-repeat-guard

Made-with: Cursor
This commit is contained in:
Xubin Ren
2026-04-18 19:42:48 +00:00
101 changed files with 9975 additions and 524 deletions
View File
+40
View File
@@ -65,6 +65,46 @@ class TestConsolidatorSummarize:
assert result is None
class TestConsolidatorArchiveErrorHandling:
"""archive() must fall back to raw_archive when the LLM returns an error
response (finish_reason == 'error'), e.g. overloaded / quota exceeded.
See https://github.com/HKUDS/nanobot/issues/3244
"""
async def test_archive_falls_back_on_error_finish_reason(self, consolidator, mock_provider, store):
"""LLM returning finish_reason='error' should trigger raw_archive, not write error text."""
mock_provider.chat_with_retry.return_value = MagicMock(
content="Error: {'type': 'error', 'error': {'type': 'overloaded_error', 'message': 'overloaded_error (529)'}}",
finish_reason="error",
)
messages = [
{"role": "user", "content": "fix the auth bug"},
{"role": "assistant", "content": "Done, fixed the race condition."},
]
result = await consolidator.archive(messages)
assert result is None
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert "[RAW]" in entries[0]["content"]
assert "Error:" not in entries[0]["content"]
async def test_archive_preserves_summary_on_success(self, consolidator, mock_provider, store):
"""Normal LLM response should still produce a proper summary entry."""
mock_provider.chat_with_retry.return_value = MagicMock(
content="User fixed a bug in the auth module.",
finish_reason="stop",
)
messages = [
{"role": "user", "content": "fix the auth bug"},
{"role": "assistant", "content": "Done."},
]
result = await consolidator.archive(messages)
assert result == "User fixed a bug in the auth module."
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert "[RAW]" not in entries[0]["content"]
class TestConsolidatorTokenBudget:
async def test_prompt_below_threshold_does_not_consolidate(self, consolidator):
"""No consolidation when tokens are within budget."""
+77 -2
View File
@@ -149,16 +149,39 @@ def test_partial_dream_processing_shows_only_remainder(tmp_path) -> None:
def test_execution_rules_in_system_prompt(tmp_path) -> None:
"""New execution rules should appear in the system prompt."""
"""Execution rules should appear in the system prompt via default SOUL.md."""
from nanobot.utils.helpers import sync_workspace_templates
workspace = _make_workspace(tmp_path)
sync_workspace_templates(workspace, silent=True)
builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt()
assert "Act, don't narrate" in prompt
assert "single-step tasks" in prompt
assert "multi-step tasks" in prompt
assert "Read before you write" in prompt
assert "verify the result" in prompt
def test_identity_has_no_behavioral_instructions(tmp_path) -> None:
"""Identity template should not contain behavioral rules or hardcoded name."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
identity = builder._get_identity(channel=None)
assert "You are nanobot" not in identity
assert "Act, don't narrate" not in identity
assert "Execution Rules" not in identity
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")
assert "## Execution Rules" in soul
assert "single-step tasks" in soul
assert "multi-step tasks" in soul
def test_channel_format_hint_telegram(tmp_path) -> None:
"""Telegram channel should get messaging-app format hint."""
workspace = _make_workspace(tmp_path)
@@ -219,3 +242,55 @@ def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path
for left, right in zip(messages, messages[1:]):
assert not (left.get("role") == right.get("role") == "assistant")
def test_always_skills_excluded_from_skills_index(tmp_path) -> None:
"""Always skills should appear in Active Skills but NOT in the skills index."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt()
# memory skill should be in Active Skills section
assert "# Active Skills" in prompt
assert "### Skill: memory" in prompt
# memory skill should NOT appear in the skills index
skills_section = prompt.split("# Skills\n", 1)
if len(skills_section) > 1:
index_text = skills_section[1].split("\n\n---")[0]
assert "**memory**" not in index_text
def test_template_memory_md_is_skipped(tmp_path) -> None:
"""MEMORY.md matching the bundled template should not inject the Memory section."""
workspace = _make_workspace(tmp_path)
from nanobot.utils.helpers import sync_workspace_templates
sync_workspace_templates(workspace, silent=True)
builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt()
# The "# Memory\n\n## Long-term Memory" block is produced only by
# build_system_prompt() when MEMORY.md is injected. The memory skill
# also contains "# Memory" but is followed by "## Structure", not
# "## Long-term Memory".
assert "# Memory\n\n## Long-term Memory" not in prompt
assert "This file is automatically updated by nanobot" not in prompt
def test_customized_memory_md_is_injected(tmp_path) -> None:
"""A Dream-populated MEMORY.md should be injected normally."""
workspace = _make_workspace(tmp_path)
from nanobot.utils.helpers import sync_workspace_templates
sync_workspace_templates(workspace, silent=True)
(workspace / "memory" / "MEMORY.md").write_text(
"# Long-term Memory\n\nUser prefers dark mode.\n", encoding="utf-8"
)
builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt()
assert "# Memory\n\n## Long-term Memory" in prompt
assert "User prefers dark mode" in prompt
+134 -1
View File
@@ -2,11 +2,12 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
from nanobot.agent.memory import Dream, MemoryStore
from nanobot.agent.runner import AgentRunResult
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
from nanobot.utils.gitstore import LineAge
@pytest.fixture
@@ -123,3 +124,135 @@ class TestDreamRun:
assert "Successfully wrote" in result
assert (store.workspace / "skills" / "test-skill" / "SKILL.md").exists()
async def test_phase1_prompt_includes_line_age_annotations(self, dream, mock_provider, mock_runner, store):
"""Phase 1 prompt should have per-line age suffixes in MEMORY.md when git is available."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
# Init git so line_ages works
store.git.init()
store.git.auto_commit("initial memory state")
await dream.run()
# The MEMORY.md section should not crash and should contain the memory content
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
assert "## Current MEMORY.md" in user_msg
async def test_phase1_annotates_only_memory_not_soul_or_user(self, dream, mock_provider, mock_runner, store):
"""SOUL.md and USER.md should never have age annotations — they are permanent."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
store.git.init()
store.git.auto_commit("initial state")
await dream.run()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
# The ← suffix should only appear in MEMORY.md section
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
soul_section = user_msg.split("## Current SOUL.md")[1].split("## Current USER.md")[0]
user_section = user_msg.split("## Current USER.md")[1]
# SOUL and USER should not contain age arrows
assert "\u2190" not in soul_section
assert "\u2190" not in user_section
async def test_phase1_prompt_works_without_git(self, dream, mock_provider, mock_runner, store):
"""Phase 1 should work fine even if git is not initialized (no age annotations)."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
await dream.run()
# Should still succeed — just without age annotations
mock_provider.chat_with_retry.assert_called_once()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
assert "## Current MEMORY.md" in user_msg
async def test_phase1_prompt_carries_age_suffix_for_stale_lines(
self, dream, mock_provider, mock_runner, store,
):
"""End-to-end: ages >14d must appear verbatim in the LLM prompt, ages ≤14d must not."""
# MEMORY.md fixture has 2 non-blank lines ("# Memory" and "- Project X active").
# Inject four ages to cover threshold boundaries: >14 suffix, ==14 no suffix, <14 no suffix.
store.write_memory("# Memory\n- Project X active\n- fresh item\n- edge case line")
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
fake_ages = [
LineAge(age_days=30), # "# Memory" → should get ← 30d
LineAge(age_days=20), # "- Project X..." → should get ← 20d
LineAge(age_days=14), # "- fresh item" → ==14, threshold is strictly >14, no suffix
LineAge(age_days=5), # "- edge case..." → no suffix
]
with patch.object(store.git, "line_ages", return_value=fake_ages):
await dream.run()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
assert "\u2190 30d" in memory_section
assert "\u2190 20d" in memory_section
assert "\u2190 14d" not in memory_section
assert "\u2190 5d" not in memory_section
async def test_phase1_skips_annotation_when_disabled(
self, dream, mock_provider, mock_runner, store,
):
"""`annotate_line_ages=False` must bypass the git lookup entirely and keep MEMORY.md raw."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
dream.annotate_line_ages = False
# line_ages must be bypassed entirely — verify with a spy rather than a
# raising side_effect, because _annotate_with_ages catches Exception
# (which swallows AssertionError) and would hide an accidental call.
with patch.object(store.git, "line_ages") as mock_line_ages:
await dream.run()
mock_line_ages.assert_not_called()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
assert "\u2190" not in user_msg
async def test_phase1_skips_annotation_on_line_ages_length_mismatch(
self, dream, mock_provider, mock_runner, store,
):
"""If ages length != lines length (dirty working tree), skip annotation instead of mis-tagging."""
# MEMORY.md has 2 non-blank lines but we hand back only 1 age → mismatch.
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
with patch.object(store.git, "line_ages", return_value=[LineAge(age_days=999)]):
await dream.run()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
# No age arrow at all — we refused to annotate rather than tag the wrong line.
assert "\u2190" not in memory_section
async def test_phase1_prompt_uses_threshold_from_template_var(
self, dream, mock_provider, mock_runner, store,
):
"""System prompt should reference the stale-threshold constant, not a hardcoded 14."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
await dream.run()
system_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][0]["content"]
# The template renders with stale_threshold_days=14 → LLM must see "N>14"
assert "N>14" in system_msg
+268
View File
@@ -1,3 +1,4 @@
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
@@ -308,3 +309,270 @@ async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(t
{"role": "assistant", "content": "new answer"},
]
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
@pytest.mark.asyncio
async def test_stop_preserves_runtime_checkpoint_for_next_turn(tmp_path: Path) -> None:
from nanobot.command.builtin import cmd_stop
from nanobot.command.router import CommandContext
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
checkpoint_saved = asyncio.Event()
async def interrupted_run_agent_loop(_initial_messages, *, session=None, **_kwargs):
assert session is not None
loop._set_runtime_checkpoint(
session,
{
"assistant_message": {
"role": "assistant",
"content": "working",
"tool_calls": [
{
"id": "call_done",
"type": "function",
"function": {"name": "read_file", "arguments": "{}"},
},
{
"id": "call_pending",
"type": "function",
"function": {"name": "exec", "arguments": "{}"},
},
],
},
"completed_tool_results": [
{
"role": "tool",
"tool_call_id": "call_done",
"name": "read_file",
"content": "ok",
}
],
"pending_tool_calls": [
{
"id": "call_pending",
"type": "function",
"function": {"name": "exec", "arguments": "{}"},
}
],
},
)
checkpoint_saved.set()
await asyncio.Event().wait()
loop._run_agent_loop = interrupted_run_agent_loop # type: ignore[method-assign]
first_msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="keep progress")
task = asyncio.create_task(loop._process_message(first_msg))
loop._active_tasks[first_msg.session_key] = [task]
await asyncio.wait_for(checkpoint_saved.wait(), timeout=1.0)
stop_msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="/stop")
stop_ctx = CommandContext(msg=stop_msg, session=None, key=stop_msg.session_key, raw="/stop", loop=loop)
stop_result = await cmd_stop(stop_ctx)
assert "Stopped 1 task" in stop_result.content
assert task.done()
loop.sessions.invalidate("feishu:c4")
interrupted = loop.sessions.get_or_create("feishu:c4")
assert interrupted.metadata.get(AgentLoop._PENDING_USER_TURN_KEY) is True
assert interrupted.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is not None
async def resumed_run_agent_loop(initial_messages, **_kwargs):
return (
"next answer",
None,
[*initial_messages, {"role": "assistant", "content": "next answer"}],
"stop",
False,
)
loop._run_agent_loop = resumed_run_agent_loop # type: ignore[method-assign]
result = await loop._process_message(
InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="continue here")
)
assert result is not None
assert result.content == "next answer"
session = loop.sessions.get_or_create("feishu:c4")
assert [
{k: v for k, v in m.items() if k in {"role", "content", "tool_call_id", "name"}}
for m in session.messages
] == [
{"role": "user", "content": "keep progress"},
{"role": "assistant", "content": "working"},
{"role": "tool", "tool_call_id": "call_done", "name": "read_file", "content": "ok"},
{
"role": "tool",
"tool_call_id": "call_pending",
"name": "exec",
"content": "Error: Task interrupted before this tool finished.",
},
{"role": "user", "content": "continue here"},
{"role": "assistant", "content": "next answer"},
]
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
assert AgentLoop._RUNTIME_CHECKPOINT_KEY not in session.metadata
@pytest.mark.asyncio
async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
session.add_message("user", "question")
session.add_message("assistant", "working")
loop.sessions.save(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]
await loop._process_message(
InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:test",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
)
)
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 non_system[2]["content"].count("subagent result") == 1
assert "Current Time:" in non_system[2]["content"]
loop.sessions.invalidate("cli:test")
persisted = loop.sessions.get_or_create("cli:test")
assert [
{k: v for k, v in m.items() if k in {"role", "content", "injected_event", "subagent_task_id"}}
for m in persisted.messages
] == [
{"role": "user", "content": "question"},
{"role": "assistant", "content": "working"},
{
"role": "assistant",
"content": "subagent result",
"injected_event": "subagent_result",
"subagent_task_id": "sub-1",
},
{"role": "assistant", "content": "done"},
]
@pytest.mark.asyncio
async def test_multiple_subagent_followups_all_persist_as_standalone_history(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
async def fake_run_agent_loop(initial_messages, **_kwargs):
return (
"ack",
[],
[*initial_messages, {"role": "assistant", "content": "ack"}],
"stop",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
for idx in range(3):
await loop._process_message(
InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:multi",
content=f"subagent result {idx}",
metadata={"subagent_task_id": f"sub-{idx}"},
)
)
loop.sessions.invalidate("cli:multi")
persisted = loop.sessions.get_or_create("cli:multi")
followups = [m for m in persisted.messages if m.get("injected_event") == "subagent_result"]
assert [m["content"] for m in followups] == [
"subagent result 0",
"subagent result 1",
"subagent result 2",
]
def test_prompt_merge_does_not_replace_standalone_subagent_history_entry(tmp_path: Path) -> None:
loop = _mk_loop()
session = Session(key="cli:merge")
session.add_message("assistant", "previous assistant")
inserted = loop._persist_subagent_followup(
session,
InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:merge",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
),
)
assert inserted is True
builder = ContextBuilder(tmp_path)
projected = builder.build_messages(
history=session.get_history(max_messages=0),
current_message="",
current_role="assistant",
channel="cli",
chat_id="merge",
)
non_system = [m for m in projected if m.get("role") != "system"]
assert len(non_system) == 2
assert "subagent result" in non_system[-1]["content"]
assert session.messages[-1]["content"] == "subagent result"
assert session.messages[-1]["injected_event"] == "subagent_result"
def test_subagent_followup_dedupes_by_task_id() -> None:
loop = _mk_loop()
session = Session(key="cli:dedupe")
msg = InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:dedupe",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
)
assert loop._persist_subagent_followup(session, msg) is True
assert loop._persist_subagent_followup(session, msg) is False
assert len(session.messages) == 1
def test_subagent_followup_skips_empty_content() -> None:
loop = _mk_loop()
session = Session(key="cli:empty")
msg = InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:empty",
content="",
metadata={"subagent_task_id": "sub-empty"},
)
assert loop._persist_subagent_followup(session, msg) is False
assert session.messages == []
+23
View File
@@ -79,6 +79,29 @@ class TestHistoryWithCursor:
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 2
def test_read_unprocessed_skips_entries_without_cursor(self, store):
"""Regression: entries missing the cursor key should be silently skipped."""
store.history_file.write_text(
'{"timestamp": "2026-04-01 10:00", "content": "no cursor"}\n'
'{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "valid"}\n'
'{"cursor": 3, "timestamp": "2026-04-01 10:02", "content": "also valid"}\n',
encoding="utf-8",
)
entries = store.read_unprocessed_history(since_cursor=0)
assert [e["cursor"] for e in entries] == [2, 3]
def test_next_cursor_falls_back_when_last_entry_has_no_cursor(self, store):
"""Regression: _next_cursor should not KeyError on entries without cursor."""
store.history_file.write_text(
'{"timestamp": "2026-04-01 10:01", "content": "no cursor"}\n',
encoding="utf-8",
)
# Delete .cursor file so _next_cursor falls back to reading JSONL
store._cursor_file.unlink(missing_ok=True)
# Last entry has no cursor — should safely return 1, not KeyError
cursor = store.append_history("new event")
assert cursor == 1
def test_compact_history_drops_oldest(self, tmp_path):
store = MemoryStore(tmp_path, max_history_entries=2)
store.append_history("event 1")
+467
View File
@@ -22,6 +22,9 @@ from nanobot.cli.onboard import (
_format_value,
_get_field_display_name,
_get_field_type_info,
_get_constraint_hint,
_input_text,
_validate_field_constraint,
run_onboard,
)
from nanobot.config.schema import Config
@@ -207,6 +210,25 @@ class TestGetFieldTypeInfo:
assert type_name == "str"
assert inner is None
def test_literal_type_returns_literal_with_choices(self):
"""Literal["a", "b"] should return ("literal", ["a", "b"])."""
from typing import Literal
class Model(BaseModel):
mode: Literal["standard", "persistent"] = "standard"
type_name, inner = _get_field_type_info(Model.model_fields["mode"])
assert type_name == "literal"
assert inner == ["standard", "persistent"]
def test_real_provider_retry_mode_field(self):
"""Validate against actual AgentDefaults.provider_retry_mode field."""
from nanobot.config.schema import AgentDefaults
type_name, inner = _get_field_type_info(AgentDefaults.model_fields["provider_retry_mode"])
assert type_name == "literal"
assert inner == ["standard", "persistent"]
class TestGetFieldDisplayName:
"""Tests for _get_field_display_name human-readable name generation."""
@@ -493,3 +515,448 @@ class TestRunOnboardExitBehavior:
assert result.should_save is False
assert result.config.model_dump(by_alias=True) == initial_config.model_dump(by_alias=True)
class TestValidateFieldConstraint:
"""Tests for _validate_field_constraint schema-aware input validation."""
def test_returns_none_when_no_constraints(self):
"""Fields without constraints should pass validation."""
from pydantic import BaseModel
class M(BaseModel):
name: str = "hello"
field_info = M.model_fields["name"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint("anything", field_info) is None
def test_rejects_value_below_ge_bound(self):
"""Value below ge (>=) bound should return error."""
from pydantic import BaseModel, Field
class M(BaseModel):
count: int = Field(default=3, ge=0)
field_info = M.model_fields["count"]
from nanobot.cli.onboard import _validate_field_constraint
result = _validate_field_constraint(-1, field_info)
assert result is not None
assert "0" in result
def test_accepts_value_at_ge_bound(self):
"""Value exactly at ge (>=) bound should pass."""
from pydantic import BaseModel, Field
class M(BaseModel):
count: int = Field(default=3, ge=0)
field_info = M.model_fields["count"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint(0, field_info) is None
def test_rejects_value_above_le_bound(self):
"""Value above le (<=) bound should return error."""
from pydantic import BaseModel, Field
class M(BaseModel):
retries: int = Field(default=3, le=10)
field_info = M.model_fields["retries"]
from nanobot.cli.onboard import _validate_field_constraint
result = _validate_field_constraint(11, field_info)
assert result is not None
assert "10" in result
def test_accepts_value_at_le_bound(self):
"""Value exactly at le (<=) bound should pass."""
from pydantic import BaseModel, Field
class M(BaseModel):
retries: int = Field(default=3, le=10)
field_info = M.model_fields["retries"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint(10, field_info) is None
def test_combined_ge_and_le_bounds(self):
"""Field with both ge and le should validate both."""
from pydantic import BaseModel, Field
class M(BaseModel):
retries: int = Field(default=3, ge=0, le=10)
field_info = M.model_fields["retries"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint(5, field_info) is None
assert _validate_field_constraint(-1, field_info) is not None
assert _validate_field_constraint(11, field_info) is not None
def test_gt_and_lt_bounds(self):
"""Strict inequality bounds (gt, lt) should exclude boundary."""
from pydantic import BaseModel, Field
class M(BaseModel):
ratio: float = Field(default=0.5, gt=0.0, lt=1.0)
field_info = M.model_fields["ratio"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint(0.5, field_info) is None
assert _validate_field_constraint(0.0, field_info) is not None
assert _validate_field_constraint(1.0, field_info) is not None
def test_min_length_constraint(self):
"""min_length should validate string/list length."""
from pydantic import BaseModel, Field
class M(BaseModel):
name: str = Field(default="x", min_length=1)
field_info = M.model_fields["name"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint("a", field_info) is None
assert _validate_field_constraint("", field_info) is not None
def test_max_length_constraint(self):
"""max_length should validate string/list length."""
from pydantic import BaseModel, Field
class M(BaseModel):
tag: str = Field(default="x", max_length=5)
field_info = M.model_fields["tag"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint("abc", field_info) is None
assert _validate_field_constraint("abcdef", field_info) is not None
def test_real_send_max_retries_field(self):
"""Validate against the actual ChannelsConfig.send_max_retries field."""
from nanobot.config.schema import ChannelsConfig
from nanobot.cli.onboard import _validate_field_constraint
field_info = ChannelsConfig.model_fields["send_max_retries"]
assert _validate_field_constraint(3, field_info) is None
assert _validate_field_constraint(0, field_info) is None
assert _validate_field_constraint(10, field_info) is None
assert _validate_field_constraint(-1, field_info) is not None
assert _validate_field_constraint(11, field_info) is not None
class TestGetConstraintHint:
"""Tests for _get_constraint_hint field display suffix."""
def test_no_constraints_returns_empty(self):
"""Fields without constraints should return empty string."""
from pydantic import BaseModel
class M(BaseModel):
name: str = "hello"
field_info = M.model_fields["name"]
assert _get_constraint_hint(field_info) == ""
def test_ge_le_range(self):
"""Field with ge+le should show '(min-max)'."""
from pydantic import BaseModel, Field
class M(BaseModel):
retries: int = Field(default=3, ge=0, le=10)
field_info = M.model_fields["retries"]
hint = _get_constraint_hint(field_info)
assert "0" in hint
assert "10" in hint
def test_ge_only(self):
"""Field with only ge should show '(>= N)'."""
from pydantic import BaseModel, Field
class M(BaseModel):
count: int = Field(default=1, ge=0)
field_info = M.model_fields["count"]
hint = _get_constraint_hint(field_info)
assert "0" in hint
assert ">=" in hint
def test_le_only(self):
"""Field with only le should show '(<= N)'."""
from pydantic import BaseModel, Field
class M(BaseModel):
ratio: float = Field(default=1.0, le=100.0)
field_info = M.model_fields["ratio"]
hint = _get_constraint_hint(field_info)
assert "100" in hint
assert "<=" in hint
def test_real_send_max_retries_hint(self):
"""Actual ChannelsConfig.send_max_retries should show '(0-10)'."""
from nanobot.config.schema import ChannelsConfig
field_info = ChannelsConfig.model_fields["send_max_retries"]
hint = _get_constraint_hint(field_info)
assert "0" in hint
assert "10" in hint
class TestInputTextWithValidation:
"""Tests for _input_text integration with constraint validation."""
def test_rejects_out_of_range_int(self, monkeypatch):
"""_input_text with field_info should reject values violating ge/le constraints."""
from pydantic import BaseModel, Field
class M(BaseModel):
retries: int = Field(default=3, ge=0, le=10)
field_info = M.model_fields["retries"]
monkeypatch.setattr(
onboard_wizard,
"_get_questionary",
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "15")),
)
result = _input_text("Retries", 3, "int", field_info=field_info)
assert result is None
def test_accepts_valid_int(self, monkeypatch):
"""_input_text with field_info should accept valid constrained values."""
from pydantic import BaseModel, Field
class M(BaseModel):
retries: int = Field(default=3, ge=0, le=10)
field_info = M.model_fields["retries"]
monkeypatch.setattr(
onboard_wizard,
"_get_questionary",
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "5")),
)
result = _input_text("Retries", 3, "int", field_info=field_info)
assert result == 5
def test_works_without_field_info(self, monkeypatch):
"""_input_text without field_info should work as before (no validation)."""
monkeypatch.setattr(
onboard_wizard,
"_get_questionary",
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "42")),
)
result = _input_text("Count", 0, "int")
assert result == 42
class TestChannelCommonRegistration:
"""Tests for Channel Common menu registration."""
def test_channel_common_in_settings_sections(self):
"""Channel Common should be registered in _SETTINGS_SECTIONS."""
from nanobot.cli.onboard import _SETTINGS_SECTIONS
assert "Channel Common" in _SETTINGS_SECTIONS
def test_channel_common_getter_returns_channels(self):
"""Channel Common getter should return config.channels."""
from nanobot.cli.onboard import _SETTINGS_GETTER
config = Config()
result = _SETTINGS_GETTER["Channel Common"](config)
assert result is config.channels
def test_channel_common_setter_writes_channels(self):
"""Channel Common setter should update config.channels."""
from nanobot.cli.onboard import _SETTINGS_SETTER
config = Config()
original = config.channels
new_channels = original.model_copy(deep=True)
new_channels.send_tool_hints = True
_SETTINGS_SETTER["Channel Common"](config, new_channels)
assert config.channels.send_tool_hints is True
def test_channel_common_edit_preserves_extras(self):
"""Editing Channel Common should not lose per-channel extras."""
config = Config()
config.channels.feishu = {"enabled": True, "appId": "test123"}
channels = config.channels.model_copy(deep=True)
channels.send_tool_hints = True
config.channels = channels
assert config.channels.send_tool_hints is True
assert config.channels.feishu["appId"] == "test123"
class TestApiServerRegistration:
"""Tests for API Server menu registration."""
def test_api_server_in_settings_sections(self):
"""API Server should be registered in _SETTINGS_SECTIONS."""
from nanobot.cli.onboard import _SETTINGS_SECTIONS
assert "API Server" in _SETTINGS_SECTIONS
def test_api_server_getter_returns_api(self):
"""API Server getter should return config.api."""
from nanobot.cli.onboard import _SETTINGS_GETTER
config = Config()
result = _SETTINGS_GETTER["API Server"](config)
assert result is config.api
def test_api_server_setter_writes_api(self):
"""API Server setter should update config.api."""
from nanobot.cli.onboard import _SETTINGS_SETTER
config = Config()
from nanobot.config.schema import ApiConfig
new_api = ApiConfig(host="0.0.0.0", port=9999)
_SETTINGS_SETTER["API Server"](config, new_api)
assert config.api.host == "0.0.0.0"
assert config.api.port == 9999
class TestMainMenuUpdate:
"""Tests for main menu including new Channel Common and API Server items."""
def test_main_menu_dispatch_includes_channel_common(self):
"""Main menu dispatch should route [H] to Channel Common."""
from nanobot.cli.onboard import run_onboard
# We verify by checking the dispatch table is set up correctly
# The menu items are defined inline in run_onboard, so we test
# that _configure_general_settings handles the new sections.
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
assert "Channel Common" in _SETTINGS_SECTIONS
assert "Channel Common" in _SETTINGS_GETTER
assert "Channel Common" in _SETTINGS_SETTER
def test_main_menu_dispatch_includes_api_server(self):
"""Main menu dispatch should route [I] to API Server."""
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
assert "API Server" in _SETTINGS_SECTIONS
assert "API Server" in _SETTINGS_GETTER
assert "API Server" in _SETTINGS_SETTER
def test_run_onboard_channel_common_edit(self, monkeypatch):
"""run_onboard should handle [H] Channel Common correctly."""
initial_config = Config()
responses = iter([
"[H] Channel Common",
KeyboardInterrupt(),
"[S] Save and Exit",
])
class FakePrompt:
def __init__(self, response):
self.response = response
def ask(self):
if isinstance(self.response, BaseException):
raise self.response
return self.response
def fake_select(*_args, **_kwargs):
return FakePrompt(next(responses))
def fake_configure_general_settings(config, section):
if section == "Channel Common":
config.channels.send_tool_hints = True
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
monkeypatch.setattr(onboard_wizard, "_configure_general_settings", fake_configure_general_settings)
result = run_onboard(initial_config=initial_config)
assert result.should_save is True
assert result.config.channels.send_tool_hints is True
def test_run_onboard_api_server_edit(self, monkeypatch):
"""run_onboard should handle [I] API Server correctly."""
initial_config = Config()
responses = iter([
"[I] API Server",
KeyboardInterrupt(),
"[S] Save and Exit",
])
class FakePrompt:
def __init__(self, response):
self.response = response
def ask(self):
if isinstance(self.response, BaseException):
raise self.response
return self.response
def fake_select(*_args, **_kwargs):
return FakePrompt(next(responses))
def fake_configure_general_settings(config, section):
if section == "API Server":
config.api.port = 9999
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
monkeypatch.setattr(onboard_wizard, "_configure_general_settings", fake_configure_general_settings)
result = run_onboard(initial_config=initial_config)
assert result.should_save is True
assert result.config.api.port == 9999
def test_view_summary_calls_pause(self, monkeypatch):
"""[V] View Summary should pause before returning to main menu."""
initial_config = Config()
pause_called = {"n": 0}
responses = iter([
"[V] View Configuration Summary",
"[S] Save and Exit",
])
class FakePrompt:
def __init__(self, response):
self.response = response
def ask(self):
if isinstance(self.response, BaseException):
raise self.response
return self.response
def fake_select(*_args, **_kwargs):
return FakePrompt(next(responses))
def fake_pause():
pause_called["n"] += 1
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
# _pause is called inside _show_summary, so we patch it there
monkeypatch.setattr(onboard_wizard, "_pause", fake_pause)
# Suppress summary output but still call _pause
monkeypatch.setattr(onboard_wizard, "_print_summary_panel", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "_get_provider_names", lambda: {})
monkeypatch.setattr(onboard_wizard, "_get_channel_names", lambda: {})
result = run_onboard(initial_config=initial_config)
assert result.should_save is True
assert pause_called["n"] == 1
+236 -8
View File
@@ -643,10 +643,11 @@ def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch
trimmed = runner._snip_history(spec, messages)
assert trimmed == [
{"role": "system", "content": "system"},
{"role": "assistant", "content": "after tool"},
]
# After the fix, the user message is recovered so the sequence is valid
# for providers that require system → user (e.g. GLM error 1214).
assert trimmed[0]["role"] == "system"
non_system = [m for m in trimmed if m["role"] != "system"]
assert non_system[0]["role"] == "user", f"Expected user after system, got {non_system[0]['role']}"
@pytest.mark.asyncio
@@ -689,11 +690,20 @@ async def test_runner_keeps_going_when_tool_result_persistence_fails():
class _DelayTool(Tool):
def __init__(self, name: str, *, delay: float, read_only: bool, shared_events: list[str]):
def __init__(
self,
name: str,
*,
delay: float,
read_only: bool,
shared_events: list[str],
exclusive: bool = False,
):
self._name = name
self._delay = delay
self._read_only = read_only
self._shared_events = shared_events
self._exclusive = exclusive
@property
def name(self) -> str:
@@ -711,6 +721,10 @@ class _DelayTool(Tool):
def read_only(self) -> bool:
return self._read_only
@property
def exclusive(self) -> bool:
return self._exclusive
async def execute(self, **kwargs):
self._shared_events.append(f"start:{self._name}")
await asyncio.sleep(self._delay)
@@ -756,6 +770,48 @@ async def test_runner_batches_read_only_tools_before_exclusive_work():
assert shared_events[-2:] == ["start:write_a", "end:write_a"]
@pytest.mark.asyncio
async def test_runner_does_not_batch_exclusive_read_only_tools():
from nanobot.agent.runner import AgentRunSpec, AgentRunner
tools = ToolRegistry()
shared_events: list[str] = []
read_a = _DelayTool("read_a", delay=0.03, read_only=True, shared_events=shared_events)
read_b = _DelayTool("read_b", delay=0.03, read_only=True, shared_events=shared_events)
ddg_like = _DelayTool(
"ddg_like",
delay=0.01,
read_only=True,
shared_events=shared_events,
exclusive=True,
)
tools.register(read_a)
tools.register(ddg_like)
tools.register(read_b)
runner = AgentRunner(MagicMock())
await runner._execute_tools(
AgentRunSpec(
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
concurrent_tools=True,
),
[
ToolCallRequest(id="ro1", name="read_a", arguments={}),
ToolCallRequest(id="ddg1", name="ddg_like", arguments={}),
ToolCallRequest(id="ro2", name="read_b", arguments={}),
],
{},
)
assert shared_events[0] == "start:read_a"
assert shared_events.index("end:read_a") < shared_events.index("start:ddg_like")
assert shared_events.index("end:ddg_like") < shared_events.index("start:read_b")
@pytest.mark.asyncio
async def test_runner_blocks_repeated_external_fetches():
from nanobot.agent.runner import AgentRunSpec, AgentRunner
@@ -1060,7 +1116,7 @@ async def test_runner_tool_error_sets_final_content():
@pytest.mark.asyncio
async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, monkeypatch):
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.bus.queue import MessageBus
bus = MessageBus()
@@ -1083,7 +1139,8 @@ async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, mon
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic())
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status)
mgr._announce_result.assert_awaited_once()
args = mgr._announce_result.await_args.args
@@ -2773,4 +2830,175 @@ async def test_injection_cycle_cap_on_error_path():
assert result.had_injections is True
# Should cap: _MAX_INJECTION_CYCLES drained rounds + 1 final round that breaks
assert call_count["n"] == _MAX_INJECTION_CYCLES + 1
assert drain_count["n"] == _MAX_INJECTION_CYCLES
# ---------------------------------------------------------------------------
# Regression tests for GLM-1214: _snip_history must preserve a user message
# ---------------------------------------------------------------------------
def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
"""When _snip_history truncates messages and the only user message ends up
outside the kept window, the method must recover the nearest user message
so the resulting sequence is valid for providers like GLM (which reject
system→assistant with error 1214).
This reproduces the exact scenario from the bug report:
- Normal interaction: user asks, assistant calls tool, tool returns,
assistant replies.
- Injection adds a phantom user message, triggering more tool calls.
- _snip_history activates, keeping only recent assistant/tool pairs.
- The injected user message is in the truncated prefix and gets lost.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner(provider)
messages = [
{"role": "system", "content": "system"},
{"role": "assistant", "content": "previous reply"},
{"role": "user", "content": ".nanobot的同目录"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "tc_1", "type": "function", "function": {"name": "exec", "arguments": "{}"}}],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "tool output 1"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "tc_2", "type": "function", "function": {"name": "exec", "arguments": "{}"}}],
},
{"role": "tool", "tool_call_id": "tc_2", "content": "tool output 2"},
]
spec = AgentRunSpec(
initial_messages=messages,
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
context_window_tokens=2000,
context_block_limit=100,
)
# Make estimate_prompt_tokens_chain report above budget so _snip_history activates.
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_a, **_kw: (500, None))
# Make kept window small: only the last 2 messages fit the budget.
token_sizes = {
"system": 0,
"previous reply": 200,
".nanobot的同目录": 80,
"tool output 1": 80,
"tool output 2": 80,
}
monkeypatch.setattr(
"nanobot.agent.runner.estimate_message_tokens",
lambda msg: token_sizes.get(str(msg.get("content")), 100),
)
trimmed = runner._snip_history(spec, messages)
# The first non-system message MUST be user (not assistant).
non_system = [m for m in trimmed if m.get("role") != "system"]
assert non_system, "trimmed should contain at least one non-system message"
assert non_system[0]["role"] == "user", (
f"First non-system message must be 'user', got '{non_system[0]['role']}'. "
f"Roles: {[m['role'] for m in trimmed]}"
)
def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch):
"""Edge case: if non_system has zero user messages, _snip_history should
still return a valid sequence (not crash or produce system→assistant)."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner(provider)
messages = [
{"role": "system", "content": "system"},
{"role": "assistant", "content": "reply"},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
{"role": "assistant", "content": "reply 2"},
{"role": "tool", "tool_call_id": "tc_2", "content": "result 2"},
]
spec = AgentRunSpec(
initial_messages=messages,
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
context_window_tokens=2000,
context_block_limit=100,
)
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_a, **_kw: (500, None))
monkeypatch.setattr(
"nanobot.agent.runner.estimate_message_tokens",
lambda msg: 100,
)
trimmed = runner._snip_history(spec, messages)
# Should not crash. The result should still be a valid list.
assert isinstance(trimmed, list)
# Must have at least system.
assert any(m.get("role") == "system" for m in trimmed)
# The _enforce_role_alternation safety net must be able to fix whatever
# _snip_history returns here — verify it produces a valid sequence.
from nanobot.providers.base import LLMProvider
fixed = LLMProvider._enforce_role_alternation(trimmed)
non_system = [m for m in fixed if m["role"] != "system"]
if non_system:
assert non_system[0]["role"] in ("user", "tool"), (
f"Safety net should ensure first non-system is user/tool, got {non_system[0]['role']}"
)
@pytest.mark.asyncio
async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
"""Regression: provider retry heartbeats must route through
``retry_wait_callback``, not ``progress_callback``. Binding them to
the progress callback (as an earlier runtime refactor did) caused
internal retry diagnostics like "Model request failed, retry in 1s"
to leak to end-user channels as normal progress updates.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
captured: dict = {}
async def chat_with_retry(**kwargs):
captured.update(kwargs)
return LLMResponse(content="done", tool_calls=[], usage={})
provider = MagicMock()
provider.chat_with_retry = chat_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
progress_cb = AsyncMock()
retry_wait_cb = AsyncMock()
runner = AgentRunner(provider)
await runner.run(AgentRunSpec(
initial_messages=[
{"role": "system", "content": "system"},
{"role": "user", "content": "hi"},
],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
retry_wait_callback=retry_wait_cb,
))
assert captured["on_retry_wait"] is retry_wait_cb
assert captured["on_retry_wait"] is not progress_cb
+87
View File
@@ -310,3 +310,90 @@ def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None
always = loader.get_always_skills()
assert "alpha" not in always
assert "beta" in always
# -- multiline description tests (YAML folded > and literal |) -----------------
def test_build_skills_summary_folded_description(tmp_path: Path) -> None:
"""description: > (YAML folded scalar) should be parsed correctly."""
workspace = tmp_path / "ws"
ws_skills = workspace / "skills"
ws_skills.mkdir(parents=True)
skill_dir = ws_skills / "pdf"
skill_dir.mkdir(parents=True)
skill_path = skill_dir / "SKILL.md"
skill_path.write_text(
"---\n"
"name: pdf\n"
"description: >\n"
" Use this skill when visual quality and design identity matter for a PDF.\n"
" CREATE (generate from scratch): \"make a PDF\".\n"
"---\n\n# PDF Skill\n",
encoding="utf-8",
)
builtin = tmp_path / "builtin"
builtin.mkdir()
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
summary = loader.build_skills_summary()
assert "pdf" in summary
assert "visual quality" in summary
def test_build_skills_summary_literal_description(tmp_path: Path) -> None:
"""description: | (YAML literal scalar) should be parsed correctly."""
workspace = tmp_path / "ws"
ws_skills = workspace / "skills"
ws_skills.mkdir(parents=True)
skill_dir = ws_skills / "multi"
skill_dir.mkdir(parents=True)
skill_path = skill_dir / "SKILL.md"
skill_path.write_text(
"---\n"
"name: multi\n"
"description: |\n"
" Line one of description.\n"
" Line two of description.\n"
"---\n\n# Multi\n",
encoding="utf-8",
)
builtin = tmp_path / "builtin"
builtin.mkdir()
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
meta = loader.get_skill_metadata("multi")
assert meta is not None
desc = meta.get("description")
assert isinstance(desc, str)
assert "Line one" in desc
assert "Line two" in desc
def test_get_skill_metadata_handles_yaml_types(tmp_path: Path) -> None:
"""yaml.safe_load returns native types; always should be True, not 'true'."""
workspace = tmp_path / "ws"
ws_skills = workspace / "skills"
ws_skills.mkdir(parents=True)
skill_dir = ws_skills / "typed"
skill_dir.mkdir(parents=True)
payload = json.dumps({"nanobot": {"requires": {"bins": ["gh"]}, "always": True}}, separators=(",", ":"))
skill_path = skill_dir / "SKILL.md"
skill_path.write_text(
"---\n"
"name: typed\n"
f"metadata: {payload}\n"
"always: true\n"
"---\n\n# Typed\n",
encoding="utf-8",
)
builtin = tmp_path / "builtin"
builtin.mkdir()
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
meta = loader.get_skill_metadata("typed")
assert meta is not None
# YAML parsed 'true' to Python True
assert meta.get("always") is True
# metadata is a parsed dict, not a JSON string
assert isinstance(meta.get("metadata"), dict)
+15 -5
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@@ -269,7 +270,9 @@ class TestSubagentCancellation:
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
from nanobot.agent.subagent import SubagentStatus
status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic())
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status)
assistant_messages = [
msg for msg in captured_second_call
@@ -308,7 +311,9 @@ class TestSubagentCancellation:
mgr.runner.run = AsyncMock(side_effect=fake_run)
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
from nanobot.agent.subagent import SubagentStatus
status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic())
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status)
mgr.runner.run.assert_awaited_once()
mgr._announce_result.assert_awaited_once()
@@ -344,7 +349,9 @@ class TestSubagentCancellation:
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
from nanobot.agent.subagent import SubagentStatus
status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic())
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status)
mgr._announce_result.assert_awaited_once()
args = mgr._announce_result.await_args.args
@@ -356,7 +363,7 @@ class TestSubagentCancellation:
@pytest.mark.asyncio
async def test_cancel_by_session_cancels_running_subagent_tool(self, monkeypatch, tmp_path):
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -389,7 +396,10 @@ class TestSubagentCancellation:
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
task = asyncio.create_task(
mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
mgr._run_subagent(
"sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"},
SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic()),
)
)
mgr._running_tasks["sub-1"] = task
mgr._session_tasks["test:c1"] = {"sub-1"}
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
"""Tests for subagent tool registration and wiring."""
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.config.schema import AgentDefaults
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@pytest.mark.asyncio
async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path):
"""allowed_env_keys from ExecToolConfig must be forwarded to the subagent's ExecTool."""
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ExecToolConfig
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
mgr = SubagentManager(
provider=provider,
workspace=tmp_path,
bus=bus,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
exec_config=ExecToolConfig(allowed_env_keys=["GOPATH", "JAVA_HOME"]),
)
mgr._announce_result = AsyncMock()
async def fake_run(spec):
exec_tool = spec.tools.get("exec")
assert exec_tool is not None
assert exec_tool.allowed_env_keys == ["GOPATH", "JAVA_HOME"]
return SimpleNamespace(
stop_reason="done",
final_content="done",
error=None,
tool_events=[],
)
mgr.runner.run = AsyncMock(side_effect=fake_run)
status = SubagentStatus(
task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic()
)
await mgr._run_subagent(
"sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status
)
mgr.runner.run.assert_awaited_once()
+12
View File
@@ -23,3 +23,15 @@ def test_is_allowed_requires_exact_match() -> None:
assert channel.is_allowed("allow@email.com") is True
assert channel.is_allowed("attacker|allow@email.com") is False
def test_is_allowed_supports_dict_allow_from_alias() -> None:
channel = _DummyChannel({"allowFrom": ["alice"]}, MessageBus())
assert channel.is_allowed("alice") is True
def test_is_allowed_denies_empty_dict_allow_from() -> None:
channel = _DummyChannel({"allow_from": []}, MessageBus())
assert channel.is_allowed("alice") is False
@@ -296,3 +296,50 @@ class TestDispatchOutboundWithCoalescing:
# Should have pending regular message
assert len(pending) == 1
assert pending[0].content == "Final"
class TestRetryWaitFiltering:
"""Internal provider retry heartbeats must never reach channels."""
@pytest.mark.asyncio
async def test_retry_wait_message_dropped(self, manager, bus):
"""A ``_retry_wait`` message must be filtered before channel dispatch.
Regression: provider retry diagnostics like
``Model request failed, retry in 1s (attempt 1).`` were being
delivered to end-user channels because the runner bound
``on_retry_wait`` to the progress callback.
"""
retry_msg = OutboundMessage(
channel="mock",
chat_id="chat1",
content="Model request failed, retry in 1s (attempt 1).",
metadata={"_retry_wait": True},
)
real_msg = OutboundMessage(
channel="mock",
chat_id="chat1",
content="final answer",
metadata={},
)
await bus.publish_outbound(retry_msg)
await bus.publish_outbound(real_msg)
task = asyncio.create_task(manager._dispatch_outbound())
try:
for _ in range(30):
if manager.channels["mock"]._send_mock.await_count >= 1:
break
await asyncio.sleep(0.05)
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
send_mock = manager.channels["mock"]._send_mock
assert send_mock.await_count == 1
sent = send_mock.await_args_list[0].args[0]
assert sent.content == "final answer"
assert not sent.metadata.get("_retry_wait")
+131 -2
View File
@@ -175,7 +175,7 @@ async def test_manager_loads_plugin_from_dict_config():
channels=ChannelsConfig.model_validate({
"fakeplugin": {"enabled": True, "allowFrom": ["*"]},
}),
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
providers=SimpleNamespace(groq=SimpleNamespace(api_key="", api_base="")),
)
with patch(
@@ -193,6 +193,113 @@ async def test_manager_loads_plugin_from_dict_config():
assert isinstance(mgr.channels["fakeplugin"], _FakePlugin)
@pytest.mark.asyncio
async def test_manager_propagates_groq_transcription_api_base_to_channels():
from nanobot.channels.manager import ChannelManager
fake_config = SimpleNamespace(
channels=ChannelsConfig.model_validate({
"fakeplugin": {"enabled": True, "allowFrom": ["*"]},
}),
transcription_provider="groq",
providers=SimpleNamespace(
groq=SimpleNamespace(api_key="groq-key", api_base="http://proxy.local/v1/audio/transcriptions"),
openai=SimpleNamespace(api_key="openai-key", api_base="https://api.openai.com/v1/audio/transcriptions"),
),
)
with patch(
"nanobot.channels.registry.discover_all",
return_value={"fakeplugin": _FakePlugin},
):
mgr = ChannelManager.__new__(ChannelManager)
mgr.config = fake_config
mgr.bus = MessageBus()
mgr.channels = {}
mgr._dispatch_task = None
mgr._init_channels()
channel = mgr.channels["fakeplugin"]
assert channel.transcription_provider == "groq"
assert channel.transcription_api_key == "groq-key"
assert channel.transcription_api_base == "http://proxy.local/v1/audio/transcriptions"
@pytest.mark.asyncio
async def test_manager_propagates_openai_transcription_api_base_to_channels():
from nanobot.channels.manager import ChannelManager
fake_config = SimpleNamespace(
channels=ChannelsConfig.model_validate({
"fakeplugin": {"enabled": True, "allowFrom": ["*"]},
"transcriptionProvider": "openai",
}),
providers=SimpleNamespace(
openai=SimpleNamespace(
api_key="openai-key",
api_base="http://proxy.local/v1/audio/transcriptions",
),
groq=SimpleNamespace(api_key="groq-key", api_base=""),
),
)
with patch(
"nanobot.channels.registry.discover_all",
return_value={"fakeplugin": _FakePlugin},
):
mgr = ChannelManager.__new__(ChannelManager)
mgr.config = fake_config
mgr.bus = MessageBus()
mgr.channels = {}
mgr._dispatch_task = None
mgr._init_channels()
channel = mgr.channels["fakeplugin"]
assert channel.transcription_provider == "openai"
assert channel.transcription_api_key == "openai-key"
assert channel.transcription_api_base == "http://proxy.local/v1/audio/transcriptions"
@pytest.mark.asyncio
async def test_base_channel_passes_api_base_to_openai_transcription_provider():
"""BaseChannel.transcribe_audio must forward transcription_api_base to OpenAI."""
from nanobot.providers import transcription as transcription_mod
channel = _FakePlugin({"enabled": True, "allowFrom": ["*"]}, MessageBus())
channel.transcription_provider = "openai"
channel.transcription_api_key = "k"
channel.transcription_api_base = "http://override/v1/audio/transcriptions"
captured: dict[str, object] = {}
class _StubOpenAI:
def __init__(self, api_key=None, api_base=None):
captured["api_key"] = api_key
captured["api_base"] = api_base
async def transcribe(self, file_path):
return "ok"
with patch.object(transcription_mod, "OpenAITranscriptionProvider", _StubOpenAI):
result = await channel.transcribe_audio("/tmp/does-not-matter.wav")
assert result == "ok"
assert captured["api_key"] == "k"
assert captured["api_base"] == "http://override/v1/audio/transcriptions"
def test_openai_transcription_provider_honors_api_base_argument():
from nanobot.providers.transcription import OpenAITranscriptionProvider
default = OpenAITranscriptionProvider(api_key="k")
assert default.api_url == "https://api.openai.com/v1/audio/transcriptions"
custom = OpenAITranscriptionProvider(
api_key="k", api_base="http://override/v1/audio/transcriptions"
)
assert custom.api_url == "http://override/v1/audio/transcriptions"
def test_channels_login_uses_discovered_plugin_class(monkeypatch):
from nanobot.cli.commands import app
from nanobot.config.schema import Config
@@ -646,7 +753,10 @@ class _ChannelWithAllowFrom(BaseChannel):
def __init__(self, config, bus, allow_from):
super().__init__(config, bus)
self.config.allow_from = allow_from
if isinstance(self.config, dict):
self.config["allow_from"] = allow_from
else:
self.config.allow_from = allow_from
async def start(self) -> None:
pass
@@ -714,6 +824,25 @@ async def test_validate_allow_from_passes_with_asterisk():
mgr._validate_allow_from()
@pytest.mark.asyncio
async def test_validate_allow_from_raises_on_empty_dict_allow_from():
"""_validate_allow_from should reject empty dict-backed allow_from lists."""
fake_config = SimpleNamespace(
channels=ChannelsConfig(),
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
)
mgr = ChannelManager.__new__(ChannelManager)
mgr.config = fake_config
mgr.channels = {"test": _ChannelWithAllowFrom({"enabled": True}, None, [])}
mgr._dispatch_task = None
with pytest.raises(SystemExit) as exc_info:
mgr._validate_allow_from()
assert "empty allowFrom" in str(exc_info.value)
@pytest.mark.asyncio
async def test_get_channel_returns_channel_if_exists():
"""get_channel should return the channel if it exists."""
+39
View File
@@ -313,6 +313,45 @@ async def test_on_message_accepts_allowlisted_dm() -> None:
assert handled[0]["metadata"] == {"message_id": "789", "guild_id": None, "reply_to": None}
@pytest.mark.asyncio
async def test_on_message_accepts_when_channel_in_allow_channels() -> None:
# When allow_channels is set, messages from listed channels should be forwarded.
channel = DiscordChannel(
DiscordConfig(enabled=True, allow_from=["*"], allow_channels=["456"]),
MessageBus(),
)
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
await channel._on_message(_make_message(author_id=123, channel_id=456))
assert len(handled) == 1
assert handled[0]["chat_id"] == "456"
@pytest.mark.asyncio
async def test_on_message_drops_when_channel_not_in_allow_channels() -> None:
# When allow_channels is set and incoming channel is not listed, drop silently.
channel = DiscordChannel(
DiscordConfig(enabled=True, allow_from=["*"], allow_channels=["999"]),
MessageBus(),
)
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
await channel._on_message(_make_message(author_id=123, channel_id=456))
assert handled == []
@pytest.mark.asyncio
async def test_on_message_ignores_unmentioned_guild_message() -> None:
# With mention-only group policy, guild messages without a bot mention are dropped.
+103
View File
@@ -92,6 +92,109 @@ def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None:
assert items_again == []
def test_fetch_new_messages_skips_self_sent_email_and_marks_seen(monkeypatch) -> None:
raw = _make_raw_email(from_addr="Nanobot <bot@example.com>", subject="Loop test")
class FakeIMAP:
def __init__(self) -> None:
self.store_calls: list[tuple[bytes, str, str]] = []
def login(self, _user: str, _pw: str):
return "OK", [b"logged in"]
def select(self, _mailbox: str):
return "OK", [b"1"]
def search(self, *_args):
return "OK", [b"1"]
def fetch(self, _imap_id: bytes, _parts: str):
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
def store(self, imap_id: bytes, op: str, flags: str):
self.store_calls.append((imap_id, op, flags))
return "OK", [b""]
def logout(self):
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(_make_config(from_address="bot@example.com"), MessageBus())
items = channel._fetch_new_messages()
assert items == []
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
# Same UID should still be deduped after being ignored.
items_again = channel._fetch_new_messages()
assert items_again == []
@pytest.mark.parametrize(
"config_override,from_header",
[
# Only smtp_username matches — simulates an SMTP relay where
# outbound From gets rewritten to the SMTP login identity.
(
{"from_address": "", "smtp_username": "bot@example.com", "imap_username": "other@imap.com"},
"bot@example.com",
),
# Only imap_username matches — simulates mailbox-based identity
# with no explicit from_address set.
(
{"from_address": "", "smtp_username": "other@smtp.com", "imap_username": "bot@example.com"},
"bot@example.com",
),
# Case-insensitive: inbound From arrives upper-cased.
(
{"from_address": "bot@example.com", "smtp_username": "other@smtp.com", "imap_username": "other@imap.com"},
"BOT@EXAMPLE.COM",
),
],
ids=["smtp_username_only", "imap_username_only", "case_insensitive"],
)
def test_fetch_new_messages_skips_self_sent_across_identity_sources(
monkeypatch, config_override, from_header
) -> None:
"""Self-address detection must fire when any of from_address / smtp_username /
imap_username matches, and must be case-insensitive."""
raw = _make_raw_email(from_addr=from_header, subject="Loop test")
class FakeIMAP:
def __init__(self) -> None:
self.store_calls: list[tuple[bytes, str, str]] = []
def login(self, _user: str, _pw: str):
return "OK", [b"logged in"]
def select(self, _mailbox: str):
return "OK", [b"1"]
def search(self, *_args):
return "OK", [b"1"]
def fetch(self, _imap_id: bytes, _parts: str):
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
def store(self, imap_id: bytes, op: str, flags: str):
self.store_calls.append((imap_id, op, flags))
return "OK", [b""]
def logout(self):
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(_make_config(**config_override), MessageBus())
items = channel._fetch_new_messages()
assert items == []
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeypatch) -> None:
raw = _make_raw_email(subject="Invoice", body="Please pay")
fail_once = {"pending": True}
+9 -56
View File
@@ -205,53 +205,22 @@ class TestSendDelta:
ch._client.im.v1.message.create.assert_called_once()
@pytest.mark.asyncio
async def test_stream_end_resuming_keeps_buffer(self):
"""_resuming=True flushes text to card but keeps the buffer for the next segment."""
async def test_stream_end_fallback_when_final_update_fails(self):
"""If streaming mode was closed (e.g. Feishu timeout), fall back to a regular card."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0,
text="Lost content", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(success=False)
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True, "_resuming": True})
assert "oc_chat1" in ch._stream_bufs
buf = ch._stream_bufs["oc_chat1"]
assert buf.card_id == "card_1"
assert buf.sequence == 3
ch._client.cardkit.v1.card_element.content.assert_called_once()
ch._client.cardkit.v1.card.settings.assert_not_called()
@pytest.mark.asyncio
async def test_stream_end_resuming_then_final_end(self):
"""Full multi-segment flow: resuming mid-turn, then final end closes the card."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Seg1", card_id="card_1", sequence=1, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True, "_resuming": True})
assert "oc_chat1" in ch._stream_bufs
ch._stream_bufs["oc_chat1"].text += " Seg2"
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
assert "oc_chat1" not in ch._stream_bufs
ch._client.cardkit.v1.card.settings.assert_called_once()
@pytest.mark.asyncio
async def test_stream_end_resuming_no_card_is_noop(self):
"""_resuming with no card_id (card creation failed) is a safe no-op."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="text", card_id=None, sequence=0, last_edit=0.0,
)
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True, "_resuming": True})
assert "oc_chat1" in ch._stream_bufs
ch._client.cardkit.v1.card_element.content.assert_not_called()
# Should NOT attempt to close streaming mode since update failed
ch._client.cardkit.v1.card.settings.assert_not_called()
# Should fall back to sending a regular interactive card
ch._client.im.v1.message.create.assert_called_once()
@pytest.mark.asyncio
async def test_stream_end_without_buf_is_noop(self):
@@ -375,22 +344,6 @@ class TestToolHintInlineStreaming:
assert "🔧 $ cd /project" in buf.text
assert "🔧 $ git status" in buf.text
@pytest.mark.asyncio
async def test_tool_hint_preserved_on_resuming_flush(self):
"""When _resuming flushes the buffer, tool hint is kept as permanent content."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer\n\n🔧 $ cd /project\n\n",
card_id="card_1", sequence=2, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True, "_resuming": True})
buf = ch._stream_bufs["oc_chat1"]
assert "Partial answer" in buf.text
assert "🔧 $ cd /project" in buf.text
@pytest.mark.asyncio
async def test_tool_hint_preserved_on_final_stream_end(self):
"""When final _stream_end closes the card, tool hint is kept in the final text."""
@@ -1,6 +1,7 @@
"""Tests for FeishuChannel tool hint code block formatting."""
"""Tests for FeishuChannel tool hint formatting."""
import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@@ -28,15 +29,24 @@ def mock_feishu_channel():
config.app_secret = "test_app_secret"
config.encrypt_key = None
config.verification_token = None
config.tool_hint_prefix = "\U0001f527" # 🔧
bus = MagicMock()
channel = FeishuChannel(config, bus)
channel._client = MagicMock() # Simulate initialized client
channel._client = MagicMock()
return channel
def _get_tool_hint_card(mock_send):
"""Extract the interactive card from _send_message_sync calls."""
call_args = mock_send.call_args[0]
_, _, msg_type, content = call_args
assert msg_type == "interactive"
return json.loads(content)
@mark.asyncio
async def test_tool_hint_sends_code_message(mock_feishu_channel):
"""Tool hint messages should be sent as interactive cards with code blocks."""
async def test_tool_hint_sends_interactive_card(mock_feishu_channel):
"""Tool hint without active buffer sends an interactive card with 🔧 style."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
@@ -47,23 +57,12 @@ async def test_tool_hint_sends_code_message(mock_feishu_channel):
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
# Verify interactive message with card was sent
assert mock_send.call_count == 1
call_args = mock_send.call_args[0]
receive_id_type, receive_id, msg_type, content = call_args
assert receive_id_type == "chat_id"
assert receive_id == "oc_123456"
assert msg_type == "interactive"
# Parse content to verify card structure
card = json.loads(content)
card = _get_tool_hint_card(mock_send)
assert card["config"]["wide_screen_mode"] is True
assert len(card["elements"]) == 1
assert card["elements"][0]["tag"] == "markdown"
# Check that code block is properly formatted with language hint
expected_md = "**Tool Calls**\n\n```text\nweb_search(\"test query\")\n```"
assert card["elements"][0]["content"] == expected_md
md = card["elements"][0]["content"]
assert "\U0001f527" in md
assert "web_search" in md
@mark.asyncio
@@ -78,8 +77,6 @@ async def test_tool_hint_empty_content_does_not_send(mock_feishu_channel):
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
# Should not send any message
mock_send.assert_not_called()
@@ -96,7 +93,6 @@ async def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel):
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
# Should send as text message (detected format)
assert mock_send.call_count == 1
call_args = mock_send.call_args[0]
_, _, msg_type, content = call_args
@@ -106,7 +102,7 @@ async def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel):
@mark.asyncio
async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel):
"""Multiple tool calls should be displayed each on its own line in a code block."""
"""Multiple tool calls should each get the 🔧 prefix."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
@@ -117,13 +113,11 @@ async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel):
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
call_args = mock_send.call_args[0]
msg_type = call_args[2]
content = json.loads(call_args[3])
assert msg_type == "interactive"
# Each tool call should be on its own line
expected_md = "**Tool Calls**\n\n```text\nweb_search(\"query\"),\nread_file(\"/path/to/file\")\n```"
assert content["elements"][0]["content"] == expected_md
card = _get_tool_hint_card(mock_send)
md = card["elements"][0]["content"]
assert "web_search" in md
assert "read_file" in md
assert "\U0001f527" in md
@mark.asyncio
@@ -139,8 +133,8 @@ async def test_tool_hint_new_format_basic(mock_feishu_channel):
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
content = json.loads(mock_send.call_args[0][3])
md = content["elements"][0]["content"]
card = _get_tool_hint_card(mock_send)
md = card["elements"][0]["content"]
assert "read src/main.py" in md
assert 'grep "TODO"' in md
@@ -158,16 +152,15 @@ async def test_tool_hint_new_format_with_comma_in_quotes(mock_feishu_channel):
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
content = json.loads(mock_send.call_args[0][3])
md = content["elements"][0]["content"]
# The comma inside quotes should NOT cause a line break
card = _get_tool_hint_card(mock_send)
md = card["elements"][0]["content"]
assert 'grep "hello, world"' in md
assert "$ echo test" in md
@mark.asyncio
async def test_tool_hint_new_format_with_folding(mock_feishu_channel):
"""Folded calls (× N) should display on separate lines."""
"""Folded calls (× N) should display correctly."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
@@ -178,8 +171,8 @@ async def test_tool_hint_new_format_with_folding(mock_feishu_channel):
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
content = json.loads(mock_send.call_args[0][3])
md = content["elements"][0]["content"]
card = _get_tool_hint_card(mock_send)
md = card["elements"][0]["content"]
assert "\u00d7 3" in md
assert 'grep "pattern"' in md
@@ -197,9 +190,12 @@ async def test_tool_hint_new_format_mcp(mock_feishu_channel):
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
content = json.loads(mock_send.call_args[0][3])
md = content["elements"][0]["content"]
card = _get_tool_hint_card(mock_send)
md = card["elements"][0]["content"]
assert "4_5v::analyze_image" in md
@mark.asyncio
async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel):
"""Commas inside a single tool argument must not be split onto a new line."""
msg = OutboundMessage(
@@ -212,10 +208,7 @@ async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel):
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
content = json.loads(mock_send.call_args[0][3])
expected_md = (
"**Tool Calls**\n\n```text\n"
"web_search(\"foo, bar\"),\n"
"read_file(\"/path/to/file\")\n```"
)
assert content["elements"][0]["content"] == expected_md
card = _get_tool_hint_card(mock_send)
md = card["elements"][0]["content"]
assert 'web_search("foo, bar")' in md
assert 'read_file("/path/to/file")' in md
+167 -2
View File
@@ -10,8 +10,7 @@ except ImportError:
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.slack import SlackChannel
from nanobot.channels.slack import SlackConfig
from nanobot.channels.slack import SlackChannel, SlackConfig
class _FakeAsyncWebClient:
@@ -20,6 +19,12 @@ class _FakeAsyncWebClient:
self.file_upload_calls: list[dict[str, object | None]] = []
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.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._users_pages: list[dict[str, object]] = []
self._open_dm_response: dict[str, object] = {"channel": {"id": "D_OPENED"}}
async def chat_postMessage(
self,
@@ -81,6 +86,22 @@ class _FakeAsyncWebClient:
}
)
async def conversations_list(self, **kwargs):
self.conversations_list_calls.append(kwargs)
if self._conversations_pages:
return self._conversations_pages.pop(0)
return {"channels": [], "response_metadata": {"next_cursor": ""}}
async def users_list(self, **kwargs):
self.users_list_calls.append(kwargs)
if self._users_pages:
return self._users_pages.pop(0)
return {"members": [], "response_metadata": {"next_cursor": ""}}
async def conversations_open(self, **kwargs):
self.conversations_open_calls.append(kwargs)
return self._open_dm_response
@pytest.mark.asyncio
async def test_send_uses_thread_for_channel_messages() -> None:
@@ -151,3 +172,147 @@ async def test_send_updates_reaction_when_final_response_sent() -> None:
assert fake_web.reactions_add_calls == [
{"channel": "C123", "name": "white_check_mark", "timestamp": "1700000000.000100"}
]
@pytest.mark.asyncio
async def test_send_resolves_channel_name_to_channel_id() -> None:
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
fake_web._conversations_pages = [
{
"channels": [{"id": "C999", "name": "channel_x"}],
"response_metadata": {"next_cursor": ""},
}
]
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="#channel_x",
content="hello",
)
)
assert fake_web.chat_post_calls == [
{"channel": "C999", "text": "hello\n", "thread_ts": None}
]
assert len(fake_web.conversations_list_calls) == 1
@pytest.mark.asyncio
async def test_send_resolves_user_handle_to_dm_channel() -> None:
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
fake_web._users_pages = [
{
"members": [
{
"id": "U234",
"name": "alice",
"profile": {"display_name": "Alice"},
}
],
"response_metadata": {"next_cursor": ""},
}
]
fake_web._open_dm_response = {"channel": {"id": "D234"}}
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="@alice",
content="hello",
)
)
assert fake_web.conversations_open_calls == [{"users": "U234"}]
assert fake_web.chat_post_calls == [
{"channel": "D234", "text": "hello\n", "thread_ts": None}
]
@pytest.mark.asyncio
async def test_send_updates_reaction_on_origin_channel_for_cross_channel_send() -> None:
channel = SlackChannel(SlackConfig(enabled=True, react_emoji="eyes"), MessageBus())
fake_web = _FakeAsyncWebClient()
fake_web._conversations_pages = [
{
"channels": [{"id": "C999", "name": "channel_x"}],
"response_metadata": {"next_cursor": ""},
}
]
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="channel_x",
content="done",
metadata={
"slack": {
"event": {"ts": "1700000000.000100", "channel": "D_ORIGIN"},
"channel_type": "im",
},
},
)
)
assert fake_web.chat_post_calls == [
{"channel": "C999", "text": "done\n", "thread_ts": None}
]
assert fake_web.reactions_remove_calls == [
{"channel": "D_ORIGIN", "name": "eyes", "timestamp": "1700000000.000100"}
]
assert fake_web.reactions_add_calls == [
{"channel": "D_ORIGIN", "name": "white_check_mark", "timestamp": "1700000000.000100"}
]
@pytest.mark.asyncio
async def test_send_does_not_reuse_origin_thread_ts_for_cross_channel_send() -> None:
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
fake_web._conversations_pages = [
{
"channels": [{"id": "C999", "name": "channel_x"}],
"response_metadata": {"next_cursor": ""},
}
]
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="channel_x",
content="done",
metadata={
"slack": {
"event": {"ts": "1700000000.000100", "channel": "C_ORIGIN"},
"thread_ts": "1700000000.000200",
"channel_type": "channel",
},
},
)
)
assert fake_web.chat_post_calls == [
{"channel": "C999", "text": "done\n", "thread_ts": None}
]
@pytest.mark.asyncio
async def test_send_raises_when_named_target_cannot_be_resolved() -> None:
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
channel._web_client = fake_web
with pytest.raises(ValueError, match="was not found"):
await channel.send(
OutboundMessage(
channel="slack",
chat_id="#missing-channel",
content="hello",
)
)
+234 -7
View File
@@ -17,9 +17,11 @@ from nanobot.bus.events import OutboundMessage
from nanobot.channels.websocket import (
WebSocketChannel,
WebSocketConfig,
_is_valid_chat_id,
_issue_route_secret_matches,
_normalize_config_path,
_normalize_http_path,
_parse_envelope,
_parse_inbound_payload,
_parse_query,
_parse_request_path,
@@ -168,7 +170,7 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._connections["chat-1"] = mock_ws
channel._attach(mock_ws, "chat-1")
msg = OutboundMessage(
channel="websocket",
@@ -182,6 +184,7 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None:
mock_ws.send.assert_awaited_once()
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["reply_to"] == "m1"
assert payload["media"] == ["/tmp/a.png"]
@@ -201,12 +204,13 @@ async def test_send_removes_connection_on_connection_closed() -> None:
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
channel._connections["chat-1"] = mock_ws
channel._attach(mock_ws, "chat-1")
msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello")
await channel.send(msg)
assert "chat-1" not in channel._connections
assert "chat-1" not in channel._subs
assert mock_ws not in channel._conn_chats
@pytest.mark.asyncio
@@ -215,11 +219,12 @@ async def test_send_delta_removes_connection_on_connection_closed() -> None:
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus)
mock_ws = AsyncMock()
mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
channel._connections["chat-1"] = mock_ws
channel._attach(mock_ws, "chat-1")
await channel.send_delta("chat-1", "chunk", {"_stream_delta": True, "_stream_id": "s1"})
assert "chat-1" not in channel._connections
assert "chat-1" not in channel._subs
assert mock_ws not in channel._conn_chats
@pytest.mark.asyncio
@@ -227,7 +232,7 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus)
mock_ws = AsyncMock()
channel._connections["chat-1"] = mock_ws
channel._attach(mock_ws, "chat-1")
await channel.send_delta("chat-1", "part", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"})
@@ -236,9 +241,11 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
first = json.loads(mock_ws.send.call_args_list[0][0][0])
second = json.loads(mock_ws.send.call_args_list[1][0][0])
assert first["event"] == "delta"
assert first["chat_id"] == "chat-1"
assert first["text"] == "part"
assert first["stream_id"] == "sid"
assert second["event"] == "stream_end"
assert second["chat_id"] == "chat-1"
assert second["stream_id"] == "sid"
@@ -248,7 +255,7 @@ async def test_send_non_connection_closed_exception_is_raised() -> None:
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
mock_ws.send.side_effect = RuntimeError("unexpected")
channel._connections["chat-1"] = mock_ws
channel._attach(mock_ws, "chat-1")
msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello")
with pytest.raises(RuntimeError, match="unexpected"):
@@ -596,3 +603,223 @@ async def test_websocket_requires_token_without_issue_path(bus: MagicMock) -> No
finally:
await channel.stop()
await server_task
# -- Multi-chat multiplexing -------------------------------------------------
#
# The multiplex protocol lets one WS connection route N logical chats over
# typed envelopes (`new_chat` / `attach` / `message`). Legacy frames must keep
# working on the connection's default chat_id.
@pytest.mark.asyncio
async def test_multiplex_legacy_still_works(bus: MagicMock) -> None:
port = 29930
channel = _ch(bus, port=port)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=legacy") as client:
ready = json.loads(await client.recv())
default_chat = ready["chat_id"]
# Plain text frame routes to default chat_id
await client.send("hello from legacy")
await asyncio.sleep(0.1)
inbound = bus.publish_inbound.call_args[0][0]
assert inbound.chat_id == default_chat
assert inbound.content == "hello from legacy"
# {"content": ...} frame routes to default chat_id
await client.send(json.dumps({"content": "structured legacy"}))
await asyncio.sleep(0.1)
assert bus.publish_inbound.call_args[0][0].chat_id == default_chat
assert bus.publish_inbound.call_args[0][0].content == "structured legacy"
# Outbound still reaches the legacy client, with chat_id annotated
await channel.send(
OutboundMessage(channel="websocket", chat_id=default_chat, content="reply")
)
reply = json.loads(await client.recv())
assert reply["event"] == "message"
assert reply["chat_id"] == default_chat
assert reply["text"] == "reply"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_multiplex_new_chat_roundtrip(bus: MagicMock) -> None:
port = 29931
channel = _ch(bus, port=port)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=mp") as client:
ready = json.loads(await client.recv())
default_chat = ready["chat_id"]
await client.send(json.dumps({"type": "new_chat"}))
attached = json.loads(await client.recv())
assert attached["event"] == "attached"
new_chat = attached["chat_id"]
assert new_chat and new_chat != default_chat
# Send on the new chat via typed envelope
await client.send(
json.dumps({"type": "message", "chat_id": new_chat, "content": "hi on new"})
)
await asyncio.sleep(0.1)
inbound = bus.publish_inbound.call_args[0][0]
assert inbound.chat_id == new_chat
assert inbound.content == "hi on new"
# Server pushes a message back; chat_id must match
await channel.send(
OutboundMessage(channel="websocket", chat_id=new_chat, content="ok")
)
reply = json.loads(await client.recv())
assert reply["event"] == "message"
assert reply["chat_id"] == new_chat
assert reply["text"] == "ok"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_multiplex_two_chats_isolated(bus: MagicMock) -> None:
port = 29932
channel = _ch(bus, port=port)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=two") as client:
await client.recv() # ready
await client.send(json.dumps({"type": "new_chat"}))
chat_a = json.loads(await client.recv())["chat_id"]
await client.send(json.dumps({"type": "new_chat"}))
chat_b = json.loads(await client.recv())["chat_id"]
assert chat_a != chat_b
# Push A → client sees A only (FIFO over the single WS).
await channel.send(
OutboundMessage(channel="websocket", chat_id=chat_a, content="for-A")
)
msg_a = json.loads(await client.recv())
assert msg_a["chat_id"] == chat_a
assert msg_a["text"] == "for-A"
# Push B → client sees B only.
await channel.send(
OutboundMessage(channel="websocket", chat_id=chat_b, content="for-B")
)
msg_b = json.loads(await client.recv())
assert msg_b["chat_id"] == chat_b
assert msg_b["text"] == "for-B"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_multiplex_invalid_frames_return_error(bus: MagicMock) -> None:
port = 29933
channel = _ch(bus, port=port)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=bad") as client:
await client.recv() # ready
# attach with bad chat_id
await client.send(json.dumps({"type": "attach", "chat_id": "has space"}))
err1 = json.loads(await client.recv())
assert err1["event"] == "error"
# message with missing content
await client.send(json.dumps({"type": "message", "chat_id": "abc", "content": ""}))
err2 = json.loads(await client.recv())
assert err2["event"] == "error"
# unknown type
await client.send(json.dumps({"type": "nope"}))
err3 = json.loads(await client.recv())
assert err3["event"] == "error"
# Connection survives: legacy frame still works.
await client.send("still-alive")
await asyncio.sleep(0.1)
bus.publish_inbound.assert_awaited()
assert bus.publish_inbound.call_args[0][0].content == "still-alive"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_multiplex_cleanup_on_disconnect(bus: MagicMock) -> None:
port = 29934
channel = _ch(bus, port=port)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=dc") as client:
ready = json.loads(await client.recv())
default_chat = ready["chat_id"]
await client.send(json.dumps({"type": "new_chat"}))
extra_chat = json.loads(await client.recv())["chat_id"]
assert default_chat in channel._subs
assert extra_chat in channel._subs
# Client gone. Server-side tracking must be empty.
await asyncio.sleep(0.2)
assert default_chat not in channel._subs
assert extra_chat not in channel._subs
assert not channel._conn_chats
assert not channel._conn_default
finally:
await channel.stop()
await server_task
def test_parse_envelope_detects_typed_frames() -> None:
assert _parse_envelope('{"type":"new_chat"}') == {"type": "new_chat"}
env = _parse_envelope('{"type":"message","chat_id":"abc","content":"hi"}')
assert env == {"type": "message", "chat_id": "abc", "content": "hi"}
def test_parse_envelope_rejects_legacy_and_garbage() -> None:
# No `type` field → legacy, caller falls back to _parse_inbound_payload.
assert _parse_envelope('{"content":"hi"}') is None
assert _parse_envelope("plain text") is None
assert _parse_envelope("{broken") is None
assert _parse_envelope("[1,2,3]") is None
# Non-string `type` is not a valid envelope.
assert _parse_envelope('{"type":123}') is None
@pytest.mark.parametrize(
("value", "expected"),
[
("abc", True),
("a1b2_c:d-e", True),
("x" * 64, True),
("unified:default", True),
("", False),
("x" * 65, False),
("has space", False),
("a/b", False),
("a.b", False),
(None, False),
(123, False),
],
)
def test_is_valid_chat_id(value: Any, expected: bool) -> None:
assert _is_valid_chat_id(value) is expected
+2 -1
View File
@@ -290,10 +290,11 @@ async def test_disconnected_client_cleanup(bus: MagicMock) -> None:
async with WsTestClient("ws://127.0.0.1:29914/", client_id="tmp") as c:
chat_id = (await c.recv_ready()).chat_id
# disconnected
await asyncio.sleep(0.1)
await ch.send(OutboundMessage(
channel="websocket", chat_id=chat_id, content="orphan",
))
assert chat_id not in ch._connections
assert chat_id not in ch._subs
finally:
await ch.stop(); await t
+44
View File
@@ -541,6 +541,50 @@ async def test_process_voice_message() -> None:
assert "[voice]" in msg.content
@pytest.mark.asyncio
async def test_process_mixed_message() -> None:
"""Mixed message: contains picture and text message types."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["user1"]), MessageBus())
client = _FakeWeComClient()
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n")
saved = f.name
client.download_file.return_value = (b"\x89PNG\r\n", "photo.png")
channel._client = client
try:
with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(os.path.dirname(saved))):
frame = _FakeFrame(body={
"msgid": "msg_mixed_1",
"chatid": "chat1",
"msgtype": "mixed",
"from": {"userid": "user1"},
"mixed": {
"msg_item": [
{"msgtype": "text", "text": {"content": "hello wecom"}},
{"msgtype": "image", "image": {"url": "https://example.com/img.png", "aeskey": "key123"}}
]
}
})
await channel._process_message(frame, "mixed")
msg = await channel.bus.consume_inbound()
assert msg.sender_id == "user1"
assert msg.chat_id == "chat1"
assert msg.content.startswith("hello wecom")
assert msg.metadata["msg_type"] == "mixed"
assert len(msg.media) == 1
assert msg.media[0].endswith("photo.png")
assert "[image:" in msg.content
finally:
# Clean up any photo.png in tempdir
p = os.path.join(os.path.dirname(saved), "photo.png")
if os.path.exists(p):
os.unlink(p)
@pytest.mark.asyncio
async def test_process_message_deduplication() -> None:
"""Same msg_id is not processed twice."""
+169
View File
@@ -257,6 +257,28 @@ def test_config_accepts_camel_case_explicit_provider_name_for_coding_plan():
assert config.get_api_base() == "https://ark.cn-beijing.volces.com/api/coding/v3"
def test_config_accepts_lm_studio_without_api_key_and_uses_default_localhost_api_base():
config = Config.model_validate(
{
"agents": {
"defaults": {
"provider": "lm_studio",
"model": "local-model",
}
},
"providers": {
"lmStudio": {
"apiKey": None,
}
},
}
)
assert config.get_provider_name() == "lm_studio"
assert config.get_api_key() is None
assert config.get_api_base() == "http://localhost:1234/v1"
def test_find_by_name_accepts_camel_case_and_hyphen_aliases():
assert find_by_name("volcengineCodingPlan") is not None
assert find_by_name("volcengineCodingPlan").name == "volcengine_coding_plan"
@@ -1126,6 +1148,153 @@ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path)
assert "port 18792" in result.stdout
def test_gateway_health_endpoint_binds_and_serves_expected_responses(
monkeypatch, tmp_path: Path
) -> None:
config_file = _write_instance_config(tmp_path)
config = Config()
config.gateway.port = 18791
captured: dict[str, object] = {}
class _FakeDream:
model = None
max_batch_size = 0
max_iterations = 0
async def run(self) -> None:
return None
class _FakeAgentLoop:
def __init__(self, **_kwargs) -> None:
self.model = "test-model"
self.dream = _FakeDream()
async def run(self) -> None:
await asyncio.Event().wait()
async def close_mcp(self) -> None:
return None
def stop(self) -> None:
return None
class _FakeChannelManager:
def __init__(self, _config, _bus) -> None:
self.enabled_channels = ["telegram", "discord"]
async def start_all(self) -> None:
await asyncio.Event().wait()
async def stop_all(self) -> None:
return None
class _FakeCronService:
def __init__(self, _store_path: Path) -> None:
self.on_job = None
async def start(self) -> None:
return None
def stop(self) -> None:
return None
def status(self) -> dict[str, int]:
return {"jobs": 0}
def register_system_job(self, _job) -> None:
return None
class _FakeHeartbeatService:
def __init__(self, **_kwargs) -> None:
return None
async def start(self) -> None:
return None
def stop(self) -> None:
return None
class _FakeServer:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb) -> bool:
return False
async def serve_forever(self) -> None:
raise _StopGatewayError("stop")
async def _fake_start_server(handler, host: str, port: int):
captured["handler"] = handler
captured["host"] = host
captured["port"] = port
return _FakeServer()
class _FakeReader:
def __init__(self, payload: bytes) -> None:
self.payload = payload
async def read(self, _size: int) -> bytes:
return self.payload
class _FakeWriter:
def __init__(self) -> None:
self.output = b""
self.closed = False
def write(self, data: bytes) -> None:
self.output += data
async def drain(self) -> None:
return None
def close(self) -> None:
self.closed = True
_patch_cli_command_runtime(
monkeypatch,
config,
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
)
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService)
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
assert result.exit_code == 0
assert captured["host"] == "127.0.0.1"
assert captured["port"] == 18791
assert "Health endpoint: http://127.0.0.1:18791/health" in result.stdout
def _call_handler(path: str) -> tuple[str, _FakeWriter]:
request = f"GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode()
writer = _FakeWriter()
handler = captured["handler"]
assert callable(handler)
asyncio.run(handler(_FakeReader(request), writer))
return writer.output.decode(), writer
root_response, root_writer = _call_handler("/")
assert root_writer.closed is True
assert "HTTP/1.0 404 Not Found" in root_response
assert root_response.endswith("\r\n\r\nNot Found")
health_response, health_writer = _call_handler("/health")
assert health_writer.closed is True
assert "HTTP/1.0 200 OK" in health_response
health_body = json.loads(health_response.split("\r\n\r\n", 1)[1])
assert health_body == {"status": "ok"}
missing_response, missing_writer = _call_handler("/missing")
assert missing_writer.closed is True
assert "HTTP/1.0 404 Not Found" in missing_response
assert missing_response.endswith("\r\n\r\nNot Found")
def test_serve_uses_api_config_defaults_and_workspace_override(
monkeypatch, tmp_path: Path
) -> None:
+31 -2
View File
@@ -140,6 +140,7 @@ class TestRestartCommand:
loop.consolidator.estimate_session_prompt_tokens = MagicMock(
return_value=(20500, "tiktoken")
)
loop.subagents.get_running_count_by_session.return_value = 0
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status")
@@ -148,11 +149,36 @@ class TestRestartCommand:
assert response is not None
assert "Model: test-model" in response.content
assert "Tokens: 0 in / 0 out" in response.content
assert "Context: 20k/65k (31%)" in response.content
assert "Context: 20k/65k (31% of input budget)" in response.content
assert "Session: 3 messages" in response.content
assert "Uptime: 2m 5s" in response.content
assert "Tasks: 0 active" in response.content
assert response.metadata == {"render_as": "text"}
@pytest.mark.asyncio
async def test_status_counts_running_dispatch_and_subagent_tasks(self):
loop, _bus = _make_loop()
session = MagicMock()
session.get_history.return_value = [{"role": "user"}]
loop.sessions.get_or_create.return_value = session
loop.consolidator.estimate_session_prompt_tokens = MagicMock(
return_value=(1000, "tiktoken")
)
running_task = MagicMock()
running_task.done.return_value = False
finished_task = MagicMock()
finished_task.done.return_value = True
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status")
loop._active_tasks[msg.session_key] = [running_task, finished_task]
loop.subagents.get_running_count_by_session.return_value = 2
response = await loop._process_message(msg)
assert response is not None
assert "Tasks: 3 active" in response.content
@pytest.mark.asyncio
async def test_run_agent_loop_resets_usage_when_provider_omits_it(self):
loop, _bus = _make_loop()
@@ -179,6 +205,7 @@ class TestRestartCommand:
loop.consolidator.estimate_session_prompt_tokens = MagicMock(
return_value=(0, "none")
)
loop.subagents.get_running_count_by_session.return_value = 0
response = await loop._process_message(
InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status")
@@ -186,7 +213,8 @@ class TestRestartCommand:
assert response is not None
assert "Tokens: 1200 in / 34 out" in response.content
assert "Context: 1k/65k (1%)" in response.content
assert "Context: 1k/65k (1% of input budget)" in response.content
assert "Tasks: 0 active" in response.content
@pytest.mark.asyncio
async def test_process_direct_preserves_render_metadata(self):
@@ -195,6 +223,7 @@ class TestRestartCommand:
session.get_history.return_value = []
loop.sessions.get_or_create.return_value = session
loop.subagents.get_running_count.return_value = 0
loop.subagents.get_running_count_by_session.return_value = 0
response = await loop.process_direct("/status", session_key="cli:test")
+65
View File
@@ -140,6 +140,71 @@ def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch)
assert saved["channels"]["qq"]["msgFormat"] == "plain"
def test_load_config_migrates_legacy_my_tool_keys(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"tools": {
"myEnabled": False,
"mySet": True,
}
}
),
encoding="utf-8",
)
config = load_config(config_path)
assert config.tools.my.enable is False
assert config.tools.my.allow_set is True
def test_save_config_rewrites_legacy_my_tool_keys(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"tools": {
"myEnabled": False,
"mySet": True,
}
}
),
encoding="utf-8",
)
config = load_config(config_path)
save_config(config, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8"))
tools = saved["tools"]
assert "myEnabled" not in tools
assert "mySet" not in tools
assert tools["my"] == {"enable": False, "allowSet": True}
def test_new_my_tool_keys_take_precedence_over_legacy(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"tools": {
"myEnabled": False,
"mySet": False,
"my": {"enable": True, "allowSet": True},
}
}
),
encoding="utf-8",
)
config = load_config(config_path)
assert config.tools.my.enable is True
assert config.tools.my.allow_set is True
def test_load_config_resets_ssrf_whitelist_when_next_config_is_empty(tmp_path) -> None:
whitelisted = tmp_path / "whitelisted.json"
whitelisted.write_text(
@@ -0,0 +1,99 @@
"""Regression tests for the cron tool's JSON-schema / runtime contract (#3113).
The schema advertised ``required=["action"]`` while ``_add_job`` rejected empty
``message``; LLMs rationally omitted ``message`` and looped on the runtime
error. The fix keeps ``required=["action"]`` (so ``list``/``remove`` stay
callable) but states the per-action requirement in each field's description
and tightens the runtime error for ``add`` without ``message``.
"""
from __future__ import annotations
import pytest
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.registry import ToolRegistry
class _SvcStub:
"""Minimal CronService stand-in; we only exercise schema/dispatch paths."""
def list_jobs(self):
return []
def get_job(self, _job_id):
return None
def remove_job(self, _job_id):
return "not-found"
def add_job(self, **kwargs):
class _J:
pass
j = _J()
j.id = "id1"
j.name = kwargs.get("name", "x")
return j
@pytest.fixture
def registry() -> ToolRegistry:
tool = CronTool(_SvcStub(), default_timezone="UTC")
tool.set_context("channel", "chat-id")
reg = ToolRegistry()
reg.register(tool)
return reg
class TestSchemaContract:
def test_list_accepted_without_message(self, registry: ToolRegistry) -> None:
# action='list' must pass schema validation with nothing but 'action'.
_, _, err = registry.prepare_call("cron", {"action": "list"})
assert err is None
def test_remove_accepted_without_message(self, registry: ToolRegistry) -> None:
# action='remove' must pass schema validation with just 'action' + 'job_id'.
_, _, err = registry.prepare_call("cron", {"action": "remove", "job_id": "abc"})
assert err is None
def test_add_with_message_accepted(self, registry: ToolRegistry) -> None:
_, _, err = registry.prepare_call(
"cron", {"action": "add", "message": "ping", "at": "2030-01-01T00:00:00"}
)
assert err is None
def test_add_without_message_surfaces_actionable_runtime_error(
self, registry: ToolRegistry
) -> None:
# Schema permits omitting message; the runtime must return a message
# that tells the LLM exactly what's missing and how to retry, so it
# doesn't loop like #3113 reports.
import asyncio
tool = registry._tools["cron"] # type: ignore[attr-defined]
out = asyncio.run(tool.execute(action="add", at="2030-01-01T00:00:00"))
assert "message" in out
assert "add" in out
assert "Retry" in out or "retry" in out
class TestSchemaSelfDescribesRequirements:
def test_message_description_flags_add_requirement(self) -> None:
# LLMs rely on field descriptions to infer when something is actually
# needed. Without this hint, #3113's loop returns.
tool = CronTool(_SvcStub())
desc = tool.parameters["properties"]["message"]["description"]
assert "REQUIRED" in desc and "action='add'" in desc
def test_job_id_description_flags_remove_requirement(self) -> None:
tool = CronTool(_SvcStub())
desc = tool.parameters["properties"]["job_id"]["description"]
assert "REQUIRED" in desc and "action='remove'" in desc
def test_top_level_required_stays_narrow(self) -> None:
# If 'message' or 'job_id' ever creep back into top-level required,
# list/remove start failing schema validation (the bug PR #3163 v1
# accidentally introduced).
tool = CronTool(_SvcStub())
assert tool.parameters["required"] == ["action"]
@@ -1,6 +1,6 @@
"""Tests for LLMProvider._enforce_role_alternation."""
from nanobot.providers.base import LLMProvider
from nanobot.providers.base import LLMProvider, _SYNTHETIC_USER_CONTENT
class TestEnforceRoleAlternation:
@@ -195,3 +195,46 @@ class TestEnforceRoleAlternation:
assert result[3]["role"] == "user"
assert "And 3+3?" in result[3]["content"]
assert "(please be quick)" in result[3]["content"]
def test_leading_assistant_after_system_inserts_synthetic_user(self):
"""When the first non-system message is assistant (no tool_calls), a
synthetic user message is inserted to prevent GLM error 1214."""
msgs = [
{"role": "system", "content": "sys"},
{"role": "assistant", "content": "previous reply"},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
{"role": "assistant", "content": "after tool"},
]
result = LLMProvider._enforce_role_alternation(msgs)
non_system = [m for m in result if m["role"] != "system"]
assert non_system[0]["role"] == "user"
assert non_system[0]["content"] == _SYNTHETIC_USER_CONTENT
# The original assistant should follow.
assert non_system[1]["role"] == "assistant"
def test_leading_assistant_with_tool_calls_not_patched(self):
"""An assistant message with tool_calls at the start is left as-is
because tool messages will follow and some providers accept this."""
msgs = [
{"role": "system", "content": "sys"},
{"role": "assistant", "content": None, "tool_calls": [
{"id": "tc_1", "type": "function", "function": {"name": "ls", "arguments": "{}"}}
]},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
]
result = LLMProvider._enforce_role_alternation(msgs)
non_system = [m for m in result if m["role"] != "system"]
# The assistant has tool_calls so it should NOT be patched.
assert non_system[0]["role"] == "assistant"
assert non_system[0].get("tool_calls") is not None
def test_user_after_system_not_patched(self):
"""Normal system→user sequence is not modified."""
msgs = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert result[1]["role"] == "user"
assert result[1]["content"] == "hello"
+119
View File
@@ -584,6 +584,78 @@ def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -
assert sanitized[2]["tool_call_id"] == "3ec83c30d"
def test_openai_compat_stringifies_dict_tool_arguments() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
sanitized = provider._sanitize_messages([
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "exec", "arguments": {"cmd": "ls -la"}},
}
],
},
{"role": "tool", "tool_call_id": "call_1", "name": "exec", "content": "ok"},
{"role": "user", "content": "done"},
])
assert sanitized[1]["tool_calls"][0]["function"]["arguments"] == '{"cmd": "ls -la"}'
def test_openai_compat_repairs_non_json_tool_arguments_string() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
sanitized = provider._sanitize_messages([
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "exec", "arguments": "{'cmd': 'pwd'}"},
}
],
},
{"role": "tool", "tool_call_id": "call_1", "name": "exec", "content": "ok"},
{"role": "user", "content": "done"},
])
assert sanitized[1]["tool_calls"][0]["function"]["arguments"] == '{"cmd": "pwd"}'
def test_openai_compat_defaults_missing_tool_arguments_to_empty_object() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
sanitized = provider._sanitize_messages([
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "exec"},
}
],
},
{"role": "tool", "tool_call_id": "call_1", "name": "exec", "content": "ok"},
{"role": "user", "content": "done"},
])
assert sanitized[1]["tool_calls"][0]["function"]["arguments"] == "{}"
@pytest.mark.asyncio
async def test_openai_compat_stream_watchdog_returns_error_on_stall(monkeypatch) -> None:
monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "0")
@@ -658,3 +730,50 @@ def test_openai_no_thinking_extra_body() -> None:
"""Non-thinking providers should never get extra_body for thinking."""
kw = _build_kwargs_for("openai", "gpt-4o", reasoning_effort="medium")
assert "extra_body" not in kw
def test_kimi_k25_thinking_enabled() -> None:
"""kimi-k2.5 with reasoning_effort set should opt in to thinking."""
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="medium")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
def test_kimi_k25_thinking_disabled_for_minimal() -> None:
"""reasoning_effort='minimal' maps to thinking disabled for kimi-k2.5."""
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="minimal")
assert kw.get("extra_body") == {"thinking": {"type": "disabled"}}
def test_kimi_k25_no_extra_body_when_reasoning_effort_none() -> None:
"""Without reasoning_effort the thinking param must not be injected."""
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort=None)
assert "extra_body" not in kw
def test_kimi_k25_thinking_enabled_with_openrouter_prefix() -> None:
"""OpenRouter-style model names like moonshotai/kimi-k2.5 must trigger thinking."""
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort="medium")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
def test_kimi_k25_thinking_disabled_with_openrouter_prefix() -> None:
"""OpenRouter names must NOT trigger thinking without reasoning_effort."""
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort=None)
assert "extra_body" not in kw
def test_kimi_k26_code_preview_thinking_enabled() -> None:
"""k2.6-code-preview also supports thinking; should behave like k2.5."""
kw = _build_kwargs_for("moonshot", "k2.6-code-preview", reasoning_effort="high")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
def test_kimi_k2_series_no_thinking_injection() -> None:
"""kimi-k2 (non-thinking) models must NOT receive extra_body.thinking."""
kw = _build_kwargs_for("moonshot", "kimi-k2", reasoning_effort="high")
assert "extra_body" not in kw
def test_kimi_k2_thinking_series_no_thinking_injection() -> None:
"""kimi-k2-thinking series models must NOT receive extra_body.thinking."""
kw = _build_kwargs_for("moonshot", "kimi-k2-thinking", reasoning_effort="high")
assert "extra_body" not in kw
+57
View File
@@ -0,0 +1,57 @@
"""Regression tests for ``LLMResponse.should_execute_tools`` (#3220).
The agent used to execute tool calls whenever ``has_tool_calls`` was true, regardless
of ``finish_reason``. Non-compliant API gateways that inject empty / bogus tool calls
under ``refusal`` / ``content_filter`` / ``error`` pushed the agent into a tight loop
until ``max_iterations`` fired. ``should_execute_tools`` is the single guard that
every tool-execution site now funnels through.
"""
from __future__ import annotations
import pytest
from nanobot.providers.base import LLMResponse, ToolCallRequest
def _response(finish_reason: str, *, with_tool_call: bool = True) -> LLMResponse:
tool_calls = (
[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})]
if with_tool_call
else []
)
return LLMResponse(content=None, tool_calls=tool_calls, finish_reason=finish_reason)
class TestShouldExecuteTools:
def test_no_tool_calls_never_executes(self) -> None:
# No tool calls present -> guard must reject regardless of finish_reason.
for reason in ("tool_calls", "stop", "length", "error", "refusal", "content_filter"):
resp = _response(reason, with_tool_call=False)
assert resp.should_execute_tools is False, f"rejected for finish_reason={reason!r}"
def test_tool_calls_with_tool_calls_reason_executes(self) -> None:
# The canonical case: provider explicitly signals tool intent.
resp = _response("tool_calls")
assert resp.has_tool_calls is True
assert resp.should_execute_tools is True
def test_tool_calls_with_stop_reason_executes(self) -> None:
# Some compliant providers emit "stop" together with tool_calls; the
# guard must accept this to avoid breaking real tool-calling flows.
# See openai_compat_provider.py:~633,678 where ("tool_calls", "stop")
# are both treated as terminal tool-call states.
resp = _response("stop")
assert resp.should_execute_tools is True
@pytest.mark.parametrize(
"anomalous_reason",
["refusal", "content_filter", "error", "length", "function_call", ""],
)
def test_tool_calls_under_anomalous_reason_blocked(self, anomalous_reason: str) -> None:
# This is the #3220 bug: gateways injecting tool_calls under any of these
# finish_reasons must not cause execution. Blocking here is what prevents
# the infinite empty tool-call loop.
resp = _response(anomalous_reason)
assert resp.has_tool_calls is True
assert resp.should_execute_tools is False
@@ -0,0 +1,21 @@
"""Tests for the MiniMax Anthropic provider registration."""
from nanobot.config.schema import ProvidersConfig
from nanobot.providers.registry import PROVIDERS
def test_minimax_anthropic_config_field_exists():
"""ProvidersConfig should expose a minimax_anthropic field."""
config = ProvidersConfig()
assert hasattr(config, "minimax_anthropic")
def test_minimax_anthropic_provider_in_registry():
"""MiniMax Anthropic endpoint should be registered with Anthropic backend."""
specs = {s.name: s for s in PROVIDERS}
assert "minimax_anthropic" in specs
minimax_anthropic = specs["minimax_anthropic"]
assert minimax_anthropic.env_key == "MINIMAX_API_KEY"
assert minimax_anthropic.backend == "anthropic"
assert minimax_anthropic.default_api_base == "https://api.minimax.io/anthropic"
+91
View File
@@ -87,6 +87,33 @@ async def test_chat_with_retry_returns_final_error_after_retries(monkeypatch) ->
assert delays == [1, 2, 4]
@pytest.mark.asyncio
async def test_chat_with_retry_emits_terminal_progress_when_standard_retries_exhaust(monkeypatch) -> None:
provider = ScriptedProvider([
LLMResponse(content="429 rate limit a", finish_reason="error"),
LLMResponse(content="429 rate limit b", finish_reason="error"),
LLMResponse(content="429 rate limit c", finish_reason="error"),
LLMResponse(content="503 final server error", finish_reason="error"),
])
progress: list[str] = []
async def _fake_sleep(delay: int) -> None:
return None
async def _progress(msg: str) -> None:
progress.append(msg)
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
response = await provider.chat_with_retry(
messages=[{"role": "user", "content": "hello"}],
on_retry_wait=_progress,
)
assert response.content == "503 final server error"
assert progress[-1] == "Model request failed after 4 retries, giving up."
@pytest.mark.asyncio
async def test_chat_with_retry_preserves_cancelled_error() -> None:
provider = ScriptedProvider([asyncio.CancelledError()])
@@ -469,3 +496,67 @@ async def test_persistent_retry_aborts_after_ten_identical_transient_errors(monk
assert response.content == "429 rate limit"
assert provider.calls == 10
assert delays == [1, 2, 4, 4, 4, 4, 4, 4, 4]
@pytest.mark.asyncio
async def test_persistent_retry_emits_terminal_progress_on_identical_error_limit(monkeypatch) -> None:
provider = ScriptedProvider([
*[LLMResponse(content="429 rate limit", finish_reason="error") for _ in range(10)],
])
progress: list[str] = []
async def _fake_sleep(delay: float) -> None:
return None
async def _progress(msg: str) -> None:
progress.append(msg)
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
response = await provider.chat_with_retry(
messages=[{"role": "user", "content": "hello"}],
retry_mode="persistent",
on_retry_wait=_progress,
)
assert response.finish_reason == "error"
assert progress[-1] == "Persistent retry stopped after 10 identical errors."
@pytest.mark.asyncio
async def test_chat_with_retry_normalizes_explicit_none_max_tokens() -> None:
"""Explicit max_tokens=None must fall back to generation defaults.
Regression for #3102: callers that construct AgentRunSpec with
max_tokens=None propagate None into chat_with_retry, which used to
reach ``_build_kwargs`` and crash on ``max(1, None)``.
"""
provider = ScriptedProvider([LLMResponse(content="ok")])
response = await provider.chat_with_retry(
messages=[{"role": "user", "content": "hi"}],
max_tokens=None,
temperature=None,
)
assert response.content == "ok"
# Generation settings default to 4096 / 0.7; explicit None should
# have been replaced before reaching chat().
assert provider.last_kwargs["max_tokens"] == 4096
assert provider.last_kwargs["temperature"] == 0.7
@pytest.mark.asyncio
async def test_chat_stream_with_retry_normalizes_explicit_none_max_tokens() -> None:
"""chat_stream_with_retry must apply the same None-guard as chat_with_retry."""
provider = ScriptedProvider([LLMResponse(content="ok")])
response = await provider.chat_stream_with_retry(
messages=[{"role": "user", "content": "hi"}],
max_tokens=None,
temperature=None,
)
assert response.content == "ok"
assert provider.last_kwargs["max_tokens"] == 4096
assert provider.last_kwargs["temperature"] == 0.7
+496
View File
@@ -0,0 +1,496 @@
"""Tests for API file upload functionality (JSON base64 + multipart)."""
from __future__ import annotations
import base64
from io import BytesIO
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from nanobot.api.server import (
_FileSizeExceeded,
_parse_json_content,
_save_base64_data_url,
create_app,
)
from nanobot.utils.document import extract_documents
try:
from aiohttp.test_utils import TestClient, TestServer
HAS_AIOHTTP = True
except ImportError:
HAS_AIOHTTP = False
pytest_plugins = ("pytest_asyncio",)
def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
agent = MagicMock()
agent.process_direct = AsyncMock(return_value=response_text)
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
return agent
@pytest.fixture
def mock_agent():
return _make_mock_agent()
@pytest.fixture
def app(mock_agent):
return create_app(mock_agent, model_name="test-model", request_timeout=10.0)
@pytest_asyncio.fixture
async def aiohttp_client():
clients: list[TestClient] = []
async def _make_client(app):
client = TestClient(TestServer(app))
await client.start_server()
clients.append(client)
return client
try:
yield _make_client
finally:
for client in clients:
await client.close()
# ---------------------------------------------------------------------------
# Helper function tests
# ---------------------------------------------------------------------------
def test_save_base64_data_url_saves_png(tmp_path) -> None:
"""Saving a base64 data URL creates a file with correct extension."""
b64_data = base64.b64encode(b"fake png data").decode()
data_url = f"data:image/png;base64,{b64_data}"
result = _save_base64_data_url(data_url, tmp_path)
assert result is not None
assert result.endswith(".png")
assert (tmp_path / result.replace(str(tmp_path) + "/", "")).read_bytes() == b"fake png data"
def test_save_base64_data_url_handles_invalid_b64(tmp_path) -> None:
"""Invalid base64 returns None."""
result = _save_base64_data_url("data:image/png;base64,not-valid-base64!!!", tmp_path)
assert result is None
def test_save_base64_data_url_handles_unknown_mime(tmp_path) -> None:
"""Unknown MIME type defaults to .bin."""
b64_data = base64.b64encode(b"some data").decode()
data_url = f"data:unknown/type;base64,{b64_data}"
result = _save_base64_data_url(data_url, tmp_path)
assert result is not None
assert result.endswith(".bin")
def test_save_base64_data_url_rejects_oversized_payload(tmp_path) -> None:
"""Base64 uploads should respect the same per-file limit as multipart."""
large_payload = base64.b64encode(b"x" * (11 * 1024 * 1024)).decode()
data_url = f"data:image/png;base64,{large_payload}"
with pytest.raises(_FileSizeExceeded, match="10MB limit"):
_save_base64_data_url(data_url, tmp_path)
def test_parse_json_content_extracts_text_and_media(tmp_path) -> None:
"""Parse JSON with text + base64 image saves image and returns paths."""
b64_data = base64.b64encode(b"img").decode()
body = {
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "describe this"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_data}"}},
],
}
]
}
import os
original_cwd = os.getcwd()
os.chdir(tmp_path)
try:
text, media_paths = _parse_json_content(body)
assert text == "describe this"
assert len(media_paths) == 1
finally:
os.chdir(original_cwd)
def test_parse_json_content_plain_text_only() -> None:
"""Plain text string content returns no media."""
body = {"messages": [{"role": "user", "content": "hello"}]}
text, media_paths = _parse_json_content(body)
assert text == "hello"
assert media_paths == []
def test_parse_json_content_validates_single_message() -> None:
"""Multiple messages raise ValueError."""
body = {
"messages": [
{"role": "user", "content": "first"},
{"role": "user", "content": "second"},
]
}
with pytest.raises(ValueError, match="single user message"):
_parse_json_content(body)
def test_parse_json_content_validates_user_role() -> None:
"""Non-user role raises ValueError."""
body = {"messages": [{"role": "system", "content": "you are a bot"}]}
with pytest.raises(ValueError, match="single user message"):
_parse_json_content(body)
def test_parse_json_content_rejects_oversized_base64_file(tmp_path) -> None:
"""Oversized JSON data URLs should fail before writing to disk."""
large_payload = base64.b64encode(b"x" * (11 * 1024 * 1024)).decode()
body = {
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "describe"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{large_payload}"}},
],
}
]
}
import os
original_cwd = os.getcwd()
os.chdir(tmp_path)
try:
with pytest.raises(_FileSizeExceeded, match="10MB limit"):
_parse_json_content(body)
finally:
os.chdir(original_cwd)
# ---------------------------------------------------------------------------
# Multipart upload tests
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_multipart_upload_saves_file(aiohttp_client, mock_agent, tmp_path) -> None:
"""Multipart upload saves file to media dir and passes path to process_direct."""
import os
original_cwd = os.getcwd()
os.chdir(tmp_path)
try:
app = create_app(mock_agent, model_name="m")
client = await aiohttp_client(app)
file_data = b"test file content"
data = BytesIO(file_data)
resp = await client.post(
"/v1/chat/completions",
data={"message": "analyze this", "files": data},
)
assert resp.status == 200
call_kwargs = mock_agent.process_direct.call_args.kwargs
assert call_kwargs["content"] == "analyze this"
assert len(call_kwargs.get("media") or []) == 1
finally:
os.chdir(original_cwd)
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_multipart_multiple_files(aiohttp_client, mock_agent, tmp_path) -> None:
"""Multipart upload with multiple files saves all and passes paths."""
import os
original_cwd = os.getcwd()
os.chdir(tmp_path)
try:
app = create_app(mock_agent, model_name="m")
client = await aiohttp_client(app)
# Note: aiohttp test client has limited multipart support
# This test verifies the basic flow
file_data = b"test content"
data = BytesIO(file_data)
resp = await client.post(
"/v1/chat/completions",
data={"message": "analyze", "files": data},
)
assert resp.status == 200
finally:
os.chdir(original_cwd)
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_multipart_file_size_limit(aiohttp_client, mock_agent, tmp_path) -> None:
"""File exceeding MAX_FILE_SIZE returns 413."""
import os
original_cwd = os.getcwd()
os.chdir(tmp_path)
try:
app = create_app(mock_agent, model_name="m")
client = await aiohttp_client(app)
# Create a file larger than 10MB
large_data = b"x" * (11 * 1024 * 1024)
data = BytesIO(large_data)
resp = await client.post(
"/v1/chat/completions",
data={"message": "analyze", "files": data},
)
assert resp.status == 413
finally:
os.chdir(original_cwd)
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_multipart_defaults_text_when_missing(aiohttp_client, mock_agent, tmp_path) -> None:
"""Multipart without message field uses default text."""
import os
original_cwd = os.getcwd()
os.chdir(tmp_path)
try:
app = create_app(mock_agent, model_name="m")
client = await aiohttp_client(app)
file_data = b"content"
data = BytesIO(file_data)
resp = await client.post(
"/v1/chat/completions",
data={"files": data},
)
assert resp.status == 200
call_kwargs = mock_agent.process_direct.call_args.kwargs
assert call_kwargs["content"] == "请分析上传的文件"
finally:
os.chdir(original_cwd)
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_multipart_with_session_id(aiohttp_client, mock_agent, tmp_path) -> None:
"""Multipart upload with session_id uses custom session key."""
import os
original_cwd = os.getcwd()
os.chdir(tmp_path)
try:
app = create_app(mock_agent, model_name="m")
client = await aiohttp_client(app)
file_data = b"content"
data = BytesIO(file_data)
resp = await client.post(
"/v1/chat/completions",
data={"message": "hello", "session_id": "my-session", "files": data},
)
assert resp.status == 200
call_kwargs = mock_agent.process_direct.call_args.kwargs
assert call_kwargs["session_key"] == "api:my-session"
finally:
os.chdir(original_cwd)
# ---------------------------------------------------------------------------
# Backward compatibility tests
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_plain_text_backward_compat(aiohttp_client, mock_agent) -> None:
"""Plain text JSON request (no media) works as before."""
app = create_app(mock_agent, model_name="m")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hello world"}]},
)
assert resp.status == 200
body = await resp.json()
assert body["choices"][0]["message"]["content"] == "mock response"
call_kwargs = mock_agent.process_direct.call_args.kwargs
assert call_kwargs["content"] == "hello world"
assert call_kwargs.get("media") is None
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_json_base64_image_upload(aiohttp_client, mock_agent, tmp_path) -> None:
"""JSON request with base64 data URL saves file and passes path."""
import os
original_cwd = os.getcwd()
os.chdir(tmp_path)
try:
app = create_app(mock_agent, model_name="m")
client = await aiohttp_client(app)
# Use valid base64 for a tiny PNG (1x1 transparent pixel)
tiny_png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
resp = await client.post(
"/v1/chat/completions",
json={
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "what is this"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{tiny_png_b64}"}},
],
}
]
},
)
assert resp.status == 200
call_kwargs = mock_agent.process_direct.call_args.kwargs
assert call_kwargs["content"] == "what is this"
assert len(call_kwargs.get("media", [])) == 1
finally:
os.chdir(original_cwd)
# ---------------------------------------------------------------------------
# extract_documents tests (now in nanobot.utils.document)
# ---------------------------------------------------------------------------
def test_extract_documents_separates_images_from_docs(tmp_path) -> None:
"""Images stay in media; document text is appended to content."""
from docx import Document
png = tmp_path / "chart.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
doc = Document()
doc.add_paragraph("Quarterly revenue is $5M")
docx_path = tmp_path / "report.docx"
doc.save(docx_path)
text, image_paths = extract_documents("summarize", [str(png), str(docx_path)])
assert len(image_paths) == 1
assert image_paths[0] == str(png)
assert "Quarterly revenue" in text
assert "summarize" in text
def test_extract_documents_skips_extraction_errors(tmp_path, monkeypatch) -> None:
"""Document extraction errors should not leak into user text."""
bad_file = tmp_path / "broken.docx"
bad_file.write_text("not a docx", encoding="utf-8")
import nanobot.utils.document as _doc
monkeypatch.setattr(
_doc, "extract_text",
lambda _path: "[error: failed to extract DOCX: boom]",
)
text, image_paths = extract_documents("hello", [str(bad_file)])
assert text == "hello"
assert image_paths == []
def test_extract_documents_images_only(tmp_path) -> None:
"""When all files are images, text is unchanged and all paths kept."""
png = tmp_path / "a.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
text, image_paths = extract_documents("describe", [str(png)])
assert text == "describe"
assert len(image_paths) == 1
def test_extract_documents_skips_oversized_files(tmp_path) -> None:
"""Files exceeding the size limit should be silently skipped."""
big = tmp_path / "huge.txt"
big.write_bytes(b"x" * 200)
text, image_paths = extract_documents("hello", [str(big)], max_file_size=100)
assert text == "hello"
assert image_paths == []
def test_extract_documents_does_not_read_full_file_for_mime(tmp_path) -> None:
"""MIME detection should only read header bytes, not the entire file."""
from pathlib import Path as _Path
big_txt = tmp_path / "big.txt"
big_txt.write_bytes(b"hello world " * 100_000) # ~1.2 MB
original_read_bytes = _Path.read_bytes
read_sizes: list[int] = []
def _tracking_read_bytes(self):
data = original_read_bytes(self)
read_sizes.append(len(data))
return data
import unittest.mock
with unittest.mock.patch.object(_Path, "read_bytes", _tracking_read_bytes):
extract_documents("test", [str(big_txt)])
# If the full file was read for MIME detection, read_sizes would
# contain a >1MB entry. After the fix, only a small header is read.
assert all(size <= 4096 for size in read_sizes), (
f"extract_documents read full file for MIME detection: sizes={read_sizes}"
)
# ---------------------------------------------------------------------------
# DOCX upload test — API saves file, loop layer extracts text
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_docx_upload_passes_media_path(aiohttp_client, tmp_path) -> None:
"""Uploaded DOCX is saved to disk and its path passed as media.
(Text extraction happens later in AgentLoop._process_message.)"""
agent = _make_mock_agent("report summary")
import os
original_cwd = os.getcwd()
os.chdir(tmp_path)
try:
app = create_app(agent, model_name="m")
client = await aiohttp_client(app)
from docx import Document
doc = Document()
doc.add_paragraph("Total revenue: $5,000,000")
buf = BytesIO()
doc.save(buf)
import aiohttp
data = aiohttp.FormData()
data.add_field("message", "summarize the report")
data.add_field("files", buf.getvalue(), filename="report.docx",
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document")
resp = await client.post("/v1/chat/completions", data=data)
assert resp.status == 200
call_kwargs = agent.process_direct.call_args.kwargs
assert call_kwargs["content"] == "summarize the report"
media = call_kwargs.get("media", [])
assert len(media) == 1
assert "report.docx" in media[0]
finally:
os.chdir(original_cwd)
+280
View File
@@ -0,0 +1,280 @@
"""Tests for SSE streaming support in /v1/chat/completions."""
from __future__ import annotations
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from nanobot.api.server import (
_sse_chunk,
_SSE_DONE,
create_app,
)
try:
from aiohttp.test_utils import TestClient, TestServer
HAS_AIOHTTP = True
except ImportError:
HAS_AIOHTTP = False
pytest_plugins = ("pytest_asyncio",)
# ---------------------------------------------------------------------------
# Unit tests for SSE helpers
# ---------------------------------------------------------------------------
def test_sse_chunk_with_delta() -> None:
raw = _sse_chunk("hello", "test-model", "chatcmpl-abc123")
line = raw.decode()
assert line.startswith("data: ")
payload = json.loads(line[len("data: "):])
assert payload["id"] == "chatcmpl-abc123"
assert payload["object"] == "chat.completion.chunk"
assert payload["model"] == "test-model"
assert payload["choices"][0]["delta"]["content"] == "hello"
assert payload["choices"][0]["finish_reason"] is None
def test_sse_chunk_finish_reason() -> None:
raw = _sse_chunk("", "m", "id1", finish_reason="stop")
payload = json.loads(raw.decode().split("data: ", 1)[1])
assert payload["choices"][0]["delta"] == {}
assert payload["choices"][0]["finish_reason"] == "stop"
def test_sse_done_format() -> None:
assert _SSE_DONE == b"data: [DONE]\n\n"
# ---------------------------------------------------------------------------
# Integration tests with aiohttp TestClient
# ---------------------------------------------------------------------------
def _make_streaming_agent(tokens: list[str]) -> MagicMock:
"""Create a mock agent that streams tokens via on_stream callback."""
agent = MagicMock()
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
async def fake_process_direct(*, content="", media=None, session_key="",
channel="", chat_id="", on_stream=None,
on_stream_end=None, **kwargs):
if on_stream:
for token in tokens:
await on_stream(token)
if on_stream_end:
await on_stream_end()
return " ".join(tokens)
agent.process_direct = fake_process_direct
return agent
@pytest_asyncio.fixture
async def aiohttp_client():
clients: list[TestClient] = []
async def _make_client(app):
client = TestClient(TestServer(app))
await client.start_server()
clients.append(client)
return client
try:
yield _make_client
finally:
for client in clients:
await client.close()
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_stream_true_returns_sse(aiohttp_client) -> None:
"""stream=true should return text/event-stream with SSE chunks."""
agent = _make_streaming_agent(["Hello", " world"])
app = create_app(agent, model_name="test-model")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hi"}], "stream": True},
)
assert resp.status == 200
assert resp.content_type == "text/event-stream"
body = await resp.text()
lines = [l for l in body.split("\n") if l.startswith("data: ")]
# Should have: 2 token chunks + 1 finish chunk + [DONE]
data_lines = [l[len("data: "):] for l in lines]
assert data_lines[-1] == "[DONE]"
chunks = [json.loads(l) for l in data_lines[:-1]]
assert chunks[0]["choices"][0]["delta"]["content"] == "Hello"
assert chunks[1]["choices"][0]["delta"]["content"] == " world"
# Last chunk before [DONE] should have finish_reason=stop
assert chunks[-1]["choices"][0]["finish_reason"] == "stop"
assert chunks[-1]["choices"][0]["delta"] == {}
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_stream_false_returns_json(aiohttp_client) -> None:
"""stream=false should still return regular JSON response."""
agent = MagicMock()
agent.process_direct = AsyncMock(return_value="normal reply")
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
app = create_app(agent, model_name="m")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hi"}], "stream": False},
)
assert resp.status == 200
body = await resp.json()
assert body["object"] == "chat.completion"
assert body["choices"][0]["message"]["content"] == "normal reply"
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_stream_default_is_false(aiohttp_client) -> None:
"""Omitting stream should behave like stream=false."""
agent = MagicMock()
agent.process_direct = AsyncMock(return_value="default reply")
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
app = create_app(agent, model_name="m")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hi"}]},
)
assert resp.status == 200
body = await resp.json()
assert body["object"] == "chat.completion"
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_stream_sse_chunk_ids_are_consistent(aiohttp_client) -> None:
"""All SSE chunks in a single stream should share the same id."""
agent = _make_streaming_agent(["A", "B", "C"])
app = create_app(agent, model_name="m")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "go"}], "stream": True},
)
body = await resp.text()
data_lines = [l[len("data: "):] for l in body.split("\n") if l.startswith("data: ") and l != "data: [DONE]"]
chunks = [json.loads(l) for l in data_lines]
chunk_ids = {c["id"] for c in chunks}
assert len(chunk_ids) == 1, f"Expected single chunk id, got {chunk_ids}"
assert chunk_ids.pop().startswith("chatcmpl-")
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_stream_passes_on_stream_callbacks(aiohttp_client) -> None:
"""process_direct should be called with on_stream and on_stream_end when streaming."""
captured_kwargs: dict = {}
async def fake_process_direct(**kwargs):
captured_kwargs.update(kwargs)
if kwargs.get("on_stream_end"):
await kwargs["on_stream_end"]()
return "done"
agent = MagicMock()
agent.process_direct = fake_process_direct
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
app = create_app(agent, model_name="m")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hi"}], "stream": True},
)
assert resp.status == 200
assert captured_kwargs.get("on_stream") is not None
assert captured_kwargs.get("on_stream_end") is not None
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_stream_with_session_id(aiohttp_client) -> None:
"""Streaming should respect session_id for session key routing."""
captured_key: str = ""
async def fake_process_direct(*, session_key="", on_stream=None, on_stream_end=None, **kwargs):
nonlocal captured_key
captured_key = session_key
if on_stream:
await on_stream("ok")
if on_stream_end:
await on_stream_end()
return "ok"
agent = MagicMock()
agent.process_direct = fake_process_direct
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
app = create_app(agent, model_name="m")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={
"messages": [{"role": "user", "content": "hi"}],
"stream": True,
"session_id": "my-session",
},
)
assert resp.status == 200
assert captured_key == "api:my-session"
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_streaming_backend_failure_does_not_emit_success_terminator(aiohttp_client) -> None:
"""Backend exceptions should not surface as a normal stop+[DONE] stream."""
agent = MagicMock()
async def boom(**kwargs):
raise RuntimeError("backend blew up")
agent.process_direct = boom
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
app = create_app(agent, model_name="m")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hi"}], "stream": True},
)
assert resp.status == 200
body = await resp.text()
assert '"finish_reason": "stop"' not in body
assert "[DONE]" not in body
+33
View File
@@ -15,6 +15,7 @@ def test_status_shows_cache_hit_rate():
)
assert "60% cached" in content
assert "2000 in / 300 out" in content
assert "Tasks: 0 active" in content
def test_status_no_cache_info():
@@ -30,6 +31,7 @@ def test_status_no_cache_info():
)
assert "cached" not in content.lower()
assert "2000 in / 300 out" in content
assert "Tasks: 0 active" in content
def test_status_zero_cached_tokens():
@@ -57,3 +59,34 @@ def test_status_100_percent_cached():
context_tokens_estimate=3000,
)
assert "100% cached" in content
def test_status_context_pct_uses_budget_not_total():
"""Percentage should be calculated against input budget, not raw context window."""
content = build_status_content(
version="0.1.0",
model="test",
start_time=1000000.0,
last_usage={"prompt_tokens": 2000, "completion_tokens": 300},
context_window_tokens=128000,
session_msg_count=10,
context_tokens_estimate=120000,
max_completion_tokens=8192,
)
# budget = 128000 - 8192 - 1024 = 118784; pct = 120000/118784*100 ≈ 101%
assert "(101% of input budget)" in content
def test_status_context_pct_capped_at_999():
"""Extreme overflow should be capped at 999."""
content = build_status_content(
version="0.1.0",
model="test",
start_time=1000000.0,
last_usage={"prompt_tokens": 2000, "completion_tokens": 300},
context_window_tokens=10000,
session_msg_count=10,
context_tokens_estimate=100000,
max_completion_tokens=4096,
)
assert "(999% of input budget)" in content
+113
View File
@@ -0,0 +1,113 @@
"""Tests for context builder media handling.
The ContextBuilder._build_user_content method should ONLY handle images.
Document text extraction is the responsibility of the processing layer
(AgentLoop._process_message and _drain_pending).
"""
from __future__ import annotations
from pathlib import Path
from nanobot.agent.context import ContextBuilder
from nanobot.utils.document import extract_documents
def _make_builder(tmp_path: Path) -> ContextBuilder:
"""Create a minimal ContextBuilder for testing."""
return ContextBuilder(workspace=tmp_path, timezone="UTC")
def test_build_user_content_with_no_media_returns_string(tmp_path: Path) -> None:
builder = _make_builder(tmp_path)
result = builder._build_user_content("hello", None)
assert result == "hello"
def test_build_user_content_with_image_returns_list(tmp_path: Path) -> None:
"""Image files should produce base64 content blocks."""
builder = _make_builder(tmp_path)
png = tmp_path / "test.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
result = builder._build_user_content("describe this", [str(png)])
assert isinstance(result, list)
types = [b["type"] for b in result]
assert "image_url" in types
assert "text" in types
def test_build_user_content_ignores_non_image_files(tmp_path: Path) -> None:
"""Non-image files should be silently skipped — extraction is not context builder's job."""
builder = _make_builder(tmp_path)
txt = tmp_path / "notes.txt"
txt.write_text("some text", encoding="utf-8")
result = builder._build_user_content("summarize", [str(txt)])
assert result == "summarize"
def test_build_user_content_mixed_image_and_non_image(tmp_path: Path) -> None:
"""Only images should be included; non-image files are skipped."""
builder = _make_builder(tmp_path)
png = tmp_path / "chart.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
txt = tmp_path / "report.txt"
txt.write_text("report text", encoding="utf-8")
result = builder._build_user_content("analyze", [str(png), str(txt)])
assert isinstance(result, list)
assert any(b["type"] == "image_url" for b in result)
text_parts = [b.get("text", "") for b in result if b.get("type") == "text"]
assert all("report text" not in t for t in text_parts)
# ---------------------------------------------------------------------------
# Bug detection: extract_documents must be called BEFORE _build_user_content
# to prevent document media from being silently dropped.
# This simulates the _drain_pending code path.
# ---------------------------------------------------------------------------
def test_drain_pending_path_preserves_document_text(tmp_path: Path) -> None:
"""Simulates the _drain_pending path: a pending follow-up message
with a document attachment must have its text extracted before being
passed to _build_user_content. Without extract_documents, the
document is silently dropped."""
from docx import Document
doc = Document()
doc.add_paragraph("Quarterly revenue is $5M")
docx_path = tmp_path / "report.docx"
doc.save(docx_path)
content = "summarize"
media = [str(docx_path)]
# Step 1: extract_documents separates docs from images
new_content, image_only = extract_documents(content, media)
# Step 2: _build_user_content handles only images (none left here)
builder = _make_builder(tmp_path)
result = builder._build_user_content(new_content, image_only if image_only else None)
# The document text should be present in the final content
assert "Quarterly revenue" in result
assert "summarize" in result
def test_drain_pending_path_without_extract_loses_document(tmp_path: Path) -> None:
"""Demonstrates the BUG: if _drain_pending calls _build_user_content
directly without extract_documents, document content is lost."""
from docx import Document
doc = Document()
doc.add_paragraph("Secret data in document")
docx_path = tmp_path / "report.docx"
doc.save(docx_path)
builder = _make_builder(tmp_path)
# Bug path: call _build_user_content directly with document media
result = builder._build_user_content("summarize", [str(docx_path)])
# The document text is LOST — _build_user_content ignores non-images
assert result == "summarize" # only the original text, no doc content
assert "Secret data" not in result
+316
View File
@@ -0,0 +1,316 @@
"""Tests for document text extraction utilities."""
from pathlib import Path
from nanobot.utils.document import (
SUPPORTED_EXTENSIONS,
_is_text_extension,
extract_text,
)
class TestSupportedExtensions:
"""Test the SUPPORTED_EXTENSIONS constant."""
def test_supported_extensions_include_common_formats(self):
"""Test that common document formats are included."""
# Document formats
assert ".pdf" in SUPPORTED_EXTENSIONS
assert ".docx" in SUPPORTED_EXTENSIONS
assert ".xlsx" in SUPPORTED_EXTENSIONS
assert ".pptx" in SUPPORTED_EXTENSIONS
# Text formats
assert ".txt" in SUPPORTED_EXTENSIONS
assert ".md" in SUPPORTED_EXTENSIONS
assert ".csv" in SUPPORTED_EXTENSIONS
assert ".json" in SUPPORTED_EXTENSIONS
assert ".yaml" in SUPPORTED_EXTENSIONS
assert ".yml" in SUPPORTED_EXTENSIONS
# Image formats
assert ".png" in SUPPORTED_EXTENSIONS
assert ".jpg" in SUPPORTED_EXTENSIONS
assert ".jpeg" in SUPPORTED_EXTENSIONS
class TestExtractText:
"""Test the extract_text function."""
def test_extract_text_unsupported_returns_none(self, tmp_path: Path):
"""Test that unsupported file types return None."""
unsupported_file = tmp_path / "file.xyz"
unsupported_file.write_text("content")
result = extract_text(unsupported_file)
assert result is None
def test_extract_text_file_not_found(self, tmp_path: Path):
"""Test that non-existent files return error string."""
missing_file = tmp_path / "nonexistent.txt"
result = extract_text(missing_file)
assert result is not None
assert "[error: file not found:" in result
def test_extract_text_txt_file(self, tmp_path: Path):
"""Test extracting text from a .txt file."""
txt_file = tmp_path / "test.txt"
content = "Hello, world!\nThis is a test."
txt_file.write_text(content, encoding="utf-8")
result = extract_text(txt_file)
assert result == content
def test_extract_text_txt_file_with_truncation(self, tmp_path: Path):
"""Test that large text files are truncated."""
txt_file = tmp_path / "large.txt"
# Create content larger than _MAX_TEXT_LENGTH
content = "x" * 300_000
txt_file.write_text(content, encoding="utf-8")
result = extract_text(txt_file)
assert len(result) < 300_000
assert "(truncated," in result
assert "chars total)" in result
def test_extract_text_md_file(self, tmp_path: Path):
"""Test extracting text from a .md file."""
md_file = tmp_path / "test.md"
content = "# Header\n\nSome markdown content."
md_file.write_text(content, encoding="utf-8")
result = extract_text(md_file)
assert result == content
def test_extract_text_csv_file(self, tmp_path: Path):
"""Test extracting text from a .csv file."""
csv_file = tmp_path / "test.csv"
content = "name,age\nAlice,30\nBob,25"
csv_file.write_text(content, encoding="utf-8")
result = extract_text(csv_file)
assert result == content
def test_extract_text_json_file(self, tmp_path: Path):
"""Test extracting text from a .json file."""
json_file = tmp_path / "test.json"
content = '{"key": "value", "number": 42}'
json_file.write_text(content, encoding="utf-8")
result = extract_text(json_file)
assert result == content
def test_extract_text_xlsx(self, tmp_path: Path):
"""Test extracting text from an .xlsx file."""
from openpyxl import Workbook
xlsx_file = tmp_path / "test.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Sheet1"
ws["A1"] = "Name"
ws["B1"] = "Age"
ws["A2"] = "Alice"
ws["B2"] = 30
ws["A3"] = "Bob"
ws["B3"] = 25
# Add a second sheet
ws2 = wb.create_sheet("Sheet2")
ws2["A1"] = "Product"
ws2["B1"] = "Price"
ws2["A2"] = "Widget"
ws2["B2"] = 9.99
wb.save(xlsx_file)
wb.close()
result = extract_text(xlsx_file)
assert result is not None
assert "--- Sheet: Sheet1 ---" in result
assert "--- Sheet: Sheet2 ---" in result
assert "Alice" in result
assert "Bob" in result
assert "Widget" in result
assert "9.99" in result
def test_extract_text_xlsx_empty_sheet(self, tmp_path: Path):
"""Test extracting text from an .xlsx file with empty sheets."""
from openpyxl import Workbook
xlsx_file = tmp_path / "empty.xlsx"
wb = Workbook()
# Clear the default sheet
wb.remove(wb.active)
# Add an empty sheet
wb.create_sheet("EmptySheet")
wb.save(xlsx_file)
wb.close()
result = extract_text(xlsx_file)
# Empty sheets should return empty string or header only
assert result == "--- Sheet: EmptySheet ---" or result == ""
def test_extract_text_docx(self, tmp_path: Path):
"""Test extracting text from a .docx file."""
from docx import Document
docx_file = tmp_path / "test.docx"
doc = Document()
doc.add_heading("Test Document", 0)
doc.add_paragraph("This is paragraph one.")
doc.add_paragraph("This is paragraph two.")
doc.save(docx_file)
result = extract_text(docx_file)
assert result is not None
assert "Test Document" in result
assert "This is paragraph one." in result
assert "This is paragraph two." in result
def test_extract_text_docx_empty(self, tmp_path: Path):
"""Test extracting text from an empty .docx file."""
from docx import Document
docx_file = tmp_path / "empty.docx"
doc = Document()
doc.save(docx_file)
result = extract_text(docx_file)
assert result == ""
def test_extract_text_pptx(self, tmp_path: Path):
"""Test extracting text from a .pptx file."""
from pptx import Presentation
pptx_file = tmp_path / "test.pptx"
prs = Presentation()
# Slide 1
slide1 = prs.slides.add_slide(prs.slide_layouts[0])
for shape in slide1.shapes:
if hasattr(shape, "text"):
shape.text = "First Slide Title"
# Slide 2
slide2 = prs.slides.add_slide(prs.slide_layouts[5])
left = top = width = height = 1000000
textbox = slide2.shapes.add_textbox(left, top, width, height)
text_frame = textbox.text_frame
text_frame.text = "Bullet point content"
prs.save(pptx_file)
result = extract_text(pptx_file)
assert result is not None
assert "--- Slide 1 ---" in result
assert "--- Slide 2 ---" in result
# Text content may vary depending on PowerPoint layout defaults
assert len(result) > 0
def test_extract_text_pptx_table(self, tmp_path: Path):
"""Table cells should be extracted, not silently dropped."""
from pptx import Presentation
from pptx.util import Inches
pptx_file = tmp_path / "table.pptx"
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])
table = slide.shapes.add_table(
2, 2, Inches(1), Inches(1), Inches(4), Inches(1)
).table
table.cell(0, 0).text = "Header A"
table.cell(0, 1).text = "Header B"
table.cell(1, 0).text = "Alice"
table.cell(1, 1).text = "Bob"
prs.save(pptx_file)
result = extract_text(pptx_file)
assert result is not None
assert "Header A" in result
assert "Header B" in result
assert "Alice" in result
assert "Bob" in result
def test_extract_text_pptx_grouped_shapes(self, tmp_path: Path):
"""Text inside grouped shapes must be extracted recursively."""
from pptx import Presentation
from pptx.util import Inches
pptx_file = tmp_path / "grouped.pptx"
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])
group = slide.shapes.add_group_shape()
inner = group.shapes.add_textbox(
Inches(1), Inches(1), Inches(3), Inches(1)
)
inner.text_frame.text = "Inside group"
prs.save(pptx_file)
result = extract_text(pptx_file)
assert result is not None
assert "Inside group" in result
def test_extract_text_pdf_not_found(self, tmp_path: Path):
"""Test that missing PDF files return error string."""
missing_pdf = tmp_path / "nonexistent.pdf"
result = extract_text(missing_pdf)
assert result is not None
assert "[error: file not found:" in result
def test_extract_text_image_files(self, tmp_path: Path):
"""Test that image files return placeholder text."""
# Create a minimal PNG file (1x1 pixel)
png_file = tmp_path / "test.png"
# Minimal valid PNG: 8-byte signature + IHDR + IDAT + IEND
png_data = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x02\x00\x00\x00\x90wS\xde"
b"\x00\x00\x00\x0cIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01"
b"\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
)
png_file.write_bytes(png_data)
result = extract_text(png_file)
assert result is not None
assert "[image:" in result
assert "test.png" in result
class TestIsTextExtension:
"""Test the _is_text_extension helper."""
def test_text_extensions_return_true(self):
"""Test that known text extensions return True."""
assert _is_text_extension(".txt") is True
assert _is_text_extension(".md") is True
assert _is_text_extension(".csv") is True
assert _is_text_extension(".json") is True
assert _is_text_extension(".yaml") is True
assert _is_text_extension(".yml") is True
assert _is_text_extension(".xml") is True
assert _is_text_extension(".html") is True
assert _is_text_extension(".htm") is True
def test_non_text_extensions_return_false(self):
"""Test that non-text extensions return False."""
assert _is_text_extension(".pdf") is False
assert _is_text_extension(".docx") is False
assert _is_text_extension(".xlsx") is False
assert _is_text_extension(".pptx") is False
assert _is_text_extension(".png") is False
assert _is_text_extension(".xyz") is False
def test_case_sensitivity(self):
"""Test that _is_text_extension requires lowercase extension.
Note: The main extract_text function handles case-insensitivity by
converting extensions to lowercase before calling _is_text_extension.
"""
# _is_text_extension itself is case-sensitive (lowercase only)
assert _is_text_extension(".txt") is True
assert _is_text_extension(".TXT") is False
assert _is_text_extension(".pdf") is False
+562
View File
@@ -0,0 +1,562 @@
import json
import pytest
# Check optional msteams dependencies before running tests
try:
from nanobot.channels import msteams
MSTEAMS_AVAILABLE = getattr(msteams, "MSTEAMS_AVAILABLE", False)
except ImportError:
MSTEAMS_AVAILABLE = False
if not MSTEAMS_AVAILABLE:
pytest.skip("MSTeams dependencies not installed (PyJWT, cryptography). Run: pip install nanobot-ai[msteams]", allow_module_level=True)
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
import nanobot.channels.msteams as msteams_module
from nanobot.bus.events import OutboundMessage
from nanobot.channels.msteams import ConversationRef, MSTeamsChannel, MSTeamsConfig
class DummyBus:
def __init__(self):
self.inbound = []
async def publish_inbound(self, msg):
self.inbound.append(msg)
class FakeResponse:
def __init__(self, payload=None, *, should_raise=False):
self._payload = payload or {}
self._should_raise = should_raise
def raise_for_status(self):
if self._should_raise:
raise RuntimeError("boom")
return None
def json(self):
return self._payload
class FakeHttpClient:
def __init__(self, payload=None, *, should_raise=False):
self.payload = payload or {"access_token": "tok", "expires_in": 3600}
self.should_raise = should_raise
self.calls = []
async def post(self, url, **kwargs):
self.calls.append((url, kwargs))
return FakeResponse(self.payload, should_raise=self.should_raise)
async def aclose(self):
pass
@pytest.fixture
def make_channel(tmp_path, monkeypatch):
monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
def _make_channel(**config_overrides):
config = {
"enabled": True,
"appId": "app-id",
"appPassword": "secret",
"tenantId": "tenant-id",
"allowFrom": ["*"],
}
config.update(config_overrides)
return MSTeamsChannel(config, DummyBus())
return _make_channel
@pytest.mark.asyncio
async def test_handle_activity_personal_message_publishes_and_stores_ref(make_channel, tmp_path):
ch = make_channel()
activity = {
"type": "message",
"id": "activity-1",
"text": "Hello from Teams",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
"conversation": {
"id": "conv-123",
"conversationType": "personal",
},
"from": {
"id": "29:user-id",
"aadObjectId": "aad-user-1",
"name": "Bob",
},
"recipient": {
"id": "28:bot-id",
"name": "nanobot",
},
"channelData": {
"tenant": {"id": "tenant-id"},
},
}
await ch._handle_activity(activity)
assert len(ch.bus.inbound) == 1
msg = ch.bus.inbound[0]
assert msg.channel == "msteams"
assert msg.sender_id == "aad-user-1"
assert msg.chat_id == "conv-123"
assert msg.content == "Hello from Teams"
assert msg.metadata["msteams"]["conversation_id"] == "conv-123"
assert "conv-123" in ch._conversation_refs
saved = json.loads((tmp_path / "state" / "msteams_conversations.json").read_text(encoding="utf-8"))
assert saved["conv-123"]["conversation_id"] == "conv-123"
assert saved["conv-123"]["tenant_id"] == "tenant-id"
@pytest.mark.asyncio
async def test_handle_activity_ignores_group_messages(make_channel):
ch = make_channel()
activity = {
"type": "message",
"id": "activity-2",
"text": "Hello group",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
"conversation": {
"id": "conv-group",
"conversationType": "channel",
},
"from": {
"id": "29:user-id",
"aadObjectId": "aad-user-1",
"name": "Bob",
},
"recipient": {
"id": "28:bot-id",
"name": "nanobot",
},
}
await ch._handle_activity(activity)
assert ch.bus.inbound == []
assert ch._conversation_refs == {}
@pytest.mark.asyncio
async def test_handle_activity_denied_sender_does_not_store_ref(make_channel, tmp_path):
ch = make_channel(allowFrom=["allowed-user"])
activity = {
"type": "message",
"id": "activity-denied",
"text": "Hello from denied user",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
"conversation": {
"id": "conv-denied",
"conversationType": "personal",
},
"from": {
"id": "29:user-id",
"aadObjectId": "aad-user-1",
"name": "Bob",
},
"recipient": {
"id": "28:bot-id",
"name": "nanobot",
},
"channelData": {
"tenant": {"id": "tenant-id"},
},
}
await ch._handle_activity(activity)
assert ch.bus.inbound == []
assert ch._conversation_refs == {}
assert not (tmp_path / "state" / "msteams_conversations.json").exists()
@pytest.mark.asyncio
async def test_handle_activity_mention_only_uses_default_response(make_channel):
ch = make_channel()
activity = {
"type": "message",
"id": "activity-3",
"text": "<at>Nanobot</at>",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
"conversation": {
"id": "conv-empty",
"conversationType": "personal",
},
"from": {
"id": "29:user-id",
"aadObjectId": "aad-user-1",
"name": "Bob",
},
"recipient": {
"id": "28:bot-id",
"name": "nanobot",
},
}
await ch._handle_activity(activity)
assert len(ch.bus.inbound) == 1
assert ch.bus.inbound[0].content == "Hi — what can I help with?"
assert "conv-empty" in ch._conversation_refs
@pytest.mark.asyncio
async def test_handle_activity_mention_only_ignores_when_response_disabled(make_channel):
ch = make_channel(mentionOnlyResponse=" ")
activity = {
"type": "message",
"id": "activity-4",
"text": "<at>Nanobot</at>",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
"conversation": {
"id": "conv-empty-disabled",
"conversationType": "personal",
},
"from": {
"id": "29:user-id",
"aadObjectId": "aad-user-1",
"name": "Bob",
},
"recipient": {
"id": "28:bot-id",
"name": "nanobot",
},
}
await ch._handle_activity(activity)
assert ch.bus.inbound == []
assert ch._conversation_refs == {}
def test_strip_possible_bot_mention_removes_generic_at_tags(make_channel):
ch = make_channel()
assert ch._strip_possible_bot_mention("<at>Nanobot</at> hello") == "hello"
assert ch._strip_possible_bot_mention("hi <at>Some Bot</at> there") == "hi there"
def test_sanitize_inbound_text_keeps_normal_inline_message(make_channel):
ch = make_channel()
activity = {
"text": "<at>Nanobot</at> normal inline message",
"channelData": {},
}
assert ch._sanitize_inbound_text(activity) == "normal inline message"
def test_sanitize_inbound_text_normalizes_reply_wrapper_without_reply_metadata(make_channel):
ch = make_channel()
activity = {
"text": "Reply wrapper \r\nQuoted prior message\r\n\r\nThis is a reply with quote test",
"channelData": {},
}
assert ch._sanitize_inbound_text(activity) == (
"User is replying to: Quoted prior message\n"
"User reply: This is a reply with quote test"
)
def test_sanitize_inbound_text_structures_reply_quote_prefix(make_channel):
ch = make_channel()
activity = {
"text": "Replying to Bob Smith\nactual reply text",
"replyToId": "parent-activity",
"channelData": {"messageType": "reply"},
}
assert ch._sanitize_inbound_text(activity) == "User is replying to: Bob Smith\nUser reply: actual reply text"
def test_sanitize_inbound_text_structures_live_reply_wrapper_shape(make_channel):
ch = make_channel()
activity = {
"text": "Reply wrapper Got it. Ill watch for the exact text reply with quote test and then inspect that turn specifically. Reply with quote test",
"replyToId": "parent-activity",
"channelData": {"messageType": "reply"},
}
assert ch._sanitize_inbound_text(activity) == (
"User is replying to: Got it. Ill watch for the exact text reply with quote test and then inspect that turn specifically.\n"
"User reply: Reply with quote test"
)
def test_normalize_teams_reply_quote_leaves_plain_text_test_phrase_untouched(make_channel):
ch = make_channel()
text = "Normal message ending with Reply with quote test"
assert ch._normalize_teams_reply_quote(text) == text
def test_sanitize_inbound_text_structures_multiline_reply_wrapper_shape(make_channel):
ch = make_channel()
activity = {
"text": (
"Reply wrapper\r\n"
"Understood — then the restart already happened, and the new Teams quote normalization should now be live. "
"Next best step: • send one more real reply-with-quote message in Teams • I&rsquo…\r\n"
"\r\n"
"This is a reply with quote"
),
"replyToId": "parent-activity",
"channelData": {"messageType": "reply"},
}
assert ch._sanitize_inbound_text(activity) == (
"User is replying to: Understood — then the restart already happened, and the new Teams quote normalization should now be live. "
"Next best step: • send one more real reply-with-quote message in Teams • I’…\n"
"User reply: This is a reply with quote"
)
def test_sanitize_inbound_text_structures_exact_live_crlf_reply_wrapper_shape(make_channel):
ch = make_channel()
activity = {
"text": (
"Reply wrapper \r\n"
"Please send one real reply-with-quote message in Teams. That single test should be enough now: "
"• Ill check the new MSTeams sanitized inbound text ... log • and compare it to the prompt…\r\n"
"\r\n"
"This is a reply with quote test"
),
"replyToId": "parent-activity",
"channelData": {"messageType": "reply"},
}
assert ch._sanitize_inbound_text(activity) == (
"User is replying to: Please send one real reply-with-quote message in Teams. That single test should be enough now: "
"• Ill check the new MSTeams sanitized inbound text ... log • and compare it to the prompt…\n"
"User reply: This is a reply with quote test"
)
@pytest.mark.asyncio
async def test_get_access_token_uses_configured_tenant(make_channel):
ch = make_channel(tenantId="tenant-123")
fake_http = FakeHttpClient()
ch._http = fake_http
token = await ch._get_access_token()
assert token == "tok"
assert len(fake_http.calls) == 1
url, kwargs = fake_http.calls[0]
assert url == "https://login.microsoftonline.com/tenant-123/oauth2/v2.0/token"
assert kwargs["data"]["client_id"] == "app-id"
assert kwargs["data"]["client_secret"] == "secret"
assert kwargs["data"]["scope"] == "https://api.botframework.com/.default"
@pytest.mark.asyncio
async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channel):
ch = make_channel(replyInThread=True)
fake_http = FakeHttpClient()
ch._http = fake_http
ch._token = "tok"
ch._token_expires_at = 9999999999
ch._conversation_refs["conv-123"] = ConversationRef(
service_url="https://smba.trafficmanager.net/amer/",
conversation_id="conv-123",
activity_id="activity-1",
)
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
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 kwargs["headers"]["Authorization"] == "Bearer tok"
assert kwargs["json"]["text"] == "Reply text"
assert kwargs["json"]["replyToId"] == "activity-1"
@pytest.mark.asyncio
async def test_send_posts_to_conversation_when_thread_reply_disabled(make_channel):
ch = make_channel(replyInThread=False)
fake_http = FakeHttpClient()
ch._http = fake_http
ch._token = "tok"
ch._token_expires_at = 9999999999
ch._conversation_refs["conv-123"] = ConversationRef(
service_url="https://smba.trafficmanager.net/amer/",
conversation_id="conv-123",
activity_id="activity-1",
)
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
assert len(fake_http.calls) == 1
url, kwargs = fake_http.calls[0]
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 "replyToId" not in kwargs["json"]
@pytest.mark.asyncio
async def test_send_posts_to_conversation_when_thread_reply_enabled_but_no_activity_id(make_channel):
ch = make_channel(replyInThread=True)
fake_http = FakeHttpClient()
ch._http = fake_http
ch._token = "tok"
ch._token_expires_at = 9999999999
ch._conversation_refs["conv-123"] = ConversationRef(
service_url="https://smba.trafficmanager.net/amer/",
conversation_id="conv-123",
activity_id=None,
)
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
assert len(fake_http.calls) == 1
url, kwargs = fake_http.calls[0]
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 "replyToId" not in kwargs["json"]
@pytest.mark.asyncio
async def test_send_raises_when_conversation_ref_missing(make_channel):
ch = make_channel()
ch._http = FakeHttpClient()
with pytest.raises(RuntimeError, match="conversation ref not found"):
await ch.send(OutboundMessage(channel="msteams", chat_id="missing", content="Reply text"))
@pytest.mark.asyncio
async def test_send_raises_delivery_failures_for_retry(make_channel):
ch = make_channel()
ch._http = FakeHttpClient(should_raise=True)
ch._token = "tok"
ch._token_expires_at = 9999999999
ch._conversation_refs["conv-123"] = ConversationRef(
service_url="https://smba.trafficmanager.net/amer/",
conversation_id="conv-123",
activity_id="activity-1",
)
with pytest.raises(RuntimeError, match="boom"):
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
def _make_test_rsa_jwk(kid: str = "test-kid"):
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(public_key))
jwk["kid"] = kid
jwk["use"] = "sig"
jwk["kty"] = "RSA"
jwk["alg"] = "RS256"
return private_key, jwk
@pytest.mark.asyncio
async def test_validate_inbound_auth_accepts_observed_botframework_shape(make_channel):
ch = make_channel(validateInboundAuth=True)
private_key, jwk = _make_test_rsa_jwk()
ch._botframework_jwks = {"keys": [jwk]}
ch._botframework_jwks_expires_at = 9999999999
service_url = "https://smba.trafficmanager.net/amer/tenant/"
token = jwt.encode(
{
"iss": "https://api.botframework.com",
"aud": "app-id",
"serviceurl": service_url,
"nbf": 1700000000,
"exp": 4100000000,
},
private_key,
algorithm="RS256",
headers={"kid": jwk["kid"]},
)
await ch._validate_inbound_auth(
f"Bearer {token}",
{"serviceUrl": service_url},
)
@pytest.mark.asyncio
async def test_validate_inbound_auth_rejects_service_url_mismatch(make_channel):
ch = make_channel(validateInboundAuth=True)
private_key, jwk = _make_test_rsa_jwk()
ch._botframework_jwks = {"keys": [jwk]}
ch._botframework_jwks_expires_at = 9999999999
token = jwt.encode(
{
"iss": "https://api.botframework.com",
"aud": "app-id",
"serviceurl": "https://smba.trafficmanager.net/amer/tenant-a/",
"nbf": 1700000000,
"exp": 4100000000,
},
private_key,
algorithm="RS256",
headers={"kid": jwk["kid"]},
)
with pytest.raises(ValueError, match="serviceUrl claim mismatch"):
await ch._validate_inbound_auth(
f"Bearer {token}",
{"serviceUrl": "https://smba.trafficmanager.net/amer/tenant-b/"},
)
@pytest.mark.asyncio
async def test_validate_inbound_auth_rejects_missing_bearer_token(make_channel):
ch = make_channel(validateInboundAuth=True)
with pytest.raises(ValueError, match="missing bearer token"):
await ch._validate_inbound_auth("", {"serviceUrl": "https://smba.trafficmanager.net/amer/tenant/"})
@pytest.mark.asyncio
async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypatch):
ch = make_channel()
errors = []
monkeypatch.setattr(msteams_module, "MSTEAMS_AVAILABLE", False)
monkeypatch.setattr(msteams_module.logger, "error", lambda message, *args: errors.append(message.format(*args)))
await ch.start()
assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"]
def test_msteams_default_config_includes_restart_notify_fields():
cfg = MSTeamsChannel.default_config()
assert cfg["validateInboundAuth"] is True
assert "restartNotifyEnabled" not in cfg
assert "restartNotifyPreMessage" not in cfg
assert "restartNotifyPostMessage" not in cfg
+67 -13
View File
@@ -101,15 +101,14 @@ async def test_no_user_message_returns_400(aiohttp_client, app) -> None:
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_stream_true_returns_400(aiohttp_client, app) -> None:
async def test_stream_true_returns_sse(aiohttp_client, app) -> None:
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hello"}], "stream": True},
)
assert resp.status == 400
body = await resp.json()
assert "stream" in body["error"]["message"].lower()
assert resp.status == 200
assert resp.content_type == "text/event-stream"
@pytest.mark.asyncio
@@ -194,6 +193,7 @@ async def test_successful_request_uses_fixed_api_session(aiohttp_client, mock_ag
assert body["model"] == "test-model"
mock_agent.process_direct.assert_called_once_with(
content="hello",
media=None,
session_key=API_SESSION_KEY,
channel="api",
chat_id=API_CHAT_ID,
@@ -205,7 +205,7 @@ async def test_successful_request_uses_fixed_api_session(aiohttp_client, mock_ag
async def test_followup_requests_share_same_session_key(aiohttp_client) -> None:
call_log: list[str] = []
async def fake_process(content, session_key="", channel="", chat_id=""):
async def fake_process(content, session_key="", channel="", chat_id="", **kwargs):
call_log.append(session_key)
return f"reply to {content}"
@@ -236,7 +236,7 @@ async def test_followup_requests_share_same_session_key(aiohttp_client) -> None:
async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None:
order: list[str] = []
async def slow_process(content, session_key="", channel="", chat_id=""):
async def slow_process(content, session_key="", channel="", chat_id="", **kwargs):
order.append(f"start:{content}")
await asyncio.sleep(0.1)
order.append(f"end:{content}")
@@ -307,20 +307,46 @@ async def test_multimodal_content_extracts_text(aiohttp_client, mock_agent) -> N
},
)
assert resp.status == 200
mock_agent.process_direct.assert_called_once_with(
content="describe this",
session_key=API_SESSION_KEY,
channel="api",
chat_id=API_CHAT_ID,
call_kwargs = mock_agent.process_direct.call_args.kwargs
assert call_kwargs["content"] == "describe this"
assert call_kwargs["session_key"] == API_SESSION_KEY
assert call_kwargs["channel"] == "api"
assert call_kwargs["chat_id"] == API_CHAT_ID
assert len(call_kwargs.get("media") or []) >= 0 # base64 images saved to disk
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_multimodal_remote_image_url_returns_400(aiohttp_client, mock_agent) -> None:
app = create_app(mock_agent, model_name="m")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "describe this"},
{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}},
],
}
]
},
)
assert resp.status == 400
body = await resp.json()
assert "remote image urls are not supported" in body["error"]["message"].lower()
mock_agent.process_direct.assert_not_called()
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_empty_response_retry_then_success(aiohttp_client) -> None:
call_count = 0
async def sometimes_empty(content, session_key="", channel="", chat_id=""):
async def sometimes_empty(content, session_key="", channel="", chat_id="", **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
@@ -351,7 +377,7 @@ async def test_empty_response_falls_back(aiohttp_client) -> None:
call_count = 0
async def always_empty(content, session_key="", channel="", chat_id=""):
async def always_empty(content, session_key="", channel="", chat_id="", **kwargs):
nonlocal call_count
call_count += 1
return ""
@@ -371,3 +397,31 @@ async def test_empty_response_falls_back(aiohttp_client) -> None:
body = await resp.json()
assert body["choices"][0]["message"]["content"] == EMPTY_FINAL_RESPONSE_MESSAGE
assert call_count == 2
@pytest.mark.asyncio
async def test_process_direct_accepts_media() -> None:
"""process_direct should forward media paths to _process_message."""
from nanobot.agent.loop import AgentLoop
loop = AgentLoop.__new__(AgentLoop)
loop._connect_mcp = AsyncMock()
captured_msg = None
async def fake_process(msg, *, session_key="", on_progress=None, on_stream=None, on_stream_end=None):
nonlocal captured_msg
captured_msg = msg
return None
loop._process_message = fake_process
await loop.process_direct(
content="analyze this",
media=["/tmp/image.png", "/tmp/report.pdf"],
session_key="test:1",
)
assert captured_msg is not None
assert captured_msg.media == ["/tmp/image.png", "/tmp/report.pdf"]
assert captured_msg.content == "analyze this"
+68
View File
@@ -1,5 +1,8 @@
"""Tests for ReadFileTool enhancements: description fix, read dedup, PDF support, device blacklist."""
import os
import sys
import pytest
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
@@ -143,6 +146,7 @@ class TestReadPdf:
# Device path blacklist
# ---------------------------------------------------------------------------
@pytest.mark.skipif(sys.platform == "win32", reason="/dev directory doesn't exist on Windows")
class TestReadDeviceBlacklist:
@pytest.fixture()
@@ -178,3 +182,67 @@ class TestReadDeviceBlacklist:
result = await tool.execute(path=str(link))
assert "Error" in result
assert "blocked" in result.lower() or "device" in result.lower()
# ---------------------------------------------------------------------------
# file_state: mtime-unchanged / content-changed fallback
# ---------------------------------------------------------------------------
# On filesystems with coarse mtime resolution (NTFS ~100ms, FAT 2s) a fast
# write-after-read can leave mtime unchanged. The content-hash fallback is
# what protects against stale-read warnings being false-negative on those
# platforms. Lock that behavior down here so nobody reverts it silently.
class TestFileStateHashFallback:
def test_check_read_warns_when_content_changed_but_mtime_same(self, tmp_path):
f = tmp_path / "data.txt"
f.write_text("original", encoding="utf-8")
file_state.record_read(f)
original_mtime = os.path.getmtime(f)
f.write_text("modified", encoding="utf-8")
os.utime(f, (original_mtime, original_mtime))
assert os.path.getmtime(f) == original_mtime
warning = file_state.check_read(f)
assert warning is not None
assert "modified" in warning.lower()
def test_check_read_passes_when_content_and_mtime_unchanged(self, tmp_path):
f = tmp_path / "data.txt"
f.write_text("stable", encoding="utf-8")
file_state.record_read(f)
assert file_state.check_read(f) is None
# ---------------------------------------------------------------------------
# Line-ending normalization
# ---------------------------------------------------------------------------
# ReadFileTool normalizes CRLF -> LF before line-splitting. This primarily
# helps Windows users whose checkouts carry CRLF line endings and whose
# subsequent StrReplace edits would otherwise miss on `\r` boundaries. The
# normalization applies on all platforms; these tests lock that in so the
# behavior is intentional and discoverable, not accidental.
class TestReadFileLineEndingNormalization:
@pytest.fixture()
def tool(self, tmp_path):
return ReadFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_crlf_is_normalized_to_lf(self, tool, tmp_path):
f = tmp_path / "crlf.txt"
f.write_bytes(b"alpha\r\nbeta\r\ngamma\r\n")
result = await tool.execute(path=str(f))
assert "\r" not in result
assert "alpha" in result and "beta" in result and "gamma" in result
@pytest.mark.asyncio
async def test_lf_only_is_preserved(self, tool, tmp_path):
f = tmp_path / "lf.txt"
f.write_bytes(b"alpha\nbeta\ngamma\n")
result = await tool.execute(path=str(f))
assert "\r" not in result
assert "alpha" in result and "beta" in result and "gamma" in result
+10 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
import time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -10,7 +11,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.agent.tools.search import GlobTool, GrepTool
from nanobot.bus.queue import MessageBus
@@ -179,9 +180,13 @@ async def test_grep_files_with_matches_supports_head_limit_and_offset(tmp_path:
offset=1,
)
lines = result.splitlines()
assert lines[0] == "src/b.py"
# Filesystem order is not deterministic across platforms, so just verify:
# 1. Only one file path is returned (head_limit=1 after offset=1)
# 2. The pagination info is correct
assert "pagination: limit=1, offset=1" in result
# Count non-empty lines that start with src/ (file paths)
file_lines = [l for l in result.splitlines() if l.startswith("src/")]
assert len(file_lines) == 1
@pytest.mark.asyncio
@@ -319,7 +324,8 @@ async def test_subagent_registers_grep_and_glob(tmp_path: Path) -> None:
mgr.runner.run = fake_run
mgr._announce_result = AsyncMock()
await mgr._run_subagent("sub-1", "search task", "label", {"channel": "cli", "chat_id": "direct"})
status = SubagentStatus(task_id="sub-1", label="label", task_description="search task", started_at=time.monotonic())
await mgr._run_subagent("sub-1", "search task", "label", {"channel": "cli", "chat_id": "direct"}, status)
assert "grep" in captured["tool_names"]
assert "glob" in captured["tool_names"]
+30
View File
@@ -71,3 +71,33 @@ def test_prepare_call_other_tools_keep_generic_object_validation() -> None:
assert tool is not None
assert params == ["TODO"]
assert error == "Error: Invalid parameters for tool 'grep': parameters must be an object, got list"
def test_get_definitions_returns_cached_result() -> None:
registry = ToolRegistry()
registry.register(_FakeTool("read_file"))
first = registry.get_definitions()
assert registry._cached_definitions is not None
second = registry.get_definitions()
assert first == second
def test_register_invalidates_cache() -> None:
registry = ToolRegistry()
registry.register(_FakeTool("read_file"))
first = registry.get_definitions()
registry.register(_FakeTool("write_file"))
second = registry.get_definitions()
assert first is not second
assert len(second) == 2
def test_unregister_invalidates_cache() -> None:
registry = ToolRegistry()
registry.register(_FakeTool("read_file"))
registry.register(_FakeTool("write_file"))
first = registry.get_definitions()
registry.unregister("write_file")
second = registry.get_definitions()
assert first is not second
assert len(second) == 1
+13 -8
View File
@@ -545,18 +545,23 @@ async def test_exec_always_returns_exit_code() -> None:
assert "hello" in result
async def test_exec_head_tail_truncation() -> None:
async def test_exec_head_tail_truncation(tmp_path) -> None:
"""Long output should preserve both head and tail."""
tool = ExecTool()
# Generate output that exceeds _MAX_OUTPUT (10_000 chars)
# Use current interpreter (PATH may not have `python`). ExecTool uses
# create_subprocess_shell: POSIX needs shlex.quote; Windows uses cmd.exe
# rules, so list2cmdline is appropriate there.
script = "print('A' * 6000 + '\\n' + 'B' * 6000)"
# Generate output that exceeds _MAX_OUTPUT (10_000 chars).
# Use a temp script file so the output-generating logic lives in a file
# (Windows cmd.exe has finicky rules for quoting `-c` payloads with
# embedded newlines). ExecTool runs via create_subprocess_shell, so we
# must quote *both* the interpreter path and the script path — tmp_path
# on some CI runners and on many local Windows installs contains spaces
# (e.g. C:\Users\John Doe\AppData\...) which would otherwise break the
# shell's argv split.
script_file = tmp_path / "gen_output.py"
script_file.write_text("print('A' * 6000 + chr(10) + 'B' * 6000)", encoding="utf-8")
if sys.platform == "win32":
command = subprocess.list2cmdline([sys.executable, "-c", script])
command = subprocess.list2cmdline([sys.executable, str(script_file)])
else:
command = f"{shlex.quote(sys.executable)} -c {shlex.quote(script)}"
command = f"{shlex.quote(sys.executable)} {shlex.quote(str(script_file))}"
result = await tool.execute(command=command)
assert "chars truncated" in result
# Head portion should start with As
+19 -5
View File
@@ -1,7 +1,5 @@
"""Tests for multi-provider web search."""
import asyncio
import httpx
import pytest
@@ -20,6 +18,25 @@ def _response(status: int = 200, json: dict | None = None) -> httpx.Response:
return r
def test_duckduckgo_search_is_exclusive():
tool = _tool(provider="duckduckgo")
assert tool.exclusive is True
assert tool.concurrency_safe is False
def test_brave_with_api_key_remains_concurrency_safe():
tool = _tool(provider="brave", api_key="brave-key")
assert tool.exclusive is False
assert tool.concurrency_safe is True
def test_brave_without_api_key_is_treated_as_duckduckgo_for_concurrency(monkeypatch):
monkeypatch.delenv("BRAVE_API_KEY", raising=False)
tool = _tool(provider="brave", api_key="")
assert tool.exclusive is True
assert tool.concurrency_safe is False
@pytest.mark.asyncio
async def test_brave_search(monkeypatch):
async def mock_get(self, url, **kw):
@@ -79,7 +96,6 @@ async def test_duckduckgo_search(monkeypatch):
import nanobot.agent.tools.web as web_mod
monkeypatch.setattr(web_mod, "DDGS", MockDDGS, raising=False)
from ddgs import DDGS
monkeypatch.setattr("ddgs.DDGS", MockDDGS)
tool = _tool(provider="duckduckgo")
@@ -265,5 +281,3 @@ async def test_duckduckgo_timeout_returns_error(monkeypatch):
result = await tool.execute(query="test")
gate.set()
assert "Error" in result
+216
View File
@@ -0,0 +1,216 @@
"""Tests for GitStore — line_ages() and core git operations."""
import subprocess
import time
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from nanobot.utils.gitstore import GitStore
@pytest.fixture
def git(tmp_path):
"""Create an initialized GitStore with tracked MEMORY.md."""
g = GitStore(tmp_path, tracked_files=["MEMORY.md", "SOUL.md"])
g.init()
return g
class TestLineAges:
def test_returns_empty_when_not_initialized(self, tmp_path):
"""line_ages should return [] if the git repo is not initialized."""
git = GitStore(tmp_path, tracked_files=["MEMORY.md"])
assert git.line_ages("MEMORY.md") == []
def test_returns_empty_for_missing_file(self, git):
"""line_ages should return [] for a file that doesn't exist."""
assert git.line_ages("SOUL.md") == []
def test_returns_empty_for_empty_file(self, git, tmp_path):
"""line_ages should return [] for an empty tracked file."""
(tmp_path / "SOUL.md").write_text("", encoding="utf-8")
git.auto_commit("empty soul")
assert git.line_ages("SOUL.md") == []
def test_one_age_per_line(self, git, tmp_path):
"""line_ages should return one entry per line in the file."""
content = "# Memory\n\n## Section A\n- item 1\n"
(tmp_path / "MEMORY.md").write_text(content, encoding="utf-8")
git.auto_commit("initial")
ages = git.line_ages("MEMORY.md")
assert len(ages) == len(content.splitlines())
def test_fresh_lines_have_age_zero(self, git, tmp_path):
"""Lines committed today should have age_days=0."""
(tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8")
git.auto_commit("initial")
ages = git.line_ages("MEMORY.md")
assert all(a.age_days == 0 for a in ages)
def test_age_differentiates_across_days(self, git, tmp_path):
"""Lines committed today should show correct age when 'now' is mocked forward."""
(tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8")
git.auto_commit("initial")
future_now = datetime.now(tz=timezone.utc) + timedelta(days=30)
with patch("nanobot.utils.gitstore.datetime") as mock_dt:
mock_dt.now.return_value = future_now
mock_dt.fromtimestamp = datetime.fromtimestamp
ages = git.line_ages("MEMORY.md")
assert len(ages) == 2
assert all(a.age_days == 30 for a in ages)
def test_annotate_failure_returns_empty(self, tmp_path):
"""If annotate fails, line_ages should return [] gracefully."""
git = GitStore(tmp_path, tracked_files=["MEMORY.md"])
# Don't init — annotate will fail
assert git.line_ages("MEMORY.md") == []
def test_partial_edit_only_updates_changed_lines(self, git, tmp_path):
"""Only modified lines should reflect the new commit's timestamp."""
(tmp_path / "MEMORY.md").write_text(
"# Memory\n\n## A\n- old\n\n## B\n- keep\n", encoding="utf-8"
)
git.auto_commit("commit1")
time.sleep(1.1)
# Only modify section A
(tmp_path / "MEMORY.md").write_text(
"# Memory\n\n## A\n- new\n\n## B\n- keep\n", encoding="utf-8"
)
git.auto_commit("commit2")
ages = git.line_ages("MEMORY.md")
lines = (tmp_path / "MEMORY.md").read_text(encoding="utf-8").splitlines()
# All lines are from today, but verify line-level tracking works
assert len(ages) == len(lines)
# "- new" line and "- keep" line both age=0 (same day), but
# the key point is we get per-line results
assert len(ages) == 7
class TestNestedRepoProtection:
"""Regression tests for GitHub issue #2980: nested repo protection."""
def test_init_refuses_inside_git_repo(self, tmp_path):
"""init() should detect it's inside an existing git repo and refuse."""
project = tmp_path / "project"
project.mkdir()
(project / ".git").mkdir()
workspace = project / "workspace"
workspace.mkdir()
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is False
assert not (workspace / ".git").is_dir()
def test_init_preserves_existing_gitignore(self, tmp_path):
"""init() should preserve existing .gitignore entries and append new ones."""
workspace = tmp_path / "workspace"
workspace.mkdir()
existing = "*.pyc\n__pycache__/\n"
(workspace / ".gitignore").write_text(existing, encoding="utf-8")
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is True
gitignore = (workspace / ".gitignore").read_text(encoding="utf-8")
assert "*.pyc" in gitignore
assert "__pycache__/" in gitignore
assert "!MEMORY.md" in gitignore
assert "!.gitignore" in gitignore
def test_init_no_gitignore_creates_new(self, tmp_path):
"""init() should create .gitignore with Dream content when none exists."""
workspace = tmp_path / "workspace"
workspace.mkdir()
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is True
gitignore = (workspace / ".gitignore").read_text(encoding="utf-8")
expected = g._build_gitignore()
assert gitignore == expected
def test_init_gitignore_merge_idempotent(self, tmp_path):
"""init() should not duplicate Dream entries already in .gitignore."""
workspace = tmp_path / "workspace"
workspace.mkdir()
# Pre-existing .gitignore that already has some Dream entries
existing = "*.pyc\n/*\n!MEMORY.md\n"
(workspace / ".gitignore").write_text(existing, encoding="utf-8")
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is True
gitignore = (workspace / ".gitignore").read_text(encoding="utf-8")
# No duplicate lines
lines = gitignore.splitlines()
assert lines.count("/*") == 1
assert lines.count("!MEMORY.md") == 1
# Existing entry preserved, new Dream entries appended
assert "*.pyc" in gitignore
assert "!.gitignore" in gitignore
def test_init_outside_git_repo_works_normally(self, tmp_path):
"""init() should succeed and create .git when not inside a git repo."""
workspace = tmp_path / "workspace"
workspace.mkdir()
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is True
assert (workspace / ".git").is_dir()
def test_init_refuses_inside_git_worktree(self, tmp_path):
"""init() should refuse when the parent checkout is a git worktree."""
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q", str(repo)], check=True)
(repo / "README.md").write_text("x\n", encoding="utf-8")
subprocess.run(["git", "-C", str(repo), "add", "README.md"], check=True)
subprocess.run(
[
"git",
"-C",
str(repo),
"-c",
"user.name=test",
"-c",
"user.email=test@example.com",
"commit",
"-q",
"-m",
"init",
],
check=True,
)
subprocess.run(["git", "-C", str(repo), "branch", "wt-branch"], check=True)
worktree = tmp_path / "worktree"
subprocess.run(
["git", "-C", str(repo), "worktree", "add", "-q", str(worktree), "wt-branch"],
check=True,
)
assert (worktree / ".git").is_file()
workspace = worktree / "workspace"
workspace.mkdir()
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is False
assert not (workspace / ".git").exists()