diff --git a/nanobot/agent/automation_turns.py b/nanobot/agent/automation_turns.py index c16168c48..072223b7e 100644 --- a/nanobot/agent/automation_turns.py +++ b/nanobot/agent/automation_turns.py @@ -140,10 +140,3 @@ class AutomationTurnCoordinator: if pending_id: pending_ids.add(pending_id) return pending_ids - - async def publish_next_deferred(self, session_key: str) -> bool: - return await publish_next_deferred_turn( - deferred_queues=self.deferred_queues, - publish_inbound=self._publish_inbound, - session_key=session_key, - ) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index d1406604f..ac52ca868 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -785,22 +785,6 @@ def _best_window(old_text: str, content: str) -> tuple[float, int, list[str], li return best_ratio, best_start, best_window_lines, hints -def _find_match(content: str, old_text: str) -> tuple[str | None, int]: - """Locate old_text in content with a multi-level fallback chain: - - 1. Exact substring match - 2. Line-trimmed sliding window (handles indentation differences) - 3. Smart quote normalization (curly ↔ straight quotes) - - Both inputs should use LF line endings (caller normalises CRLF). - Returns (matched_fragment, count) or (None, 0). - """ - matches = _find_matches(content, old_text) - if not matches: - return None, 0 - return matches[0].text, len(matches) - - @tool_parameters( tool_parameters_schema( path=StringSchema("The file path to edit"), diff --git a/nanobot/agent/tools/path_utils.py b/nanobot/agent/tools/path_utils.py index ca3f10e74..3f111e147 100644 --- a/nanobot/agent/tools/path_utils.py +++ b/nanobot/agent/tools/path_utils.py @@ -3,15 +3,7 @@ from pathlib import Path from nanobot.config.paths import get_media_dir -from nanobot.security.workspace_policy import ( - is_path_within, - resolve_allowed_path, -) - - -def is_under(path: Path, directory: Path) -> bool: - """Return True when path resolves under directory.""" - return is_path_within(path, directory) +from nanobot.security.workspace_policy import resolve_allowed_path def resolve_workspace_path( diff --git a/nanobot/apps/cli/utils.py b/nanobot/apps/cli/utils.py index 5668a486d..c48bb56b5 100644 --- a/nanobot/apps/cli/utils.py +++ b/nanobot/apps/cli/utils.py @@ -12,15 +12,6 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]: return {"cli_apps": cli_apps} if isinstance(cli_apps, list) and cli_apps else {} -def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[str]: - """Return model-visible CLI app annotations for the current turn.""" - if skip: - return [] - text = message.content if isinstance(getattr(message, "content", None), str) else "" - metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None - return runtime_lines_for_request(text, metadata, workspace) - - def runtime_lines_for_request( text: str, metadata: Mapping[str, Any] | None, diff --git a/nanobot/channels/feishu/runtime.py b/nanobot/channels/feishu/runtime.py index 96ad3a328..23bbb1569 100644 --- a/nanobot/channels/feishu/runtime.py +++ b/nanobot/channels/feishu/runtime.py @@ -470,15 +470,6 @@ def _extract_post_content(content_json: dict[str, Any]) -> tuple[str, list[str]] return "", [] -def _extract_post_text(content_json: dict[str, Any]) -> str: # pyright: ignore[reportUnusedFunction] - """Extract plain text from Feishu post (rich text) message content. - - Legacy wrapper for _extract_post_content, returns only text. - """ - text, _ = _extract_post_content(content_json) - return text - - # ============================================================================= # QR scan-to-create onboarding # diff --git a/nanobot/channels/mattermost/runtime.py b/nanobot/channels/mattermost/runtime.py index 6cd32fce3..dc58e7698 100644 --- a/nanobot/channels/mattermost/runtime.py +++ b/nanobot/channels/mattermost/runtime.py @@ -658,11 +658,6 @@ class MattermostChannel(BaseChannel): resp.raise_for_status() return cast(dict[str, Any], resp.json()) - async def _api_put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]: - resp = await self._require_http_client().put(path, json=json_data) - resp.raise_for_status() - return cast(dict[str, Any], resp.json()) - async def _create_post( self, channel_id: str, @@ -681,9 +676,6 @@ class MattermostChannel(BaseChannel): body["file_ids"] = file_ids return await self._api_post("/api/v4/posts", body) - async def _edit_post(self, post_id: str, message: str) -> dict[str, Any]: - return await self._api_put(f"/api/v4/posts/{post_id}", {"id": post_id, "message": message}) - async def _upload_file(self, channel_id: str, file_path: str) -> str | None: path = Path(file_path) if not path.exists(): diff --git a/nanobot/channels/msteams/runtime.py b/nanobot/channels/msteams/runtime.py index e040092e5..25af38bed 100644 --- a/nanobot/channels/msteams/runtime.py +++ b/nanobot/channels/msteams/runtime.py @@ -811,11 +811,6 @@ class MSTeamsChannel(BaseChannel): except Exception as e: self.logger.warning("Failed to save conversation refs: {}", e) - def _save_refs(self, *, prune: bool = True) -> None: - """Persist conversation references.""" - with self._refs_guard: - self._save_refs_locked(prune=prune) - async def _get_access_token(self) -> str: """Fetch an access token for Bot Framework / Azure Bot auth.""" diff --git a/nanobot/channels/msteams/tests/test_msteams.py b/nanobot/channels/msteams/tests/test_msteams.py index cb3fa9ace..7fdef347f 100644 --- a/nanobot/channels/msteams/tests/test_msteams.py +++ b/nanobot/channels/msteams/tests/test_msteams.py @@ -228,7 +228,8 @@ def test_save_prunes_unsupported_conversation_refs(make_channel, tmp_path, monke ), } - ch._save_refs() + with ch._refs_guard: + ch._save_refs_locked() assert set(ch._conversation_refs.keys()) == {"conv-valid"} @@ -378,7 +379,8 @@ def test_save_uses_atomic_replace_and_keeps_existing_file_on_replace_error(make_ raise OSError("replace failed") monkeypatch.setattr(msteams_module.os, "replace", _raise_replace) - ch._save_refs() + with ch._refs_guard: + ch._save_refs_locked() persisted = json.loads(refs_path.read_text(encoding="utf-8")) assert set(persisted.keys()) == {"conv-old"} @@ -934,7 +936,8 @@ def test_save_refs_prunes_webchat_and_stale_refs(make_channel): ), } - ch._save_refs() + with ch._refs_guard: + ch._save_refs_locked() assert set(ch._conversation_refs) == {"teams-good"} saved = json.loads(ch._refs_path.read_text(encoding="utf-8")) diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index dc909ed72..3db23bcbb 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -30,7 +30,6 @@ from nanobot.bus.outbound_events import ( TurnEndEvent, TurnModelUpdatedEvent, outbound_event_from_message, - outbound_message_for_event, ) from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel @@ -280,21 +279,6 @@ class WebSocketConfig(Base): ) -def publish_runtime_model_update( - bus: MessageBus, - model: str, - model_preset: str | None, -) -> None: - """Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel).""" - bus.outbound.put_nowait( - outbound_message_for_event( - channel="websocket", - chat_id="*", - event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset), - ) - ) - - def _parse_inbound_payload(raw: str) -> str | None: """Parse a client frame into text; return None for empty or unrecognized content.""" text = raw.strip() diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 2e265bd6b..70b781abd 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -32,7 +32,6 @@ from nanobot.channels.websocket.runtime import ( _is_valid_chat_id, _parse_envelope, _parse_inbound_payload, - publish_runtime_model_update, ) from nanobot.config.loader import load_config, save_config from nanobot.config.schema import Config, ModelPresetConfig @@ -1105,8 +1104,14 @@ async def test_send_broadcasts_runtime_model_updates() -> None: mock_ws = AsyncMock() channel._attach(mock_ws, "chat-1") - publish_runtime_model_update(bus, "openai/gpt-4.1", "fast") - await channel.send(bus.outbound.get_nowait()) + await channel.send( + OutboundMessage( + channel="websocket", + chat_id="*", + content="", + event=RuntimeModelUpdatedEvent(model="openai/gpt-4.1", model_preset="fast"), + ) + ) payload = json.loads(mock_ws.send.call_args[0][0]) assert payload["event"] == "runtime_model_updated" @@ -1141,26 +1146,6 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None: chat_two.send.assert_not_awaited() -@pytest.mark.asyncio -async def test_runtime_model_update_publisher_uses_websocket_outbound_event() -> None: - bus = MessageBus() - - publish_runtime_model_update( - bus, - "openai/gpt-4.1", - "fast", - ) - - event = bus.outbound.get_nowait() - assert event.channel == "websocket" - assert event.chat_id == "*" - assert event.content == "" - assert event.metadata == {} - assert isinstance(event.event, RuntimeModelUpdatedEvent) - assert event.event.model == "openai/gpt-4.1" - assert event.event.model_preset == "fast" - - @pytest.mark.asyncio async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None: bus = MagicMock() diff --git a/nanobot/channels/websocket/tests/test_websocket_media_route.py b/nanobot/channels/websocket/tests/test_websocket_media_route.py index 376b5c930..ad3bb7892 100644 --- a/nanobot/channels/websocket/tests/test_websocket_media_route.py +++ b/nanobot/channels/websocket/tests/test_websocket_media_route.py @@ -22,6 +22,7 @@ from nanobot.webui.gateway_services import build_gateway_services from nanobot.webui.media_api import ( b64url_decode, b64url_encode, + sign_media_path, ) from .ws_test_client import InProcessHttpChannel @@ -84,8 +85,16 @@ def _fake_media_dir(root: Path): return inner +def _sign_media_path(channel: WebSocketChannel, path: Path) -> str | None: + return sign_media_path( + path, + secret=channel.gateway.media.secret, + media_dir=channel.gateway.media._media_dir, + ) + + # --------------------------------------------------------------------------- -# gateway.media.sign_media_path: the URL minter +# media_api.sign_media_path: the URL minter # --------------------------------------------------------------------------- @@ -105,10 +114,10 @@ def test_sign_media_path_rejects_paths_outside_media_root( media.mkdir() channel = _ch(bus, port=0) with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media): - assert channel.gateway.media.sign_media_path(outside) is None + assert _sign_media_path(channel, outside) is None # Traversal via the media root is also rejected — the resolve() step # normalises ``..`` out before the relative_to check. - assert channel.gateway.media.sign_media_path(media / ".." / "secrets" / "cred.txt") is None + assert _sign_media_path(channel, media / ".." / "secrets" / "cred.txt") is None def test_sign_media_path_round_trips_via_hmac( @@ -120,7 +129,7 @@ def test_sign_media_path_round_trips_via_hmac( (media / "a.png").write_bytes(_PNG_BYTES) channel = _ch(bus, port=0) with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media): - url = channel.gateway.media.sign_media_path(media / "a.png") + url = _sign_media_path(channel, media / "a.png") assert url is not None assert url.startswith("/api/media/") sig, payload = url[len("/api/media/"):].split("/", 1) @@ -235,7 +244,7 @@ async def test_media_route_serves_signed_file( channel = _ch(bus, port=29920) with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media): - url_path = channel.gateway.media.sign_media_path(target) + url_path = _sign_media_path(channel, target) assert url_path is not None server_task = asyncio.create_task(channel.start()) try: @@ -267,7 +276,7 @@ async def test_media_route_serves_video_byte_ranges( channel = _ch(bus, port=29927) with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media): - url_path = channel.gateway.media.sign_media_path(target) + url_path = _sign_media_path(channel, target) assert url_path is not None server_task = asyncio.create_task(channel.start()) try: @@ -298,7 +307,7 @@ async def test_media_route_serves_suffix_video_byte_ranges( channel = _ch(bus, port=29928) with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media): - url_path = channel.gateway.media.sign_media_path(target) + url_path = _sign_media_path(channel, target) assert url_path is not None server_task = asyncio.create_task(channel.start()) try: @@ -326,7 +335,7 @@ async def test_media_route_rejects_unsatisfiable_byte_range( channel = _ch(bus, port=29929) with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media): - url_path = channel.gateway.media.sign_media_path(target) + url_path = _sign_media_path(channel, target) assert url_path is not None server_task = asyncio.create_task(channel.start()) try: @@ -358,7 +367,7 @@ async def test_media_route_rejects_bad_signature( channel = _ch(bus, port=29921) with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media): - good = channel.gateway.media.sign_media_path(media / "f.png") + good = _sign_media_path(channel, media / "f.png") assert good is not None _, payload = good[len("/api/media/"):].split("/", 1) # Forge a sig with a *different* secret. @@ -423,7 +432,7 @@ async def test_media_route_404s_missing_file( channel = _ch(bus, port=29923) with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media): - url_path = channel.gateway.media.sign_media_path(target) + url_path = _sign_media_path(channel, target) assert url_path is not None target.unlink() # the file vanishes between signing and fetching server_task = asyncio.create_task(channel.start()) @@ -480,7 +489,7 @@ async def test_media_route_serves_svg_with_strict_csp( channel = _ch(bus, port=29928) with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media): - url_path = channel.gateway.media.sign_media_path(target) + url_path = _sign_media_path(channel, target) assert url_path is not None server_task = asyncio.create_task(channel.start()) try: diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index ce0df35b4..20fbe4be6 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -25,9 +25,6 @@ from nanobot.cron.types import ( CronSchedule, CronStore, ) -from nanobot.utils.run_records import ( - safe_run_record_name, -) from nanobot.utils.run_records import ( write_run_record as write_automation_run_record, ) @@ -440,10 +437,6 @@ class CronService: tmp_path.unlink(missing_ok=True) raise - @staticmethod - def _safe_run_record_name(run_id: str) -> str: - return safe_run_record_name(run_id) - def write_run_record(self, run_id: str, record: dict[str, Any]) -> None: """Write an internal audit record for one cron execution.""" write_automation_run_record(self._run_records_dir, run_id, record) diff --git a/nanobot/cron/session_turns.py b/nanobot/cron/session_turns.py index 27622b83d..b0c9e847f 100644 --- a/nanobot/cron/session_turns.py +++ b/nanobot/cron/session_turns.py @@ -7,7 +7,6 @@ from typing import Any, Mapping from nanobot.cron.types import CronJob from nanobot.session.automation_turns import ( AutomationTurnSpec, - automation_history_overrides_for_spec, automation_trigger, ) @@ -63,11 +62,6 @@ def cron_run_id(metadata: Mapping[str, Any] | None) -> str | None: return value if isinstance(value, str) and value else None -def cron_history_overrides(metadata: Mapping[str, Any] | None) -> tuple[str | None, dict[str, Any]]: - """Return session-history text/metadata overrides for a cron turn.""" - return automation_history_overrides_for_spec(metadata, CRON_AUTOMATION_SPEC) - - def is_bound_cron_job(job: CronJob) -> bool: """True for session-bound cron jobs with complete delivery context.""" payload = job.payload diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 723067ba9..81682e85b 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -1086,10 +1086,6 @@ class SessionManager: """Attempt to recover a session from a corrupt JSONL file.""" return self._jsonl_store.repair(key, path=path) - @staticmethod - def _session_payload(session: Session) -> SessionPayload: - return JsonlSessionStore.session_payload(session) - def save(self, session: Session, *, fsync: bool = False) -> None: """Persist a session and retain it in the cache.""" archiver = self._file_cap_archiver diff --git a/nanobot/triggers/local_session_turns.py b/nanobot/triggers/local_session_turns.py index 6459640cc..8ac456bdf 100644 --- a/nanobot/triggers/local_session_turns.py +++ b/nanobot/triggers/local_session_turns.py @@ -6,7 +6,6 @@ from typing import Any, Mapping from nanobot.session.automation_turns import ( AutomationTurnSpec, - automation_history_overrides_for_spec, automation_trigger, ) @@ -50,13 +49,3 @@ def local_trigger_delivery_id(metadata: Mapping[str, Any] | None) -> str | None: return None value = trigger.get("delivery_id") return value if isinstance(value, str) and value else None - - -def local_trigger_history_overrides( - metadata: Mapping[str, Any] | None, -) -> tuple[str | None, dict[str, Any]]: - """Return session-history text/metadata overrides for a local trigger turn.""" - return automation_history_overrides_for_spec( - metadata, - LOCAL_TRIGGER_AUTOMATION_SPEC, - ) diff --git a/nanobot/utils/document.py b/nanobot/utils/document.py index 6d27fadff..20b77625f 100644 --- a/nanobot/utils/document.py +++ b/nanobot/utils/document.py @@ -11,35 +11,6 @@ from loguru import logger from nanobot.utils.helpers import detect_image_mime -# Supported file extensions for text extraction -SUPPORTED_EXTENSIONS: set[str] = { - # Document formats - ".pdf", - ".docx", - ".xlsx", - ".pptx", - # Text formats - ".txt", - ".md", - ".csv", - ".json", - ".xml", - ".html", - ".htm", - ".log", - ".yaml", - ".yml", - ".toml", - ".ini", - ".cfg", - # Image formats (for future OCR support) - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", -} - _MAX_TEXT_LENGTH = 200_000 _MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB _MAX_OFFICE_ARCHIVE_MEMBERS = 10_000 diff --git a/nanobot/utils/file_edit_events.py b/nanobot/utils/file_edit_events.py index e6740df8c..270a4ccb7 100644 --- a/nanobot/utils/file_edit_events.py +++ b/nanobot/utils/file_edit_events.py @@ -274,24 +274,6 @@ def _text_line_count(text: str) -> int: return line_count if last_was_newline else line_count + 1 -def prepare_file_edit_tracker( - *, - call_id: str, - tool_name: str, - tool: Any, - workspace: Path | None, - params: dict[str, Any] | None, -) -> FileEditTracker | None: - trackers = prepare_file_edit_trackers( - call_id=call_id, - tool_name=tool_name, - tool=tool, - workspace=workspace, - params=params, - ) - return trackers[0] if trackers else None - - def prepare_file_edit_trackers( *, call_id: str, diff --git a/nanobot/utils/gitstore.py b/nanobot/utils/gitstore.py index f09325077..4140b153b 100644 --- a/nanobot/utils/gitstore.py +++ b/nanobot/utils/gitstore.py @@ -5,14 +5,13 @@ from __future__ import annotations import io import time from dataclasses import dataclass -from datetime import datetime, timezone from pathlib import Path -from typing import TYPE_CHECKING, Iterable, cast +from typing import TYPE_CHECKING, cast from loguru import logger if TYPE_CHECKING: - from dulwich.objects import Blob, Commit, ObjectID, Tree, TreeEntry + from dulwich.objects import Blob, Commit, ObjectID, Tree from dulwich.refs import Ref from dulwich.repo import Repo @@ -45,25 +44,6 @@ class CommitInfo: return f"{header}\n(no file changes)" -@dataclass -class LineAge: - """Age of a single line based on git blame.""" - - age_days: int # days since last modification - - -def _compute_line_ages( - annotated: Iterable[tuple[tuple["Commit", "TreeEntry"], bytes]], -) -> list[LineAge]: - """Convert annotate results to per-line ages.""" - now = datetime.now(tz=timezone.utc).date() - ages: list[LineAge] = [] - for (commit, _tree_entry), _line_bytes in annotated: - dt = datetime.fromtimestamp(commit.commit_time, tz=timezone.utc).date() - ages.append(LineAge(age_days=(now - dt).days)) - return ages - - class GitStore: """Git-backed version control for memory files.""" @@ -293,33 +273,6 @@ class GitStore: except Exception as exc: raise GitStoreError("Git log failed") from exc - def line_ages(self, file_path: str) -> list[LineAge]: - """Compute the age of each line in a tracked file via git blame. - - Returns one LineAge per line, in order. - Returns an empty list if the repo is not initialized or the file is - empty. Annotation failures raise :class:`GitStoreError`. - """ - - if not self.is_initialized(): - return [] - - target = self._workspace / file_path - if not target.exists() or target.stat().st_size == 0: - return [] - - try: - from dulwich import porcelain - - annotated = porcelain.annotate(str(self._workspace), file_path) - except Exception as exc: - raise GitStoreError(f"Git line annotation failed for {file_path}") from exc - - if not annotated: - return [] - - return _compute_line_ages(annotated) - def diff_commits(self, sha1: str, sha2: str) -> str: """Show diff between two commits.""" if not self.is_initialized(): @@ -461,13 +414,6 @@ class GitStore: commit = cast("Commit", commit_obj) return cast("Tree", repo[commit.tree]) - def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None: - """Find a commit by short SHA prefix match.""" - for c in self.log(max_entries=max_entries): - if c.sha.startswith(short_sha): - return c - return None - def show_commit_diff( self, short_sha: str, diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 8ab5f32bb..1f1720dd3 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -351,18 +351,6 @@ def timestamp() -> str: return datetime.now().isoformat() -def current_time_str(timezone: str | None = None) -> str: - """Return the current time string.""" - from zoneinfo import ZoneInfo - - tz = ZoneInfo(timezone) if timezone else None - now = datetime.now(tz=tz) if tz else datetime.now().astimezone() - offset = now.strftime("%z") - offset_fmt = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset - tz_name = timezone or (time.strftime("%Z") or "UTC") - return f"{now.strftime('%Y-%m-%d %H:%M (%A)')} ({tz_name}, UTC{offset_fmt})" - - _UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]') _TOOL_RESULT_PREVIEW_CHARS = 1200 _TOOL_RESULTS_DIR = ".nanobot/tool-results" diff --git a/nanobot/webui/media_gateway.py b/nanobot/webui/media_gateway.py index 5e83be142..6e3f4d424 100644 --- a/nanobot/webui/media_gateway.py +++ b/nanobot/webui/media_gateway.py @@ -18,7 +18,6 @@ from nanobot.webui.attachment_ingress import ( from nanobot.webui.ingress_policy import AttachmentIngressLimits from nanobot.webui.media_api import ( serve_signed_media, - sign_media_path, sign_or_stage_media_path, signed_media_attachments, ) @@ -71,13 +70,6 @@ class WebUIMediaGateway: media_dir=self._media_dir, ) - def sign_media_path(self, abs_path: Path) -> str | None: - return sign_media_path( - abs_path, - secret=self.secret, - media_dir=self._media_dir, - ) - def sign_or_stage_media_path(self, path: Path) -> dict[str, str] | None: return sign_or_stage_media_path( path, diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 0d5638e09..8fc233b39 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -1313,21 +1313,6 @@ def _recover_incomplete_turns( return recovered -def recover_incomplete_turns_from_session( - lines: list[dict[str, Any]], - session_messages: list[dict[str, Any]] | None, - *, - session_key: str, -) -> list[dict[str, Any]]: - """Recover marked transcript answers only when one durable session turn matches.""" - if not lines or not session_messages or not _needs_incomplete_turn_recovery(lines): - return lines - session_turns = _session_backfill_turns(session_key, session_messages) - if not session_turns: - return lines - return _recover_incomplete_turns(lines, session_turns) - - def _with_backfilled_user( records: list[dict[str, Any]], user_event: dict[str, Any], @@ -1365,20 +1350,6 @@ def _inject_missing_user_events( return out -def inject_missing_user_events_from_session( - session_key: str, - lines: list[dict[str, Any]], - session_messages: list[dict[str, Any]] | None, -) -> list[dict[str, Any]]: - """Backfill user rows for legacy WebUI transcripts that only stored assistant streams.""" - if not lines or not session_messages or not _needs_user_event_backfill(lines): - return lines - session_turns = _session_backfill_turns(session_key, session_messages) - if not session_turns: - return lines - return _inject_missing_user_events(lines, session_turns) - - def _format_tool_call_trace(call: Any) -> str | None: if not call or not isinstance(call, dict): return None diff --git a/tests/agent/test_git_store.py b/tests/agent/test_git_store.py index b3c281608..473624811 100644 --- a/tests/agent/test_git_store.py +++ b/tests/agent/test_git_store.py @@ -169,19 +169,6 @@ class TestDiffCommits: assert git_ready.diff_commits("deadbeef", "cafebabe") == "" -class TestFindCommit: - def test_finds_by_prefix(self, git_ready): - ws = git_ready._workspace - (ws / "SOUL.md").write_text("v2", encoding="utf-8") - sha = git_ready.auto_commit("v2") - found = git_ready.find_commit(sha[:4]) - assert found is not None - assert found.sha == sha - - def test_returns_none_for_unknown(self, git_ready): - assert git_ready.find_commit("deadbeef") is None - - class TestShowCommitDiff: def test_returns_commit_with_diff(self, git_ready): ws = git_ready._workspace diff --git a/tests/agent/test_runner_injections.py b/tests/agent/test_runner_injections.py index f76ad10bd..5fbed09b4 100644 --- a/tests/agent/test_runner_injections.py +++ b/tests/agent/test_runner_injections.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent.runner_helpers import make_run_spec +from nanobot.agent.automation_turns import publish_next_deferred_turn from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMResponse, ToolCallRequest @@ -1047,7 +1048,11 @@ async def test_cron_turn_deferred_while_session_active(tmp_path): assert loop._cron_turns.deferred_queues[session_key] == [msg] assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"} - await loop._cron_turns.publish_next_deferred(session_key) + await publish_next_deferred_turn( + deferred_queues=loop._cron_turns.deferred_queues, + publish_inbound=loop.bus.publish_inbound, + session_key=session_key, + ) queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5) assert queued is msg assert session_key not in loop._cron_turns.deferred_queues @@ -1097,7 +1102,11 @@ async def test_local_trigger_turn_deferred_while_session_active(tmp_path): assert loop._local_trigger_turns.deferred_queues[session_key] == [msg] assert loop.pending_local_trigger_ids_for_session(session_key) == {"trg_123"} - assert await loop._local_trigger_turns.publish_next_deferred(session_key) is True + assert await publish_next_deferred_turn( + deferred_queues=loop._local_trigger_turns.deferred_queues, + publish_inbound=loop.bus.publish_inbound, + session_key=session_key, + ) is True queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5) assert queued is msg assert session_key not in loop._local_trigger_turns.deferred_queues diff --git a/tests/cli_apps/test_utils.py b/tests/cli_apps/test_utils.py index 2a2b01d0e..4c5d7d64e 100644 --- a/tests/cli_apps/test_utils.py +++ b/tests/cli_apps/test_utils.py @@ -1,9 +1,7 @@ """Tests for CLI Apps loop helpers.""" -from types import SimpleNamespace - from nanobot.apps.cli.service import CliAppManager -from nanobot.apps.cli.utils import runtime_lines, session_extra +from nanobot.apps.cli.utils import runtime_lines_for_request, session_extra def test_session_extra_returns_cli_apps_only_when_present() -> None: @@ -30,8 +28,9 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch): } ) - lines = runtime_lines( - SimpleNamespace(content="please use @zoom tonight; ignore @krita?", metadata={}), + lines = runtime_lines_for_request( + "please use @zoom tonight; ignore @krita?", + {}, tmp_path, ) @@ -43,17 +42,15 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch): def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path): - lines = runtime_lines( - SimpleNamespace( - content="please use @zoom tonight", - metadata={ - "cli_apps": [{ - "name": "zoom", - "entry_point": "cli-anything-zoom", - "display_name": "Zoom", - }], - }, - ), + lines = runtime_lines_for_request( + "please use @zoom tonight", + { + "cli_apps": [{ + "name": "zoom", + "entry_point": "cli-anything-zoom", + "display_name": "Zoom", + }], + }, tmp_path, ) diff --git a/tests/test_document_parsing.py b/tests/test_document_parsing.py index b2ae4e7d7..abf87413d 100644 --- a/tests/test_document_parsing.py +++ b/tests/test_document_parsing.py @@ -6,7 +6,6 @@ from zipfile import ZipFile import pytest from nanobot.utils.document import ( - SUPPORTED_EXTENSIONS, PdfSafetyError, _is_text_extension, extract_pdf_pages, @@ -14,31 +13,6 @@ from nanobot.utils.document import ( ) -class TestSupportedExtensions: - """Test the SUPPORTED_EXTENSIONS constant.""" - - def test_supported_extensions_include_common_formats(self): - """Test that common document formats are included.""" - # Document formats - assert ".pdf" in SUPPORTED_EXTENSIONS - assert ".docx" in SUPPORTED_EXTENSIONS - assert ".xlsx" in SUPPORTED_EXTENSIONS - assert ".pptx" in SUPPORTED_EXTENSIONS - - # Text formats - assert ".txt" in SUPPORTED_EXTENSIONS - assert ".md" in SUPPORTED_EXTENSIONS - assert ".csv" in SUPPORTED_EXTENSIONS - assert ".json" in SUPPORTED_EXTENSIONS - assert ".yaml" in SUPPORTED_EXTENSIONS - assert ".yml" in SUPPORTED_EXTENSIONS - - # Image formats - assert ".png" in SUPPORTED_EXTENSIONS - assert ".jpg" in SUPPORTED_EXTENSIONS - assert ".jpeg" in SUPPORTED_EXTENSIONS - - class TestExtractText: """Test the extract_text function.""" diff --git a/tests/tools/test_edit_advanced.py b/tests/tools/test_edit_advanced.py index df1f001da..ea467bb43 100644 --- a/tests/tools/test_edit_advanced.py +++ b/tests/tools/test_edit_advanced.py @@ -13,7 +13,7 @@ import os import pytest from nanobot.agent.tools import file_state -from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, _find_match +from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool @pytest.fixture(autouse=True) @@ -68,41 +68,6 @@ class TestDeleteLineCleanup: # --------------------------------------------------------------------------- -class TestSmartQuoteNormalization: - """_find_match should handle curly ↔ straight quote fallback.""" - - def test_curly_double_quotes_match_straight(self): - content = 'She said \u201chello\u201d to him' - old_text = 'She said "hello" to him' - match, count = _find_match(content, old_text) - assert match is not None - assert count == 1 - # Returned match should be the ORIGINAL content with curly quotes - assert "\u201c" in match - - def test_curly_single_quotes_match_straight(self): - content = "it\u2019s a test" - old_text = "it's a test" - match, count = _find_match(content, old_text) - assert match is not None - assert count == 1 - assert "\u2019" in match - - def test_straight_matches_curly_in_old_text(self): - content = 'x = "hello"' - old_text = 'x = \u201chello\u201d' - match, count = _find_match(content, old_text) - assert match is not None - assert count == 1 - - def test_exact_match_still_preferred_over_quote_normalization(self): - content = 'x = "hello"' - old_text = 'x = "hello"' - match, count = _find_match(content, old_text) - assert match == old_text - assert count == 1 - - class TestQuoteStylePreservation: """When quote-normalized matching occurs, replacement should preserve actual quote style.""" diff --git a/tests/tools/test_filesystem_tools.py b/tests/tools/test_filesystem_tools.py index 9b917dc6d..b221bfcc1 100644 --- a/tests/tools/test_filesystem_tools.py +++ b/tests/tools/test_filesystem_tools.py @@ -7,7 +7,6 @@ from nanobot.agent.tools.filesystem import ( ListDirTool, ReadFileTool, WriteFileTool, - _find_match, ) # --------------------------------------------------------------------------- @@ -116,52 +115,6 @@ class TestReadFileTool: assert "Maximum is 100 MiB" in result -# --------------------------------------------------------------------------- -# _find_match (unit tests for the helper) -# --------------------------------------------------------------------------- - -class TestFindMatch: - - def test_exact_match(self): - match, count = _find_match("hello world", "world") - assert match == "world" - assert count == 1 - - def test_exact_no_match(self): - match, count = _find_match("hello world", "xyz") - assert match is None - assert count == 0 - - def test_crlf_normalisation(self): - # Caller normalises CRLF before calling _find_match, so test with - # pre-normalised content to verify exact match still works. - content = "line1\nline2\nline3" - old_text = "line1\nline2\nline3" - match, count = _find_match(content, old_text) - assert match is not None - assert count == 1 - - def test_line_trim_fallback(self): - content = " def foo():\n pass\n" - old_text = "def foo():\n pass" - match, count = _find_match(content, old_text) - assert match is not None - assert count == 1 - # The returned match should be the *original* indented text - assert " def foo():" in match - - def test_line_trim_multiple_candidates(self): - content = " a\n b\n a\n b\n" - old_text = "a\nb" - match, count = _find_match(content, old_text) - assert count == 2 - - def test_empty_old_text(self): - match, count = _find_match("hello", "") - # Empty string is always "in" any string via exact match - assert match == "" - - # --------------------------------------------------------------------------- # EditFileTool # --------------------------------------------------------------------------- diff --git a/tests/utils/test_file_edit_events.py b/tests/utils/test_file_edit_events.py index dae8dec59..2a12697b7 100644 --- a/tests/utils/test_file_edit_events.py +++ b/tests/utils/test_file_edit_events.py @@ -9,7 +9,6 @@ from nanobot.utils.file_edit_events import ( build_file_edit_start_event, build_unified_diff_payload, line_diff_stats, - prepare_file_edit_tracker, prepare_file_edit_trackers, read_file_snapshot, ) @@ -44,15 +43,14 @@ def test_write_file_start_tracks_snapshot_and_end_emits_exact_diff(tmp_path: Pat target = tmp_path / "notes.txt" target.write_text("old\nkeep\n", encoding="utf-8") params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"} - tracker = prepare_file_edit_tracker( + trackers = prepare_file_edit_trackers( call_id="call-write", tool_name="write_file", tool=_write_tool(tmp_path), workspace=tmp_path, params=params, ) - - assert tracker is not None + [tracker] = trackers start = build_file_edit_start_event(tracker) assert start == { "version": 1, @@ -103,15 +101,14 @@ def test_unified_diff_payload_truncates_large_diffs() -> None: def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None: target = tmp_path / "data.bin" target.write_bytes(b"\x00\x01before") - tracker = prepare_file_edit_tracker( + trackers = prepare_file_edit_trackers( call_id="call-bin", tool_name="edit_file", tool=_edit_tool(tmp_path), workspace=tmp_path, params={"path": "data.bin", "old_text": "before", "new_text": "after"}, ) - - assert tracker is not None + [tracker] = trackers assert not read_file_snapshot(target).countable target.write_bytes(b"\x00\x01after") event = build_file_edit_end_event(tracker) @@ -123,15 +120,14 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None: def test_binary_before_file_is_reported_but_not_counted(tmp_path: Path) -> None: target = tmp_path / "data.bin" target.write_bytes(b"\x00\x01before") - tracker = prepare_file_edit_tracker( + trackers = prepare_file_edit_trackers( call_id="call-bin", tool_name="write_file", tool=_write_tool(tmp_path), workspace=tmp_path, params={"path": "data.bin", "content": "after\n"}, ) - - assert tracker is not None + [tracker] = trackers target.write_text("after\n", encoding="utf-8") event = build_file_edit_end_event(tracker) assert event["binary"] is True @@ -215,15 +211,14 @@ def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) def test_oversized_file_is_reported_but_not_counted(tmp_path: Path) -> None: target = tmp_path / "large.txt" params = {"path": "large.txt", "content": "x"} - tracker = prepare_file_edit_tracker( + trackers = prepare_file_edit_trackers( call_id="call-large", tool_name="write_file", tool=_write_tool(tmp_path), workspace=tmp_path, params=params, ) - - assert tracker is not None + [tracker] = trackers target.write_text("x" * (2 * 1024 * 1024 + 1), encoding="utf-8") event = build_file_edit_end_event(tracker) assert event["binary"] is True @@ -232,11 +227,11 @@ def test_oversized_file_is_reported_but_not_counted(tmp_path: Path) -> None: assert "diff" not in event -def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None: - assert prepare_file_edit_tracker( +def test_untracked_tools_do_not_prepare_file_edit_trackers(tmp_path: Path) -> None: + assert prepare_file_edit_trackers( call_id="call-exec", tool_name="exec", tool=None, workspace=tmp_path, params={"path": "created-by-shell.txt"}, - ) is None + ) == [] diff --git a/tests/utils/test_gitstore.py b/tests/utils/test_gitstore.py index 14a2d7fd6..04fe452a0 100644 --- a/tests/utils/test_gitstore.py +++ b/tests/utils/test_gitstore.py @@ -1,13 +1,12 @@ -"""Tests for GitStore — line_ages() and core git operations.""" +"""Tests for GitStore core operations.""" import subprocess -from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import patch import pytest -from nanobot.utils.gitstore import GitStore, GitStoreError +from nanobot.utils.gitstore import GitStore @pytest.fixture @@ -18,89 +17,6 @@ def git(tmp_path): return g -class TestLineAges: - def test_returns_empty_when_not_initialized(self, tmp_path): - """line_ages should return [] if the git repo is not initialized.""" - git = GitStore(tmp_path, tracked_files=["MEMORY.md"]) - assert git.line_ages("MEMORY.md") == [] - - def test_returns_empty_for_missing_file(self, git): - """line_ages should return [] for a file that doesn't exist.""" - assert git.line_ages("SOUL.md") == [] - - def test_returns_empty_for_empty_file(self, git, tmp_path): - """line_ages should return [] for an empty tracked file.""" - (tmp_path / "SOUL.md").write_text("", encoding="utf-8") - git.auto_commit("empty soul") - assert git.line_ages("SOUL.md") == [] - - def test_one_age_per_line(self, git, tmp_path): - """line_ages should return one entry per line in the file.""" - content = "# Memory\n\n## Section A\n- item 1\n" - (tmp_path / "MEMORY.md").write_text(content, encoding="utf-8") - git.auto_commit("initial") - ages = git.line_ages("MEMORY.md") - assert len(ages) == len(content.splitlines()) - - def test_fresh_lines_have_age_zero(self, git, tmp_path): - """Lines committed today should have age_days=0.""" - (tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8") - git.auto_commit("initial") - ages = git.line_ages("MEMORY.md") - assert all(a.age_days == 0 for a in ages) - - def test_age_differentiates_across_days(self, git, tmp_path): - """Lines committed today should show correct age when 'now' is mocked forward.""" - (tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8") - git.auto_commit("initial") - - future_now = datetime.now(tz=timezone.utc) + timedelta(days=30) - with patch("nanobot.utils.gitstore.datetime") as mock_dt: - mock_dt.now.return_value = future_now - mock_dt.fromtimestamp = datetime.fromtimestamp - ages = git.line_ages("MEMORY.md") - - assert len(ages) == 2 - assert all(a.age_days == 30 for a in ages) - - def test_annotate_failure_is_explicit(self, git, tmp_path): - (tmp_path / "MEMORY.md").write_text("important\n", encoding="utf-8") - git.auto_commit("initial") - - with patch("dulwich.porcelain.annotate", side_effect=OSError("broken repo")): - with pytest.raises(GitStoreError, match="annotation failed"): - git.line_ages("MEMORY.md") - - def test_partial_edit_only_updates_changed_lines(self, git, tmp_path): - """Only modified lines should reflect the new commit's timestamp.""" - now = datetime(2026, 5, 1, tzinfo=timezone.utc) - old = now - timedelta(days=30) - - (tmp_path / "MEMORY.md").write_text( - "# Memory\n\n## A\n- old\n\n## B\n- keep\n", encoding="utf-8" - ) - with patch("dulwich.worktree.time.time", return_value=old.timestamp()): - git.auto_commit("commit1") - - # Only modify section A - (tmp_path / "MEMORY.md").write_text( - "# Memory\n\n## A\n- new\n\n## B\n- keep\n", encoding="utf-8" - ) - with patch("dulwich.worktree.time.time", return_value=now.timestamp()): - git.auto_commit("commit2") - - with patch("nanobot.utils.gitstore.datetime") as mock_dt: - mock_dt.now.return_value = now - mock_dt.fromtimestamp = datetime.fromtimestamp - ages = git.line_ages("MEMORY.md") - - lines = (tmp_path / "MEMORY.md").read_text(encoding="utf-8").splitlines() - assert len(ages) == len(lines) - age_by_line = {line: age.age_days for line, age in zip(lines, ages, strict=True)} - assert age_by_line["- new"] == 0 - assert age_by_line["- keep"] == 30 - - class TestSummarizeWorkingTree: """Ground-truth diff summary used to keep Dream audit records honest.""" diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index a526d17c6..c43d760c4 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -1,14 +1,11 @@ from pathlib import Path -from zoneinfo import ZoneInfoNotFoundError -import pytest import tiktoken from nanobot.utils import helpers from nanobot.utils.helpers import ( _write_text_atomic, content_with_media_breadcrumbs, - current_time_str, split_message, truncate_text_to_tokens, ) @@ -51,11 +48,6 @@ def test_truncate_text_to_tokens_non_positive_budget_returns_text(): assert truncate_text_to_tokens(text, 0) == text -def test_current_time_str_rejects_unknown_timezone(): - with pytest.raises(ZoneInfoNotFoundError): - current_time_str("Not/AZone") - - def test_content_with_media_breadcrumbs_preserves_valid_paths(): assert content_with_media_breadcrumbs( "user", diff --git a/webui/bun.lock b/webui/bun.lock index bc46bcff9..d84a59437 100644 --- a/webui/bun.lock +++ b/webui/bun.lock @@ -9,7 +9,6 @@ "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.6", "class-variance-authority": "^0.7.1", @@ -250,8 +249,6 @@ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], - "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], @@ -1332,8 +1329,6 @@ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], - "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], diff --git a/webui/package.json b/webui/package.json index dd6eb8e34..dc086c3cd 100644 --- a/webui/package.json +++ b/webui/package.json @@ -16,7 +16,6 @@ "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.6", "class-variance-authority": "^0.7.1", diff --git a/webui/public/brand/nanobot_icon.png b/webui/public/brand/nanobot_icon.png deleted file mode 100644 index 046086efe..000000000 Binary files a/webui/public/brand/nanobot_icon.png and /dev/null differ diff --git a/webui/public/brand/nanobot_logo.png b/webui/public/brand/nanobot_logo.png deleted file mode 100644 index f519cbcf0..000000000 Binary files a/webui/public/brand/nanobot_logo.png and /dev/null differ diff --git a/webui/public/brand/nanobot_logo.webp b/webui/public/brand/nanobot_logo.webp deleted file mode 100644 index cb39dc449..000000000 Binary files a/webui/public/brand/nanobot_logo.webp and /dev/null differ diff --git a/webui/src/components/thread/promptNavigation.ts b/webui/src/components/thread/promptNavigation.ts index 7aeca8acb..3cc08fdc1 100644 --- a/webui/src/components/thread/promptNavigation.ts +++ b/webui/src/components/thread/promptNavigation.ts @@ -59,16 +59,6 @@ function truncatePreview(text: string, maxLength: number): string { return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text; } -export function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void { - if (!scrollEl || !promptId) return; - const target = findPromptElement(scrollEl, promptId); - if (!target) return; - scrollEl.scrollTo({ - top: Math.max(0, promptTop(scrollEl, target) - 16), - behavior: "smooth", - }); -} - export function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null { const candidates = scrollEl.querySelectorAll("[data-user-prompt-id]"); return Array.from(candidates).find( diff --git a/webui/src/components/ui/dropdown-menu.tsx b/webui/src/components/ui/dropdown-menu.tsx index 40087d90b..f52b879d2 100644 --- a/webui/src/components/ui/dropdown-menu.tsx +++ b/webui/src/components/ui/dropdown-menu.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; -import { Check, ChevronRight, Circle } from "lucide-react"; +import { Circle } from "lucide-react"; import { floatingItemClassName, @@ -12,52 +12,11 @@ import { cn } from "@/lib/utils"; const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -const DropdownMenuGroup = DropdownMenuPrimitive.Group; -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; -const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const menuItemClassName = `${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`; -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)); -DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; - interface DropdownMenuContentProps extends React.ComponentPropsWithoutRef { portalContainer?: HTMLElement | null; @@ -103,31 +62,6 @@ const DropdownMenuItem = React.forwardRef< )); DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName; - const DropdownMenuRadioItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -183,17 +117,11 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; export { DropdownMenu, - DropdownMenuCheckboxItem, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, }; diff --git a/webui/src/components/ui/separator.tsx b/webui/src/components/ui/separator.tsx deleted file mode 100644 index 4407ae5f1..000000000 --- a/webui/src/components/ui/separator.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as React from "react"; -import * as SeparatorPrimitive from "@radix-ui/react-separator"; - -import { cn } from "@/lib/utils"; - -const Separator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->( - ( - { className, orientation = "horizontal", decorative = true, ...props }, - ref, - ) => ( - - ), -); -Separator.displayName = SeparatorPrimitive.Root.displayName; - -export { Separator }; diff --git a/webui/src/globals.css b/webui/src/globals.css index 140909a42..2a9652e60 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -235,10 +235,6 @@ height: 0; } - .shadow-inner-right { - box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02); - } - /* Keep the outer document rhythm clean at message boundaries. */ .markdown-content > :first-child { @apply mt-0; @@ -338,7 +334,6 @@ animation: none; content: ""; } - .markdown-content-streaming > :last-child::after, .streaming-text-fallback::after { animation: none; } @@ -565,52 +560,6 @@ } } - @keyframes cli-app-linked-sheen { - 0% { - transform: translateX(-140%) skewX(-14deg); - opacity: 0; - } - 18% { - opacity: 0.7; - } - 72%, - 100% { - transform: translateX(140%) skewX(-14deg); - opacity: 0; - } - } - .cli-app-linked-chip::after { - content: ""; - position: absolute; - inset: -1px; - pointer-events: none; - background: linear-gradient( - 90deg, - transparent 0%, - hsl(var(--foreground) / 0.14) 46%, - hsl(var(--background) / 0.7) 50%, - hsl(var(--foreground) / 0.12) 54%, - transparent 100% - ); - animation: cli-app-linked-sheen 1.25s ease-out 1; - } - .dark .cli-app-linked-chip::after { - background: linear-gradient( - 90deg, - transparent 0%, - hsl(var(--foreground) / 0.12) 46%, - hsl(var(--background) / 0.5) 50%, - hsl(var(--foreground) / 0.1) 54%, - transparent 100% - ); - } - @media (prefers-reduced-motion: reduce) { - .cli-app-linked-chip::after { - animation: none; - content: none; - } - } - /* Subtle scrollbar that doesn't fight the dark background. */ .scrollbar-thin { scrollbar-width: thin; diff --git a/webui/src/hooks/useAttachedImages.ts b/webui/src/hooks/useAttachedImages.ts index 48d497d8f..a76b9a09c 100644 --- a/webui/src/hooks/useAttachedImages.ts +++ b/webui/src/hooks/useAttachedImages.ts @@ -55,7 +55,6 @@ export type AttachmentError = | "io"; // file read failed at the browser layer export const MAX_ATTACHMENTS_PER_MESSAGE = 4; -export const MAX_IMAGES_PER_MESSAGE = MAX_ATTACHMENTS_PER_MESSAGE; export const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024; export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;