Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	webui/src/components/settings/SettingsView.tsx
This commit is contained in:
chengyongru
2026-06-15 18:11:22 +08:00
45 changed files with 1010 additions and 203 deletions
+15
View File
@@ -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",
+17
View File
@@ -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(
+64 -1
View File
@@ -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
+28
View File
@@ -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")
+46
View File
@@ -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
+1
View File
@@ -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
+8
View File
@@ -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)
+44
View File
@@ -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)
+24
View File
@@ -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)