Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/webui.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ topic history or long-term memory:
2. Select the **Temporary chat** control in the page header.
3. Send the first message.

From an existing topic, enter `/side` to open a temporary conversation beside
the topic. The right-hand pane inherits the topic's current context without
adding the side conversation back to the original topic. Run `/side` again to
add another temporary conversation to the right-hand pane, then switch or close
individual side conversations from its tab bar.

You can keep more than one temporary chat open and switch between them under
**Temporary chats** in the sidebar while the current WebUI connection remains
open. Reloading or closing the page, restarting the gateway, or losing the
Expand Down
25 changes: 25 additions & 0 deletions nanobot/channels/websocket/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,31 @@ async def _dispatch_envelope(
temporary=True,
)
return
if t == "new_side_chat":
source_id = envelope.get("source_chat_id")
if not _is_valid_chat_id(source_id):
await self._send_event(connection, "error", detail="invalid source_chat_id")
return
if websocket_turn_wall_started_at(source_id) is not None:
await self._send_event(connection, "error", detail="side_chat_unavailable")
return
try:
new_id = self._temporary_chats.create_side(
connection,
source_id,
trusted_webui=connection in self._webui_connections,
)
except TemporaryChatError as exc:
await self._send_event(connection, "error", detail=exc.detail)
return
self._attach(connection, new_id)
await self._send_event(
connection,
"attached",
chat_id=new_id,
temporary=True,
)
return
if t == "fork_chat":
await handle_webui_fork_chat(self, connection, envelope)
return
Expand Down
66 changes: 66 additions & 0 deletions nanobot/channels/websocket/tests/test_websocket_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,72 @@ async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
assert read_transcript_lines(inbound.session_key) == []


@pytest.mark.asyncio
async def test_side_chat_inherits_context_without_persisting(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
project = tmp_path / "project"
project.mkdir()
source = sessions.get_or_create("websocket:source")
source.metadata[WORKSPACE_SCOPE_METADATA_KEY] = {
"project_path": str(project),
"access_mode": "full",
}
source.add_message("user", "main question")
source.add_message("assistant", "main answer")
sessions.save(source)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
connection = AsyncMock()
connection.remote_address = ("127.0.0.1", 5000)
channel._webui_connections.add(connection)

await channel._dispatch_envelope(connection, "webui-client", {
"type": "new_side_chat",
"source_chat_id": "source",
})

attached = json.loads(connection.send.await_args.args[0])
assert attached["temporary"] is True
side_id = attached["chat_id"]
side_key = f"websocket:{side_id}"
side = sessions.get_cached(side_key)
assert side is not None
assert [message["content"] for message in side.messages] == [
"main question",
"main answer",
]
assert side.policy.persist is False
assert side.metadata[WORKSPACE_SCOPE_METADATA_KEY] == {
"project_path": str(project.resolve()),
"access_mode": "restricted",
}
assert sessions.read_session_file(side_key) is None

connection.send.reset_mock()
await channel._dispatch_envelope(connection, "webui-client", {
"type": "message",
"chat_id": side_id,
"content": "side question",
"turn_id": "side-turn",
"webui": True,
})

inbound = bus.publish_inbound.await_args.args[0]
assert inbound.session_key_override == side_key
assert inbound.metadata[WORKSPACE_SCOPE_METADATA_KEY] == {
"project_path": str(project.resolve()),
"access_mode": "restricted",
}
assert sessions.read_session_file(side_key) is None
assert read_transcript_lines(side_key) == []
assert [payload["event"] for payload in _sent_ws_payloads(connection)] == [
"message_accepted",
]


@pytest.mark.asyncio
@pytest.mark.parametrize("content", ["/goal private", "/trigger later", "/dream"])
async def test_temporary_chat_rejects_persistent_commands(bus, tmp_path, content) -> None:
Expand Down
17 changes: 17 additions & 0 deletions nanobot/command/builtin.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ def as_dict(self) -> dict[str, str | bool]:
"square-pen",
lifecycle="finalize_active_turn",
),
BuiltinCommandSpec(
"/side",
"Side conversation",
"Start a temporary conversation with the current chat context.",
"messages-square",
),
BuiltinCommandSpec(
"/stop",
"Stop current task",
Expand Down Expand Up @@ -1007,6 +1013,16 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage:
)


async def cmd_side(ctx: CommandContext) -> OutboundMessage:
"""Explain the WebUI-owned side conversation command on other channels."""
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content="/side is available from an existing WebUI chat.",
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)


