mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
feat(webui): add temporary chat mode
This commit is contained in:
@@ -346,6 +346,18 @@ class TestBuildSystemPrompt:
|
||||
assert "## AGENTS.md" not in result
|
||||
assert "[Archived Context Summary]" not in result
|
||||
|
||||
def test_can_exclude_long_term_memory_without_changing_agent_identity(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
builder.memory.write_memory("# Memory\n- private detail")
|
||||
|
||||
result = builder.build_system_prompt(
|
||||
include_long_term_memory=False,
|
||||
include_memory_recent_history=False,
|
||||
)
|
||||
|
||||
assert "private detail" not in result
|
||||
assert "workspace" in result.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_messages
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
||||
|
||||
|
||||
def _message(key: str, content: str) -> InboundMessage:
|
||||
return InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id=key.removeprefix("websocket:"),
|
||||
content=content,
|
||||
session_key_override=key,
|
||||
transient_session=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_keeps_agent_capabilities_and_only_live_history(tmp_path) -> None:
|
||||
(tmp_path / "AGENTS.md").write_text("project instruction", encoding="utf-8")
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings()
|
||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(content="first answer", usage={}),
|
||||
LLMResponse(content="second answer", usage={}),
|
||||
])
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
loop.context.memory.write_memory("# Memory\n- private remembered detail")
|
||||
key = "websocket:temporary-test"
|
||||
loop.sessions.get_or_create_transient(key)
|
||||
|
||||
await loop._process_message(_message(key, "first question"))
|
||||
await loop._process_message(_message(key, "second question"))
|
||||
|
||||
first_call, second_call = provider.chat_with_retry.await_args_list
|
||||
assert first_call.kwargs["tools"]
|
||||
assert "project instruction" in str(first_call.kwargs["messages"])
|
||||
assert "private remembered detail" not in str(first_call.kwargs["messages"])
|
||||
assert "first answer" in str(second_call.kwargs["messages"])
|
||||
session = loop.sessions.get_cached(key)
|
||||
assert session is not None
|
||||
assert [message["role"] for message in session.messages] == [
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
]
|
||||
assert loop.sessions.read_session_file(key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_compacts_only_in_memory(tmp_path) -> None:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings(max_tokens=256)
|
||||
provider.estimate_prompt_tokens.return_value = (100, "test")
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="Earlier temporary decisions.", usage={})
|
||||
)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
context_window_tokens=4096,
|
||||
)
|
||||
key = "websocket:temporary-compact"
|
||||
session = loop.sessions.get_or_create_transient(key)
|
||||
for index in range(6):
|
||||
session.add_message("user", f"question {index}")
|
||||
session.add_message("assistant", f"answer {index}")
|
||||
|
||||
await loop.consolidator.maybe_consolidate_by_tokens(
|
||||
session,
|
||||
runtime=loop.runtime_for_session(session),
|
||||
replay_max_messages=4,
|
||||
)
|
||||
|
||||
assert session.last_consolidated > 0
|
||||
assert len(session.messages) == 12
|
||||
assert session.metadata["_last_summary"]["text"] == "Earlier temporary decisions."
|
||||
assert loop.sessions.read_session_file(key) is None
|
||||
assert loop.context.memory.read_unprocessed_history(since_cursor=0) == []
|
||||
_, summary = loop.auto_compact.prepare_session(session, key)
|
||||
assert summary is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_uses_the_regular_compaction_pipeline(tmp_path) -> None:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings()
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="answer", usage={})
|
||||
)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock()
|
||||
key = "websocket:temporary-pipeline"
|
||||
loop.sessions.get_or_create_transient(key)
|
||||
|
||||
await loop._process_message(_message(key, "question"))
|
||||
|
||||
loop.consolidator.maybe_consolidate_by_tokens.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discarded_temporary_turn_cannot_create_a_session_file(tmp_path) -> None:
|
||||
provider_started = asyncio.Event()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings()
|
||||
|
||||
async def block_provider(**_kwargs):
|
||||
provider_started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
provider.chat_with_retry = AsyncMock(side_effect=block_provider)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
key = "websocket:temporary-cancelled"
|
||||
loop.sessions.get_or_create_transient(key)
|
||||
task = asyncio.create_task(loop._dispatch(_message(key, "private")))
|
||||
loop._active_tasks.setdefault(key, set()).add(task)
|
||||
|
||||
await provider_started.wait()
|
||||
assert loop.sessions.discard_transient(key) is True
|
||||
assert await loop._cancel_active_tasks(key) == 1
|
||||
|
||||
assert task.cancelled()
|
||||
assert loop.sessions.read_session_file(key) is None
|
||||
@@ -231,6 +231,32 @@ async def test_read_session_filters_by_query_and_returns_recent_matches(tmp_path
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_session_searches_current_temporary_chat_in_memory(tmp_path):
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create_transient("websocket:temporary-current")
|
||||
session.messages = [
|
||||
{"role": "user", "content": "the launch codename is firefly"},
|
||||
{"role": "assistant", "content": "I will remember that during this chat"},
|
||||
{"role": "user", "content": "unrelated recent message"},
|
||||
]
|
||||
session.last_consolidated = 2
|
||||
|
||||
with _webui_request("websocket:temporary-current"):
|
||||
result = _decode(await ReadSessionTool(manager).execute(
|
||||
session_key="current",
|
||||
query="firefly",
|
||||
))
|
||||
|
||||
assert result["session_key"] == "websocket:temporary-current"
|
||||
assert result["session_ref"] is None
|
||||
assert result["title"] == "Current conversation"
|
||||
assert [message["content"] for message in result["messages"]] == [
|
||||
"the launch codename is firefly",
|
||||
]
|
||||
assert manager.read_session_file("websocket:temporary-current") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_session_reports_invalid_requests(tmp_path):
|
||||
with _webui_request():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import gc
|
||||
import weakref
|
||||
|
||||
from nanobot.session.manager import SESSION_CACHE_MAX_SIZE, SessionManager
|
||||
from nanobot.session.manager import FILE_MAX_MESSAGES, SESSION_CACHE_MAX_SIZE, SessionManager
|
||||
|
||||
|
||||
def _bounded_manager(tmp_path, limit: int) -> SessionManager:
|
||||
@@ -73,3 +73,38 @@ def test_flush_all_includes_live_sessions_outside_strong_cache(tmp_path, monkeyp
|
||||
|
||||
assert manager.flush_all() == 2
|
||||
assert set(saved) == {("test:active", True), ("test:other", True)}
|
||||
|
||||
|
||||
def test_transient_session_never_reaches_storage(tmp_path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create_transient("websocket:temporary-test")
|
||||
session.add_message("user", "secret")
|
||||
|
||||
manager.save(session, fsync=True)
|
||||
|
||||
assert manager.flush_all() == 0
|
||||
assert manager.read_session_file(session.key) is None
|
||||
assert list(manager.sessions_dir.glob("*.jsonl")) == []
|
||||
|
||||
|
||||
def test_transient_session_history_is_bounded_in_memory(tmp_path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create_transient("websocket:temporary-bounded")
|
||||
for index in range(FILE_MAX_MESSAGES + 2):
|
||||
session.add_message("user", f"message {index}")
|
||||
|
||||
manager.save(session)
|
||||
|
||||
assert len(session.messages) <= FILE_MAX_MESSAGES
|
||||
assert session.messages[-1]["content"] == f"message {FILE_MAX_MESSAGES + 1}"
|
||||
assert manager.read_session_file(session.key) is None
|
||||
|
||||
|
||||
def test_discard_transient_session_forgets_live_history(tmp_path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create_transient("websocket:temporary-test")
|
||||
session.add_message("user", "secret")
|
||||
|
||||
assert manager.discard_transient(session.key) is True
|
||||
assert manager.get_cached(session.key) is None
|
||||
assert manager.discard_transient(session.key) is False
|
||||
|
||||
@@ -140,6 +140,59 @@ def test_webui_default_access_applies_to_unscoped_old_sessions(tmp_path, monkeyp
|
||||
assert new_scope.access_mode == "full"
|
||||
|
||||
|
||||
def test_transient_session_defaults_to_restricted_access(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
default = tmp_path / "default"
|
||||
default.mkdir()
|
||||
write_webui_default_access_mode("full")
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
key = "websocket:temporary-default"
|
||||
sessions.get_or_create_transient(key)
|
||||
controller = WebUIWorkspaceController(
|
||||
session_manager=sessions,
|
||||
default_workspace=default,
|
||||
default_restrict_to_workspace=False,
|
||||
)
|
||||
|
||||
scope = controller.scope_for_message(
|
||||
{},
|
||||
chat_id="temporary-default",
|
||||
chat_running=False,
|
||||
controls_available=True,
|
||||
)
|
||||
|
||||
assert scope.project_path == default.resolve()
|
||||
assert scope.access_mode == "restricted"
|
||||
|
||||
|
||||
def test_transient_session_uses_live_scope_during_active_turn(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
default = tmp_path / "default"
|
||||
project = tmp_path / "project"
|
||||
default.mkdir()
|
||||
project.mkdir()
|
||||
write_webui_default_access_mode("full")
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
sessions.get_or_create_transient("websocket:temporary-scoped")
|
||||
controller = WebUIWorkspaceController(
|
||||
session_manager=sessions,
|
||||
default_workspace=default,
|
||||
default_restrict_to_workspace=False,
|
||||
)
|
||||
restricted = default_workspace_scope(project, restrict_to_workspace=True)
|
||||
controller.persist_scope("temporary-scoped", restricted)
|
||||
|
||||
scope = controller.scope_for_message(
|
||||
{WORKSPACE_SCOPE_METADATA_KEY: restricted.payload()},
|
||||
chat_id="temporary-scoped",
|
||||
chat_running=True,
|
||||
controls_available=True,
|
||||
)
|
||||
|
||||
assert scope.project_path == project.resolve()
|
||||
assert scope.access_mode == "restricted"
|
||||
|
||||
|
||||
def test_indexed_scope_preserves_missing_and_explicit_null_semantics(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
default = tmp_path / "default"
|
||||
|
||||
Reference in New Issue
Block a user