Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # webui/src/components/settings/SettingsView.tsx
This commit is contained in:
@@ -87,6 +87,21 @@ class TestBuildDreamPrompt:
|
||||
assert "entry-21" in next_prompt
|
||||
assert "entry-25" in next_prompt
|
||||
|
||||
def test_skips_malformed_history_entries(self, store):
|
||||
"""Dream prompt building should tolerate externally corrupted JSONL rows."""
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 1, "timestamp": "2026-04-01 10:00"}\n'
|
||||
'{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "usable memory"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = store.build_dream_prompt()
|
||||
|
||||
assert result is not None
|
||||
prompt, cursor = result
|
||||
assert cursor == 2
|
||||
assert "usable memory" in prompt
|
||||
|
||||
def test_dream_prompt_consumes_consolidator_attribute_tags(self):
|
||||
prompt = render_template(
|
||||
"agent/dream.md",
|
||||
|
||||
@@ -171,6 +171,23 @@ class TestHistoryWithCursor:
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert [e["cursor"] for e in entries] == [2, 3]
|
||||
|
||||
def test_read_unprocessed_skips_malformed_history_payloads(self, store):
|
||||
"""Externally edited JSONL can keep an int cursor but miss required payload fields."""
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 1, "timestamp": "2026-04-01 10:00", "content": "valid"}\n'
|
||||
'{"cursor": 2, "timestamp": "2026-04-01 10:01"}\n'
|
||||
'{"cursor": 3, "content": "missing timestamp"}\n'
|
||||
'{"cursor": 4, "timestamp": "2026-04-01 10:03", "content": 123}\n'
|
||||
'{"cursor": 5, "timestamp": "2026-04-01 10:04", "content": "bad session", "session_key": 42}\n'
|
||||
'{"cursor": 6, "timestamp": "2026-04-01 10:05", "content": "also valid", "session_key": "telegram:chat-1"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
|
||||
assert [e["cursor"] for e in entries] == [1, 6]
|
||||
assert [e["content"] for e in entries] == ["valid", "also valid"]
|
||||
|
||||
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(
|
||||
|
||||
@@ -152,6 +152,70 @@ async def test_drain_injections_skips_empty_content():
|
||||
assert result == [{"role": "user", "content": "valid"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_filters_empty_dict_payloads():
|
||||
"""Pre-normalized dict injections should obey the same empty-content guard."""
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
runner = AgentRunner(provider)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
multimodal = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}]
|
||||
msgs = [
|
||||
{"role": "user", "content": ""},
|
||||
{"role": "user", "content": " "},
|
||||
{"role": "user", "content": None},
|
||||
{"role": "assistant", "content": "should not be re-injected as user"},
|
||||
None,
|
||||
{"role": "user", "content": "valid"},
|
||||
{"role": "user", "content": multimodal},
|
||||
]
|
||||
|
||||
async def cb():
|
||||
return msgs
|
||||
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=[], tools=tools, model="m",
|
||||
max_iterations=1, max_tool_result_chars=1000,
|
||||
injection_callback=cb,
|
||||
)
|
||||
result = await runner._drain_injections(spec)
|
||||
assert result == [
|
||||
{"role": "user", "content": "valid"},
|
||||
{"role": "user", "content": multimodal},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_skips_objects_with_none_content():
|
||||
"""Objects exposing content=None should be skipped rather than stringified."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
runner = AgentRunner(provider)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
async def cb():
|
||||
return [
|
||||
SimpleNamespace(content=None),
|
||||
SimpleNamespace(content=""),
|
||||
SimpleNamespace(content="valid"),
|
||||
]
|
||||
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=[], tools=tools, model="m",
|
||||
max_iterations=1, max_tool_result_chars=1000,
|
||||
injection_callback=cb,
|
||||
)
|
||||
result = await runner._drain_injections(spec)
|
||||
assert result == [{"role": "user", "content": "valid"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_handles_callback_exception():
|
||||
"""If the callback raises, return empty list (error is logged)."""
|
||||
@@ -1155,4 +1219,3 @@ 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
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.tools.filesystem import FileToolsConfig
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
@@ -51,3 +53,29 @@ async def test_subagent_build_tools_isolates_file_read_state(tmp_path):
|
||||
second_result = await second_read.execute(path="note.txt")
|
||||
assert second_result.startswith("1| hello")
|
||||
assert "File unchanged" not in second_result
|
||||
|
||||
|
||||
def test_subagent_respects_file_tool_toggle(tmp_path):
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.get_default_model.return_value = "test"
|
||||
sm = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=MessageBus(),
|
||||
model="test",
|
||||
max_tool_result_chars=16_000,
|
||||
tools_config=ToolsConfig(file=FileToolsConfig(enable=False)),
|
||||
)
|
||||
|
||||
tools = sm._build_tools()
|
||||
|
||||
file_tools = {
|
||||
"apply_patch",
|
||||
"edit_file",
|
||||
"find_files",
|
||||
"grep",
|
||||
"list_dir",
|
||||
"read_file",
|
||||
"write_file",
|
||||
}
|
||||
assert file_tools.isdisjoint(tools.tool_names)
|
||||
|
||||
@@ -85,6 +85,41 @@ def test_opus_4_7_omits_temperature_none() -> None:
|
||||
assert "thinking" not in kw
|
||||
|
||||
|
||||
def test_opus_4_8_omits_temperature_adaptive() -> None:
|
||||
kw = _build(_make_provider("claude-opus-4-8"), "adaptive")
|
||||
assert "temperature" not in kw
|
||||
|
||||
|
||||
def test_opus_4_8_omits_temperature_enabled() -> None:
|
||||
kw = _build(_make_provider("claude-opus-4-8"), "high", max_tokens=4096)
|
||||
assert "temperature" not in kw
|
||||
|
||||
|
||||
def test_opus_4_8_omits_temperature_none() -> None:
|
||||
kw = _build(_make_provider("claude-opus-4-8"), None)
|
||||
assert "temperature" not in kw
|
||||
|
||||
|
||||
def test_fable_omits_temperature_adaptive() -> None:
|
||||
kw = _build(_make_provider("claude-fable-5"), "adaptive")
|
||||
assert "temperature" not in kw
|
||||
|
||||
|
||||
def test_fable_omits_temperature_enabled() -> None:
|
||||
kw = _build(_make_provider("claude-fable-5"), "high", max_tokens=4096)
|
||||
assert "temperature" not in kw
|
||||
|
||||
|
||||
def test_fable_omits_temperature_none() -> None:
|
||||
kw = _build(_make_provider("claude-fable-5"), None)
|
||||
assert "temperature" not in kw
|
||||
|
||||
|
||||
def test_ordinary_model_sends_temperature() -> None:
|
||||
kw = _build(_make_provider("claude-sonnet-4-6"), None)
|
||||
assert kw["temperature"] == 0.7
|
||||
|
||||
|
||||
def test_reasoning_effort_string_none_does_not_enable_thinking() -> None:
|
||||
"""reasoning_effort='none' must not enable thinking — treated same as disabled."""
|
||||
kw = _build(_make_provider(), "none")
|
||||
|
||||
@@ -84,6 +84,23 @@ class FakeClient:
|
||||
return self.get_response
|
||||
|
||||
|
||||
class CodexStreamingCompleteThenErrorResponse(FakeResponse):
|
||||
async def aiter_lines(self):
|
||||
yield 'data: {"type":"response.output_item.added","item":{"id":"ig_1","type":"image_generation_call","status":"in_progress"}}'
|
||||
yield ""
|
||||
yield (
|
||||
f'data: {{"type":"response.output_item.done","item":{{"id":"ig_1",'
|
||||
f'"type":"image_generation_call","result":"{PNG_DATA_URL}","status":"completed"}}}}'
|
||||
)
|
||||
yield ""
|
||||
yield 'data: {"type":"response.completed","response":{"status":"completed"}}'
|
||||
yield ""
|
||||
raise httpx.RemoteProtocolError(
|
||||
"peer closed connection without sending complete message body "
|
||||
"(incomplete chunked read)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_image_generation_payload_and_response(tmp_path: Path) -> None:
|
||||
ref = tmp_path / "ref.png"
|
||||
@@ -1024,6 +1041,35 @@ async def test_codex_payload_and_response(monkeypatch) -> None:
|
||||
assert body["stream"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_stops_reading_after_completed_event(monkeypatch) -> None:
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
|
||||
@dataclass
|
||||
class FakeToken:
|
||||
account_id: str = "acct-123"
|
||||
access: str = "oauth-token"
|
||||
|
||||
async def fake_to_thread(fn, *args, **kwargs):
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
|
||||
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
|
||||
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
|
||||
|
||||
fake = FakeClient(CodexStreamingCompleteThenErrorResponse({}, sse_lines=[]))
|
||||
client = CodexImageGenerationClient(
|
||||
api_key=None, client=fake # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
response = await client.generate(prompt="draw a cat", model="gpt-5.4")
|
||||
|
||||
assert response.images == [PNG_DATA_URL]
|
||||
assert response.content == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_strips_model_prefix(monkeypatch) -> None:
|
||||
import sys
|
||||
|
||||
@@ -32,6 +32,7 @@ def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
||||
agent.process_direct = AsyncMock(return_value=response_text)
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
return agent
|
||||
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ def _make_streaming_agent(tokens: list[str]) -> MagicMock:
|
||||
return " ".join(tokens)
|
||||
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._last_usage = {}
|
||||
return agent
|
||||
|
||||
|
||||
@@ -133,6 +134,7 @@ async def test_stream_false_returns_json(aiohttp_client) -> None:
|
||||
agent.process_direct = AsyncMock(return_value="normal reply")
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -155,6 +157,7 @@ async def test_stream_default_is_false(aiohttp_client) -> None:
|
||||
agent.process_direct = AsyncMock(return_value="default reply")
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -209,6 +212,7 @@ async def test_stream_passes_on_stream_callbacks(aiohttp_client) -> None:
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -241,6 +245,7 @@ async def test_stream_segment_end_does_not_close_sse(aiohttp_client) -> None:
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -279,6 +284,7 @@ async def test_stream_uses_final_response_when_no_deltas(aiohttp_client) -> None
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -320,6 +326,7 @@ async def test_stream_with_session_id(aiohttp_client) -> None:
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -348,6 +355,7 @@ async def test_streaming_backend_failure_does_not_emit_success_terminator(aiohtt
|
||||
agent.process_direct = boom
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.file_state import FileStates
|
||||
from nanobot.agent.tools.filesystem import FileToolsConfig, ReadFileTool
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.config.schema import Config, ToolsConfig
|
||||
|
||||
FILE_TOOL_NAMES = {
|
||||
"apply_patch",
|
||||
"edit_file",
|
||||
"find_files",
|
||||
"grep",
|
||||
"list_dir",
|
||||
"read_file",
|
||||
"write_file",
|
||||
}
|
||||
|
||||
|
||||
def test_file_tools_enabled_by_default():
|
||||
assert FileToolsConfig().enable is True
|
||||
assert Config().tools.file.enable is True
|
||||
|
||||
|
||||
def test_file_tool_gate_follows_flag():
|
||||
cfg = ToolsConfig()
|
||||
cfg.file.enable = False
|
||||
assert ReadFileTool.enabled(SimpleNamespace(config=cfg)) is False
|
||||
assert ReadFileTool.enabled(SimpleNamespace(config=ToolsConfig())) is True
|
||||
|
||||
|
||||
def test_file_tool_loader_skips_all_builtin_file_tools_when_disabled(tmp_path):
|
||||
cfg = ToolsConfig(file=FileToolsConfig(enable=False))
|
||||
ctx = ToolContext(
|
||||
config=cfg,
|
||||
workspace=str(tmp_path),
|
||||
file_state_store=FileStates(),
|
||||
)
|
||||
registry = ToolRegistry()
|
||||
|
||||
ToolLoader().load(ctx, registry)
|
||||
|
||||
assert FILE_TOOL_NAMES.isdisjoint(registry.tool_names)
|
||||
@@ -33,6 +33,7 @@ def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
||||
agent.process_direct = AsyncMock(return_value=response_text)
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
return agent
|
||||
|
||||
|
||||
@@ -78,6 +79,25 @@ def test_chat_completion_response() -> None:
|
||||
assert result["choices"][0]["message"]["content"] == "hello world"
|
||||
assert result["choices"][0]["finish_reason"] == "stop"
|
||||
assert result["id"].startswith("chatcmpl-")
|
||||
assert result["usage"]["prompt_tokens"] == 0
|
||||
assert result["usage"]["completion_tokens"] == 0
|
||||
assert result["usage"]["total_tokens"] == 0
|
||||
|
||||
|
||||
def test_chat_completion_response_with_usage() -> None:
|
||||
usage = {"prompt_tokens": 150, "completion_tokens": 42}
|
||||
result = _chat_completion_response("hello world", "test-model", usage)
|
||||
assert result["usage"]["prompt_tokens"] == 150
|
||||
assert result["usage"]["completion_tokens"] == 42
|
||||
assert result["usage"]["total_tokens"] == 192
|
||||
|
||||
|
||||
def test_chat_completion_response_preserves_provider_total_usage() -> None:
|
||||
usage = {"total_tokens": 77}
|
||||
result = _chat_completion_response("hello world", "test-model", usage)
|
||||
assert result["usage"]["prompt_tokens"] == 0
|
||||
assert result["usage"]["completion_tokens"] == 0
|
||||
assert result["usage"]["total_tokens"] == 77
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@@ -213,6 +233,7 @@ async def test_followup_requests_share_same_session_key(aiohttp_client) -> None:
|
||||
agent.process_direct = fake_process
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -250,6 +271,7 @@ async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None:
|
||||
agent.process_direct = slow_process
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -364,6 +386,7 @@ async def test_empty_response_retry_then_success(aiohttp_client) -> None:
|
||||
agent.process_direct = sometimes_empty
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -393,6 +416,7 @@ async def test_empty_response_falls_back(aiohttp_client) -> None:
|
||||
agent.process_direct = always_empty
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
Reference in New Issue
Block a user