def build_help_text() -> str:
"""Build canonical help text shared across channels."""
lines = ["🐈 nanobot commands:"]
Expand All @@ -1024,6 +1040,7 @@ def register_builtin_commands(router: CommandRouter) -> None:
router.priority("/restart", cmd_restart)
router.priority("/status", cmd_status)
router.exact("/new", cmd_new)
router.exact("/side", cmd_side)
router.exact("/status", cmd_status)
router.exact("/model", cmd_model)
router.prefix("/model ", cmd_model)
Expand Down
32 changes: 32 additions & 0 deletions nanobot/session/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1575,6 +1575,38 @@ def get_or_create_transient(
self._remember(session)
return session

def fork_transient(
self,
source_key: str,
target_key: str,
*,
disabled_tools: Collection[str] = (),
) -> Session | None:
"""Copy a persisted session into a fresh, non-persistent session."""
source = self._cached(source_key) or self._load(source_key)
if source is None or not source.policy.persist:
return None

metadata = deepcopy(source.metadata)
for key in _FORK_VOLATILE_METADATA_KEYS:
metadata.pop(key, None)
now = datetime.now()
target = Session(
key=target_key,
messages=[public_history_message(message) for message in source.messages],
created_at=now,
updated_at=now,
metadata=metadata,
last_consolidated=source.last_consolidated,
policy=SessionPolicy(
persist=False,
log_content=False,
disabled_tools=frozenset(disabled_tools),
),
)
self._remember(target)
return target

def _load(self, key: str) -> Session | None:
return self._store.load(key)

Expand Down
50 changes: 48 additions & 2 deletions nanobot/webui/temporary_chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
InboundMessage,
)
from nanobot.bus.queue import MessageBus
from nanobot.security.workspace_access import WorkspaceScope
from nanobot.security.workspace_access import (
WORKSPACE_SCOPE_METADATA_KEY,
WorkspaceScope,
build_workspace_scope,
)
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.workspaces import WebUIWorkspaceController

Expand Down Expand Up @@ -71,6 +75,7 @@ def __init__(
# events cannot create a durable transcript after a chat is discarded.
self._known_transient_chat_ids: set[str] = set()
self._media_paths: dict[str, set[str]] = {}
self._workspace_scopes: dict[str, WorkspaceScope] = {}

def _session_key(self, chat_id: str) -> str:
return f"{self._channel_name}:{chat_id}"
Expand Down Expand Up @@ -99,6 +104,45 @@ def create(self, owner: object, *, trusted_webui: bool) -> str:
self._owner_chat_ids.setdefault(owner, set()).add(chat_id)
self._active_sessions[chat_id] = session
self._known_transient_chat_ids.add(chat_id)
self._workspace_scopes[chat_id] = self._workspaces.restricted_default_scope()
return chat_id

def create_side(
self,
owner: object,
source_chat_id: str,
*,
trusted_webui: bool,
) -> str:
"""Create a temporary fork of an existing persisted WebUI chat."""
if not trusted_webui:
raise TemporaryChatError("access_denied")
if self._sessions is None:
raise TemporaryChatError("temporary_chat_unavailable")

chat_id = str(uuid.uuid4())
session = self._sessions.fork_transient(
self._session_key(source_chat_id),
self._session_key(chat_id),
disabled_tools=_TEMPORARY_CHAT_DISABLED_TOOLS,
)
if session is None:
raise TemporaryChatError("side_chat_unavailable")
source_scope = self._workspaces.scope_for_session_key(
self._session_key(source_chat_id)
)
self._owners[chat_id] = owner
self._owner_chat_ids.setdefault(owner, set()).add(chat_id)
self._active_sessions[chat_id] = session
self._known_transient_chat_ids.add(chat_id)
self._workspace_scopes[chat_id] = build_workspace_scope(
source_scope.project_path,
"restricted",
source_channel=self._channel_name,
)
session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = self._workspace_scopes[
chat_id
].metadata()
return chat_id

def message_policy(
Expand All @@ -125,7 +169,7 @@ def message_policy(

return TemporaryChatMessagePolicy(
session_key=self._session_key(chat_id),
workspace_scope=self._workspaces.restricted_default_scope(),
workspace_scope=self._workspace_scopes[chat_id],
)

def validate_attach(self, chat_id: str) -> None:
Expand Down Expand Up @@ -190,6 +234,7 @@ async def discard(self, owner: object, chat_id: str) -> None:
session_key = self._session_key(chat_id)
self._forget_owner(owner, chat_id)
self._active_sessions.pop(chat_id, None)
self._workspace_scopes.pop(chat_id, None)
self._discard_media(chat_id)
if self._sessions is not None:
self._sessions.invalidate(session_key)
Expand All @@ -216,3 +261,4 @@ def close(self) -> None:
self._owner_chat_ids.clear()
self._active_sessions.clear()
self._known_transient_chat_ids.clear()
self._workspace_scopes.clear()
1 change: 1 addition & 0 deletions tests/command/test_router_dispatchable.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def router(self) -> CommandRouter:

def test_exact_commands_match(self, router: CommandRouter) -> None:
assert router.is_dispatchable_command("/new")
assert router.is_dispatchable_command("/side")
assert router.is_dispatchable_command("/help")
assert router.is_dispatchable_command("/model")
assert router.is_dispatchable_command("/dream")
Expand Down
Loading
Loading