Merge origin/main into fix/cron-contract-repeat-guard
Made-with: Cursor
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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. I’ll 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. I’ll 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: "
|
||||
"• I’ll 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: "
|
||||
"• I’ll 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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user