mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
refactor: remove verified dead code
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
) == []
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
@@ -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<HTMLElement>("[data-user-prompt-id]");
|
||||
return Array.from(candidates).find(
|
||||
|
||||
@@ -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<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
menuItemClassName,
|
||||
"data-[state=open]:bg-foreground/[0.055] dark:data-[state=open]:bg-white/[0.08]",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
floatingSurfaceClassName,
|
||||
"max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
interface DropdownMenuContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
|
||||
portalContainer?: HTMLElement | null;
|
||||
@@ -103,31 +62,6 @@ const DropdownMenuItem = React.forwardRef<
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
menuItemClassName,
|
||||
"pl-8 pr-2.5",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
@@ -183,17 +117,11 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@@ -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<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user