fix(webui): complete temporary chat mode

This commit is contained in:
chengyongru
2026-08-07 17:07:58 +08:00
parent 324a61dff1
commit 36253685bd
51 changed files with 1285 additions and 945 deletions
-12
View File
@@ -346,18 +346,6 @@ 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
+158
View File
@@ -0,0 +1,158 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
RUNTIME_CONTROL_SESSION_DISCARD,
InboundMessage,
)
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse
from nanobot.session.keys import UNIFIED_SESSION_KEY
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,
require_existing_session=True,
)
def _loop(tmp_path, responses: list[str], **kwargs) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = GenerationSettings()
provider.chat_with_retry = AsyncMock(
side_effect=[LLMResponse(content=response, usage={}) for response in responses]
)
return AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
cron_service=MagicMock(),
**kwargs,
)
@pytest.mark.asyncio
async def test_transient_session_keeps_history_without_persisting_or_durable_tools(tmp_path) -> None:
loop = _loop(tmp_path, ["first answer", "second answer"])
loop.context.memory.write_memory("private durable memory")
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock()
key = "websocket:transient-test"
loop.sessions.get_or_create_transient(
key,
disabled_tools={"create_goal", "update_goal", "spawn", "cron"},
)
await loop._process_message(_message(key, "first question"))
await loop._process_message(_message(key, "second question"))
calls = loop.provider.chat_with_retry.await_args_list
assert "private durable memory" not in str(calls[0].kwargs["messages"])
tool_names = {item["function"]["name"] for item in calls[0].kwargs["tools"]}
assert "read_session" in tool_names
assert {"create_goal", "update_goal", "spawn", "cron"}.isdisjoint(tool_names)
assert "first answer" in str(calls[1].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
loop.consolidator.maybe_consolidate_by_tokens.assert_not_awaited()
@pytest.mark.asyncio
async def test_transient_session_stays_outside_unified_session(tmp_path) -> None:
loop = _loop(tmp_path, ["private answer"], unified_session=True)
durable = loop.sessions.get_or_create(UNIFIED_SESSION_KEY)
durable.add_message("user", "durable question")
loop.sessions.save(durable)
key = "websocket:transient-unified"
transient = loop.sessions.get_or_create_transient(key)
await loop._dispatch(_message(key, "private question"))
assert [message["content"] for message in transient.messages] == [
"private question",
"private answer",
]
assert [message["content"] for message in durable.messages] == ["durable question"]
assert loop.sessions.read_session_file(key) is None
@pytest.mark.asyncio
async def test_missing_required_session_cannot_fall_back_to_disk(tmp_path) -> None:
loop = _loop(tmp_path, [])
key = "websocket:transient-stale"
loop.sessions.get_or_create_transient(key)
loop.sessions.invalidate(key)
with pytest.raises(RuntimeError, match="required session is not active"):
await loop._process_message(_message(key, "stale private message"))
loop.provider.chat_with_retry.assert_not_awaited()
assert loop.sessions.read_session_file(key) is None
@pytest.mark.asyncio
async def test_session_discard_control_cancels_active_turn(tmp_path, monkeypatch) -> None:
provider_started = asyncio.Event()
async def block_provider(**_kwargs: object) -> LLMResponse:
provider_started.set()
await asyncio.Event().wait()
raise AssertionError("provider blocker unexpectedly released")
loop = _loop(tmp_path, [])
async def wait_for_discard(key: str) -> None:
while loop.sessions.get_cached(key) is not None:
await asyncio.sleep(0)
loop.provider.chat_with_retry = AsyncMock(side_effect=block_provider)
monkeypatch.setattr(loop, "_connect_mcp", AsyncMock())
monkeypatch.setattr(loop, "close_mcp", AsyncMock())
key = "websocket:transient-cancelled"
loop.sessions.get_or_create_transient(
key,
disabled_tools={"create_goal", "update_goal", "spawn", "cron"},
)
run_task = asyncio.create_task(loop.run())
await loop.bus.publish_inbound(_message(key, "private"))
await asyncio.wait_for(provider_started.wait(), timeout=2)
active_task = next(iter(loop._active_tasks[key]))
await loop.bus.publish_inbound(
InboundMessage(
channel="websocket",
sender_id="webui",
chat_id="transient-cancelled",
content="",
metadata={
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD,
},
session_key_override=key,
)
)
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(active_task, timeout=2)
await asyncio.wait_for(wait_for_discard(key), timeout=2)
assert loop.sessions.get_cached(key) is None
loop.stop()
await loop.bus.publish_inbound(_message(key, "wake"))
await asyncio.wait_for(run_task, timeout=2)
-150
View File
@@ -1,150 +0,0 @@
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
-26
View File
@@ -231,32 +231,6 @@ 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():
+3 -24
View File
@@ -1,7 +1,7 @@
import gc
import weakref
from nanobot.session.manager import FILE_MAX_MESSAGES, SESSION_CACHE_MAX_SIZE, SessionManager
from nanobot.session.manager import SESSION_CACHE_MAX_SIZE, SessionManager
def _bounded_manager(tmp_path, limit: int) -> SessionManager:
@@ -82,29 +82,8 @@ def test_transient_session_never_reaches_storage(tmp_path) -> None:
manager.save(session, fsync=True)
assert manager.flush_all() == 0
assert manager.get_cached(session.key) is session
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
manager.invalidate(session.key)
assert manager.get_cached(session.key) is None
assert manager.discard_transient(session.key) is False
-53
View File
@@ -140,59 +140,6 @@ 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"