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()
|
||||
Reference in New Issue
Block a user