diff --git a/nanobot/agent/hook.py b/nanobot/agent/hook.py index 69a5cf4f..fdf15495 100644 --- a/nanobot/agent/hook.py +++ b/nanobot/agent/hook.py @@ -93,6 +93,35 @@ class AgentHook: async def before_execute_tools(self, context: AgentHookContext) -> None: pass + async def before_execute_tool( + self, + context: AgentHookContext, + tool_call: ToolCallRequest, + tool: Any, + params: Any, + ) -> None: + pass + + async def after_execute_tool( + self, + context: AgentHookContext, + tool_call: ToolCallRequest, + tool: Any, + params: Any, + result: Any, + ) -> None: + pass + + async def on_execute_tool_error( + self, + context: AgentHookContext, + tool_call: ToolCallRequest, + tool: Any, + params: Any, + error: Any, + ) -> None: + pass + async def emit_reasoning(self, reasoning_content: str | None) -> None: pass @@ -166,6 +195,49 @@ class CompositeHook(AgentHook): async def before_execute_tools(self, context: AgentHookContext) -> None: await self._for_each_hook_safe("before_execute_tools", context) + async def before_execute_tool( + self, + context: AgentHookContext, + tool_call: ToolCallRequest, + tool: Any, + params: Any, + ) -> None: + await self._for_each_hook_safe("before_execute_tool", context, tool_call, tool, params) + + async def after_execute_tool( + self, + context: AgentHookContext, + tool_call: ToolCallRequest, + tool: Any, + params: Any, + result: Any, + ) -> None: + await self._for_each_hook_safe( + "after_execute_tool", + context, + tool_call, + tool, + params, + result, + ) + + async def on_execute_tool_error( + self, + context: AgentHookContext, + tool_call: ToolCallRequest, + tool: Any, + params: Any, + error: Any, + ) -> None: + await self._for_each_hook_safe( + "on_execute_tool_error", + context, + tool_call, + tool, + params, + error, + ) + async def emit_reasoning(self, reasoning_content: str | None) -> None: await self._for_each_hook_safe("emit_reasoning", reasoning_content) diff --git a/nanobot/agent/hooks/__init__.py b/nanobot/agent/hooks/__init__.py new file mode 100644 index 00000000..5dc63edc --- /dev/null +++ b/nanobot/agent/hooks/__init__.py @@ -0,0 +1,11 @@ +"""Concrete agent hook implementations.""" + +from nanobot.agent.hooks.file_edit_activity import ( + FileEditActivityHook, + create_file_edit_activity_hook, +) + +__all__ = [ + "FileEditActivityHook", + "create_file_edit_activity_hook", +] diff --git a/nanobot/agent/hooks/file_edit_activity.py b/nanobot/agent/hooks/file_edit_activity.py new file mode 100644 index 00000000..36f93a14 --- /dev/null +++ b/nanobot/agent/hooks/file_edit_activity.py @@ -0,0 +1,109 @@ +"""Agent hook that observes file-editing tools and emits file-edit activity.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any + +from nanobot.agent.hook import AgentHook, AgentHookContext, AgentTurnHookContext +from nanobot.providers.base import ToolCallRequest +from nanobot.utils.file_edit_events import ( + FileEditTracker, + build_file_edit_end_event, + build_file_edit_error_event, + build_file_edit_start_event, + prepare_file_edit_trackers, +) +from nanobot.utils.progress_events import ( + invoke_file_edit_progress, + on_progress_accepts_file_edit_events, +) + + +class FileEditActivityHook(AgentHook): + """Translate file-editing tool lifecycle events into WebUI progress events.""" + + def __init__( + self, + *, + on_progress: Callable[..., Awaitable[None]] | None, + workspace: Path | None, + ) -> None: + super().__init__() + self._on_progress = ( + on_progress + if on_progress is not None and on_progress_accepts_file_edit_events(on_progress) + else None + ) + self._workspace = workspace + self._trackers_by_call: dict[str, list[FileEditTracker]] = {} + + async def before_iteration(self, context: AgentHookContext) -> None: + self._trackers_by_call.clear() + + async def before_execute_tool( + self, + context: AgentHookContext, + tool_call: ToolCallRequest, + tool: Any, + params: Any, + ) -> None: + if self._on_progress is None or not isinstance(params, dict): + return + trackers = prepare_file_edit_trackers( + call_id=tool_call.id, + tool_name=tool_call.name, + tool=tool, + workspace=self._workspace, + params=params, + ) + if not trackers: + return + self._trackers_by_call[self._tool_call_key(tool_call)] = trackers + await self._emit([build_file_edit_start_event(tracker, params) for tracker in trackers]) + + async def after_execute_tool( + self, + context: AgentHookContext, + tool_call: ToolCallRequest, + tool: Any, + params: Any, + result: Any, + ) -> None: + trackers = self._trackers_by_call.pop(self._tool_call_key(tool_call), []) + if trackers: + await self._emit([build_file_edit_end_event(tracker) for tracker in trackers]) + + async def on_execute_tool_error( + self, + context: AgentHookContext, + tool_call: ToolCallRequest, + tool: Any, + params: Any, + error: Any, + ) -> None: + trackers = self._trackers_by_call.pop(self._tool_call_key(tool_call), []) + if trackers: + await self._emit([ + build_file_edit_error_event(tracker, str(error)) for tracker in trackers + ]) + + async def _emit(self, events: list[dict[str, Any]]) -> None: + if self._on_progress is not None: + await invoke_file_edit_progress(self._on_progress, events) + + @staticmethod + def _tool_call_key(tool_call: ToolCallRequest) -> str: + call_id = getattr(tool_call, "id", "") or "" + return f"{call_id}|{tool_call.name}" if call_id else f"{id(tool_call)}|{tool_call.name}" + + +def create_file_edit_activity_hook(context: AgentTurnHookContext) -> AgentHook | None: + """Create the default file-edit observer for one agent turn.""" + if context.on_progress is None: + return None + return FileEditActivityHook( + on_progress=context.on_progress, + workspace=context.workspace, + ) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 267ae69e..3cb34ed1 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -21,16 +21,6 @@ from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.session.history_visibility import is_hidden_history_message -from nanobot.utils.file_edit_events import ( - StreamingFileEditTracker, - build_file_edit_end_event, - build_file_edit_error_event, - build_file_edit_start_event, - prepare_file_edit_trackers, -) -from nanobot.utils.file_edit_events import ( - prepare_file_edit_tracker as _prepare_file_edit_tracker, -) from nanobot.utils.helpers import ( IncrementalThinkExtractor, build_assistant_message, @@ -40,10 +30,6 @@ from nanobot.utils.helpers import ( strip_reasoning_tags, strip_think, ) -from nanobot.utils.progress_events import ( - invoke_file_edit_progress, - on_progress_accepts_file_edit_events, -) from nanobot.utils.prompt_templates import render_template from nanobot.utils.runtime import ( EMPTY_FINAL_RESPONSE_MESSAGE, @@ -68,10 +54,6 @@ _MAX_EMPTY_RETRIES = 2 _MAX_LENGTH_RECOVERIES = 3 _MAX_INJECTIONS_PER_TURN = 3 _MAX_INJECTION_CYCLES = 5 -# Backward-compatible module attribute for tests/extensions that monkeypatch -# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers. -prepare_file_edit_tracker = _prepare_file_edit_tracker - @dataclass(slots=True) class AgentRunSpec: @@ -461,6 +443,8 @@ class AgentRunner: response.tool_calls, external_lookup_counts, workspace_violation_counts, + hook, + context, ) tool_events.extend(new_events) tools_used.extend( @@ -766,24 +750,6 @@ class AgentRunner: ) progress_state: dict[str, bool] | None = None - live_file_edits: StreamingFileEditTracker | None = None - - if ( - spec.progress_callback is not None - and on_progress_accepts_file_edit_events(spec.progress_callback) - ): - async def _emit_live_file_edits(events: list[dict[str, Any]]) -> None: - await invoke_file_edit_progress(spec.progress_callback, events) - - live_file_edits = StreamingFileEditTracker( - workspace=spec.workspace, - tools=spec.tools, - emit=_emit_live_file_edits, - ) - - async def _tool_call_delta(delta: dict[str, Any]) -> None: - if live_file_edits is not None: - await live_file_edits.update(delta) if wants_streaming: thinking_buf = "" @@ -812,7 +778,6 @@ class AgentRunner: **kwargs, on_content_delta=_stream, on_thinking_delta=_thinking, - on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None, on_stream_recover=_stream_recover, ) elif wants_progress_streaming: @@ -843,7 +808,6 @@ class AgentRunner: coro = self.provider.chat_stream_with_retry( **kwargs, on_content_delta=_stream_progress, - on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None, ) else: coro = self.provider.chat_with_retry(**kwargs) @@ -858,14 +822,6 @@ class AgentRunner: await coro if outer_timeout_s is None else await asyncio.wait_for(coro, timeout=outer_timeout_s) ) - if live_file_edits is not None: - await live_file_edits.flush() - if response.should_execute_tools: - live_file_edits.apply_final_call_ids(response.tool_calls) - await live_file_edits.error_unmatched( - response.tool_calls if response.should_execute_tools else [], - "Tool call did not complete.", - ) except asyncio.TimeoutError: if outer_timeout_s is None: return LLMResponse( @@ -1131,14 +1087,23 @@ class AgentRunner: tool_calls: list[ToolCallRequest], external_lookup_counts: dict[str, int], workspace_violation_counts: dict[str, int], + hook: AgentHook | None = None, + context: AgentHookContext | None = None, ) -> tuple[list[Any], list[dict[str, str]], BaseException | None]: + hook = hook or AgentHook() + context = context or AgentHookContext(iteration=0, messages=[]) batches = self._partition_tool_batches(spec, tool_calls) tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = [] for batch in batches: if spec.concurrent_tools and len(batch) > 1: batch_results = await asyncio.gather(*( self._run_tool( - spec, tool_call, external_lookup_counts, workspace_violation_counts, + spec, + tool_call, + external_lookup_counts, + workspace_violation_counts, + hook, + context, ) for tool_call in batch )) @@ -1147,7 +1112,12 @@ class AgentRunner: batch_results = [] for tool_call in batch: result = await self._run_tool( - spec, tool_call, external_lookup_counts, workspace_violation_counts, + spec, + tool_call, + external_lookup_counts, + workspace_violation_counts, + hook, + context, ) tool_results.append(result) batch_results.append(result) @@ -1168,7 +1138,11 @@ class AgentRunner: tool_call: ToolCallRequest, external_lookup_counts: dict[str, int], workspace_violation_counts: dict[str, int], + hook: AgentHook | None = None, + context: AgentHookContext | None = None, ) -> tuple[Any, dict[str, str], BaseException | None]: + hook = hook or AgentHook() + context = context or AgentHookContext(iteration=0, messages=[]) hint = "\n\n[Analyze the error above and try a different approach.]" lookup_error = repeated_external_lookup_error( tool_call.name, @@ -1209,30 +1183,7 @@ class AgentRunner: return prep_error + hint, event, ( RuntimeError(prep_error) if spec.fail_on_tool_error else None ) - emit_file_edit_events = ( - spec.progress_callback is not None - and on_progress_accepts_file_edit_events(spec.progress_callback) - ) - progress_callback = spec.progress_callback if emit_file_edit_events else None - file_edit_trackers = ( - prepare_file_edit_trackers( - call_id=tool_call.id, - tool_name=tool_call.name, - tool=tool, - workspace=spec.workspace, - params=params if isinstance(params, dict) else None, - ) - if progress_callback is not None - else None - ) - if file_edit_trackers and progress_callback is not None: - await invoke_file_edit_progress( - progress_callback, - [build_file_edit_start_event( - file_edit_tracker, - params if isinstance(params, dict) else None, - ) for file_edit_tracker in file_edit_trackers], - ) + await hook.before_execute_tool(context, tool_call, tool, params) try: if tool is not None: result = await tool.execute(**params) @@ -1241,14 +1192,7 @@ class AgentRunner: except asyncio.CancelledError: raise except BaseException as exc: - if file_edit_trackers and progress_callback is not None: - await invoke_file_edit_progress( - progress_callback, - [ - build_file_edit_error_event(file_edit_tracker, str(exc)) - for file_edit_tracker in file_edit_trackers - ], - ) + await hook.on_execute_tool_error(context, tool_call, tool, params, exc) event = { "name": tool_call.name, "status": "error", @@ -1270,14 +1214,7 @@ class AgentRunner: return payload, event, None if is_tool_error_result(tool_call.name, result): - if file_edit_trackers and progress_callback is not None: - await invoke_file_edit_progress( - progress_callback, - [ - build_file_edit_error_event(file_edit_tracker, result) - for file_edit_tracker in file_edit_trackers - ], - ) + await hook.on_execute_tool_error(context, tool_call, tool, params, result) event = { "name": tool_call.name, "status": "error", @@ -1296,14 +1233,7 @@ class AgentRunner: return result + hint, event, RuntimeError(result) return result + hint, event, None - if file_edit_trackers and progress_callback is not None: - await invoke_file_edit_progress( - progress_callback, - [build_file_edit_end_event( - file_edit_tracker, - params if isinstance(params, dict) else None, - ) for file_edit_tracker in file_edit_trackers], - ) + await hook.after_execute_tool(context, tool_call, tool, params, result) detail = "" if result is None else str(result) detail = detail.replace("\n", " ").strip() diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 678e16e7..52b81064 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -61,6 +61,7 @@ from rich.text import Text # noqa: E402 from nanobot import __logo__, __version__ # noqa: E402 from nanobot import optional_features as feature_support # noqa: E402 +from nanobot.agent.hooks import create_file_edit_activity_hook # noqa: E402 from nanobot.agent.loop import AgentLoop # noqa: E402 from nanobot.bus.outbound_events import ( # noqa: E402 ProgressEvent, @@ -1177,6 +1178,7 @@ def serve( runtime_config, bus, session_manager=session_manager, image_generation_provider_configs=image_gen_provider_configs(runtime_config), + hook_factories=[create_file_edit_activity_hook], ) except ValueError as exc: console.print(f"[red]Error: {exc}[/red]") @@ -1429,6 +1431,7 @@ def _run_gateway( provider_signature=provider_snapshot.signature, hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)], local_trigger_store=trigger_store, + hook_factories=[create_file_edit_activity_hook], ) WebuiTurnCoordinator( bus=bus, @@ -1914,6 +1917,7 @@ def agent( config, bus, cron_service=cron, image_generation_provider_configs=image_gen_provider_configs(config), + hook_factories=[create_file_edit_activity_hook], ) except ValueError as exc: console.print(f"[red]Error: {exc}[/red]") diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index 27f61eee..92a68084 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any from nanobot.agent.hook import AgentHook, SDKCaptureHook +from nanobot.agent.hooks import create_file_edit_activity_hook from nanobot.agent.loop import AgentLoop from nanobot.config.schema import Config from nanobot.providers.image_generation import image_gen_provider_configs @@ -120,6 +121,7 @@ class Nanobot: loop = AgentLoop.from_config( config, image_generation_provider_configs=image_gen_provider_configs(config), + hook_factories=[create_file_edit_activity_hook], ) return cls(loop, config=config) diff --git a/nanobot/utils/file_edit_events.py b/nanobot/utils/file_edit_events.py index f5be540a..8ec6b3fd 100644 --- a/nanobot/utils/file_edit_events.py +++ b/nanobot/utils/file_edit_events.py @@ -4,15 +4,15 @@ from __future__ import annotations import difflib import re -import time -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path -from typing import Any, Awaitable, Callable +from typing import Any TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "apply_patch"}) _MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024 -_LIVE_EMIT_INTERVAL_S = 0.18 -_LIVE_EMIT_LINE_STEP = 24 +_MAX_DIFF_LINES = 500 +_MAX_DIFF_LINE_CHARS = 1200 +_DIFF_CONTEXT_LINES = 3 @dataclass(slots=True) @@ -47,32 +47,6 @@ def is_file_edit_tool(tool_name: str | None) -> bool: return bool(tool_name) and tool_name in TRACKED_FILE_EDIT_TOOLS -def resolve_file_edit_path( - tool: Any, - workspace: Path | None, - params: dict[str, Any] | None, -) -> Path | None: - """Resolve the target file path after tool argument preparation.""" - if not isinstance(params, dict): - return None - raw_path = params.get("path") - if not isinstance(raw_path, str) or not raw_path.strip(): - return None - resolver = getattr(tool, "_resolve", None) - if callable(resolver): - try: - resolved = resolver(raw_path) - if isinstance(resolved, Path): - return resolved - if resolved: - return Path(resolved) - except Exception: - return None - if workspace is None: - return Path(raw_path).expanduser().resolve() - return (workspace / raw_path).expanduser().resolve() - - def display_file_edit_path(path: Path, workspace: Path | None) -> str: if workspace is not None: try: @@ -122,6 +96,162 @@ def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]: return added, deleted +def build_unified_diff_payload( + before: str | None, + after: str | None, + *, + fromfile: str = "before", + tofile: str = "after", + context_lines: int = _DIFF_CONTEXT_LINES, + max_lines: int = _MAX_DIFF_LINES, + max_line_chars: int = _MAX_DIFF_LINE_CHARS, +) -> dict[str, Any] | None: + """Return a compact standard unified diff for WebUI rendering.""" + if before is None or after is None: + return None + before_lines = before.replace("\r\n", "\n").splitlines() + after_lines = after.replace("\r\n", "\n").splitlines() + diff_lines = list(difflib.unified_diff( + before_lines, + after_lines, + fromfile=fromfile, + tofile=tofile, + n=max(0, int(context_lines)), + lineterm="", + )) + if not diff_lines: + return None + + limited_lines, truncated, emitted_body_lines = _limit_unified_diff_lines( + diff_lines, + max_lines=max_lines, + max_line_chars=max_line_chars, + ) + if emitted_body_lines == 0: + return None + return { + "format": "unified", + "context": context_lines, + "truncated": truncated, + "text": "\n".join(limited_lines), + } + + +def _limit_unified_diff_lines( + diff_lines: list[str], + *, + max_lines: int, + max_line_chars: int, +) -> tuple[list[str], bool, int]: + """Limit unified diff body lines without inventing a wire-level hunk schema.""" + body_limit = max(0, int(max_lines)) + line_char_limit = max(0, int(max_line_chars)) + limited: list[str] = [] + emitted_body_lines = 0 + truncated = False + index = 0 + + while index < len(diff_lines): + line = diff_lines[index] + if not line.startswith("@@ "): + limited.append(line) + index += 1 + continue + + hunk_header = line + hunk_body: list[str] = [] + index += 1 + while index < len(diff_lines) and not diff_lines[index].startswith("@@ "): + hunk_body.append(diff_lines[index]) + index += 1 + + remaining = body_limit - emitted_body_lines + if remaining <= 0: + truncated = True + break + + selected_body = hunk_body[:remaining] + if len(selected_body) < len(hunk_body): + truncated = True + + selected_body, truncated_line = _limit_unified_diff_line_chars( + selected_body, + max_line_chars=line_char_limit, + ) + truncated = truncated or truncated_line + limited.append( + hunk_header + if len(selected_body) == len(hunk_body) + else _rewrite_hunk_header_for_body(hunk_header, selected_body) + ) + limited.extend(selected_body) + emitted_body_lines += len(selected_body) + + if truncated and emitted_body_lines >= body_limit: + break + + return limited, truncated, emitted_body_lines + + +def _limit_unified_diff_line_chars( + lines: list[str], + *, + max_line_chars: int, +) -> tuple[list[str], bool]: + if max_line_chars <= 0: + return lines, False + + limited: list[str] = [] + truncated = False + for line in lines: + if not line or line[0] not in (" ", "+", "-"): + limited.append(line) + continue + marker = line[0] + content = line[1:] + if len(content) > max_line_chars: + limited.append(f"{marker}{content[:max_line_chars]}") + truncated = True + else: + limited.append(line) + return limited, truncated + + +_HUNK_HEADER_RE = re.compile( + r"^@@ -(?P\d+)(?:,(?P\d+))? " + r"\+(?P\d+)(?:,(?P\d+))? @@(?P
.*)$" +) + + +def _rewrite_hunk_header_for_body(header: str, body: list[str]) -> str: + match = _HUNK_HEADER_RE.match(header) + if match is None: + return header + + old_lines = 0 + new_lines = 0 + for line in body: + if not line: + continue + marker = line[0] + if marker in (" ", "-"): + old_lines += 1 + if marker in (" ", "+"): + new_lines += 1 + + old_start = int(match.group("old_start")) + new_start = int(match.group("new_start")) + section = match.group("section") + return ( + f"@@ -{_format_hunk_range(old_start, old_lines)} " + f"+{_format_hunk_range(new_start, new_lines)} @@{section}" + ) + + +def _format_hunk_range(start: int, line_count: int) -> str: + return str(start) if line_count == 1 else f"{start},{line_count}" + + def _text_line_count(text: str) -> int: if not text: return 0 @@ -170,9 +300,10 @@ def prepare_file_edit_trackers( workspace: Path | None, params: dict[str, Any] | None, ) -> list[FileEditTracker]: - if not is_file_edit_tool(tool_name): + if not isinstance(params, dict) or not is_file_edit_tool(tool_name): return [] paths = resolve_file_edit_paths(tool_name, tool, workspace, params) + display_workspace = _display_workspace(tool, workspace) trackers: list[FileEditTracker] = [] seen: set[Path] = set() for path in paths: @@ -188,7 +319,7 @@ def prepare_file_edit_trackers( call_id=str(call_id or ""), tool=tool_name, path=path, - display_path=display_file_edit_path(path, workspace), + display_path=display_file_edit_path(path, display_workspace), before=before, )) return trackers @@ -200,47 +331,57 @@ def resolve_file_edit_paths( workspace: Path | None, params: dict[str, Any] | None, ) -> list[Path]: + if not isinstance(params, dict): + return [] if tool_name == "apply_patch": return _resolve_apply_patch_paths(tool, workspace, params) - path = resolve_file_edit_path(tool, workspace, params) - if path is None: + if tool_name not in {"write_file", "edit_file"}: return [] - return [path] + path = _resolve_single_path(tool, workspace, params.get("path")) + return [path] if path is not None else [] def _resolve_apply_patch_paths( tool: Any, workspace: Path | None, - params: dict[str, Any] | None, + params: dict[str, Any], ) -> list[Path]: - if not isinstance(params, dict): - return [] - edits = params.get("edits") - if not isinstance(edits, list) or not edits: - return [] if params.get("dry_run") is True: return [] - - resolved: list[Path] = [] + edits = params.get("edits") + if not isinstance(edits, list): + return [] + paths: list[Path] = [] seen: set[Path] = set() for edit in edits: if not isinstance(edit, dict): continue raw_path = edit.get("path") - if not isinstance(raw_path, str) or not raw_path.strip(): + if not isinstance(raw_path, str): continue - path = _resolve_raw_file_edit_path(tool, workspace, raw_path) + raw_path = raw_path.strip() + if not raw_path or "\0" in raw_path: + continue + path = _resolve_single_path(tool, workspace, raw_path) if path is not None and path not in seen: seen.add(path) - resolved.append(path) - return resolved + paths.append(path) + return paths -def _resolve_raw_file_edit_path( - tool: Any, - workspace: Path | None, - raw_path: str, -) -> Path | None: +def _resolve_single_path(tool: Any, workspace: Path | None, raw_path: Any) -> Path | None: + if not isinstance(raw_path, str) or not raw_path.strip(): + return None + resolver = getattr(tool, "_resolve_write", None) + if callable(resolver): + try: + resolved = resolver(raw_path) + if isinstance(resolved, Path): + return resolved + if resolved: + return Path(resolved) + except Exception: + return None resolver = getattr(tool, "_resolve", None) if callable(resolver): try: @@ -256,21 +397,30 @@ def _resolve_raw_file_edit_path( return (workspace / raw_path).expanduser().resolve() +def _display_workspace(tool: Any, fallback: Path | None) -> Path | None: + resolver = getattr(tool, "_display_workspace", None) + if callable(resolver): + try: + value = resolver() + except Exception: + return fallback + if isinstance(value, Path): + return value + if value: + return Path(value) + return fallback + + def build_file_edit_start_event( tracker: FileEditTracker, - params: dict[str, Any] | None, + params: dict[str, Any] | None = None, ) -> dict[str, Any]: - predicted_after = _predict_after_text(tracker.tool, params or {}, tracker.before) - if tracker.before.countable and predicted_after is not None: - added, deleted = line_diff_stats(tracker.before.text, predicted_after) - else: - added, deleted = 0, 0 return _event_payload( tracker, phase="start", status="editing", - added=added, - deleted=deleted, + added=0, + deleted=0, approximate=True, ) @@ -280,27 +430,39 @@ def build_file_edit_end_event( params: dict[str, Any] | None = None, ) -> dict[str, Any]: after = read_file_snapshot(tracker.path) - counted = False + diff_payload: dict[str, Any] | None = None if tracker.before.countable and after.countable: added, deleted = line_diff_stats(tracker.before.text, after.text) - counted = True + diff_payload = build_unified_diff_payload( + tracker.before.text, + after.text, + fromfile=tracker.display_path, + tofile=tracker.display_path, + ) + binary = False else: - predicted_after = _predict_after_text(tracker.tool, params or {}, tracker.before) - if tracker.before.countable and predicted_after is not None: - added, deleted = line_diff_stats(tracker.before.text, predicted_after) - counted = True - else: - added, deleted = 0, 0 - return _event_payload( + added, deleted = 0, 0 + binary = ( + tracker.before.binary + or tracker.before.oversized + or tracker.before.unreadable + or after.binary + or after.oversized + or after.unreadable + ) + payload = _event_payload( tracker, phase="end", status="done", added=added, deleted=deleted, approximate=False, - binary=(after.binary or after.oversized or after.unreadable) and not counted, + binary=binary, operation="delete" if tracker.before.exists and not after.exists else None, ) + if diff_payload is not None: + payload["diff"] = diff_payload + return payload def build_file_edit_error_event( @@ -320,596 +482,6 @@ def build_file_edit_error_event( return payload -def build_file_edit_live_event( - tracker: FileEditTracker, - *, - added: int, - deleted: int = 0, - operation: str | None = None, -) -> dict[str, Any]: - """Build an approximate in-progress event while tool-call arguments stream.""" - return _event_payload( - tracker, - phase="start", - status="editing", - added=added, - deleted=deleted, - approximate=True, - operation=operation, - ) - - -def build_file_edit_pending_event( - *, - call_id: str, - tool_name: str, - added: int = 0, - deleted: int = 0, -) -> dict[str, Any]: - """Build an early placeholder before the streamed JSON path is available.""" - return { - "version": 1, - "call_id": str(call_id or ""), - "tool": tool_name, - "path": "", - "phase": "start", - "added": max(0, int(added)), - "deleted": max(0, int(deleted)), - "approximate": True, - "status": "editing", - "pending": True, - } - - -class StreamingFileEditTracker: - """Track file-edit tool arguments while the model is still streaming them. - - Tool execution events only begin after the provider has completed the full - function call. For large ``write_file`` calls, the long wait is usually the - model producing the JSON ``content`` argument. Large ``edit_file`` calls - can have the same wait while ``old_text`` / ``new_text`` stream in. This - tracker converts those argument deltas into approximate WebUI file-edit - events before the final exact diff is available. - """ - - def __init__( - self, - *, - workspace: Path | None, - tools: Any, - emit: Callable[[list[dict[str, Any]]], Awaitable[None]], - ) -> None: - self._workspace = workspace - self._tools = tools - self._emit = emit - self._states: dict[str, _StreamingFileEditState] = {} - - async def update(self, payload: dict[str, Any]) -> None: - key = _stream_key(payload) - if not key: - return - state = self._states.get(key) - if state is None: - state = _StreamingFileEditState(key=key) - self._states[key] = state - - state.apply_delta(payload) - if state.name == "apply_patch": - await self._update_apply_patch(state) - return - if state.name not in {"write_file", "edit_file"}: - return - if state.path is None: - state.path = _extract_complete_json_string(state.arguments, "path") - if state.path is None: - added, deleted = state.live_diff_counts() - now = time.monotonic() - if state.should_emit_pending(added, deleted, now): - state.mark_pending_emitted(added, deleted, now) - await self._emit([build_file_edit_pending_event( - call_id=state.call_id or state.key, - tool_name=state.name, - added=added, - deleted=deleted, - )]) - return - if state.tracker is None: - tool = self._tools.get(state.name) if hasattr(self._tools, "get") else None - state.tracker = prepare_file_edit_tracker( - call_id=state.call_id or state.key, - tool_name=state.name, - tool=tool, - workspace=self._workspace, - params={"path": state.path}, - ) - if state.tracker is None: - return - - added, deleted = state.live_diff_counts() - now = time.monotonic() - if not state.should_emit(added, deleted, now): - return - state.mark_emitted(added, deleted, now) - await self._emit([build_file_edit_live_event( - state.tracker, - added=added, - deleted=deleted, - )]) - - async def _update_apply_patch(self, state: _StreamingFileEditState) -> None: - if _json_bool_true(state.arguments, "dry_run"): - return - tool = self._tools.get("apply_patch") if hasattr(self._tools, "get") else None - events: list[dict[str, Any]] = [] - now = time.monotonic() - - path_matches = list(re.finditer(r'"path"\s*:\s*"([^"]+)"', state.arguments)) - if not path_matches: - return - - for i, m in enumerate(path_matches): - raw_path = m.group(1) - path = _resolve_raw_file_edit_path(tool, self._workspace, raw_path) - if path is None: - continue - - segment_start = m.start() - segment_end = path_matches[i + 1].start() if i + 1 < len(path_matches) else len(state.arguments) - segment = state.arguments[segment_start:segment_end] - - action_match = re.search(r'"action"\s*:\s*"(replace|add)"', segment) - action = action_match.group(1) if action_match else "replace" - - old_text = _extract_json_string_prefix(segment, "old_text") or "" - new_text = _extract_json_string_prefix(segment, "new_text") or "" - - added = _text_line_count(new_text) if action in ("replace", "add") else 0 - deleted = _text_line_count(old_text) if action == "replace" else 0 - - file_state = state.patch_files.get(raw_path) - if file_state is None: - tracker = FileEditTracker( - call_id=state.call_id or state.key, - tool="apply_patch", - path=path, - display_path=display_file_edit_path(path, self._workspace), - before=read_file_snapshot(path), - ) - file_state = _StreamingPatchFileState(tracker=tracker) - state.patch_files[raw_path] = file_state - if not file_state.should_emit(added, deleted, now): - continue - file_state.mark_emitted(added, deleted, now) - events.append(build_file_edit_live_event( - file_state.tracker, - added=added, - deleted=deleted, - )) - if events: - await self._emit(events) - - async def flush(self) -> None: - events: list[dict[str, Any]] = [] - now = time.monotonic() - for state in self._states.values(): - for file_state in state.patch_files.values(): - added, deleted = file_state.last_added, file_state.last_deleted - if not file_state.emitted_once: - continue - if ( - file_state.last_emitted_added == added - and file_state.last_emitted_deleted == deleted - ): - continue - file_state.mark_emitted(added, deleted, now) - events.append(build_file_edit_live_event( - file_state.tracker, - added=added, - deleted=deleted, - )) - if state.tracker is None: - continue - added, deleted = state.live_diff_counts() - if ( - state.last_emitted_added == added - and state.last_emitted_deleted == deleted - and state.emitted_once - ): - continue - state.mark_emitted(added, deleted, now) - events.append(build_file_edit_live_event( - state.tracker, - added=added, - deleted=deleted, - )) - if events: - await self._emit(events) - - def apply_final_call_ids(self, final_tool_calls: list[Any]) -> None: - """Keep final start/end events keyed to any earlier streamed placeholder.""" - used_canonicals: set[str] = set() - for tool_call in final_tool_calls: - name = getattr(tool_call, "name", None) - if not is_file_edit_tool(name): - continue - canonical = self.canonical_call_id_for(tool_call) - if canonical and canonical not in used_canonicals: - try: - tool_call.id = canonical - used_canonicals.add(canonical) - except (AttributeError, TypeError): - pass - - def canonical_call_id_for(self, tool_call: Any) -> str | None: - for state in self._states.values(): - if state.matches_final_tool_call(tool_call): - return state.call_id or (state.tracker.call_id if state.tracker else None) or state.key - return None - - async def error_unmatched( - self, - final_tool_calls: list[Any], - error: str, - ) -> None: - """Mark streamed edits as failed when no final tool call will run.""" - events: list[dict[str, Any]] = [] - for state in self._states.values(): - for file_state in state.patch_files.values(): - if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls): - continue - events.append(build_file_edit_error_event(file_state.tracker, error)) - if state.tracker is None: - continue - if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls): - continue - events.append(build_file_edit_error_event(state.tracker, error)) - if events: - await self._emit(events) - - -@dataclass(slots=True) -class _StreamingJsonStringField: - key: str - scan_pos: int | None = None - closed: bool = False - escape: bool = False - unicode_remaining: int = 0 - unicode_buffer: str = "" - newline_count: int = 0 - has_chars: bool = False - last_char_newline: bool = False - last_char_cr: bool = False - - @property - def line_count(self) -> int: - if not self.has_chars: - return 0 - return self.newline_count + (0 if self.last_char_newline else 1) - - def reset(self) -> None: - self.scan_pos = None - self.closed = False - self.escape = False - self.unicode_remaining = 0 - self.unicode_buffer = "" - self.newline_count = 0 - self.has_chars = False - self.last_char_newline = False - self.last_char_cr = False - - def scan(self, source: str) -> None: - if self.closed: - return - if self.scan_pos is None: - match = re.search(rf'"{re.escape(self.key)}"\s*:\s*"', source) - if match is None: - return - self.scan_pos = match.end() - i = self.scan_pos - while i < len(source): - ch = source[i] - if self.unicode_remaining > 0: - self.unicode_buffer += ch - self.unicode_remaining -= 1 - if self.unicode_remaining == 0: - try: - decoded = chr(int(self.unicode_buffer, 16)) - except ValueError: - decoded = "x" - self.unicode_buffer = "" - self._mark_char(decoded) - i += 1 - continue - if self.escape: - self.escape = False - if ch == "u": - self.unicode_remaining = 4 - self.unicode_buffer = "" - elif ch == "n": - self._mark_char("\n") - elif ch == "r": - self._mark_char("\r") - else: - self._mark_char(ch) - i += 1 - continue - if ch == "\\": - self.escape = True - i += 1 - continue - if ch == '"': - self.closed = True - i += 1 - break - self._mark_char(ch) - i += 1 - self.scan_pos = i - - def _mark_char(self, ch: str) -> None: - self.has_chars = True - if ch == "\r": - self.newline_count += 1 - self.last_char_newline = True - self.last_char_cr = True - elif ch == "\n": - if not self.last_char_cr: - self.newline_count += 1 - self.last_char_newline = True - self.last_char_cr = False - else: - self.last_char_newline = False - self.last_char_cr = False - - -@dataclass(slots=True) -class _StreamingPatchFileState: - tracker: FileEditTracker - emitted_once: bool = False - last_emitted_added: int = -1 - last_emitted_deleted: int = -1 - last_emit_at: float = 0.0 - last_added: int = 0 - last_deleted: int = 0 - - def should_emit(self, added: int, deleted: int, now: float) -> bool: - self.last_added = added - self.last_deleted = deleted - if not self.emitted_once: - return True - if added == self.last_emitted_added and deleted == self.last_emitted_deleted: - return False - if max( - abs(added - self.last_emitted_added), - abs(deleted - self.last_emitted_deleted), - ) >= _LIVE_EMIT_LINE_STEP: - return True - return now - self.last_emit_at >= _LIVE_EMIT_INTERVAL_S - - def mark_emitted(self, added: int, deleted: int, now: float) -> None: - self.emitted_once = True - self.last_added = added - self.last_deleted = deleted - self.last_emitted_added = added - self.last_emitted_deleted = deleted - self.last_emit_at = now - - -@dataclass(slots=True) -class _StreamingFileEditState: - key: str - call_id: str = "" - name: str = "" - arguments: str = "" - path: str | None = None - tracker: FileEditTracker | None = None - content: _StreamingJsonStringField = field( - default_factory=lambda: _StreamingJsonStringField("content") - ) - old_text: _StreamingJsonStringField = field( - default_factory=lambda: _StreamingJsonStringField("old_text") - ) - new_text: _StreamingJsonStringField = field( - default_factory=lambda: _StreamingJsonStringField("new_text") - ) - patch_files: dict[str, _StreamingPatchFileState] = field(default_factory=dict) - emitted_once: bool = False - last_emitted_added: int = -1 - last_emitted_deleted: int = -1 - last_emit_at: float = 0.0 - pending_emitted: bool = False - last_pending_added: int = -1 - last_pending_deleted: int = -1 - last_pending_at: float = 0.0 - - def apply_delta(self, payload: dict[str, Any]) -> None: - call_id = payload.get("call_id") - if isinstance(call_id, str) and call_id: - self.call_id = call_id - name = payload.get("name") - if isinstance(name, str) and name: - self.name = name - args = payload.get("arguments") - if isinstance(args, str): - self.arguments = args - self.content.reset() - self.old_text.reset() - self.new_text.reset() - self.patch_files.clear() - return - delta = payload.get("arguments_delta") - if isinstance(delta, str) and delta: - self.arguments += delta - - def live_diff_counts(self) -> tuple[int, int]: - if self.name == "write_file": - self.content.scan(self.arguments) - return self.content.line_count, 0 - if self.name == "edit_file": - self.old_text.scan(self.arguments) - self.new_text.scan(self.arguments) - return self.new_text.line_count, self.old_text.line_count - return 0, 0 - - def should_emit(self, added: int, deleted: int, now: float) -> bool: - if not self.emitted_once: - return True - if added == self.last_emitted_added and deleted == self.last_emitted_deleted: - return False - if max( - abs(added - self.last_emitted_added), - abs(deleted - self.last_emitted_deleted), - ) >= _LIVE_EMIT_LINE_STEP: - return True - return now - self.last_emit_at >= _LIVE_EMIT_INTERVAL_S - - def mark_emitted(self, added: int, deleted: int, now: float) -> None: - self.emitted_once = True - self.last_emitted_added = added - self.last_emitted_deleted = deleted - self.last_emit_at = now - - def should_emit_pending(self, added: int, deleted: int, now: float) -> bool: - if not self.pending_emitted: - return True - if added == self.last_pending_added and deleted == self.last_pending_deleted: - return False - if max( - abs(added - self.last_pending_added), - abs(deleted - self.last_pending_deleted), - ) >= _LIVE_EMIT_LINE_STEP: - return True - return now - self.last_pending_at >= _LIVE_EMIT_INTERVAL_S - - def mark_pending_emitted(self, added: int, deleted: int, now: float) -> None: - self.pending_emitted = True - self.last_pending_added = added - self.last_pending_deleted = deleted - self.last_pending_at = now - - def matches_final_tool_call(self, tool_call: Any) -> bool: - call_id = getattr(tool_call, "id", None) - canonical = self.call_id or (self.tracker.call_id if self.tracker else "") - if isinstance(call_id, str) and call_id and canonical and call_id == canonical: - return True - name = getattr(tool_call, "name", None) - if name != self.name: - return False - if self.name == "apply_patch": - arguments = getattr(tool_call, "arguments", None) - if not isinstance(arguments, dict): - return False - edits = arguments.get("edits") - if not isinstance(edits, list): - return False - return '"edits"' in self.arguments - arguments = getattr(tool_call, "arguments", None) - if not isinstance(arguments, dict): - return False - path = arguments.get("path") - if self.path is None and isinstance(path, str) and path: - self.path = path - return True - return isinstance(path, str) and path == self.path - - -def _stream_key(payload: dict[str, Any]) -> str: - index = payload.get("index") - if isinstance(index, int): - return f"idx:{index}" - if isinstance(index, str) and index: - return f"idx:{index}" - call_id = payload.get("call_id") - if isinstance(call_id, str) and call_id: - return f"id:{call_id}" - return "" - - -def _json_bool_true(source: str, key: str) -> bool: - return re.search(rf'"{re.escape(key)}"\s*:\s*true\b', source) is not None - - -def _extract_json_string_prefix(source: str, key: str) -> str | None: - match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source) - if match is None: - return None - out: list[str] = [] - i = match.end() - escape = False - while i < len(source): - ch = source[i] - if escape: - escape = False - if ch == "n": - out.append("\n") - elif ch == "r": - out.append("\r") - elif ch == "t": - out.append("\t") - elif ch == "u": - digits = source[i + 1:i + 5] - if len(digits) < 4: - break - try: - out.append(chr(int(digits, 16))) - except ValueError: - break - i += 4 - else: - out.append(ch) - i += 1 - continue - if ch == "\\": - escape = True - i += 1 - continue - if ch == '"': - return "".join(out) - out.append(ch) - i += 1 - return "".join(out) - - -def _extract_complete_json_string(source: str, key: str) -> str | None: - match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source) - if match is None: - return None - out: list[str] = [] - i = match.end() - escape = False - while i < len(source): - ch = source[i] - if escape: - escape = False - if ch == "n": - out.append("\n") - elif ch == "r": - out.append("\r") - elif ch == "t": - out.append("\t") - elif ch == "u": - digits = source[i + 1:i + 5] - if len(digits) < 4: - return None - try: - out.append(chr(int(digits, 16))) - except ValueError: - return None - i += 4 - else: - out.append(ch) - i += 1 - continue - if ch == "\\": - escape = True - i += 1 - continue - if ch == '"': - return "".join(out) - out.append(ch) - i += 1 - return None - - def _event_payload( tracker: FileEditTracker, *, @@ -938,30 +510,3 @@ def _event_payload( if operation: payload["operation"] = operation return payload - - -def _predict_after_text( - tool_name: str, - params: dict[str, Any], - before: FileSnapshot, -) -> str | None: - if not before.countable: - return None - before_text = before.text or "" - if tool_name == "write_file": - content = params.get("content") - return content if isinstance(content, str) else "" - if tool_name == "edit_file": - old_text = params.get("old_text") - new_text = params.get("new_text") - if not isinstance(old_text, str) or not isinstance(new_text, str): - return None - replace_all = bool(params.get("replace_all")) - if old_text == "": - return new_text if not before.exists else before_text - if old_text in before_text: - if replace_all: - return before_text.replace(old_text, new_text) - return before_text.replace(old_text, new_text, 1) - return None - return None diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index a1b4dec1..3e713cb9 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -1525,16 +1525,50 @@ def replay_transcript_to_ui_messages( ) ): return i - existing_tool_events = candidate.get("toolEvents") - if isinstance(existing_tool_events, list): - for event in existing_tool_events: - if not isinstance(event, dict): - continue - key = _tool_event_file_edit_key(event) - if key and key in incoming_tool_event_keys: - return i return None + def trace_message_is_empty(message: dict[str, Any]) -> bool: + traces = message.get("traces") + if isinstance(traces, list): + has_trace = any(isinstance(trace, str) and trace.strip() for trace in traces) + else: + has_trace = bool(str(message.get("content") or "").strip()) + return ( + message.get("kind") == "trace" + and not has_trace + and not message.get("toolEvents") + and not message.get("fileEdits") + and not message.get("media") + ) + + def strip_covered_file_edit_tool_hints_from_recent_messages( + edits: list[dict[str, Any]], + turn_fields: dict[str, Any], + ) -> None: + nonlocal messages + if not edits: + return + next_messages = list(messages) + changed = False + for i in range(len(next_messages) - 1, -1, -1): + candidate = next_messages[i] + if candidate.get("role") == "user": + break + if candidate.get("kind") != "trace": + continue + if not _same_turn(candidate, turn_fields): + continue + cleaned = _strip_covered_file_edit_tool_hints(candidate, edits) + if cleaned is candidate: + continue + changed = True + if trace_message_is_empty(cleaned): + next_messages.pop(i) + else: + next_messages[i] = cleaned + if changed: + messages = next_messages + def upsert_file_edits( edits: list[dict[str, Any]], idx: int, @@ -1549,12 +1583,12 @@ def replay_transcript_to_ui_messages( segment = _new_activity_segment(activate=False) active_file_edit_segment_id = segment demote_interrupted_assistant(segment) + strip_covered_file_edit_tool_hints_from_recent_messages(edits, turn_fields) target_index = find_file_edit_trace_index(segment, edits) if target_index is not None: last = messages[target_index] segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False)) active_file_edit_segment_id = segment - last = _strip_covered_file_edit_tool_hints(last, edits) else: if not segment: segment = _new_activity_segment(activate=False) diff --git a/tests/agent/test_hook_composite.py b/tests/agent/test_hook_composite.py index be24505a..e0720e1b 100644 --- a/tests/agent/test_hook_composite.py +++ b/tests/agent/test_hook_composite.py @@ -73,6 +73,9 @@ async def test_composite_fans_out_all_async_methods(): async def emit_reasoning(self, reasoning_content: str | None) -> None: events.append(f"emit_reasoning:{reasoning_content}") + async def emit_reasoning_end(self) -> None: + events.append("emit_reasoning_end") + async def on_stream(self, context: AgentHookContext, delta: str) -> None: events.append(f"on_stream:{delta}") @@ -82,6 +85,15 @@ async def test_composite_fans_out_all_async_methods(): async def before_execute_tools(self, context: AgentHookContext) -> None: events.append("before_execute_tools") + async def before_execute_tool(self, context, tool_call, tool, params) -> None: + events.append("before_execute_tool") + + async def after_execute_tool(self, context, tool_call, tool, params, result) -> None: + events.append("after_execute_tool") + + async def on_execute_tool_error(self, context, tool_call, tool, params, error) -> None: + events.append("on_execute_tool_error") + async def after_iteration(self, context: AgentHookContext) -> None: events.append("after_iteration") @@ -101,9 +113,13 @@ async def test_composite_fans_out_all_async_methods(): await hook.before_run(run_ctx) await hook.before_iteration(ctx) await hook.emit_reasoning("thinking...") + await hook.emit_reasoning_end() await hook.on_stream(ctx, "hi") await hook.on_stream_end(ctx, resuming=True) await hook.before_execute_tools(ctx) + await hook.before_execute_tool(ctx, object(), object(), {}) + await hook.after_execute_tool(ctx, object(), object(), {}, "ok") + await hook.on_execute_tool_error(ctx, object(), object(), {}, "err") await hook.after_iteration(ctx) await hook.after_run(run_ctx) await hook.on_error(run_ctx) @@ -113,9 +129,13 @@ async def test_composite_fans_out_all_async_methods(): "before_run", "before_run", "before_iteration", "before_iteration", "emit_reasoning:thinking...", "emit_reasoning:thinking...", + "emit_reasoning_end", "emit_reasoning_end", "on_stream:hi", "on_stream:hi", "on_stream_end:True", "on_stream_end:True", "before_execute_tools", "before_execute_tools", + "before_execute_tool", "before_execute_tool", + "after_execute_tool", "after_execute_tool", + "on_execute_tool_error", "on_execute_tool_error", "after_iteration", "after_iteration", "after_run", "after_run", "on_error", "on_error", @@ -172,10 +192,20 @@ async def test_composite_error_isolation_all_async(): raise RuntimeError("err") async def emit_reasoning(self, reasoning_content): raise RuntimeError("err") + async def emit_reasoning_end(self): + raise RuntimeError("err") + async def on_stream(self, context, delta): + raise RuntimeError("err") async def on_stream_end(self, context, *, resuming): raise RuntimeError("err") async def before_execute_tools(self, context): raise RuntimeError("err") + async def before_execute_tool(self, context, tool_call, tool, params): + raise RuntimeError("err") + async def after_execute_tool(self, context, tool_call, tool, params, result): + raise RuntimeError("err") + async def on_execute_tool_error(self, context, tool_call, tool, params, error): + raise RuntimeError("err") async def after_iteration(self, context): raise RuntimeError("err") async def after_run(self, context): @@ -190,10 +220,20 @@ async def test_composite_error_isolation_all_async(): calls.append("before_run") async def emit_reasoning(self, reasoning_content): calls.append("emit_reasoning") + async def emit_reasoning_end(self): + calls.append("emit_reasoning_end") + async def on_stream(self, context, delta): + calls.append("on_stream") async def on_stream_end(self, context, *, resuming): calls.append("on_stream_end") async def before_execute_tools(self, context): calls.append("before_execute_tools") + async def before_execute_tool(self, context, tool_call, tool, params): + calls.append("before_execute_tool") + async def after_execute_tool(self, context, tool_call, tool, params, result): + calls.append("after_execute_tool") + async def on_execute_tool_error(self, context, tool_call, tool, params, error): + calls.append("on_execute_tool_error") async def after_iteration(self, context): calls.append("after_iteration") async def after_run(self, context): @@ -208,8 +248,13 @@ async def test_composite_error_isolation_all_async(): run_ctx = _run_ctx() await hook.before_run(run_ctx) await hook.emit_reasoning("test") + await hook.emit_reasoning_end() + await hook.on_stream(ctx, "delta") await hook.on_stream_end(ctx, resuming=False) await hook.before_execute_tools(ctx) + await hook.before_execute_tool(ctx, object(), object(), {}) + await hook.after_execute_tool(ctx, object(), object(), {}, "ok") + await hook.on_execute_tool_error(ctx, object(), object(), {}, "err") await hook.after_iteration(ctx) await hook.after_run(run_ctx) await hook.on_error(run_ctx) @@ -217,8 +262,13 @@ async def test_composite_error_isolation_all_async(): assert calls == [ "before_run", "emit_reasoning", + "emit_reasoning_end", + "on_stream", "on_stream_end", "before_execute_tools", + "before_execute_tool", + "after_execute_tool", + "on_execute_tool_error", "after_iteration", "after_run", "on_error", @@ -313,6 +363,9 @@ async def test_composite_empty_hooks_no_ops(): await hook.on_stream(ctx, "delta") await hook.on_stream_end(ctx, resuming=False) await hook.before_execute_tools(ctx) + await hook.before_execute_tool(ctx, object(), object(), {}) + await hook.after_execute_tool(ctx, object(), object(), {}, None) + await hook.on_execute_tool_error(ctx, object(), object(), {}, "err") await hook.after_iteration(ctx) await hook.after_run(run_ctx) await hook.on_error(run_ctx) diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index 22b62318..54ef783e 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -6,8 +6,9 @@ from unittest.mock import AsyncMock, MagicMock import pytest -import nanobot.agent.runner as runner_module +from nanobot.agent.hooks import create_file_edit_activity_hook from nanobot.agent.loop import AgentLoop +from nanobot.agent.tools.filesystem import WriteFileTool from nanobot.bus.events import InboundMessage from nanobot.bus.outbound_events import ( GoalStatusEvent, @@ -31,7 +32,13 @@ def _make_loop(tmp_path: Path) -> AgentLoop: bus = MessageBus() provider = MagicMock() provider.get_default_model.return_value = "test-model" - return AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + return AgentLoop( + bus=bus, + provider=provider, + workspace=tmp_path, + model="test-model", + hook_factories=[create_file_edit_activity_hook], + ) def _attach_webui_runtime_events(loop: AgentLoop, bus: MessageBus) -> None: @@ -122,15 +129,10 @@ class TestToolEventProgress: ]) loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls)) loop.tools.get_definitions = MagicMock(return_value=[]) + tool = WriteFileTool(workspace=tmp_path) loop.tools.prepare_call = MagicMock( - return_value=(None, {"path": "foo.txt", "content": "new\nextra\n"}, None), + return_value=(tool, {"path": "foo.txt", "content": "new\nextra\n"}, None), ) - - async def execute(name: str, params: dict) -> str: - target.write_text(params["content"], encoding="utf-8") - return "ok" - - loop.tools.execute = AsyncMock(side_effect=execute) file_events: list[dict] = [] async def on_progress( @@ -154,14 +156,15 @@ class TestToolEventProgress: "path": "foo.txt", "absolute_path": (tmp_path / "foo.txt").resolve().as_posix(), "phase": "start", - "added": 2, - "deleted": 1, + "added": 0, + "deleted": 0, "approximate": True, "status": "editing", } assert file_events[1]["status"] == "done" assert file_events[1]["approximate"] is False assert (file_events[1]["added"], file_events[1]["deleted"]) == (2, 1) + assert file_events[1]["diff"]["format"] == "unified" @pytest.mark.asyncio async def test_file_edit_snapshot_skipped_when_progress_callback_cannot_emit_file_edits( @@ -172,6 +175,16 @@ class TestToolEventProgress: loop = _make_loop(tmp_path) target = tmp_path / "foo.txt" target.write_text("old\n", encoding="utf-8") + prepare_file_edit_trackers = MagicMock() + + class ObservableWriteTool: + name = "write_file" + + async def execute(self, path: str, content: str) -> str: + target.write_text(content, encoding="utf-8") + return "ok" + + tool = ObservableWriteTool() tool_call = ToolCallRequest( id="call-write", name="write_file", @@ -184,17 +197,9 @@ class TestToolEventProgress: loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls)) loop.tools.get_definitions = MagicMock(return_value=[]) loop.tools.prepare_call = MagicMock( - return_value=(None, {"path": "foo.txt", "content": "new\n"}, None), + return_value=(tool, {"path": "foo.txt", "content": "new\n"}, None), ) - async def execute(name: str, params: dict) -> str: - target.write_text(params["content"], encoding="utf-8") - return "ok" - - loop.tools.execute = AsyncMock(side_effect=execute) - prepare_tracker = MagicMock(side_effect=AssertionError("unexpected file snapshot")) - monkeypatch.setattr(runner_module, "prepare_file_edit_tracker", prepare_tracker) - async def on_progress( content: str, *, @@ -203,11 +208,16 @@ class TestToolEventProgress: ) -> None: pass + monkeypatch.setattr( + "nanobot.agent.hooks.file_edit_activity.prepare_file_edit_trackers", + prepare_file_edit_trackers, + ) + final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress) assert final_content == "Done" assert target.read_text(encoding="utf-8") == "new\n" - prepare_tracker.assert_not_called() + prepare_file_edit_trackers.assert_not_called() @pytest.mark.asyncio async def test_exec_does_not_emit_file_edit_progress(self, tmp_path: Path) -> None: @@ -342,31 +352,18 @@ class TestToolEventProgress: assert outbound.event.file_edit_events == edit_events @pytest.mark.asyncio - async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None: + async def test_goal_turn_keeps_file_edit_progress_for_webui(self, tmp_path: Path) -> None: """The /goal command rewrites the prompt but must not bypass WebUI file-edit progress.""" bus = MessageBus() provider = MagicMock() provider.supports_progress_deltas = True provider.get_default_model.return_value = "test-model" call_count = 0 - target = tmp_path / "goal.txt" - async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs): + async def chat_stream_with_retry(**kwargs): nonlocal call_count call_count += 1 if call_count == 1: - assert on_tool_call_delta is not None - await on_tool_call_delta({ - "index": 0, - "call_id": "call-goal-write", - "name": "write_file", - "arguments_delta": '{"path":"goal.txt","content":"', - }) - await on_tool_call_delta({ - "index": 0, - "arguments_delta": "one\\ntwo\\nthree\\n", - }) - await on_tool_call_delta({"index": 0, "arguments_delta": '"}'}) return LLMResponse( content=None, tool_calls=[ @@ -383,25 +380,26 @@ class TestToolEventProgress: ) return LLMResponse(content="Done", tool_calls=[], usage={}) - async def execute(name: str, params: dict) -> str: - assert name == "write_file" - target.write_text(params["content"], encoding="utf-8") - return "ok" - provider.chat_stream_with_retry = chat_stream_with_retry provider.chat_with_retry = AsyncMock() - loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + loop = AgentLoop( + bus=bus, + provider=provider, + workspace=tmp_path, + model="test-model", + hook_factories=[create_file_edit_activity_hook], + ) + tool = WriteFileTool(workspace=tmp_path) loop.tools.get_definitions = MagicMock(return_value=[ {"type": "function", "function": {"name": "write_file"}}, ]) loop.tools.prepare_call = MagicMock( return_value=( - None, + tool, {"path": "goal.txt", "content": "one\ntwo\nthree\n"}, None, ), ) - loop.tools.execute = AsyncMock(side_effect=execute) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] await loop._dispatch(InboundMessage( @@ -425,13 +423,14 @@ class TestToolEventProgress: assert any( event["status"] == "editing" and event["approximate"] - and event["added"] == 3 + and event["added"] == 0 for event in edit_events ) assert any( event["status"] == "done" and not event["approximate"] and event["added"] == 3 + and event.get("diff", {}).get("format") == "unified" for event in edit_events ) provider.chat_with_retry.assert_not_awaited() diff --git a/tests/agent/test_runner_hooks.py b/tests/agent/test_runner_hooks.py index 7eb8c249..a61157d6 100644 --- a/tests/agent/test_runner_hooks.py +++ b/tests/agent/test_runner_hooks.py @@ -47,6 +47,12 @@ async def test_runner_calls_hooks_in_order(): [tc.name for tc in context.tool_calls], )) + async def before_execute_tool(self, context, tool_call, tool, params) -> None: + events.append(("before_execute_tool", context.iteration, tool_call.name, params)) + + async def after_execute_tool(self, context, tool_call, tool, params, result) -> None: + events.append(("after_execute_tool", context.iteration, tool_call.name, result)) + async def after_iteration(self, context: AgentHookContext) -> None: events.append(( "after_iteration", @@ -75,6 +81,8 @@ async def test_runner_calls_hooks_in_order(): assert events == [ ("before_iteration", 0), ("before_execute_tools", 0, ["list_dir"]), + ("before_execute_tool", 0, "list_dir", {"path": "."}), + ("after_execute_tool", 0, "list_dir", "tool result"), ( "after_iteration", 0, diff --git a/tests/agent/test_runner_progress_deltas.py b/tests/agent/test_runner_progress_deltas.py index 27a85ab8..a57b3710 100644 --- a/tests/agent/test_runner_progress_deltas.py +++ b/tests/agent/test_runner_progress_deltas.py @@ -4,7 +4,9 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from nanobot.agent.hooks import FileEditActivityHook from nanobot.agent.runner import AgentRunner, AgentRunSpec +from nanobot.agent.tools.filesystem import EditFileTool, WriteFileTool from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMResponse, ToolCallRequest @@ -80,42 +82,30 @@ async def test_runner_streams_provider_progress_deltas_by_default(): @pytest.mark.asyncio -async def test_runner_streams_live_write_file_activity_from_tool_argument_deltas(tmp_path): +async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_path): provider = MagicMock() provider.supports_progress_deltas = True call_count = 0 progress_events: list[dict] = [] + (tmp_path / "big.txt").write_text("old\n", encoding="utf-8") async def progress_cb(content, *, file_edit_events=None, **kwargs): if file_edit_events: progress_events.extend(file_edit_events) + tool = WriteFileTool(workspace=tmp_path) + class Tools: def get_definitions(self): return [{"type": "function", "function": {"name": "write_file"}}] - def get(self, name): - return None + def prepare_call(self, name, params): + return tool, params, None - async def execute(self, name, params): - assert name == "write_file" - assert any(event["approximate"] and event["added"] == 24 for event in progress_events) - target = tmp_path / params["path"] - target.write_text(params["content"], encoding="utf-8") - return "ok" - - async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs): + async def chat_stream_with_retry(**kwargs): nonlocal call_count call_count += 1 if call_count == 1: - assert on_tool_call_delta is not None - await on_tool_call_delta({ - "index": 0, - "call_id": "call-write", - "name": "write_file", - "arguments_delta": '{"path":"big.txt","content":"', - }) - await on_tool_call_delta({"index": 0, "arguments_delta": "line\\n" * 24}) return LLMResponse( content=None, tool_calls=[ @@ -131,29 +121,37 @@ async def test_runner_streams_live_write_file_activity_from_tool_argument_deltas provider.chat_stream_with_retry = chat_stream_with_retry provider.chat_with_retry = AsyncMock() + tools = Tools() runner = AgentRunner(provider) result = await runner.run(AgentRunSpec( initial_messages=[{"role": "user", "content": "write a large file"}], - tools=Tools(), + tools=tools, model="test-model", max_iterations=2, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, progress_callback=progress_cb, workspace=tmp_path, + hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path), )) assert result.final_content == "done" - assert any(event["approximate"] and event["added"] == 24 for event in progress_events) + assert progress_events[0]["phase"] == "start" + assert progress_events[0]["added"] == 0 + assert progress_events[0]["deleted"] == 0 assert any( - not event["approximate"] and event["phase"] == "end" and event["added"] == 24 + not event["approximate"] + and event["phase"] == "end" + and event["added"] == 24 + and event["deleted"] == 1 + and event["diff"]["format"] == "unified" for event in progress_events ) provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio -async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas(tmp_path): +async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_path): provider = MagicMock() provider.supports_progress_deltas = True call_count = 0 @@ -165,43 +163,19 @@ async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas( if file_edit_events: progress_events.extend(file_edit_events) + tool = EditFileTool(workspace=tmp_path) + class Tools: def get_definitions(self): return [{"type": "function", "function": {"name": "edit_file"}}] - def get(self, name): - return None + def prepare_call(self, name, params): + return tool, params, None - async def execute(self, name, params): - assert name == "edit_file" - assert any( - event["tool"] == "edit_file" - and event["approximate"] - and event["added"] == 3 - and event["deleted"] == 2 - for event in progress_events - ) - target.write_text(params["new_text"], encoding="utf-8") - return "ok" - - async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs): + async def chat_stream_with_retry(**kwargs): nonlocal call_count call_count += 1 if call_count == 1: - assert on_tool_call_delta is not None - await on_tool_call_delta({ - "index": 0, - "call_id": "call-edit", - "name": "edit_file", - "arguments_delta": ( - '{"path":"notes.txt","old_text":"old\\nkeep\\n","new_text":"' - ), - }) - await on_tool_call_delta({ - "index": 0, - "arguments_delta": "new\\nkeep\\nextra\\n", - }) - await on_tool_call_delta({"index": 0, "arguments_delta": '"}'}) return LLMResponse( content=None, tool_calls=[ @@ -221,75 +195,87 @@ async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas( provider.chat_stream_with_retry = chat_stream_with_retry provider.chat_with_retry = AsyncMock() + tools = Tools() runner = AgentRunner(provider) result = await runner.run(AgentRunSpec( initial_messages=[{"role": "user", "content": "edit a file"}], - tools=Tools(), + tools=tools, model="test-model", max_iterations=2, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, progress_callback=progress_cb, workspace=tmp_path, + hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path), )) assert result.final_content == "done" - assert any( - event["tool"] == "edit_file" - and event["approximate"] - and event["added"] == 3 - and event["deleted"] == 2 - for event in progress_events - ) assert any( event["tool"] == "edit_file" and not event["approximate"] and event["phase"] == "end" and event["added"] == 2 and event["deleted"] == 1 + and event["diff"]["format"] == "unified" for event in progress_events ) provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio -async def test_runner_marks_unfinished_live_write_file_activity_failed(tmp_path): +async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path): provider = MagicMock() provider.supports_progress_deltas = True + call_count = 0 progress_events: list[dict] = [] async def progress_cb(content, *, file_edit_events=None, **kwargs): if file_edit_events: progress_events.extend(file_edit_events) - async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs): - assert on_tool_call_delta is not None - await on_tool_call_delta({ - "index": 0, - "call_id": "call-write", - "name": "write_file", - "arguments_delta": '{"path":"aborted.txt","content":"partial\\n', - }) - return LLMResponse(content="stopped", tool_calls=[], finish_reason="stop", usage={}) + tool = WriteFileTool(workspace=tmp_path) + + class Tools: + def get_definitions(self): + return [{"type": "function", "function": {"name": "write_file"}}] + + def prepare_call(self, name, params): + return tool, params, None + + async def chat_stream_with_retry(**kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call-write", + name="write_file", + arguments={"path": "aborted.txt"}, + ) + ], + usage={}, + ) + return LLMResponse(content="done", tool_calls=[], usage={}) provider.chat_stream_with_retry = chat_stream_with_retry provider.chat_with_retry = AsyncMock() - tools = MagicMock() - tools.get_definitions.return_value = [{"type": "function", "function": {"name": "write_file"}}] - tools.get.return_value = None + tools = Tools() runner = AgentRunner(provider) result = await runner.run(AgentRunSpec( - initial_messages=[{"role": "user", "content": "write a large file"}], + initial_messages=[{"role": "user", "content": "write a file"}], tools=tools, model="test-model", - max_iterations=1, + max_iterations=2, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, progress_callback=progress_cb, workspace=tmp_path, + hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path), )) - assert result.final_content == "stopped" + assert result.stop_reason == "completed" assert progress_events[-1]["path"] == "aborted.txt" assert progress_events[-1]["phase"] == "error" assert progress_events[-1]["status"] == "error" diff --git a/tests/utils/test_file_edit_events.py b/tests/utils/test_file_edit_events.py index 39759aca..dae8dec5 100644 --- a/tests/utils/test_file_edit_events.py +++ b/tests/utils/test_file_edit_events.py @@ -1,13 +1,13 @@ from __future__ import annotations -import asyncio from pathlib import Path -from types import SimpleNamespace +from nanobot.agent.tools.apply_patch import ApplyPatchTool +from nanobot.agent.tools.filesystem import EditFileTool, WriteFileTool from nanobot.utils.file_edit_events import ( - StreamingFileEditTracker, build_file_edit_end_event, build_file_edit_start_event, + build_unified_diff_payload, line_diff_stats, prepare_file_edit_tracker, prepare_file_edit_trackers, @@ -15,6 +15,18 @@ from nanobot.utils.file_edit_events import ( ) +def _write_tool(workspace: Path) -> WriteFileTool: + return WriteFileTool(workspace=workspace) + + +def _edit_tool(workspace: Path) -> EditFileTool: + return EditFileTool(workspace=workspace) + + +def _patch_tool(workspace: Path) -> ApplyPatchTool: + return ApplyPatchTool(workspace=workspace) + + def test_line_diff_stats_counts_replacements_insertions_and_deletions() -> None: added, deleted = line_diff_stats("a\nb\nc\n", "a\nB\nc\nd\n") assert (added, deleted) == (2, 1) @@ -28,20 +40,20 @@ def test_line_diff_stats_counts_new_file_crlf_lines_once() -> None: assert line_diff_stats("", "a\r\nb\r\n") == (2, 0) -def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) -> None: +def test_write_file_start_tracks_snapshot_and_end_emits_exact_diff(tmp_path: Path) -> None: 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( call_id="call-write", tool_name="write_file", - tool=None, + tool=_write_tool(tmp_path), workspace=tmp_path, params=params, ) assert tracker is not None - start = build_file_edit_start_event(tracker, params) + start = build_file_edit_start_event(tracker) assert start == { "version": 1, "call_id": "call-write", @@ -49,8 +61,8 @@ def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) "path": "notes.txt", "absolute_path": (tmp_path / "notes.txt").resolve().as_posix(), "phase": "start", - "added": 2, - "deleted": 1, + "added": 0, + "deleted": 0, "approximate": True, "status": "editing", } @@ -61,6 +73,31 @@ def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) assert end["status"] == "done" assert end["approximate"] is False assert (end["added"], end["deleted"]) == (2, 1) + assert end["diff"]["format"] == "unified" + assert "hunks" not in end["diff"] + diff_text = end["diff"]["text"] + assert "--- notes.txt" in diff_text + assert "+++ notes.txt" in diff_text + assert "@@ " in diff_text + assert "-old" in diff_text + assert "+new" in diff_text + assert "+extra" in diff_text + + +def test_unified_diff_payload_truncates_large_diffs() -> None: + before = "\n".join(f"old {i}" for i in range(12)) + after = "\n".join(f"new {i}" for i in range(12)) + + diff = build_unified_diff_payload(before, after, context_lines=0, max_lines=5) + + assert diff is not None + assert diff["truncated"] is True + assert "hunks" not in diff + body_lines = [ + line for line in diff["text"].splitlines() + if line.startswith((" ", "+", "-")) and not line.startswith(("+++", "---")) + ] + assert len(body_lines) == 5 def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None: @@ -69,7 +106,7 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None: tracker = prepare_file_edit_tracker( call_id="call-bin", tool_name="edit_file", - tool=None, + tool=_edit_tool(tmp_path), workspace=tmp_path, params={"path": "data.bin", "old_text": "before", "new_text": "after"}, ) @@ -80,6 +117,26 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None: event = build_file_edit_end_event(tracker) assert event["binary"] is True assert (event["added"], event["deleted"]) == (0, 0) + assert "diff" not in event + + +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( + 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 + target.write_text("after\n", encoding="utf-8") + event = build_file_edit_end_event(tracker) + assert event["binary"] is True + assert (event["added"], event["deleted"]) == (0, 0) + assert "diff" not in event def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) -> None: @@ -95,7 +152,7 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) -> trackers = prepare_file_edit_trackers( call_id="call-patch", tool_name="apply_patch", - tool=None, + tool=_patch_tool(tmp_path), workspace=tmp_path, params={"edits": edits}, ) @@ -108,10 +165,32 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) -> (tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8") existing.write_text("new\nkeep\n", encoding="utf-8") - events = [build_file_edit_end_event(tracker, {"edits": edits}) for tracker in trackers] + events = [build_file_edit_end_event(tracker) for tracker in trackers] by_path = {event["path"]: event for event in events} assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0) assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1) + assert by_path["src/new.py"]["diff"]["format"] == "unified" + assert by_path["src/existing.py"]["diff"]["format"] == "unified" + + +def test_apply_patch_trackers_use_normalized_patch_paths(tmp_path: Path) -> None: + (tmp_path / "file.txt").write_text("old\n", encoding="utf-8") + + trackers = prepare_file_edit_trackers( + call_id="call-patch", + tool_name="apply_patch", + tool=_patch_tool(tmp_path), + workspace=tmp_path, + params={ + "edits": [ + {"path": " file.txt ", "action": "replace", "old_text": "old", "new_text": "new"}, + {"path": "bad\0.txt", "action": "add", "new_text": "ignored"}, + ], + }, + ) + + assert [tracker.display_path for tracker in trackers] == ["file.txt"] + assert trackers[0].path == (tmp_path / "file.txt").resolve() def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) -> None: @@ -120,7 +199,7 @@ def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) trackers = prepare_file_edit_trackers( call_id="call-patch", tool_name="apply_patch", - tool=None, + tool=_patch_tool(tmp_path), workspace=tmp_path, params={ "dry_run": True, @@ -133,429 +212,24 @@ def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) assert trackers == [] -def test_oversized_write_file_end_uses_known_content_for_exact_count(tmp_path: Path) -> None: +def test_oversized_file_is_reported_but_not_counted(tmp_path: Path) -> None: target = tmp_path / "large.txt" - params = {"path": "large.txt", "content": "x" * (2 * 1024 * 1024 + 1)} + params = {"path": "large.txt", "content": "x"} tracker = prepare_file_edit_tracker( call_id="call-large", tool_name="write_file", - tool=None, + tool=_write_tool(tmp_path), workspace=tmp_path, params=params, ) assert tracker is not None - target.write_text(params["content"], encoding="utf-8") - event = build_file_edit_end_event(tracker, params) - assert event.get("binary") is not True - assert event["added"] == 1 + target.write_text("x" * (2 * 1024 * 1024 + 1), encoding="utf-8") + event = build_file_edit_end_event(tracker) + assert event["binary"] is True + assert event["added"] == 0 assert event["deleted"] == 0 - - -def test_streaming_write_file_tracker_emits_live_line_counts(tmp_path: Path) -> None: - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "call_id": "call-live", - "name": "write_file", - "arguments_delta": '{"path":"notes.md","content":"', - }) - await tracker.update({ - "index": 0, - "arguments_delta": "line\\n" * 24, - }) - - asyncio.run(run()) - - assert events[0] == { - "version": 1, - "call_id": "call-live", - "tool": "write_file", - "path": "notes.md", - "absolute_path": (tmp_path / "notes.md").resolve().as_posix(), - "phase": "start", - "added": 0, - "deleted": 0, - "approximate": True, - "status": "editing", - } - assert events[-1]["path"] == "notes.md" - assert events[-1]["status"] == "editing" - assert events[-1]["approximate"] is True - assert events[-1]["added"] == 24 - assert events[-1]["deleted"] == 0 - - -def test_streaming_apply_patch_tracker_emits_live_counts_per_file(tmp_path: Path) -> None: - (tmp_path / "src").mkdir() - (tmp_path / "src" / "existing.py").write_text("old\nkeep\n", encoding="utf-8") - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "call_id": "call-patch", - "name": "apply_patch", - "arguments_delta": ( - '{"edits":[{"path":"src/existing.py","action":"replace","old_text":"old","new_text":"new"}' - ',{"path":"src/new.py","action":"add","new_text":"fresh"}]}' - ), - }) - - asyncio.run(run()) - - by_path = {event["path"]: event for event in events} - assert by_path["src/existing.py"]["tool"] == "apply_patch" - assert by_path["src/existing.py"]["status"] == "editing" - assert by_path["src/existing.py"]["approximate"] is True - assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1) - assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0) - - -def test_streaming_apply_patch_tracker_skips_dry_run(tmp_path: Path) -> None: - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "call_id": "call-patch", - "name": "apply_patch", - "arguments_delta": ( - '{"dry_run":true,"edits":[{"path":"dry.md","action":"add","new_text":"preview"}]}' - ), - }) - - asyncio.run(run()) - - assert events == [] - - -def test_streaming_write_file_tracker_emits_pending_before_path(tmp_path: Path) -> None: - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "call_id": "call-live", - "name": "write_file", - "arguments_delta": '{"content":"line\\n', - }) - await tracker.update({ - "index": 0, - "arguments_delta": 'more\\n","path":"late.md"', - }) - - asyncio.run(run()) - - assert events[0] == { - "version": 1, - "call_id": "call-live", - "tool": "write_file", - "path": "", - "phase": "start", - "added": 1, - "deleted": 0, - "approximate": True, - "status": "editing", - "pending": True, - } - assert events[-1]["path"] == "late.md" - assert events[-1].get("pending") is not True - assert events[-1]["added"] == 2 - - -def test_streaming_write_file_tracker_flushes_small_pending_count(tmp_path: Path) -> None: - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "call_id": "call-live", - "name": "write_file", - "arguments_delta": '{"path":"small.md","content":"one\\n', - }) - await tracker.flush() - - asyncio.run(run()) - assert events - assert events[-1]["path"] == "small.md" - assert events[-1]["added"] == 1 - - -def test_streaming_write_file_tracker_normalizes_crlf_line_counts(tmp_path: Path) -> None: - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "call_id": "call-live", - "name": "write_file", - "arguments_delta": '{"path":"windows.txt","content":"one\\r\\ntwo\\r\\n', - }) - await tracker.flush() - - asyncio.run(run()) - assert events[-1]["path"] == "windows.txt" - assert events[-1]["added"] == 2 - - -def test_streaming_write_file_tracker_counts_unicode_escaped_newlines(tmp_path: Path) -> None: - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "call_id": "call-live", - "name": "write_file", - "arguments_delta": '{"path":"unicode.txt","content":"one\\u000atwo', - }) - await tracker.flush() - - asyncio.run(run()) - assert events[-1]["path"] == "unicode.txt" - assert events[-1]["added"] == 2 - - -def test_streaming_edit_file_tracker_emits_live_line_counts(tmp_path: Path) -> None: - target = tmp_path / "notes.md" - target.write_text("old\nkeep\n", encoding="utf-8") - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "call_id": "call-edit", - "name": "edit_file", - "arguments_delta": '{"path":"notes.md","old_text":"old\\nkeep","new_text":"', - }) - await tracker.update({ - "index": 0, - "arguments_delta": "new\\nkeep\\nextra\\n" * 8, - }) - - asyncio.run(run()) - - assert events[0] == { - "version": 1, - "call_id": "call-edit", - "tool": "edit_file", - "path": "notes.md", - "absolute_path": (tmp_path / "notes.md").resolve().as_posix(), - "phase": "start", - "added": 0, - "deleted": 2, - "approximate": True, - "status": "editing", - } - assert events[-1]["path"] == "notes.md" - assert events[-1]["status"] == "editing" - assert events[-1]["approximate"] is True - assert events[-1]["added"] == 24 - assert events[-1]["deleted"] == 2 - - -def test_streaming_tracker_applies_canonical_call_id_to_final_tool(tmp_path: Path) -> None: - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "name": "write_file", - "arguments_delta": '{"path":"matched.md","content":"one\\n', - }) - final = SimpleNamespace( - id="provider-final-id", - name="write_file", - arguments={"path": "matched.md", "content": "one\n"}, - ) - tracker.apply_final_call_ids([final]) - assert final.id == "idx:0" - - asyncio.run(run()) - - -def test_streaming_tracker_does_not_remap_non_file_edit_final_tool(tmp_path: Path) -> None: - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "name": "read_file", - "arguments_delta": '{"path":"matched.md"}', - }) - await tracker.update({ - "index": 1, - "name": "write_file", - "arguments_delta": '{"path":"matched.md","content":"one\\n', - }) - read_final = SimpleNamespace( - id="read-unique", - name="read_file", - arguments={"path": "matched.md"}, - ) - write_final = SimpleNamespace( - id="write-final", - name="write_file", - arguments={"path": "matched.md", "content": "one\n"}, - ) - tracker.apply_final_call_ids([read_final, write_final]) - assert read_final.id == "read-unique" - assert write_final.id == "idx:1" - - asyncio.run(run()) - - -def test_streaming_tracker_does_not_restore_duplicate_canonical_ids(tmp_path: Path) -> None: - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "call_id": "call_dup", - "name": "write_file", - "arguments_delta": '{"path":"a.md","content":"one\\n"}', - }) - await tracker.update({ - "index": 1, - "call_id": "call_dup", - "name": "write_file", - "arguments_delta": '{"path":"b.md","content":"two\\n"}', - }) - final_a = SimpleNamespace( - id="call_dup", - name="write_file", - arguments={"path": "a.md", "content": "one\n"}, - ) - final_b = SimpleNamespace( - id="call_unique", - name="write_file", - arguments={"path": "b.md", "content": "two\n"}, - ) - tracker.apply_final_call_ids([final_a, final_b]) - assert final_a.id == "call_dup" - assert final_b.id == "call_unique" - - asyncio.run(run()) - - -def test_streaming_edit_file_tracker_flushes_small_pending_count(tmp_path: Path) -> None: - target = tmp_path / "small.py" - target.write_text("old\n", encoding="utf-8") - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "call_id": "call-edit", - "name": "edit_file", - "arguments_delta": '{"path":"small.py","old_text":"old\\n","new_text":"new\\nextra', - }) - await tracker.flush() - - asyncio.run(run()) - assert events - assert events[-1]["path"] == "small.py" - assert events[-1]["added"] == 2 - assert events[-1]["deleted"] == 1 - - -def test_streaming_write_file_tracker_errors_unmatched_live_edits(tmp_path: Path) -> None: - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "call_id": "call-live", - "name": "write_file", - "arguments_delta": '{"path":"aborted.md","content":"one\\n', - }) - await tracker.error_unmatched([], "Tool call did not complete.") - - asyncio.run(run()) - assert events[-1]["path"] == "aborted.md" - assert events[-1]["phase"] == "error" - assert events[-1]["status"] == "error" - - -def test_streaming_write_file_tracker_keeps_matched_final_tool_call(tmp_path: Path) -> None: - events: list[dict] = [] - - async def emit(batch: list[dict]) -> None: - events.extend(batch) - - async def run() -> None: - tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) - await tracker.update({ - "index": 0, - "call_id": "idx-only", - "name": "write_file", - "arguments_delta": '{"path":"matched.md","content":"one\\n', - }) - await tracker.error_unmatched([ - SimpleNamespace( - id="final-call", - name="write_file", - arguments={"path": "matched.md", "content": "one\n"}, - ) - ], "Tool call did not complete.") - - asyncio.run(run()) - assert events - assert all(event["status"] == "editing" for event in events) + assert "diff" not in event def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None: diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 7242b792..55a7d405 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -941,6 +941,70 @@ def test_replay_file_edit_absorbs_matching_write_tool_event() -> None: ] +def test_replay_file_edit_stays_separate_from_mixed_tool_trace() -> None: + msgs = replay_transcript_to_ui_messages([ + { + "event": "message", + "chat_id": "t-file", + "text": "", + "kind": "tool_hint", + "tool_events": [ + { + "phase": "start", + "call_id": "call-read", + "name": "read_file", + "arguments": {"path": "quicksort.py"}, + }, + { + "phase": "start", + "call_id": "call-write", + "name": "write_file", + "arguments": {"path": "sorting/quicksort.py", "content": "def quicksort():\n"}, + }, + ], + }, + { + "event": "file_edit", + "chat_id": "t-file", + "edits": [ + { + "version": 1, + "call_id": "call-write", + "tool": "write_file", + "path": "sorting/quicksort.py", + "phase": "end", + "added": 3, + "deleted": 0, + "approximate": False, + "status": "done", + }, + ], + }, + ]) + + assert len(msgs) == 2 + assert msgs[0]["kind"] == "trace" + assert msgs[0]["traces"] == ['read_file({"path": "quicksort.py"})'] + assert [event["name"] for event in msgs[0]["toolEvents"]] == ["read_file"] + assert "fileEdits" not in msgs[0] + assert msgs[1]["kind"] == "trace" + assert msgs[1]["traces"] == [] + assert "toolEvents" not in msgs[1] + assert msgs[1]["fileEdits"] == [ + { + "version": 1, + "call_id": "call-write", + "tool": "write_file", + "path": "sorting/quicksort.py", + "phase": "end", + "added": 3, + "deleted": 0, + "approximate": False, + "status": "done", + }, + ] + + def test_replay_keeps_every_file_from_one_apply_patch_call() -> None: msgs = replay_transcript_to_ui_messages([ { diff --git a/webui/bun.lock b/webui/bun.lock index c36c6bfe..4d0fa5fa 100644 --- a/webui/bun.lock +++ b/webui/bun.lock @@ -13,6 +13,7 @@ "@radix-ui/react-tooltip": "^1.1.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "diff": "^9.0.0", "i18next": "^26.0.6", "lucide-react": "^0.469.0", "react": "^18.3.1", @@ -419,6 +420,8 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], diff --git a/webui/package.json b/webui/package.json index ce7becd1..3c6fcaff 100644 --- a/webui/package.json +++ b/webui/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-tooltip": "^1.1.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "diff": "^9.0.0", "i18next": "^26.0.6", "lucide-react": "^0.469.0", "react": "^18.3.1", diff --git a/webui/src/components/FilePreviewPanel.tsx b/webui/src/components/FilePreviewPanel.tsx index 9b31e6fa..127f115c 100644 --- a/webui/src/components/FilePreviewPanel.tsx +++ b/webui/src/components/FilePreviewPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react"; -import { AlertCircle, ChevronRight, FileText, Loader2, X } from "lucide-react"; +import { AlertCircle, ChevronRight, Loader2, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { CodeBlock } from "@/components/CodeBlock"; @@ -24,11 +24,6 @@ type PreviewState = | { status: "error"; message: string } | { status: "ready"; payload: FilePreviewPayload }; -function supportsHoverCloseControl(): boolean { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; - return window.matchMedia("(hover: hover) and (pointer: fine)").matches; -} - export function FilePreviewPanel({ sessionKey, path, @@ -41,26 +36,12 @@ export function FilePreviewPanel({ const { t } = useTranslation(); const [state, setState] = useState({ status: "loading" }); const [entered, setEntered] = useState(false); - const [supportsHoverClose, setSupportsHoverClose] = useState(supportsHoverCloseControl); useEffect(() => { const frame = window.requestAnimationFrame(() => setEntered(true)); return () => window.cancelAnimationFrame(frame); }, []); - useEffect(() => { - if (typeof window.matchMedia !== "function") return undefined; - const query = window.matchMedia("(hover: hover) and (pointer: fine)"); - const update = () => setSupportsHoverClose(query.matches); - update(); - if (typeof query.addEventListener === "function") { - query.addEventListener("change", update); - return () => query.removeEventListener("change", update); - } - query.addListener(update); - return () => query.removeListener(update); - }, []); - useEffect(() => { let cancelled = false; setState({ status: "loading" }); @@ -89,15 +70,28 @@ export function FilePreviewPanel({ const normalizedPreviewPath = previewPath.replace(/\\/g, "/"); const hasRootPrefix = normalizedPreviewPath.startsWith("/"); const { name } = splitFilePath(displayPath); - const breadcrumbs = useMemo( + const fileName = name || displayPath; + const pathParts = useMemo( () => normalizedPreviewPath.split("/").filter(Boolean), [normalizedPreviewPath], ); - const compactBreadcrumbs = useMemo( - () => (breadcrumbs.length > 2 ? breadcrumbs.slice(-2) : breadcrumbs), - [breadcrumbs], + const directoryParts = useMemo( + () => (pathParts.length > 1 ? pathParts.slice(0, -1) : []), + [pathParts], ); - const hasCompactPrefix = breadcrumbs.length > compactBreadcrumbs.length; + const breadcrumbParts = useMemo( + () => (directoryParts.length > 0 ? [...directoryParts, fileName] : [fileName]), + [directoryParts, fileName], + ); + const compactBreadcrumbParts = useMemo( + () => (breadcrumbParts.length > 3 ? breadcrumbParts.slice(-3) : breadcrumbParts), + [breadcrumbParts], + ); + const hasCompactPrefix = breadcrumbParts.length > compactBreadcrumbParts.length; + const breadcrumbTitle = `${hasRootPrefix ? "/" : ""}${[ + ...directoryParts, + fileName, + ].join("/")}`; return ( ); } diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index c4404489..f41d9543 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -111,6 +111,14 @@ import { } from "@/lib/api"; import { notifyCliAppsChanged } from "@/lib/cli-app-events"; import { copyTextToClipboard } from "@/lib/clipboard"; +import { + LOCAL_PREFS_STORAGE_KEY, + readLocalPreferences, + type FileEditDisplayMode, + type LocalActivityMode, + type LocalDensity, + type LocalPreferences, +} from "@/lib/local-preferences"; import { getHostApi } from "@/lib/runtime"; import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events"; import { fmtDateTime, relativeTime } from "@/lib/format"; @@ -155,8 +163,6 @@ export type SettingsSectionKey = | "runtime" | "advanced"; -type LocalDensity = "comfortable" | "compact"; -type LocalActivityMode = "auto" | "expanded"; type AppsKindFilter = "all" | "nanobot" | "cli" | "mcp"; type AutomationFilter = "all" | "active" | "paused" | "failed" | "system"; type AutomationSort = "next" | "last" | "updated" | "name"; @@ -166,13 +172,6 @@ type AppsCatalogItem = | { id: string; kind: "cli"; app: CliAppInfo } | { id: string; kind: "mcp"; preset: McpPresetInfo }; -interface LocalPreferences { - density: LocalDensity; - activityMode: LocalActivityMode; - codeWrap: boolean; - brandLogos: boolean; -} - interface AgentSettingsDraft { model: string; provider: string; @@ -259,14 +258,6 @@ interface CustomMcpForm { toolTimeout: string; } -const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences"; - -const DEFAULT_LOCAL_PREFS: LocalPreferences = { - density: "comfortable", - activityMode: "auto", - codeWrap: true, - brandLogos: false, -}; const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [ { value: "auto", label: "Auto" }, { value: "chat_completions", label: "Chat Completions" }, @@ -318,22 +309,6 @@ interface SettingsViewProps { hostChromeInset?: boolean; } -function readLocalPreferences(): LocalPreferences { - try { - const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY); - if (!raw) return DEFAULT_LOCAL_PREFS; - const parsed = JSON.parse(raw) as Partial; - return { - density: parsed.density === "compact" ? "compact" : "comfortable", - activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto", - codeWrap: parsed.codeWrap !== false, - brandLogos: parsed.brandLogos === true, - }; - } catch { - return DEFAULT_LOCAL_PREFS; - } -} - function modelPresetValue(payload: SettingsPayload): string { return payload.agent.model_preset || "default"; } @@ -2337,6 +2312,25 @@ function AppearanceSettings({ } /> + + + onChangeLocalPrefs((prev) => ({ + ...prev, + fileEditDisplayMode: fileEditDisplayMode as FileEditDisplayMode, + })) + } + /> + summarizeFileEdits(collectFileEdits(messages), isTurnStreaming), [messages, isTurnStreaming], @@ -282,7 +286,7 @@ export function AgentActivityCluster({ }) : t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), { count: fileCount, - defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{count}} files`, + defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{count}} changes`, }) : ""; @@ -438,6 +442,7 @@ export function AgentActivityCluster({ added={added} deleted={deleted} hasDiffStats={hasDiffStats} + fileEditDisplayMode={fileEditDisplayMode} onOpenFilePreview={onOpenFilePreview} /> ); @@ -532,6 +537,7 @@ export function AgentActivityCluster({ {fileEdits.length ? ( ) : null} @@ -561,6 +567,7 @@ function FileEditFlatActivity({ added, deleted, hasDiffStats, + fileEditDisplayMode, onOpenFilePreview, }: { edits: FileEditSummary[]; @@ -575,9 +582,23 @@ function FileEditFlatActivity({ added: number; deleted: number; hasDiffStats: boolean; + fileEditDisplayMode: FileEditDisplayMode; onOpenFilePreview?: (path: string) => void; }) { - const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending); + const diffOnlyRows = edits.length === 1 + && !!singleFilePath + && fileEditDisplayMode !== "summary" + && edits.some((edit) => ( + edit.status !== "editing" + && edit.status !== "error" + && hasRenderableFileDiff(edit.diff) + )); + const showRows = edits.length > 1 + || edits.some((edit) => edit.status === "error" || edit.pending) + || ( + fileEditDisplayMode !== "summary" + && edits.some((edit) => hasRenderableFileDiff(edit.diff)) + ); return (
{showRows ? (
- +
) : null}
@@ -1579,122 +1605,32 @@ function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] { } function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] { - interface MutableSummary { - key: string; - path: string; - absolute_path?: string; - added: number; - deleted: number; - approximate: boolean; - binary: boolean; - pending: boolean; - hasSuccessfulChange: boolean; - hasActiveEditing: boolean; - hasFailed: boolean; - operation?: UIFileEdit["operation"]; - error?: string; - } + return latestFileEditEvents(edits).flatMap((edit) => { + const editing = active && edit.status === "editing"; + const failed = edit.status === "error"; + if (!edit.path && edit.pending && !editing) return []; + if (!edit.path && !editing && !failed) return []; - const order: string[] = []; - const byPath = new Map(); - for (const edit of latestFileEditEvents(edits)) { - const key = edit.path || edit.call_id || edit.tool; - let summary = byPath.get(key); - if (!summary) { - summary = { - key, - path: edit.path || "", - absolute_path: edit.absolute_path, - added: 0, - deleted: 0, - approximate: false, - binary: false, - pending: false, - hasSuccessfulChange: false, - hasActiveEditing: false, - hasFailed: false, - operation: undefined, - }; - byPath.set(key, summary); - order.push(key); - } - - if (edit.path && !summary.path) { - summary.path = edit.path; - } - if (edit.absolute_path) { - summary.absolute_path = edit.absolute_path; - } - if (edit.operation === "delete") { - summary.operation = "delete"; - } - summary.pending = summary.pending || !!edit.pending || !edit.path; - if (!edit.path && edit.pending) { - if (active && edit.status === "editing") { - summary.hasActiveEditing = true; - summary.approximate = summary.approximate || !!edit.approximate; - if (!edit.binary) { - summary.added += edit.added; - summary.deleted += edit.deleted; - } - } - continue; - } - if (active && edit.status === "editing") { - summary.hasActiveEditing = true; - summary.binary = summary.binary || !!edit.binary; - summary.approximate = summary.approximate || !!edit.approximate; - if (!edit.binary) { - summary.added += edit.added; - summary.deleted += edit.deleted; - } - continue; - } - - if (edit.status === "error") { - summary.hasFailed = true; - summary.error = edit.error ?? summary.error; - continue; - } - - summary.hasSuccessfulChange = true; - summary.binary = summary.binary || !!edit.binary; - summary.approximate = active && (summary.approximate || !!edit.approximate); - if (!edit.binary) { - summary.added += edit.added; - summary.deleted += edit.deleted; - } - } - - return order.flatMap((key) => { - const summary = byPath.get(key)!; - if ( - !summary.path - && !summary.hasActiveEditing - && !summary.hasSuccessfulChange - && !summary.hasFailed - ) { - return []; - } - const status: UIFileEdit["status"] = summary.hasActiveEditing + const status: UIFileEdit["status"] = editing ? "editing" - : summary.hasSuccessfulChange - ? "done" - : summary.hasFailed - ? "error" - : "done"; + : failed + ? "error" + : "done"; + const binary = !!edit.binary; + const diff = hasRenderableFileDiff(edit.diff) ? edit.diff : undefined; return [{ - key: summary.key, - path: summary.path, - absolute_path: summary.absolute_path, - added: summary.added, - deleted: summary.deleted, - approximate: summary.approximate, - binary: summary.binary, + key: fileEditCallKey(edit), + path: edit.path || "", + absolute_path: edit.absolute_path, + added: binary ? 0 : edit.added, + deleted: binary ? 0 : edit.deleted, + approximate: active && !!edit.approximate, + binary, status, - operation: summary.operation, - pending: summary.pending && !summary.path, - error: summary.error, + operation: edit.operation, + pending: !!edit.pending && !edit.path, + error: edit.error, + diff, }]; }); } diff --git a/webui/src/components/thread/activity/DiffPair.tsx b/webui/src/components/thread/activity/DiffPair.tsx index 9ed58b5f..b690c07f 100644 --- a/webui/src/components/thread/activity/DiffPair.tsx +++ b/webui/src/components/thread/activity/DiffPair.tsx @@ -1,5 +1,3 @@ -import { useCallback, useEffect, useRef, useState } from "react"; - import { cn } from "@/lib/utils"; export function DiffPair({ added, deleted }: { added: number; deleted: number }) { @@ -31,83 +29,7 @@ function DiffValue({ sign, value, className }: { sign: string; value: number; cl > {sign} - - - {sign}{safeValue} - - ); -} - -function AnimatedNumber({ value }: { value: number }) { - const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; - const [display, setDisplay] = useState(0); - const displayRef = useRef(0); - - const setAnimatedDisplay = useCallback((next: number) => { - displayRef.current = next; - setDisplay(next); - }, []); - - useEffect(() => { - const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; - if (reduceMotion) { - setAnimatedDisplay(safeValue); - return; - } - const start = displayRef.current; - const delta = safeValue - start; - if (delta === 0) { - setAnimatedDisplay(safeValue); - return; - } - const duration = 260; - const startedAt = performance.now(); - let frame = 0; - const tick = (now: number) => { - const progress = Math.min(1, (now - startedAt) / duration); - const eased = 1 - Math.pow(1 - progress, 3); - setAnimatedDisplay(Math.round(start + delta * eased)); - if (progress < 1) { - frame = window.requestAnimationFrame(tick); - return; - } - displayRef.current = safeValue; - }; - frame = window.requestAnimationFrame(tick); - return () => window.cancelAnimationFrame(frame); - }, [safeValue, setAnimatedDisplay]); - - return ; -} - -function RollingNumber({ value }: { value: number }) { - const digits = String(value).split(""); - return ( - - {digits.map((digit, index) => ( - - ))} - - ); -} - -function RollingDigit({ digit }: { digit: number }) { - const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0; - return ( - - 0 - - {Array.from({ length: 10 }, (_, n) => ( - - {n} - - ))} + {safeValue} ); diff --git a/webui/src/components/thread/activity/FileEditRow.tsx b/webui/src/components/thread/activity/FileEditRow.tsx index 2d9e2e63..f6157260 100644 --- a/webui/src/components/thread/activity/FileEditRow.tsx +++ b/webui/src/components/thread/activity/FileEditRow.tsx @@ -1,13 +1,47 @@ -import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronRight, + ChevronUp, + CircleDashed, + ExternalLink, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { FileReferenceChip } from "@/components/FileReferenceChip"; -import type { UIFileEdit } from "@/lib/types"; +import { + hasRenderableFileDiff, + parseRenderableFileDiff, + type RenderableFileDiff, + type RenderableFileDiffHunk, + type RenderableFileDiffLine, +} from "@/lib/file-diff"; +import type { FileEditDisplayMode } from "@/lib/local-preferences"; +import type { UIFileDiff, UIFileEdit } from "@/lib/types"; import { cn } from "@/lib/utils"; import { ActivityStep } from "./ActivityStep"; import { DiffPair } from "./DiffPair"; +const INITIAL_VISIBLE_DIFF_LINES = 160; +const AUTO_COLLAPSE_DIFF_LINES = INITIAL_VISIBLE_DIFF_LINES; + +type DiffFileEditDisplayMode = Exclude; + +interface VisibleDiffHunk { + hunk: RenderableFileDiffHunk; + skippedBefore: number; +} + +interface VisibleDiff { + hunks: VisibleDiffHunk[]; + hiddenLineCount: number; +} + +const EMPTY_VISIBLE_DIFF: VisibleDiff = { hunks: [], hiddenLineCount: 0 }; + export interface FileEditSummary { key: string; path: string; @@ -20,40 +54,97 @@ export interface FileEditSummary { operation?: UIFileEdit["operation"]; pending: boolean; error?: string; + diff?: UIFileDiff; } export function FileEditGroup({ edits, + displayMode, onOpenFilePreview, + density = "default", }: { edits: FileEditSummary[]; + displayMode: FileEditDisplayMode; onOpenFilePreview?: (path: string) => void; + density?: "default" | "diff-only"; }) { if (edits.length === 0) return null; return (
    - {edits.map((edit) => ( - - ))} + {edits.map((edit) => { + if (density === "diff-only" && canRenderDiff(edit, displayMode)) { + return ( + + ); + } + return ( + + ); + })}
); } +function canRenderDiff( + edit: FileEditSummary, + displayMode: FileEditDisplayMode, +): displayMode is DiffFileEditDisplayMode { + return ( + displayMode !== "summary" + && edit.status !== "editing" + && edit.status !== "error" + && hasRenderableFileDiff(edit.diff) + ); +} + +function FileEditDiffOnly({ + edit, + displayMode, + onOpenFilePreview, +}: { + edit: FileEditSummary; + displayMode: DiffFileEditDisplayMode; + onOpenFilePreview?: (path: string) => void; +}) { + return ( +
  • + +
  • + ); +} + function FileEditRow({ edit, + displayMode, onOpenFilePreview, }: { edit: FileEditSummary; + displayMode: FileEditDisplayMode; onOpenFilePreview?: (path: string) => void; }) { const { t } = useTranslation(); const editing = edit.status === "editing"; const failed = edit.status === "error"; const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit); + const showDiff = canRenderDiff(edit, displayMode); const rawFailureDetail = failed ? cleanFileEditError(edit.error) : ""; const failureDetail = failed ? formatFileEditError(edit.error) @@ -84,7 +175,7 @@ function FileEditRow({ active={editing} tone={failed ? "error" : editing ? "active" : "success"} className="text-xs" - contentClassName={failed ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"} + contentClassName={failed || showDiff ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"} title={rawFailureDetail || edit.absolute_path || edit.path} label={edit.pending && !edit.path ? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" }) @@ -109,6 +200,16 @@ function FileEditRow({ {failureDetail} ) : null} + {showDiff ? ( + + ) : null} ); } @@ -142,3 +243,279 @@ function formatFileEditError(error?: string): string { .replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.") .slice(0, 180); } + +function FileUnifiedDiff({ + diff, + collapsed, + added, + deleted, + showCollapsedStats = true, + previewPath, + onOpenFilePreview, +}: { + diff: UIFileDiff; + collapsed: boolean; + added: number; + deleted: number; + showCollapsedStats?: boolean; + previewPath?: string; + onOpenFilePreview?: (path: string) => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const [open, setOpen] = useState(false); + const [expandedLines, setExpandedLines] = useState(false); + const renderableDiff = useMemo(() => parseRenderableFileDiff(diff), [diff]); + const totalLineCount = useMemo(() => countDiffLines(renderableDiff), [renderableDiff]); + const shouldAutoCollapse = totalLineCount > AUTO_COLLAPSE_DIFF_LINES || !!diff.truncated; + const startsCollapsed = collapsed || shouldAutoCollapse; + const shouldRenderBody = !startsCollapsed || open; + const shouldLimitLines = totalLineCount > INITIAL_VISIBLE_DIFF_LINES; + const lineLimit = expandedLines || !shouldLimitLines + ? totalLineCount + : INITIAL_VISIBLE_DIFF_LINES; + const visibleDiff = useMemo( + () => shouldRenderBody + ? selectVisibleDiffLines(renderableDiff, lineLimit, totalLineCount) + : EMPTY_VISIBLE_DIFF, + [lineLimit, renderableDiff, shouldRenderBody, totalLineCount], + ); + const lineCountLabel = t("message.fileEditDiffLineCount", { + count: diff.truncated ? `${totalLineCount}+` : totalLineCount, + defaultValue: "{{count}} lines", + }); + const viewDiffLabel = shouldAutoCollapse + ? tx("message.fileEditViewLargeDiff", "View large diff") + : tx("message.fileEditViewDiff", "View diff"); + + useEffect(() => { + setOpen(false); + setExpandedLines(false); + }, [diff]); + + const handleToggleOpen = () => { + if (open) setExpandedLines(false); + setOpen(!open); + }; + + if (totalLineCount === 0) return null; + + const renderBody = () => ( +
    + {visibleDiff.hunks.map(({ hunk, skippedBefore }, index) => ( +
    0 && "border-t border-border/45")} + > + {skippedBefore > 0 ? : null} +
    + + + {hunk.lines.map((line, lineIndex) => ( + + ))} + +
    +
    +
    + ))} + {visibleDiff.hiddenLineCount > 0 ? ( +
    + +
    + ) : expandedLines && shouldLimitLines ? ( +
    + +
    + ) : null} + {diff.truncated ? ( +
    + + {tx("message.fileEditDiffTruncated", "Diff truncated. Open the file for the full change.")} + + {previewPath && onOpenFilePreview ? ( + + ) : null} +
    + ) : null} +
    + ); + + if (!startsCollapsed) return renderBody(); + + return ( +
    + + {open ? renderBody() : null} +
    + ); +} + +function countDiffLines(diff: RenderableFileDiff): number { + return diff.hunks.reduce((total, hunk) => total + hunk.lines.length, 0); +} + +function selectVisibleDiffLines( + diff: RenderableFileDiff, + lineLimit: number, + totalLineCount: number, +): VisibleDiff { + if (lineLimit >= totalLineCount) { + return { + hunks: diff.hunks.map((hunk, index) => ({ + hunk, + skippedBefore: index > 0 ? countSkippedUnchangedLines(diff.hunks[index - 1], hunk) : 0, + })), + hiddenLineCount: 0, + }; + } + + let remaining = Math.max(0, lineLimit); + const hunks: VisibleDiffHunk[] = []; + let previousHunk: RenderableFileDiffHunk | null = null; + for (const hunk of diff.hunks) { + if (remaining <= 0) break; + const skippedBefore = previousHunk ? countSkippedUnchangedLines(previousHunk, hunk) : 0; + if (hunk.lines.length <= remaining) { + hunks.push({ hunk, skippedBefore }); + remaining -= hunk.lines.length; + previousHunk = hunk; + continue; + } + hunks.push({ hunk: { ...hunk, lines: hunk.lines.slice(0, remaining) }, skippedBefore }); + remaining = 0; + previousHunk = hunk; + } + return { + hunks, + hiddenLineCount: Math.max(0, totalLineCount - lineLimit), + }; +} + +function countSkippedUnchangedLines( + previous: RenderableFileDiffHunk, + current: RenderableFileDiffHunk, +): number { + const oldGap = current.old_start - (previous.old_start + previous.old_lines); + const newGap = current.new_start - (previous.new_start + previous.new_lines); + return Math.max(0, oldGap, newGap); +} + +function DiffHunkGap({ lineCount }: { lineCount: number }) { + const { t } = useTranslation(); + return ( +
    + + ... + + + {t("message.fileEditUnchangedLinesHidden", { + count: lineCount, + defaultValue: "{{count}} unchanged lines hidden", + })} + +
    + ); +} + +function DiffLineRow({ line }: { line: RenderableFileDiffLine }) { + const kind = line.kind === "add" || line.kind === "delete" ? line.kind : "context"; + const marker = kind === "add" ? "+" : kind === "delete" ? "-" : " "; + return ( + + + {line.old_lineno ?? ""} + + + {line.new_lineno ?? ""} + + + {marker} + + + {line.content || " "} + + + ); +} diff --git a/webui/src/hooks/useFileEditDisplayMode.ts b/webui/src/hooks/useFileEditDisplayMode.ts new file mode 100644 index 00000000..6c340755 --- /dev/null +++ b/webui/src/hooks/useFileEditDisplayMode.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; + +import { readLocalPreferences, type FileEditDisplayMode } from "@/lib/local-preferences"; + +export function useFileEditDisplayMode(): FileEditDisplayMode { + const [mode, setMode] = useState(() => + readLocalPreferences().fileEditDisplayMode, + ); + + useEffect(() => { + const refresh = () => setMode(readLocalPreferences().fileEditDisplayMode); + window.addEventListener("storage", refresh); + window.addEventListener("focus", refresh); + return () => { + window.removeEventListener("storage", refresh); + window.removeEventListener("focus", refresh); + }; + }, []); + + return mode; +} diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 55c83e03..066ab387 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -346,6 +346,44 @@ function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]): }; } +function traceMessageIsEmpty(message: UIMessage): boolean { + const traces = message.traces; + const hasTrace = traces?.length + ? traces.some((line) => line.trim().length > 0) + : (message.content ?? "").trim().length > 0; + return ( + message.kind === "trace" + && !hasTrace + && !message.toolEvents?.length + && !message.fileEdits?.length + && !message.media?.length + ); +} + +function stripCoveredFileEditToolHintsFromMessages( + messages: UIMessage[], + edits: UIFileEdit[], + turn: UIMessageTurnFields, +): UIMessage[] { + if (edits.length === 0) return messages; + let next = messages; + for (let i = next.length - 1; i >= 0; i -= 1) { + const candidate = next[i]; + if (candidate.role === "user") break; + if (candidate.kind !== "trace") continue; + if (!matchesTurn(candidate, turn)) continue; + const cleaned = stripCoveredFileEditToolHints(candidate, edits); + if (cleaned === candidate) continue; + if (next === messages) next = [...messages]; + if (traceMessageIsEmpty(cleaned)) { + next.splice(i, 1); + } else { + next[i] = cleaned; + } + } + return next; +} + function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null { if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null; const inferredStatus = @@ -417,10 +455,6 @@ function findFileEditTraceIndex( ) ) return i; } - for (const event of candidate.toolEvents ?? []) { - const key = toolEventFileEditKey(event); - if (key && incomingToolEventKeys.has(key)) return i; - } } return null; } @@ -1040,16 +1074,15 @@ export function useNanobotStream( } setMessages((prev) => { let segmentId = eventSegmentId; - const base = prev; + const base = stripCoveredFileEditToolHintsFromMessages(prev, normalized, turn); const targetIndex = findFileEditTraceIndex(base, segmentId, normalized); if (targetIndex !== null) { const target = base[targetIndex]; segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId(); if (opensFileEditPhase) fileEditSegmentRef.current = segmentId; - const cleanedTarget = stripCoveredFileEditToolHints(target, normalized); const merged: UIMessage = { - ...cleanedTarget, - fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized), + ...target, + fileEdits: mergeFileEdits(target.fileEdits, normalized), activitySegmentId: segmentId, ...turn, }; diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 2a1a7d92..1a6b646d 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -141,6 +141,7 @@ "presetModel": "Preset model", "density": "Density", "activityMode": "Activity detail", + "fileEditDisplay": "File edit display", "codeWrap": "Code wrapping", "brandLogos": "Brand logos", "maxResults": "Max results", @@ -187,6 +188,7 @@ "presetModel": "Switch to Default to edit model and provider from the WebUI.", "density": "Stored only in this browser.", "activityMode": "Choose how much agent activity chrome to show by default.", + "fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.", "codeWrap": "Keep long code lines readable on smaller screens.", "brandLogos": "Show third-party provider and CLI logos in Settings.", "maxResults": "Results returned by each web_search call.", @@ -329,6 +331,9 @@ "compact": "Compact", "auto": "Auto", "expanded": "Expanded", + "summary": "Summary", + "diff": "Diff", + "collapsedDiff": "Collapsed diff", "on": "On", "off": "Off", "defaultPermission": "Default Permission", @@ -1038,7 +1043,15 @@ "forkFromHere": "Fork", "copyReply": "Copy", "copiedReply": "Copied", - "turnLatencyTitle": "Response time (end-to-end)" + "turnLatencyTitle": "Response time (end-to-end)", + "fileEditViewDiff": "View diff", + "fileEditViewLargeDiff": "View large diff", + "fileEditDiffLineCount": "{{count}} lines", + "fileEditUnchangedLinesHidden": "{{count}} unchanged lines hidden", + "fileEditShowMoreLines": "Show {{count}} more lines", + "fileEditShowFewerLines": "Show fewer lines", + "fileEditOpenFile": "Open file", + "fileEditDiffTruncated": "Diff truncated. Open the file for the full change." }, "lightbox": { "title": "Image preview", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 42705d77..2a4fb79e 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -121,6 +121,7 @@ "presetModel": "Modelo del preajuste", "density": "Densidad", "activityMode": "Detalle de actividad", + "fileEditDisplay": "Vista de edición de archivos", "codeWrap": "Ajuste de código", "maxResults": "Resultados máximos", "timeout": "Tiempo de espera", @@ -165,6 +166,7 @@ "presetModel": "Cambia a Default para editar modelo y proveedor desde WebUI.", "density": "Solo se guarda en este navegador.", "activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.", + "fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o el diff.", "codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.", "maxResults": "Resultados devueltos por cada llamada web_search.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.", @@ -214,6 +216,9 @@ "compact": "Compacto", "auto": "Automático", "expanded": "Expandido", + "summary": "Resumen", + "diff": "Diff", + "collapsedDiff": "Diff contraído", "on": "Activado", "off": "Desactivado", "defaultPermission": "Permiso predeterminado", @@ -1022,6 +1027,14 @@ "copyReply": "Copiar", "copiedReply": "Copiado", "turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)", + "fileEditViewDiff": "Ver diff", + "fileEditViewLargeDiff": "Ver diff grande", + "fileEditDiffLineCount": "{{count}} líneas", + "fileEditUnchangedLinesHidden": "{{count}} líneas sin cambios ocultas", + "fileEditShowMoreLines": "Mostrar {{count}} líneas más", + "fileEditShowFewerLines": "Mostrar menos líneas", + "fileEditOpenFile": "Abrir archivo", + "fileEditDiffTruncated": "Diff truncado. Abre el archivo para ver el cambio completo.", "activityThinkingFor": "Pensando durante {{duration}}", "activityThought": "Pensamiento completado", "activityThoughtFor": "Pensó durante {{duration}}", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 66f84804..19a8b7a1 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -121,6 +121,7 @@ "presetModel": "Modèle du préréglage", "density": "Densité", "activityMode": "Détail d’activité", + "fileEditDisplay": "Affichage des modifications de fichiers", "codeWrap": "Retour à la ligne du code", "maxResults": "Résultats max.", "timeout": "Délai d’attente", @@ -165,6 +166,7 @@ "presetModel": "Passez à Default pour modifier le modèle et le fournisseur depuis WebUI.", "density": "Enregistré seulement dans ce navigateur.", "activityMode": "Choisissez le niveau de détail d’activité agent affiché par défaut.", + "fileEditDisplay": "Choisissez si l’activité de modification affiche le nombre de lignes ou le diff.", "codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.", "maxResults": "Résultats renvoyés par chaque appel web_search.", "timeout": "Nombre de secondes avant l’expiration d’une requête de recherche.", @@ -214,6 +216,9 @@ "compact": "Compacte", "auto": "Automatique", "expanded": "Développé", + "summary": "Résumé", + "diff": "Diff", + "collapsedDiff": "Diff replié", "on": "Activé", "off": "Désactivé", "defaultPermission": "Autorisation par défaut", @@ -1022,6 +1027,14 @@ "copyReply": "Copier", "copiedReply": "Copié", "turnLatencyTitle": "Temps de réponse (de bout en bout)", + "fileEditViewDiff": "Voir le diff", + "fileEditViewLargeDiff": "Voir le grand diff", + "fileEditDiffLineCount": "{{count}} lignes", + "fileEditUnchangedLinesHidden": "{{count}} lignes inchangées masquées", + "fileEditShowMoreLines": "Afficher {{count}} lignes de plus", + "fileEditShowFewerLines": "Afficher moins de lignes", + "fileEditOpenFile": "Ouvrir le fichier", + "fileEditDiffTruncated": "Diff tronqué. Ouvrez le fichier pour voir la modification complète.", "activityThinkingFor": "Réflexion pendant {{duration}}", "activityThought": "Réflexion terminée", "activityThoughtFor": "Réflexion terminée en {{duration}}", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 1f58ecb7..3aa515f1 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -121,6 +121,7 @@ "presetModel": "Model preset", "density": "Kerapatan", "activityMode": "Detail aktivitas", + "fileEditDisplay": "Tampilan edit file", "codeWrap": "Bungkus kode", "maxResults": "Hasil maksimum", "timeout": "Batas waktu", @@ -165,6 +166,7 @@ "presetModel": "Beralih ke Default untuk mengedit model dan penyedia dari WebUI.", "density": "Hanya disimpan di browser ini.", "activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.", + "fileEditDisplay": "Pilih aktivitas edit file dibuka sebagai jumlah baris atau diff.", "codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.", "maxResults": "Resultados devueltos por cada llamada web_search.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.", @@ -214,6 +216,9 @@ "compact": "Ringkas", "auto": "Otomatis", "expanded": "Diperluas", + "summary": "Ringkasan", + "diff": "Diff", + "collapsedDiff": "Diff diciutkan", "on": "Aktif", "off": "Nonaktif", "defaultPermission": "Izin default", @@ -1022,6 +1027,14 @@ "copyReply": "Salin", "copiedReply": "Disalin", "turnLatencyTitle": "Waktu respons (ujung ke ujung)", + "fileEditViewDiff": "Lihat diff", + "fileEditViewLargeDiff": "Lihat diff besar", + "fileEditDiffLineCount": "{{count}} baris", + "fileEditUnchangedLinesHidden": "{{count}} baris tidak berubah disembunyikan", + "fileEditShowMoreLines": "Tampilkan {{count}} baris lagi", + "fileEditShowFewerLines": "Tampilkan lebih sedikit baris", + "fileEditOpenFile": "Buka file", + "fileEditDiffTruncated": "Diff dipotong. Buka file untuk melihat perubahan lengkap.", "activityThinkingFor": "Berpikir selama {{duration}}", "activityThought": "Selesai berpikir", "activityThoughtFor": "Selesai berpikir dalam {{duration}}", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index bcb4537a..64ad8c0b 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -121,6 +121,7 @@ "presetModel": "プリセットモデル", "density": "表示密度", "activityMode": "アクティビティ詳細", + "fileEditDisplay": "ファイル編集表示", "codeWrap": "コードの折り返し", "maxResults": "最大結果数", "timeout": "タイムアウト", @@ -165,6 +166,7 @@ "presetModel": "Default に切り替えると、WebUI からモデルとプロバイダーを編集できます。", "density": "このブラウザーにのみ保存されます。", "activityMode": "既定で表示する agent アクティビティの詳細量を選択します。", + "fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。", "codeWrap": "小さな画面でも長いコード行を読みやすくします。", "maxResults": "各 web_search 呼び出しで返す結果数です。", "timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。", @@ -214,6 +216,9 @@ "compact": "コンパクト", "auto": "自動", "expanded": "展開", + "summary": "概要", + "diff": "差分", + "collapsedDiff": "折りたたみ差分", "on": "オン", "off": "オフ", "defaultPermission": "既定の権限", @@ -1022,6 +1027,14 @@ "copyReply": "コピー", "copiedReply": "コピー済み", "turnLatencyTitle": "応答時間(全行程)", + "fileEditViewDiff": "差分を表示", + "fileEditViewLargeDiff": "大きな差分を表示", + "fileEditDiffLineCount": "{{count}} 行", + "fileEditUnchangedLinesHidden": "未変更の {{count}} 行を非表示", + "fileEditShowMoreLines": "さらに {{count}} 行を表示", + "fileEditShowFewerLines": "表示行数を減らす", + "fileEditOpenFile": "ファイルを開く", + "fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。", "activityThinkingFor": "{{duration}}考えています", "activityThought": "思考しました", "activityThoughtFor": "{{duration}}考えました", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 9e8e1395..6cdcf286 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -121,6 +121,7 @@ "presetModel": "프리셋 모델", "density": "밀도", "activityMode": "활동 상세", + "fileEditDisplay": "파일 편집 표시", "codeWrap": "코드 줄바꿈", "maxResults": "최대 결과 수", "timeout": "타임아웃", @@ -165,6 +166,7 @@ "presetModel": "Default로 전환하면 WebUI에서 모델과 제공자를 편집할 수 있습니다.", "density": "이 브라우저에만 저장됩니다.", "activityMode": "기본으로 표시할 agent 활동 세부 수준을 선택합니다.", + "fileEditDisplay": "파일 편집 활동을 줄 수 또는 diff로 표시할지 선택합니다.", "codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.", "maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.", "timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.", @@ -214,6 +216,9 @@ "compact": "컴팩트", "auto": "자동", "expanded": "펼침", + "summary": "요약", + "diff": "Diff", + "collapsedDiff": "접힌 diff", "on": "켜짐", "off": "꺼짐", "defaultPermission": "기본 권한", @@ -1022,6 +1027,14 @@ "copyReply": "복사", "copiedReply": "복사됨", "turnLatencyTitle": "응답 시간(엔드투엔드)", + "fileEditViewDiff": "Diff 보기", + "fileEditViewLargeDiff": "큰 diff 보기", + "fileEditDiffLineCount": "{{count}}줄", + "fileEditUnchangedLinesHidden": "변경되지 않은 {{count}}줄 숨김", + "fileEditShowMoreLines": "{{count}}줄 더 보기", + "fileEditShowFewerLines": "줄 줄이기", + "fileEditOpenFile": "파일 열기", + "fileEditDiffTruncated": "Diff가 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.", "activityThinkingFor": "{{duration}} 동안 생각 중", "activityThought": "생각함", "activityThoughtFor": "{{duration}} 동안 생각함", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index eb82f401..05279445 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -121,6 +121,7 @@ "presetModel": "Mô hình preset", "density": "Mật độ", "activityMode": "Chi tiết hoạt động", + "fileEditDisplay": "Hiển thị sửa tệp", "codeWrap": "Xuống dòng mã", "maxResults": "Kết quả tối đa", "timeout": "Thời gian chờ", @@ -165,6 +166,7 @@ "presetModel": "Chuyển sang Default để chỉnh sửa mô hình và nhà cung cấp từ WebUI.", "density": "Chỉ lưu trong trình duyệt này.", "activityMode": "Chọn mức chi tiết hoạt động agent hiển thị mặc định.", + "fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay diff.", "codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.", "maxResults": "Resultados devueltos por cada llamada web_search.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.", @@ -214,6 +216,9 @@ "compact": "Gọn", "auto": "Tự động", "expanded": "Mở rộng", + "summary": "Tóm tắt", + "diff": "Diff", + "collapsedDiff": "Diff thu gọn", "on": "Bật", "off": "Tắt", "defaultPermission": "Quyền mặc định", @@ -1022,6 +1027,14 @@ "copyReply": "Sao chép", "copiedReply": "Đã sao chép", "turnLatencyTitle": "Thời gian phản hồi (end-to-end)", + "fileEditViewDiff": "Xem diff", + "fileEditViewLargeDiff": "Xem diff lớn", + "fileEditDiffLineCount": "{{count}} dòng", + "fileEditUnchangedLinesHidden": "Đã ẩn {{count}} dòng không đổi", + "fileEditShowMoreLines": "Hiển thị thêm {{count}} dòng", + "fileEditShowFewerLines": "Hiển thị ít dòng hơn", + "fileEditOpenFile": "Mở tệp", + "fileEditDiffTruncated": "Diff đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.", "activityThinkingFor": "Đang suy nghĩ trong {{duration}}", "activityThought": "Đã suy nghĩ", "activityThoughtFor": "Đã suy nghĩ trong {{duration}}", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 498ee245..c6c04883 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -141,6 +141,7 @@ "presetModel": "预设模型", "density": "密度", "activityMode": "活动详情", + "fileEditDisplay": "文件编辑展示", "codeWrap": "代码换行", "brandLogos": "品牌 Logo", "maxResults": "最大结果数", @@ -187,6 +188,7 @@ "presetModel": "切回 Default 后可在 WebUI 中编辑模型和提供商。", "density": "只保存在此浏览器中。", "activityMode": "选择默认显示多少 agent 活动细节。", + "fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。", "codeWrap": "让长代码行在小屏幕上也易读。", "brandLogos": "在设置中显示第三方提供商和 CLI 图标。", "maxResults": "每次 web_search 调用返回的结果数。", @@ -329,6 +331,9 @@ "compact": "紧凑", "auto": "自动", "expanded": "展开", + "summary": "摘要", + "diff": "差异", + "collapsedDiff": "折叠差异", "on": "开启", "off": "关闭", "defaultPermission": "默认权限", @@ -1038,7 +1043,15 @@ "forkFromHere": "分叉", "copyReply": "复制", "copiedReply": "已复制", - "turnLatencyTitle": "本轮耗时(端到端)" + "turnLatencyTitle": "本轮耗时(端到端)", + "fileEditViewDiff": "查看差异", + "fileEditViewLargeDiff": "查看大型差异", + "fileEditDiffLineCount": "{{count}} 行", + "fileEditUnchangedLinesHidden": "已隐藏 {{count}} 行未修改内容", + "fileEditShowMoreLines": "显示剩余 {{count}} 行", + "fileEditShowFewerLines": "收起部分行", + "fileEditOpenFile": "打开文件", + "fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。" }, "lightbox": { "title": "图片预览", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index b9cd4195..26f0664a 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -121,6 +121,7 @@ "presetModel": "預設模型", "density": "密度", "activityMode": "活動細節", + "fileEditDisplay": "檔案編輯顯示", "codeWrap": "程式碼換行", "maxResults": "最大結果數", "timeout": "逾時", @@ -165,6 +166,7 @@ "presetModel": "切回 Default 後可在 WebUI 中編輯模型與供應商。", "density": "只儲存在此瀏覽器中。", "activityMode": "選擇預設顯示多少 agent 活動細節。", + "fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。", "codeWrap": "讓長程式碼行在小螢幕上也易讀。", "maxResults": "每次 web_search 呼叫返回的結果數。", "timeout": "搜尋供應商請求逾時前的秒數。", @@ -214,6 +216,9 @@ "compact": "緊湊", "auto": "自動", "expanded": "展開", + "summary": "摘要", + "diff": "差異", + "collapsedDiff": "摺疊差異", "on": "開啟", "off": "關閉", "defaultPermission": "預設權限", @@ -1022,6 +1027,14 @@ "copyReply": "複製", "copiedReply": "已複製", "turnLatencyTitle": "本輪耗時(端到端)", + "fileEditViewDiff": "查看差異", + "fileEditViewLargeDiff": "查看大型差異", + "fileEditDiffLineCount": "{{count}} 行", + "fileEditUnchangedLinesHidden": "已隱藏 {{count}} 行未修改內容", + "fileEditShowMoreLines": "顯示其餘 {{count}} 行", + "fileEditShowFewerLines": "收起部分行", + "fileEditOpenFile": "開啟檔案", + "fileEditDiffTruncated": "差異內容已截斷。開啟檔案可查看完整更改。", "activityThinkingFor": "思考中,已 {{duration}}", "activityThought": "已思考", "activityThoughtFor": "已思考 {{duration}}", diff --git a/webui/src/lib/file-diff.ts b/webui/src/lib/file-diff.ts new file mode 100644 index 00000000..ae76a9ff --- /dev/null +++ b/webui/src/lib/file-diff.ts @@ -0,0 +1,94 @@ +import { parsePatch } from "diff"; + +import type { UIFileDiff } from "@/lib/types"; + +export interface RenderableFileDiffLine { + kind: "context" | "add" | "delete"; + old_lineno?: number | null; + new_lineno?: number | null; + content: string; +} + +export interface RenderableFileDiffHunk { + old_start: number; + old_lines: number; + new_start: number; + new_lines: number; + lines: RenderableFileDiffLine[]; +} + +export interface RenderableFileDiff { + hunks: RenderableFileDiffHunk[]; +} + +export function hasRenderableFileDiff(diff?: UIFileDiff): boolean { + if (!diff) return false; + return typeof diff.text === "string" && diff.text.trim().length > 0; +} + +export function parseRenderableFileDiff(diff: UIFileDiff): RenderableFileDiff { + if (typeof diff.text === "string" && diff.text.trim().length > 0) { + return parseUnifiedDiffText(diff.text); + } + return { hunks: [] }; +} + +function parseUnifiedDiffText(text: string): RenderableFileDiff { + let files: ReturnType; + try { + files = parsePatch(text); + } catch { + return { hunks: [] }; + } + return { + hunks: files.flatMap((file) => + file.hunks.map((hunk) => { + let oldLineno = hunk.oldStart; + let newLineno = hunk.newStart; + const lines: RenderableFileDiffLine[] = []; + + for (const rawLine of hunk.lines) { + if (rawLine.startsWith("\\")) continue; + const marker = rawLine[0]; + const content = rawLine.slice(1); + if (marker === "+") { + lines.push({ + kind: "add", + old_lineno: null, + new_lineno: newLineno, + content, + }); + newLineno += 1; + continue; + } + if (marker === "-") { + lines.push({ + kind: "delete", + old_lineno: oldLineno, + new_lineno: null, + content, + }); + oldLineno += 1; + continue; + } + lines.push({ + kind: "context", + old_lineno: oldLineno, + new_lineno: newLineno, + content: marker === " " ? content : rawLine, + }); + oldLineno += 1; + newLineno += 1; + } + + return { + old_start: hunk.oldStart, + old_lines: hunk.oldLines, + new_start: hunk.newStart, + new_lines: hunk.newLines, + lines, + }; + }), + ), + }; +} diff --git a/webui/src/lib/local-preferences.ts b/webui/src/lib/local-preferences.ts new file mode 100644 index 00000000..7027857a --- /dev/null +++ b/webui/src/lib/local-preferences.ts @@ -0,0 +1,42 @@ +export type LocalDensity = "comfortable" | "compact"; +export type LocalActivityMode = "auto" | "expanded"; +export type FileEditDisplayMode = "summary" | "diff" | "collapsed_diff"; + +export interface LocalPreferences { + density: LocalDensity; + activityMode: LocalActivityMode; + codeWrap: boolean; + brandLogos: boolean; + fileEditDisplayMode: FileEditDisplayMode; +} + +export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences"; + +export const DEFAULT_LOCAL_PREFS: LocalPreferences = { + density: "comfortable", + activityMode: "auto", + codeWrap: true, + brandLogos: false, + fileEditDisplayMode: "summary", +}; + +export function normalizeFileEditDisplayMode(value: unknown): FileEditDisplayMode { + return value === "diff" || value === "collapsed_diff" ? value : "summary"; +} + +export function readLocalPreferences(): LocalPreferences { + try { + const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY); + if (!raw) return DEFAULT_LOCAL_PREFS; + const parsed = JSON.parse(raw) as Partial; + return { + density: parsed.density === "compact" ? "compact" : "comfortable", + activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto", + codeWrap: parsed.codeWrap !== false, + brandLogos: parsed.brandLogos === true, + fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode), + }; + } catch { + return DEFAULT_LOCAL_PREFS; + } +} diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index f9ca7308..2cfb6678 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -210,6 +210,13 @@ export interface ToolProgressEvent { embeds?: unknown[]; } +export interface UIFileDiff { + format: "unified" | string; + context?: number; + truncated?: boolean; + text?: string; +} + export interface UIFileEdit { version?: number; call_id: string; @@ -225,6 +232,7 @@ export interface UIFileEdit { binary?: boolean; error?: string; pending?: boolean; + diff?: UIFileDiff; } export interface ChatSummary { diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx index 9e10bfa3..ab190a81 100644 --- a/webui/src/tests/agent-activity-cluster.test.tsx +++ b/webui/src/tests/agent-activity-cluster.test.tsx @@ -41,6 +41,15 @@ const BROWSERBASE_MCP: McpPresetInfo = { connection_summary: "https://mcp.browserbase.com/mcp", }; +function unifiedFileDiff(lines: string[], truncated = false) { + return { + format: "unified" as const, + context: 3, + truncated, + text: lines.join("\n"), + }; +} + function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] { const rows: UIMessage[] = [ { @@ -447,6 +456,301 @@ describe("AgentActivityCluster", () => { } }); + it("renders GitHub-like file edit diffs when the local preference is enabled", () => { + localStorage.setItem( + "nanobot-webui.settings-preferences", + JSON.stringify({ fileEditDisplayMode: "diff" }), + ); + + try { + render( + ;", + "+ return ;", + ]), + }], + createdAt: 3, + }]} + isTurnStreaming={false} + hasBodyBelow={false} + />, + ); + + expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument(); + expect(screen.queryByText("@@ -10,2 +10,2 @@")).not.toBeInTheDocument(); + expect(screen.getByText("return ;")).toBeInTheDocument(); + expect(screen.getByText("return ;")).toBeInTheDocument(); + expect(screen.getAllByText("11").length).toBeGreaterThanOrEqual(2); + expect(screen.getAllByTestId("activity-header-file-reference")).toHaveLength(1); + expect(screen.queryByTestId("activity-file-reference")).not.toBeInTheDocument(); + expect(screen.getAllByTestId("activity-diff-pair")).toHaveLength(1); + } finally { + localStorage.removeItem("nanobot-webui.settings-preferences"); + } + }); + + it("renders folded separators between separated file edit hunks", () => { + localStorage.setItem( + "nanobot-webui.settings-preferences", + JSON.stringify({ fileEditDisplayMode: "diff" }), + ); + + try { + render( + , + ); + + expect(screen.getByTestId("file-edit-diff-hunk-gap")).toHaveTextContent( + "21 unchanged lines hidden", + ); + expect(screen.queryByText("@@ -25,3 +25,3 @@")).not.toBeInTheDocument(); + expect(screen.getByText("return newSecond;")).toBeInTheDocument(); + } finally { + localStorage.removeItem("nanobot-webui.settings-preferences"); + } + }); + + it("keeps long file edit diffs collapsed until opened", () => { + localStorage.setItem( + "nanobot-webui.settings-preferences", + JSON.stringify({ fileEditDisplayMode: "diff" }), + ); + const lines = Array.from({ length: 165 }, (_, index) => `line-${index + 1}`); + + try { + render( + `+${line}`), + ]), + }], + createdAt: 3, + }]} + isTurnStreaming={false} + hasBodyBelow={false} + />, + ); + + const toggle = screen.getByTestId("file-edit-diff-toggle"); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(toggle).toHaveTextContent("View large diff"); + expect(toggle).toHaveTextContent("165 lines"); + expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument(); + expect(screen.queryByText("line-1")).not.toBeInTheDocument(); + + fireEvent.click(toggle); + + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("line-160")).toBeInTheDocument(); + expect(screen.queryByText("line-161")).not.toBeInTheDocument(); + expect(screen.getByTestId("file-edit-diff-expand-lines")).toHaveTextContent("Show 5 more lines"); + + fireEvent.click(screen.getByTestId("file-edit-diff-expand-lines")); + + expect(screen.getByText("line-165")).toBeInTheDocument(); + expect(screen.getByTestId("file-edit-diff-collapse-lines")).toHaveTextContent("Show fewer lines"); + + fireEvent.click(screen.getByTestId("file-edit-diff-collapse-lines")); + + expect(screen.queryByText("line-165")).not.toBeInTheDocument(); + expect(screen.getByTestId("file-edit-diff-expand-lines")).toHaveTextContent("Show 5 more lines"); + + fireEvent.click(toggle); + + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument(); + } finally { + localStorage.removeItem("nanobot-webui.settings-preferences"); + } + }); + + it("does not mount collapsed file edit diff rows until opened", () => { + localStorage.setItem( + "nanobot-webui.settings-preferences", + JSON.stringify({ fileEditDisplayMode: "collapsed_diff" }), + ); + + try { + render( + ;", + "+ return ;", + ]), + }], + createdAt: 3, + }]} + isTurnStreaming={false} + hasBodyBelow={false} + />, + ); + + const toggle = screen.getByTestId("file-edit-diff-toggle"); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(toggle).toHaveTextContent("View diff"); + expect(toggle).toHaveTextContent("3 lines"); + expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument(); + expect(screen.queryByText("return ;")).not.toBeInTheDocument(); + + fireEvent.click(toggle); + + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument(); + expect(screen.getByText("return ;")).toBeInTheDocument(); + } finally { + localStorage.removeItem("nanobot-webui.settings-preferences"); + } + }); + + it("offers the file preview entry point when a diff payload is truncated", () => { + localStorage.setItem( + "nanobot-webui.settings-preferences", + JSON.stringify({ fileEditDisplayMode: "diff" }), + ); + const onOpenFilePreview = vi.fn(); + + try { + render( + , + ); + + const toggle = screen.getByTestId("file-edit-diff-toggle"); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(toggle).toHaveTextContent("View large diff"); + expect(screen.queryByTestId("file-edit-diff-truncated")).not.toBeInTheDocument(); + + fireEvent.click(toggle); + + expect(screen.getByTestId("file-edit-diff-truncated")).toHaveTextContent("Diff truncated"); + fireEvent.click(screen.getByTestId("file-edit-diff-open-file")); + + expect(onOpenFilePreview).toHaveBeenCalledWith("/repo/src/app.tsx"); + } finally { + localStorage.removeItem("nanobot-webui.settings-preferences"); + } + }); + it("labels whole-file deletes as deleted instead of edited", () => { render( { expect(screen.queryByText(/\[Errno 13\]/)).not.toBeInTheDocument(); }); - it("merges repeated edits for the same path and lets successful edits win over failures", async () => { - const restoreMotion = installReducedMotion(); + it("renders repeated edits for the same path as separate actions", () => { + localStorage.setItem( + "nanobot-webui.settings-preferences", + JSON.stringify({ fileEditDisplayMode: "diff" }), + ); try { render( { deleted: 1, approximate: false, status: "done", + diff: unifiedFileDiff([ + "--- minecraft-fps/index.html", + "+++ minecraft-fps/index.html", + "@@ -1,1 +1,2 @@", + "
    ", + "+ ", + ]), }, { call_id: "call-edit-2", @@ -1027,6 +1341,14 @@ describe("AgentActivityCluster", () => { deleted: 6, approximate: false, status: "done", + diff: unifiedFileDiff([ + "--- minecraft-fps/index.html", + "+++ minecraft-fps/index.html", + "@@ -8,2 +8,2 @@", + "-const fps = 30;", + "+const fps = 60;", + " start();", + ]), }, ], createdAt: 3, @@ -1036,20 +1358,24 @@ describe("AgentActivityCluster", () => { />, ); - expect(screen.getByRole("button", { name: /edited index\.html/i })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /failed index\.html/i })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: /edited index\.html/i })); + const toggle = screen.getByRole("button", { name: "Edited 3 changes" }); + expect(toggle).toHaveTextContent("+8"); + expect(toggle).toHaveTextContent("-7"); + fireEvent.click(toggle); const fileRefs = screen.getAllByTestId("activity-file-reference"); - expect(fileRefs).toHaveLength(1); - expect(fileRefs[0]).toHaveTextContent("minecraft-fps/index.html"); - expect(screen.queryByText("Failed")).not.toBeInTheDocument(); - await waitFor(() => { - expect(screen.getAllByText("+8").length).toBeGreaterThan(0); - expect(screen.getAllByText("-7").length).toBeGreaterThan(0); - }); + expect(fileRefs).toHaveLength(3); + expect(fileRefs.every((ref) => ref.textContent?.includes("minecraft-fps/index.html"))).toBe(true); + expect(screen.getByText("patch failed")).toBeInTheDocument(); + expect(screen.getAllByTestId("file-edit-diff")).toHaveLength(2); + expect(screen.getByText("")).toBeInTheDocument(); + expect(screen.getByText("const fps = 60;")).toBeInTheDocument(); + expect(screen.getAllByText("+2").length).toBeGreaterThan(0); + expect(screen.getAllByText("-1").length).toBeGreaterThan(0); + expect(screen.getAllByText("+6").length).toBeGreaterThan(0); + expect(screen.getAllByText("-6").length).toBeGreaterThan(0); } finally { - restoreMotion(); + localStorage.removeItem("nanobot-webui.settings-preferences"); } }); diff --git a/webui/src/tests/file-preview-panel.test.tsx b/webui/src/tests/file-preview-panel.test.tsx new file mode 100644 index 00000000..dcb6fcf1 --- /dev/null +++ b/webui/src/tests/file-preview-panel.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { FilePreviewPanel } from "@/components/FilePreviewPanel"; +import { fetchFilePreview } from "@/lib/api"; + +vi.mock("@/components/CodeBlock", () => ({ + CodeBlock: ({ code }: { code: string }) =>
    {code}
    , +})); + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchFilePreview: vi.fn(), + }; +}); + +describe("FilePreviewPanel", () => { + beforeEach(() => { + vi.mocked(fetchFilePreview).mockReset(); + }); + + it("shows a compact breadcrumb with one file name and a visible close action", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + vi.mocked(fetchFilePreview).mockResolvedValue({ + path: "/Users/hr/workspace/quicksort.py", + display_path: "quicksort.py", + language: "python", + content: "print('ok')", + truncated: false, + }); + + render( + , + ); + + expect(await screen.findByTestId("mock-code-block")).toHaveTextContent("print('ok')"); + expect(screen.getByTestId("file-preview-breadcrumb")).toHaveTextContent("..."); + expect(screen.getByTestId("file-preview-breadcrumb")).toHaveTextContent("workspace"); + expect(screen.getByTestId("file-preview-title")).toHaveTextContent("quicksort.py"); + expect(screen.getAllByText("quicksort.py")).toHaveLength(1); + + const closeButton = screen.getByRole("button", { name: "Close file preview" }); + expect(closeButton).toBeVisible(); + + await user.click(closeButton); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index d440efaa..bf35b062 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -81,6 +81,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [ "settings.rows.language", "settings.rows.density", "settings.rows.activityMode", + "settings.rows.fileEditDisplay", "settings.rows.codeWrap", "settings.rows.brandLogos", "settings.rows.currentModel", @@ -91,6 +92,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [ "settings.help.language", "settings.help.density", "settings.help.activityMode", + "settings.help.fileEditDisplay", "settings.help.codeWrap", "settings.help.brandLogos", "settings.help.currentModel", diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx index 10dd09fb..97e562c5 100644 --- a/webui/src/tests/settings-view.test.tsx +++ b/webui/src/tests/settings-view.test.tsx @@ -159,7 +159,7 @@ const installedAnyGen = { function renderSettingsView( options: { - initialSection?: "overview" | "apps" | "automations" | "advanced" | "models" | "browser"; + initialSection?: "overview" | "appearance" | "apps" | "automations" | "advanced" | "models" | "browser"; initialSettings?: SettingsPayload; showSidebar?: boolean; onSettingsChange?: (payload: SettingsPayload) => void; @@ -185,10 +185,27 @@ function renderSettingsView( describe("SettingsView Apps catalog", () => { afterEach(() => { + localStorage.removeItem("nanobot-webui.settings-preferences"); vi.useRealTimers(); vi.unstubAllGlobals(); }); + it("persists the file edit display local preference", async () => { + renderSettingsView({ + initialSection: "appearance", + initialSettings: settingsPayload(), + showSidebar: true, + }); + + expect(screen.getByText("File edit display")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Diff" })); + + await waitFor(() => { + const saved = JSON.parse(localStorage.getItem("nanobot-webui.settings-preferences") || "{}"); + expect(saved.fileEditDisplayMode).toBe("diff"); + }); + }); + it("does not show the Settings kicker on the standalone Automations surface", async () => { vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 97f46da8..13cb25b7 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -596,6 +596,71 @@ describe("useNanobotStream", () => { expect(result.current.messages[0].toolEvents).toBeUndefined(); }); + it("keeps live file edits separate from mixed non-file tool traces", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-file-edit-mixed-tools", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-file-edit-mixed-tools", { + event: "message", + chat_id: "chat-file-edit-mixed-tools", + text: "", + kind: "tool_hint", + tool_events: [ + { + phase: "start", + call_id: "call-read", + name: "read_file", + arguments: { path: "quicksort.py" }, + }, + { + phase: "start", + call_id: "call-write", + name: "write_file", + arguments: { path: "sorting/quicksort.py", content: "def quicksort():\n" }, + }, + ], + }); + fake.emit("chat-file-edit-mixed-tools", { + event: "file_edit", + chat_id: "chat-file-edit-mixed-tools", + edits: [{ + call_id: "call-write", + tool: "write_file", + path: "sorting/quicksort.py", + phase: "end", + added: 3, + deleted: 0, + approximate: false, + status: "done", + }], + }); + }); + + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toMatchObject({ + role: "tool", + kind: "trace", + traces: ['read_file({"path":"quicksort.py"})'], + }); + expect(result.current.messages[0].toolEvents?.map((event) => event.name)).toEqual(["read_file"]); + expect(result.current.messages[0].fileEdits).toBeUndefined(); + expect(result.current.messages[1]).toMatchObject({ + role: "tool", + kind: "trace", + traces: [], + fileEdits: [{ + call_id: "call-write", + tool: "write_file", + path: "sorting/quicksort.py", + status: "done", + }], + }); + expect(result.current.messages[1].toolEvents).toBeUndefined(); + }); + it("keeps every file from one apply_patch call", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-apply-patch-many", EMPTY_MESSAGES), {