Merge pull request #2180 from nesquena/stage-346

stage-346: 10-PR contributor batch — stale-stream guard extension + guarded worktree remove + CSP report collector + perf + i18n + ctl fix
This commit is contained in:
nesquena-hermes
2026-05-13 00:18:54 -07:00
committed by GitHub
29 changed files with 1579 additions and 150 deletions
+3 -2
View File
@@ -156,10 +156,11 @@ Python stdlib ThreadingHTTPServer (from http.server). Each HTTP request runs in
thread. The Handler class subclasses BaseHTTPRequestHandler with two methods:
do_GET Routes: /, /health, /api/session, /api/sessions, /api/list,
/api/chat/stream, /api/file, /api/approval/pending
/api/chat/stream, /api/file, /api/approval/pending,
/api/session/worktree/status
do_POST Routes: /api/upload, /api/session/new, /api/session/update,
/api/session/delete, /api/chat/start, /api/chat,
/api/approval/respond
/api/approval/respond, /api/session/worktree/remove
Routing is a flat if/elif chain inside each method. No routing framework.
+32
View File
@@ -2,6 +2,38 @@
## [Unreleased]
### Added
- **PR #2156** by @franksong2702 — Issue #2057 Slice 2: guarded worktree remove action. New `POST /api/session/worktree/remove` and `remove_worktree_for_session(session, *, force=False)` helper. Rejects removal when the worktree is locked by an active stream or terminal, when it has local changes, untracked files, or unpushed commits ahead of origin. Clean removal runs without `--force`; `--force` is only used when the backend is explicitly called with `force=True`. Adds explicit per-session UI in the sidebar action menu (i18n strings for 9 locales), confirm dialog with two screenshot artifacts in `docs/pr-media/2156/`, and a 335-line regression suite in `tests/test_worktree_remove.py` covering the five fail-closed cases plus the explicit `force=True` override.
- **PR #2160** by @franksong2702 — CSP report collector endpoint (closes #2095). New unauthenticated `POST /api/csp-report` accepts both legacy `report-uri` JSON (`{"csp-report": ...}`) and modern `application/reports+json` array payloads, with per-client in-memory rate limiting; over-limit reports are dropped with a warning while still returning 204 to avoid browser retry amplification. Existing CSP report-only header now advertises the collector via `report-uri /api/csp-report; report-to csp-endpoint`, with a matching `Report-To` response header. 117-line regression suite covering headers, auth/CSRF carve-out, both payload shapes, and rate-limit behavior.
### Fixed
- **PR #2158** by @franksong2702 (closes #2154) — Extends the stale-stream writeback guard from PR #2136 to two additional sites Opus advisor flagged on stage-345 review: the outer exception path (`api/streaming.py:3989`) that materializes `pending_user_message` and appends an `_error_message`, and the self-heal retry success path (`api/streaming.py:3947`) that persists `_heal_result`. Both can run after `active_stream_id` has rotated to a newer stream — same corruption pattern PR #2136 fixed on the normal success path. Each new site now mirrors the canonical guard: `if not _stream_writeback_is_current(s, stream_id): logger.info("Skipping stale stream writeback at <site>"); return`. Adds regression coverage that pins both guards before their respective persistence operations.
- **PR #2159** by @franksong2702 (closes #2157) — `/api/sessions` no longer serializes stale `active_stream_id` / `pending_*` fields after a stream dies or the server restarts. Adds a bounded route-layer post-pass that only considers rows with `active_stream_id` set and `is_streaming` not true, loads candidates with `metadata_only=True`, and delegates cleanup to the existing safe `_clear_stale_stream_state()` helper (preserves the #1558 full-load safety path and per-session lock recheck). Re-reads `all_sessions()` after a cleanup so the JSON response matches the persisted session state.
- **PR #2161** by @franksong2702 (closes #2098) — Localized 5 Logs severity-filter keys (`logs_severity_*`) for `ja`, `ru`, `es`, `de`, `zh`, `zh-Hant`, `pt`, `ko`. Removes the affected `// TODO: translate` placeholders and adds the missing Traditional Chinese entries for these five keys. Regression coverage verifies each target locale has the expected localized values and that these key lines no longer carry TODO placeholders.
- **PR #2173** by @franksong2702 (closes #2172) — `ctl.sh status` and `ctl.sh stop` now correctly recognize daemons started through a custom `HERMES_WEBUI_PYTHON` wrapper. Persists the resolved Python executable in `webui.ctl.env` as `PYTHON_EXE`, and `_is_owned_webui_pid()` now recognizes the recorded wrapper path while preserving the existing repo-root state guard. Stabilizes the existing ctl tests by waiting for the fake-wrapper log before reading it. Fixes the Python 3.13 CI failure exposed by PR #2171's session-tail tests.
- **PR #2175** by @Michaelyklam (refs #2155) — Softened the session-lineage count badge from `X segments` to `X prior turn(s)` in the English base locale. Existing lineage expand/collapse behavior and accessibility attributes unchanged. Focused regression test verifies the new English badge label and forbids the old "segments" wording.
- **PR #2176** by @MrFant`_apply_provider_prefix()` no longer crashes with `AttributeError: 'dict' object has no attribute 'startswith'` when a provider's `models` config contains dict entries (`{"id": "x", "label": "y"}`) instead of plain strings. Fix extracts `id` and `label` from dict entries while keeping string entries as-is. Resolves `/api/models` and `/api/onboarding/status` 500 errors for users with dict-shaped model lists.
### Performance
- **PR #2166** by @franksong2702 — Consolidated session post-render processing into a single `postProcessRenderedMessages(container)` pass instead of two overlapping passes after both cached and freshly-rebuilt message DOM (plus a third highlight pass during idle session-loads). Scopes inline preview, tree-view, Mermaid, KaTeX, and code/copy-button passes to one walk over the rendered container. Vanilla JS architecture preserved; no changes to the markdown renderer, session loading, or DOM diffing model.
- **PR #2170** by @franksong2702`/api/session?messages=0&resolve_model=0` metadata loads no longer pay the `_lookup_cli_session_metadata()` Agent/CLI scan for native WebUI sessions. New `_needs_cli_session_metadata()` predicate keeps the Agent metadata merge path for imported CLI sessions, messaging-backed sessions, read-only sessions, and external-agent sessions, but skips it for ordinary WebUI-native sessions. Profiling on real production state showed this was the remaining hot path after PR #2166 removed the duplicate browser post-render work.
### Stage-346 maintainer fixes
- **`server.py` CSP-report auth carve-out scoped to POST only** — Opus SHOULD-FIX from stage-346 review on PR #2160. The original carve-out (`parsed.path != "/api/csp-report" and not check_auth`) bypassed auth for all write methods on the endpoint, not just the POST that browsers actually use for CSP violation reports. PATCH/DELETE to that path currently fall through to a 403 (CSRF check) or 404 (routing), so the broad bypass was harmless — but defense-in-depth says scope the carve-out to its actual use. New check: `parsed.path == "/api/csp-report" and self.command == "POST"`. ~6 LOC. CSP report regression suite (6 tests) still passes.
## [v0.51.52] — 2026-05-12 — Release AB (stage-345 — 2-PR low-risk batch — stream-ownership guard + Refresh-usage button on provider quota card)
### Fixed
- **PR #2136** by @LumenYoung — Stale stream writebacks no longer poison the active session transcript. `cancel_stream()` intentionally clears `active_stream_id` early so the UI can accept a follow-up turn while an old worker is unwinding — but the old worker could still return later from `run_conversation()` and persist its stale result over the newer transcript, causing visible transcript / turn journal / `state.db` to disagree (especially around cancel+retry on compressed continuations). Adds a single-line ownership check `_stream_writeback_is_current(session, stream_id)` (token equality against `session.active_stream_id`) and short-circuits both finalize paths: the success path in `_run_agent_streaming` and the cancel-handler path in `cancel_stream()`. When the stream no longer owns the writeback, both paths log `Skipping stale stream/cancel writeback` and return cleanly without persisting. 89-line regression suite in `tests/test_stale_stream_writeback.py`; companion updates to `tests/test_issue1361_cancel_data_loss.py` and `tests/test_sprint42.py` for the new return-without-persist behavior.
+3 -1
View File
@@ -3500,7 +3500,9 @@ def get_available_models() -> dict:
if isinstance(cfg_models, dict):
raw_models = [{"id": k, "label": k} for k in cfg_models.keys()]
elif isinstance(cfg_models, list):
raw_models = [{"id": k, "label": k} for k in cfg_models]
raw_models = [{"id": k["id"] if isinstance(k, dict) else k,
"label": k.get("label", k["id"]) if isinstance(k, dict) else k}
for k in cfg_models]
if not raw_models:
raw_models = _models_from_live_provider_ids(
+143 -1
View File
@@ -63,6 +63,12 @@ _MESSAGING_SESSION_METADATA_CACHE: dict[str, object] = {
}
_MESSAGING_SESSION_METADATA_LOCK = threading.Lock()
_STALE_MESSAGING_END_REASONS = {"session_reset", "session_switch"}
_CSP_REPORT_LOGGER = logging.getLogger("csp_report")
_CSP_REPORT_RATE_LIMIT: dict[str, list[float]] = {}
_CSP_REPORT_RATE_LIMIT_LOCK = threading.Lock()
_CSP_REPORT_RATE_LIMIT_WINDOW_SECONDS = 60
_CSP_REPORT_RATE_LIMIT_MAX = 100
_CSP_REPORT_MAX_BODY_BYTES = 64 * 1024
# ── Profile-scoped session/project filtering (#1611, #1614) ────────────────
@@ -953,6 +959,32 @@ def _clear_stale_stream_state(session) -> bool:
pass
return True
def _reconcile_stale_stream_state_for_session_rows(session_rows) -> bool:
"""Clear stale persisted stream fields before /api/sessions serializes rows."""
changed = False
for row in session_rows:
if not isinstance(row, dict):
continue
sid = row.get("session_id")
if not sid or not row.get("active_stream_id"):
continue
if row.get("is_streaming") is True:
continue
try:
session = get_session(sid, metadata_only=True)
except Exception:
logger.debug(
"Failed to load session %s while reconciling stale stream state",
sid,
exc_info=True,
)
continue
if session is None:
continue
changed = _clear_stale_stream_state(session) or changed
return changed
# ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
import re as _re
@@ -1057,6 +1089,69 @@ def _check_csrf(handler) -> bool:
return False
def _client_ip_for_rate_limit(handler) -> str:
try:
address = getattr(handler, "client_address", None)
if address:
return str(address[0])
except Exception:
pass
return "unknown"
def _csp_report_rate_limited(handler, *, now: float | None = None) -> bool:
now = time.time() if now is None else now
key = _client_ip_for_rate_limit(handler)
cutoff = now - _CSP_REPORT_RATE_LIMIT_WINDOW_SECONDS
with _CSP_REPORT_RATE_LIMIT_LOCK:
timestamps = [ts for ts in _CSP_REPORT_RATE_LIMIT.get(key, []) if ts >= cutoff]
if len(timestamps) >= _CSP_REPORT_RATE_LIMIT_MAX:
_CSP_REPORT_RATE_LIMIT[key] = timestamps
return True
timestamps.append(now)
_CSP_REPORT_RATE_LIMIT[key] = timestamps
return False
def _send_no_content(handler, status: int = 204) -> bool:
handler.send_response(status)
handler.send_header("Content-Length", "0")
handler.end_headers()
return True
def _read_csp_report_payload(handler):
try:
length = int(handler.headers.get("Content-Length", 0))
except Exception:
length = 0
if length > _CSP_REPORT_MAX_BODY_BYTES:
try:
handler.rfile.read(_CSP_REPORT_MAX_BODY_BYTES)
except Exception:
pass
return {"discarded": "body_too_large", "bytes": length}
raw = handler.rfile.read(length) if length else b"{}"
try:
return json.loads(raw.decode("utf-8"))
except Exception:
return {"invalid": True, "bytes": len(raw)}
def _handle_csp_report(handler) -> bool:
"""Collect browser CSP report-only violations without requiring auth."""
if _csp_report_rate_limited(handler):
_CSP_REPORT_LOGGER.warning(
"Dropped CSP report from %s: rate limit exceeded",
_client_ip_for_rate_limit(handler),
)
return _send_no_content(handler)
payload = _read_csp_report_payload(handler)
_CSP_REPORT_LOGGER.info("CSP report from %s: %s", _client_ip_for_rate_limit(handler), payload)
return _send_no_content(handler)
def _normalize_provider_id(value: str | None) -> str:
raw = str(value or "").strip().lower()
if not raw:
@@ -1430,6 +1525,18 @@ def _lookup_cli_session_metadata(session_id: str) -> dict:
return {}
def _needs_cli_session_metadata(session) -> bool:
"""Return true when /api/session should pay for Agent/CLI metadata lookup."""
if not session:
return False
is_cli = (
bool(session.get("is_cli_session"))
if isinstance(session, dict)
else bool(getattr(session, "is_cli_session", False))
)
return is_cli or _is_messaging_session_record(session)
def _messaging_session_identity(session: dict, raw_source: str) -> str:
metadata = _lookup_gateway_session_identity(session.get("session_id"))
session_key = _safe_first(
@@ -3066,6 +3173,7 @@ def handle_get(handler, parsed) -> bool:
if parsed.path.startswith("/static/"):
return _serve_static(handler, parsed)
if parsed.path == "/api/session/worktree/status":
query = parse_qs(parsed.query)
sid = query.get("session_id", [""])[0]
@@ -3125,7 +3233,7 @@ def handle_get(handler, parsed) -> bool:
_t1 = _time.monotonic()
s = get_session(sid, metadata_only=(not load_messages))
_clear_stale_stream_state(s)
cli_meta = _lookup_cli_session_metadata(sid)
cli_meta = _lookup_cli_session_metadata(sid) if _needs_cli_session_metadata(s) else {}
is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta)
cli_messages = []
if is_messaging_session:
@@ -3385,6 +3493,10 @@ def handle_get(handler, parsed) -> bool:
try:
diag.stage("all_sessions")
webui_sessions = all_sessions(diag=diag)
diag.stage("reconcile_stale_stream_state")
if _reconcile_stale_stream_state_for_session_rows(webui_sessions):
diag.stage("all_sessions_after_stale_stream_reconcile")
webui_sessions = all_sessions(diag=diag)
diag.stage("load_settings")
settings = load_settings()
show_cli_sessions = bool(settings.get("show_cli_sessions"))
@@ -3872,6 +3984,14 @@ def handle_get(handler, parsed) -> bool:
def handle_post(handler, parsed) -> bool:
"""Handle all POST routes. Returns True if handled, False for 404."""
diag = RequestDiagnostics.maybe_start("POST", parsed.path, logger=logger)
if parsed.path == "/api/csp-report":
if diag:
diag.stage("csp_report")
try:
return _handle_csp_report(handler)
finally:
if diag:
diag.finish()
# CSRF: reject cross-origin browser requests
if diag:
diag.stage("csrf")
@@ -4272,6 +4392,28 @@ def handle_post(handler, parsed) -> bool:
logger.debug("Failed to close workspace terminal after workspace update")
set_last_workspace(new_ws)
return j(handler, {"session": s.compact() | {"messages": s.messages}})
if parsed.path == "/api/session/worktree/remove":
sid = body.get("session_id", "")
if not sid or not isinstance(sid, str) or not sid.strip():
return bad(handler, "session_id must be a non-empty string", status=400)
sid = sid.strip()
if not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid):
return bad(handler, "Invalid session_id", 400)
try:
s = get_session(sid, metadata_only=True)
except KeyError:
return bad(handler, "Session not found", status=404)
force = bool(body.get("force", False))
try:
from api.worktrees import remove_worktree_for_session
result = remove_worktree_for_session(s, force=force)
return j(handler, result)
except ValueError as exc:
return bad(handler, str(exc), status=400)
except Exception as exc:
logger.exception("failed to remove worktree for session %s", sid)
return bad(handler, _sanitize_error(exc), status=500)
if parsed.path == "/api/session/delete":
sid = body.get("session_id", "")
+16
View File
@@ -3946,6 +3946,14 @@ def _run_agent_streaming(
_ckpt_thread.join(timeout=15)
_lock_ctx = _agent_lock if _agent_lock is not None else contextlib.nullcontext()
with _lock_ctx:
if not ephemeral and not _stream_writeback_is_current(s, stream_id):
logger.info(
"Skipping stale stream self-heal writeback for session %s stream %s; active_stream_id=%s",
getattr(s, 'session_id', session_id),
stream_id,
getattr(s, 'active_stream_id', None),
)
return
_result_messages = _heal_result.get('messages') or _previous_context_messages
_next_context_messages = _restore_reasoning_metadata(
_previous_context_messages, _result_messages,
@@ -3987,6 +3995,14 @@ def _run_agent_streaming(
# API calls so the LLM never sees its own error as prior context on the next turn.
_lock_ctx = _agent_lock if _agent_lock is not None else contextlib.nullcontext()
with _lock_ctx:
if not ephemeral and not _stream_writeback_is_current(s, stream_id):
logger.info(
"Skipping stale stream error writeback for session %s stream %s; active_stream_id=%s",
getattr(s, 'session_id', session_id),
stream_id,
getattr(s, 'active_stream_id', None),
)
return
_materialize_pending_user_turn_before_error(s)
s.active_stream_id = None
s.pending_user_message = None
+96
View File
@@ -201,6 +201,102 @@ def worktree_status_for_session(session) -> dict:
return status
def remove_worktree_for_session(session, *, force: bool = False) -> dict:
"""Remove a session's git worktree from disk.
Returns status dict with keys: ok, removed_path, warnings.
Raises ValueError for terminal blockers (locked by stream/terminal,
dirty with force=False).
"""
raw_path = getattr(session, "worktree_path", None)
if not raw_path:
raise ValueError("Session is not worktree-backed")
worktree_path = _resolve_path(raw_path)
if worktree_path is None:
raise ValueError("Session is not worktree-backed")
# Read current status before removal
status = worktree_status_for_session(session)
if not status["exists"]:
return {
"ok": True,
"removed_path": str(worktree_path),
"warnings": ["Worktree directory no longer exists on disk."],
}
warnings = []
# Guard: locked by stream
if status["locked_by_stream"]:
raise ValueError("Worktree is locked by an active streaming session")
# Guard: locked by terminal
if status["locked_by_terminal"]:
raise ValueError("Worktree is locked by an active terminal session")
# Guard: local changes and unpushed commits without explicit force.
if status["dirty"] and not force:
raise ValueError(
"Worktree has uncommitted changes. Use force=true to override."
)
if status["untracked_count"] > 0:
if force:
warnings.append(
f"{status['untracked_count']} untracked file(s) will be removed."
)
else:
raise ValueError(
f"Worktree has {status['untracked_count']} untracked file(s). "
"Use force=true to override."
)
ahead = int((status.get("ahead_behind") or {}).get("ahead") or 0)
if ahead > 0:
if force:
warnings.append(f"{ahead} unpushed commit(s) will be removed.")
else:
raise ValueError(
f"Worktree has {ahead} unpushed commit(s). "
"Use force=true to override."
)
# Remove the worktree — must run from the repo root, not the worktree dir
repo_root = getattr(session, "worktree_repo_root", None)
if not repo_root:
raise ValueError("Session missing worktree_repo_root")
try:
remove_args = ["worktree", "remove"]
if force:
remove_args.append("--force")
remove_args.append(str(worktree_path))
result = _run_git(remove_args, str(repo_root), timeout=10)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ValueError(f"Failed to remove worktree: {exc}") from exc
if result.returncode != 0:
stderr = (result.stderr or "").strip().split("\n")[-1]
raise ValueError(
f"git worktree remove failed: {stderr or result.stdout.strip()}"
)
# Prune in case the worktree dir was already gone
try:
_run_git(
["worktree", "prune"],
str(repo_root),
timeout=5,
)
except Exception:
pass
return {
"ok": True,
"removed_path": str(worktree_path),
"warnings": warnings or None,
}
def find_git_repo_root(workspace: str | Path) -> Path:
"""Return the enclosing git repo root for *workspace*.
+6 -4
View File
@@ -129,11 +129,12 @@ _build_bootstrap_args() {
}
_write_state() {
local pid="$1" host="$2" port="$3"
local pid="$1" host="$2" port="$3" python_exe="${4:-}"
local state_dir="${HERMES_WEBUI_STATE_DIR:-${DEFAULT_STATE_DIR}}"
{
printf 'PID=%q\n' "${pid}"
printf 'REPO_ROOT=%q\n' "${REPO_ROOT}"
printf 'PYTHON_EXE=%q\n' "${python_exe}"
printf 'HOST=%q\n' "${host}"
printf 'PORT=%q\n' "${port}"
printf 'LOG_FILE=%q\n' "${LOG_FILE}"
@@ -168,14 +169,15 @@ _proc_args() {
}
_is_owned_webui_pid() {
local pid="$1" args state_repo=""
local pid="$1" args state_repo="" state_python=""
[[ -f "${STATE_FILE}" ]] || return 1
_load_state_if_present
state_repo="${REPO_ROOT:-}"
state_python="${PYTHON_EXE:-}"
[[ "${state_repo}" == "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ]] || return 1
args="$(_proc_args "${pid}")"
[[ -n "${args}" ]] || return 1
[[ "${args}" == *"${state_repo}/bootstrap.py"* || "${args}" == *"${state_repo}/server.py"* || "${args}" == *"${state_repo}/start.sh"* ]]
[[ "${args}" == *"${state_repo}/bootstrap.py"* || "${args}" == *"${state_repo}/server.py"* || "${args}" == *"${state_repo}/start.sh"* || ( -n "${state_python}" && "${args}" == *"${state_python}"* ) ]]
}
_current_pid() {
@@ -222,7 +224,7 @@ start_cmd() {
pid=$!
printf '%s\n' "${pid}" > "${PID_FILE}"
_write_state "${pid}" "${CTL_HOST}" "${CTL_PORT}"
_write_state "${pid}" "${CTL_HOST}" "${CTL_PORT}" "${python_exe}"
sleep 0.15
if ! _is_alive "${pid}"; then
echo "[ctl] Hermes WebUI failed to stay running. Log: ${LOG_FILE}" >&2
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+13 -2
View File
@@ -210,8 +210,10 @@ class Handler(BaseHTTPRequestHandler):
"img-src 'self' data: blob:; "
"font-src 'self' data:; "
"media-src 'self' data: blob:; "
"connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*"
"connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; "
"report-uri /api/csp-report; report-to csp-endpoint"
)
_CSP_REPORT_TO = '{"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"/api/csp-report"}]}'
@classmethod
def csp_report_only_policy(cls) -> str:
@@ -219,6 +221,7 @@ class Handler(BaseHTTPRequestHandler):
def end_headers(self) -> None:
self.send_header("Content-Security-Policy-Report-Only", self.csp_report_only_policy())
self.send_header("Report-To", self._CSP_REPORT_TO)
super().end_headers()
def log_message(self, fmt, *args): pass # suppress default Apache-style log
@@ -262,7 +265,15 @@ class Handler(BaseHTTPRequestHandler):
set_request_profile(cookie_profile)
try:
parsed = urlparse(self.path)
if not check_auth(self, parsed): return
# Stage-346 Opus SHOULD-FIX defense-in-depth: scope the CSP-report
# auth carve-out to POST only. The endpoint is intentionally
# unauthenticated (browsers omit cookies on CSP reports), but the
# carve-out should not extend to PATCH/DELETE on that path even
# though they currently fail through CSRF/routing fallthrough.
_is_csp_report_post = (
parsed.path == "/api/csp-report" and self.command == "POST"
)
if not _is_csp_report_post and not check_auth(self, parsed): return
result = route_func(self, parsed)
if result is False:
return j(self, {'error': 'not found'}, status=404)
+198 -36
View File
@@ -430,6 +430,20 @@ const LOCALES = {
session_delete_worktree_confirm: (path) => `Delete this conversation? The worktree at ${path} will remain on disk.`,
session_deleted: 'Conversation deleted',
session_deleted_worktree: 'Conversation deleted. Worktree remains on disk.',
session_worktree_remove: 'Remove worktree',
session_worktree_remove_desc: (path) => `Delete the git worktree at ${path} from disk`,
session_worktree_remove_confirm: (path) => `Remove git worktree from disk?\n\nPath: ${path}\n\nThis will delete the entire worktree directory. Session data remains in WebUI.`,
session_worktree_remove_not_exists: (path) => `The worktree at ${path} no longer exists on disk.`,
session_worktree_remove_confirm_label: 'Remove',
session_worktree_removed: 'Worktree removed.',
session_worktree_remove_failed: 'Failed to remove worktree: ',
session_worktree_remove_status_failed: 'Failed to read worktree status: ',
session_worktree_remove_locked_by_stream: 'Cannot remove — an active streaming session is using this worktree.',
session_worktree_remove_locked_by_terminal: 'Cannot remove — an active terminal session is using this worktree.',
session_worktree_remove_unsafe_blocked: 'Resolve local changes or unpushed commits before removing this worktree.',
session_worktree_remove_dirty_warning: 'WARNING: This worktree has uncommitted changes which will be lost.',
session_worktree_remove_untracked_warning: (count) => `${count} untracked file(s) will be permanently deleted.`,
session_worktree_remove_ahead_warning: (ahead) => `${ahead} unpushed commit(s) will be lost.`,
session_select_mode: 'Select',
session_select_mode_desc: 'Select conversations to batch manage',
session_select_all: 'Select all',
@@ -686,7 +700,10 @@ const LOCALES = {
workspace_desc: 'Add and switch workspaces for your sessions.',
session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`,
session_meta_children: (n) => `${n} child${n === 1 ? '' : 'ren'}`,
session_meta_segments: (n) => `${n} segment${n === 1 ? '' : 's'}`,
// Softened label: avoids exposing the technical internal term
// 'segment' in the default visible badge. User-facing copy remains
// translatable for locales that prefer a different wording. (#2155)
session_meta_segments: (n) => `${n} prior turn${n === 1 ? '' : 's'}`,
session_lineage_segment_untitled: 'Untitled segment',
session_lineage_segment_open: 'Open lineage segment',
new_profile: 'New profile',
@@ -1538,6 +1555,20 @@ const LOCALES = {
session_delete_worktree_confirm: (path) => `Eliminare questa conversazione? Il worktree in ${path} rimarrà su disco.`,
session_deleted: 'Conversazione eliminata',
session_deleted_worktree: 'Conversazione eliminata. Il worktree rimane su disco.',
session_worktree_remove: 'Rimuovi worktree',
session_worktree_remove_desc: (path) => `Elimina il git worktree in ${path} dal disco`,
session_worktree_remove_confirm: (path) => `Rimuovere il git worktree dal disco?\n\nPercorso: ${path}\n\nVerrà eliminata l'intera directory del worktree. I dati della sessione restano in WebUI.`,
session_worktree_remove_not_exists: (path) => `Il worktree in ${path} non esiste più sul disco.`,
session_worktree_remove_confirm_label: 'Rimuovi',
session_worktree_removed: 'Worktree rimosso.',
session_worktree_remove_failed: 'Rimozione worktree fallita: ',
session_worktree_remove_status_failed: 'Lettura stato worktree fallita: ',
session_worktree_remove_locked_by_stream: 'Impossibile rimuovere — una sessione di streaming attiva sta usando questo worktree.',
session_worktree_remove_locked_by_terminal: 'Impossibile rimuovere — una sessione terminale attiva sta usando questo worktree.',
session_worktree_remove_unsafe_blocked: 'Risolvi le modifiche locali o i commit non inviati prima di rimuovere questo worktree.',
session_worktree_remove_dirty_warning: 'ATTENZIONE: Questo worktree ha modifiche non committate che andranno perse.',
session_worktree_remove_untracked_warning: (count) => `${count} file non tracciati verranno eliminati definitivamente.`,
session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit non inviati andranno persi.`,
session_select_mode: 'Seleziona',
session_select_mode_desc: 'Seleziona conversazioni per gestione in blocco',
session_select_all: 'Seleziona tutto',
@@ -2641,6 +2672,20 @@ const LOCALES = {
session_delete_worktree_confirm: (path) => `この会話を削除しますか? ${path} の worktree はディスク上に残ります。`,
session_deleted: '会話を削除しました',
session_deleted_worktree: '会話を削除しました。Worktree はディスク上に残ります。',
session_worktree_remove: 'ワークツリーを削除',
session_worktree_remove_desc: (path) => `${path} のgitワークツリーをディスクから削除します`,
session_worktree_remove_confirm: (path) => `gitワークツリーをディスクから削除しますか?\n\nパス: ${path}\n\nワークツリーディレクトリ全体が削除されます。セッションデータはWebUIに残ります。`,
session_worktree_remove_not_exists: (path) => `${path} のワークツリーはディスク上に存在しません。`,
session_worktree_remove_confirm_label: '削除',
session_worktree_removed: 'ワークツリーを削除しました。',
session_worktree_remove_failed: 'ワークツリーの削除に失敗: ',
session_worktree_remove_status_failed: 'ワークツリー状態の読み取りに失敗: ',
session_worktree_remove_locked_by_stream: '削除できません — アクティブなストリーミングセッションがこのワークツリーを使用中です。',
session_worktree_remove_locked_by_terminal: '削除できません — アクティブな端末セッションがこのワークツリーを使用中です。',
session_worktree_remove_unsafe_blocked: 'このワークツリーを削除する前に、ローカル変更または未プッシュのコミットを解消してください。',
session_worktree_remove_dirty_warning: '警告: このワークツリーにはコミットされていない変更があり、失われます。',
session_worktree_remove_untracked_warning: (count) => `${count}件の追跡されていないファイルが完全に削除されます。`,
session_worktree_remove_ahead_warning: (ahead) => `${ahead}件の未プッシュコミットが失われます。`,
session_select_mode: '選択',
session_select_mode_desc: '会話を選択して一括管理',
session_select_all: 'すべて選択',
@@ -2865,11 +2910,11 @@ const LOCALES = {
logs_no_mtime: '未書き込み',
logs_truncated_hint: '大きなログファイルの末尾を表示しています。メモリ使用量を抑えるため、古いデータは省略されました。',
logs_copied: 'ログをコピーしました',
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
logs_severity: '重大度',
logs_severity_all: 'すべて',
logs_severity_errors: 'エラー',
logs_severity_warnings: '警告+',
logs_filter_active: '表示中(フィルター有効)',
// Insights
insights_title: '使用状況分析',
@@ -3778,11 +3823,11 @@ const LOCALES = {
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
logs_severity: 'Уровень',
logs_severity_all: 'Все',
logs_severity_errors: 'Ошибки',
logs_severity_warnings: 'Предупреждения+',
logs_filter_active: 'показано (фильтр активен)',
new_conversation: 'Новая беседа',
filter_conversations: 'Фильтр бесед...',
session_time_unknown: 'Неизвестно',
@@ -4187,6 +4232,20 @@ const LOCALES = {
session_delete_worktree_confirm: (path) => `Delete this conversation? The worktree at ${path} will remain on disk.`,
session_deleted: 'Conversation deleted',
session_deleted_worktree: 'Conversation deleted. Worktree remains on disk.',
session_worktree_remove: 'Remove worktree',
session_worktree_remove_desc: (path) => `Delete the git worktree at ${path} from disk`,
session_worktree_remove_confirm: (path) => `Remove git worktree from disk?\n\nPath: ${path}\n\nThis will delete the entire worktree directory. Session data remains in WebUI.`,
session_worktree_remove_not_exists: (path) => `The worktree at ${path} no longer exists on disk.`,
session_worktree_remove_confirm_label: 'Remove',
session_worktree_removed: 'Worktree removed.',
session_worktree_remove_failed: 'Failed to remove worktree: ',
session_worktree_remove_status_failed: 'Failed to read worktree status: ',
session_worktree_remove_locked_by_stream: 'Cannot remove — an active streaming session is using this worktree.',
session_worktree_remove_locked_by_terminal: 'Cannot remove — an active terminal session is using this worktree.',
session_worktree_remove_unsafe_blocked: 'Resolve local changes or unpushed commits before removing this worktree.',
session_worktree_remove_dirty_warning: 'WARNING: This worktree has uncommitted changes which will be lost.',
session_worktree_remove_untracked_warning: (count) => `${count} untracked file(s) will be permanently deleted.`,
session_worktree_remove_ahead_warning: (ahead) => `${ahead} unpushed commit(s) will be lost.`,
session_duplicate: 'Duplicate conversation',
session_duplicate_desc: 'Create a copy with the same workspace and model',
session_duplicate_failed: 'Duplicate failed: ',
@@ -4820,11 +4879,11 @@ const LOCALES = {
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
logs_severity: 'Severidad',
logs_severity_all: 'Todo',
logs_severity_errors: 'Errores',
logs_severity_warnings: 'Advertencias+',
logs_filter_active: 'mostrados (filtro activo)',
new_conversation: 'Nueva conversación',
filter_conversations: 'Filtrar conversaciones...',
session_time_unknown: 'Desconocido',
@@ -5217,6 +5276,20 @@ const LOCALES = {
session_delete_worktree_confirm: (path) => `¿Eliminar esta conversación? El worktree en ${path} permanecerá en disco.`,
session_deleted: 'Conversación eliminada',
session_deleted_worktree: 'Conversación eliminada. El worktree permanece en disco.',
session_worktree_remove: 'Eliminar worktree',
session_worktree_remove_desc: (path) => `Eliminar el git worktree en ${path} del disco`,
session_worktree_remove_confirm: (path) => `¿Eliminar el git worktree del disco?\n\nRuta: ${path}\n\nSe eliminará todo el directorio del worktree. Los datos de la sesión permanecen en WebUI.`,
session_worktree_remove_not_exists: (path) => `El worktree en ${path} ya no existe en el disco.`,
session_worktree_remove_confirm_label: 'Eliminar',
session_worktree_removed: 'Worktree eliminado.',
session_worktree_remove_failed: 'Error al eliminar worktree: ',
session_worktree_remove_status_failed: 'Error al leer el estado del worktree: ',
session_worktree_remove_locked_by_stream: 'No se puede eliminar — una sesión de streaming activa está usando este worktree.',
session_worktree_remove_locked_by_terminal: 'No se puede eliminar — una sesión de terminal activa está usando este worktree.',
session_worktree_remove_unsafe_blocked: 'Resuelve los cambios locales o los commits no enviados antes de eliminar este worktree.',
session_worktree_remove_dirty_warning: 'ADVERTENCIA: Este worktree tiene cambios no confirmados que se perderán.',
session_worktree_remove_untracked_warning: (count) => `${count} archivo(s) no rastreados se eliminarán permanentemente.`,
session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit(s) no enviados se perderán.`,
session_duplicate: 'Duplicate conversation',
session_duplicate_desc: 'Create a copy with the same workspace and model',
session_duplicate_failed: 'Duplicate failed: ',
@@ -5845,11 +5918,11 @@ const LOCALES = {
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
logs_severity: 'Schweregrad',
logs_severity_all: 'Alle',
logs_severity_errors: 'Fehler',
logs_severity_warnings: 'Warnungen+',
logs_filter_active: 'angezeigt (Filter aktiv)',
new_conversation: 'Neuer Chat',
filter_conversations: 'Chats filtern...',
scheduled_jobs: 'Geplante Aufgaben',
@@ -5987,6 +6060,20 @@ const LOCALES = {
session_delete_worktree_confirm: (path) => `Diese Konversation löschen? Der Worktree unter ${path} bleibt auf der Festplatte.`,
session_deleted: 'Konversation gelöscht',
session_deleted_worktree: 'Konversation gelöscht. Der Worktree bleibt auf der Festplatte.',
session_worktree_remove: 'Worktree entfernen',
session_worktree_remove_desc: (path) => `Git-Worktree unter ${path} von der Festplatte löschen`,
session_worktree_remove_confirm: (path) => `Git-Worktree von der Festplatte entfernen?\n\nPfad: ${path}\n\nDas gesamte Worktree-Verzeichnis wird gelöscht. Sitzungsdaten bleiben in WebUI.`,
session_worktree_remove_not_exists: (path) => `Der Worktree unter ${path} existiert nicht mehr auf der Festplatte.`,
session_worktree_remove_confirm_label: 'Entfernen',
session_worktree_removed: 'Worktree entfernt.',
session_worktree_remove_failed: 'Fehler beim Entfernen des Worktree: ',
session_worktree_remove_status_failed: 'Fehler beim Lesen des Worktree-Status: ',
session_worktree_remove_locked_by_stream: 'Entfernen nicht möglich — eine aktive Streaming-Sitzung verwendet diesen Worktree.',
session_worktree_remove_locked_by_terminal: 'Entfernen nicht möglich — eine aktive Terminal-Sitzung verwendet diesen Worktree.',
session_worktree_remove_unsafe_blocked: 'Löse lokale Änderungen oder nicht gepushte Commits, bevor du diesen Worktree entfernst.',
session_worktree_remove_dirty_warning: 'WARNUNG: Dieser Worktree hat nicht festgeschriebene Änderungen, die verloren gehen.',
session_worktree_remove_untracked_warning: (count) => `${count} nicht verfolgte Datei(en) werden dauerhaft gelöscht.`,
session_worktree_remove_ahead_warning: (ahead) => `${ahead} nicht gepushte Commit(s) gehen verloren.`,
session_duplicate: 'Duplicate conversation',
session_duplicate_desc: 'Create a copy with the same workspace and model',
session_duplicate_failed: 'Duplicate failed: ',
@@ -6903,11 +6990,11 @@ const LOCALES = {
logs_no_mtime: '尚未写入',
logs_truncated_hint: '此处显示的是日志文件的末尾内容。为节省内存,已省略较早的数据。',
logs_copied: '日志已复制',
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
logs_severity: '严重性',
logs_severity_all: '全部',
logs_severity_errors: '错误',
logs_severity_warnings: '警告+',
logs_filter_active: '已显示(筛选器已启用)',
new_conversation: '新建对话',
filter_conversations: '筛选对话…',
session_time_unknown: '未知',
@@ -7298,6 +7385,20 @@ const LOCALES = {
session_delete_worktree_confirm: (path) => `删除此会话?位于 ${path} 的 worktree 将保留在磁盘上。`,
session_deleted: '会话已删除',
session_deleted_worktree: '会话已删除。Worktree 仍保留在磁盘上。',
session_worktree_remove: '删除 worktree',
session_worktree_remove_desc: (path) => `删除位于 ${path} 的 git worktree`,
session_worktree_remove_confirm: (path) => `确定从磁盘删除 git worktree\n\n路径:${path}\n\n整个 worktree 目录将被删除,WebUI 中的会话数据保留。`,
session_worktree_remove_not_exists: (path) => `位于 ${path} 的 worktree 在磁盘上已不存在。`,
session_worktree_remove_confirm_label: '删除',
session_worktree_removed: 'Worktree 已删除。',
session_worktree_remove_failed: '删除 worktree 失败:',
session_worktree_remove_status_failed: '读取 worktree 状态失败:',
session_worktree_remove_locked_by_stream: '无法删除 — 存在活跃的流式会话正在使用此 worktree。',
session_worktree_remove_locked_by_terminal: '无法删除 — 存在活跃的终端会话正在使用此 worktree。',
session_worktree_remove_unsafe_blocked: '请先处理本地更改或未推送提交,再删除此 worktree。',
session_worktree_remove_dirty_warning: '⚠️ 此 worktree 有未提交的更改,将被永久删除。',
session_worktree_remove_untracked_warning: (count) => `${count} 个未追踪文件将被永久删除。`,
session_worktree_remove_ahead_warning: (ahead) => `${ahead} 个未推送的提交将丢失。`,
session_duplicate: '复制会话',
session_duplicate_desc: '用相同工作区和模型创建副本',
session_duplicate_failed: '复制失败:',
@@ -7743,6 +7844,20 @@ const LOCALES = {
session_delete_worktree_confirm: (path) => `刪除此對話?位於 ${path} 的 worktree 將保留在磁碟上。`,
session_deleted: '對話已刪除',
session_deleted_worktree: '對話已刪除。Worktree 仍保留在磁碟上。',
session_worktree_remove: '刪除 worktree',
session_worktree_remove_desc: (path) => `刪除位於 ${path} 的 git worktree`,
session_worktree_remove_confirm: (path) => `確定從磁碟刪除 git worktree\n\n路徑:${path}\n\n整個 worktree 目錄將被刪除,WebUI 中的工作階段資料保留。`,
session_worktree_remove_not_exists: (path) => `位於 ${path} 的 worktree 在磁碟上已不存在。`,
session_worktree_remove_confirm_label: '刪除',
session_worktree_removed: 'Worktree 已刪除。',
session_worktree_remove_failed: '刪除 worktree 失敗:',
session_worktree_remove_status_failed: '讀取 worktree 狀態失敗:',
session_worktree_remove_locked_by_stream: '無法刪除 — 存在活躍的串流工作階段正在使用此 worktree。',
session_worktree_remove_locked_by_terminal: '無法刪除 — 存在活躍的終端機工作階段正在使用此 worktree。',
session_worktree_remove_unsafe_blocked: '請先處理本機變更或未推送提交,再刪除此 worktree。',
session_worktree_remove_dirty_warning: '⚠️ 此 worktree 有未提交的變更,將被永久刪除。',
session_worktree_remove_untracked_warning: (count) => `${count} 個未追蹤檔案將被永久刪除。`,
session_worktree_remove_ahead_warning: (ahead) => `${ahead} 個未推送的提交將丟失。`,
session_select_mode: '選取',
session_select_mode_desc: '選取會話以批次管理',
session_select_all: '全選',
@@ -7933,6 +8048,11 @@ const LOCALES = {
kanban_dispatch_auto_blocked: '自動封鎖',
kanban_dispatch_timed_out: '逾時',
kanban_dispatch_crashed: '崩潰',
logs_severity: '嚴重性',
logs_severity_all: '全部',
logs_severity_errors: '錯誤',
logs_severity_warnings: '警告+',
logs_filter_active: '已顯示(篩選器已啟用)',
new_conversation: '新對話',
filter_conversations: '篩選對話',
scheduled_jobs: '排程任務',
@@ -8945,6 +9065,20 @@ const LOCALES = {
session_delete_worktree_confirm: (path) => `Excluir esta conversa? O worktree em ${path} permanecerá no disco.`,
session_deleted: 'Conversa excluída',
session_deleted_worktree: 'Conversa excluída. O worktree permanece no disco.',
session_worktree_remove: 'Remover worktree',
session_worktree_remove_desc: (path) => `Excluir o git worktree em ${path} do disco`,
session_worktree_remove_confirm: (path) => `Remover git worktree do disco?\n\nCaminho: ${path}\n\nTodo o diretório do worktree será excluído. Dados da sessão permanecem no WebUI.`,
session_worktree_remove_not_exists: (path) => `O worktree em ${path} não existe mais no disco.`,
session_worktree_remove_confirm_label: 'Remover',
session_worktree_removed: 'Worktree removido.',
session_worktree_remove_failed: 'Falha ao remover worktree: ',
session_worktree_remove_status_failed: 'Falha ao ler o status do worktree: ',
session_worktree_remove_locked_by_stream: 'Não é possível remover — uma sessão de streaming ativa está usando este worktree.',
session_worktree_remove_locked_by_terminal: 'Não é possível remover — uma sessão de terminal ativa está usando este worktree.',
session_worktree_remove_unsafe_blocked: 'Resolva alterações locais ou commits não enviados antes de remover este worktree.',
session_worktree_remove_dirty_warning: 'AVISO: Este worktree tem alterações não confirmadas que serão perdidas.',
session_worktree_remove_untracked_warning: (count) => `${count} arquivo(s) não rastreados serão excluídos permanentemente.`,
session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit(s) não enviados serão perdidos.`,
session_batch_delete_worktree_confirm: 'Excluir {0} conversas? {1} conversa(s) com worktree manterão seus diretórios de worktree no disco.',
session_batch_archive_worktree_confirm: 'Arquivar {0} conversas? {1} conversa(s) com worktree manterão seus diretórios de worktree no disco.',
session_batch_delete_confirm: 'Excluir {0} conversas?',
@@ -9138,11 +9272,11 @@ const LOCALES = {
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
logs_severity: 'Severidade',
logs_severity_all: 'Todos',
logs_severity_errors: 'Erros',
logs_severity_warnings: 'Avisos+',
logs_filter_active: 'exibidos (filtro ativo)',
new_conversation: 'Nova conversa',
filter_conversations: 'Filtrar conversas...',
session_time_unknown: 'Desconhecido',
@@ -9947,6 +10081,20 @@ const LOCALES = {
session_delete_worktree_confirm: (path) => `이 대화를 삭제하시겠습니까? ${path}의 worktree는 디스크에 남아 있습니다.`,
session_deleted: '대화가 삭제되었습니다',
session_deleted_worktree: '대화가 삭제되었습니다. Worktree는 디스크에 남아 있습니다.',
session_worktree_remove: '워크트리 삭제',
session_worktree_remove_desc: (path) => `${path}의 git worktree를 디스크에서 삭제합니다`,
session_worktree_remove_confirm: (path) => `git worktree를 디스크에서 삭제하시겠습니까?\n\n경로: ${path}\n\n전체 worktree 디렉토리가 삭제됩니다. 세션 데이터는 WebUI에 보존됩니다.`,
session_worktree_remove_not_exists: (path) => `${path}의 worktree가 디스크에 더 이상 존재하지 않습니다.`,
session_worktree_remove_confirm_label: '삭제',
session_worktree_removed: '워크트리가 삭제되었습니다.',
session_worktree_remove_failed: '워크트리 삭제 실패: ',
session_worktree_remove_status_failed: '워크트리 상태 읽기 실패: ',
session_worktree_remove_locked_by_stream: '삭제할 수 없습니다 — 활성 스트리밍 세션이 이 worktree를 사용 중입니다.',
session_worktree_remove_locked_by_terminal: '삭제할 수 없습니다 — 활성 터미널 세션이 이 worktree를 사용 중입니다.',
session_worktree_remove_unsafe_blocked: '이 worktree를 삭제하기 전에 로컬 변경 사항이나 푸시되지 않은 커밋을 정리하세요.',
session_worktree_remove_dirty_warning: '경고: 이 worktree에는 커밋되지 않은 변경 사항이 있으며 손실됩니다.',
session_worktree_remove_untracked_warning: (count) => `${count}개의 추적되지 않은 파일이 영구적으로 삭제됩니다.`,
session_worktree_remove_ahead_warning: (ahead) => `${ahead}개의 푸시되지 않은 커밋이 손실됩니다.`,
session_select_mode: '선택',
session_select_mode_desc: '일괄 관리할 대화를 선택하세요',
session_select_all: '전체 선택',
@@ -10144,11 +10292,11 @@ const LOCALES = {
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
logs_severity: '심각도',
logs_severity_all: '전체',
logs_severity_errors: '오류',
logs_severity_warnings: '경고+',
logs_filter_active: '표시됨(필터 활성)',
new_conversation: '새 대화',
filter_conversations: '대화 필터…',
session_time_unknown: 'Unknown',
@@ -10973,6 +11121,20 @@ const LOCALES = {
session_delete_worktree_desc: 'Supprimez uniquement la conversation WebUI ; garder l\'arbre de travail sur le disque',
session_deleted: 'Conversation supprimée',
session_deleted_worktree: 'Conversation supprimée. Worktree reste sur le disque.',
session_worktree_remove: 'Supprimer le worktree',
session_worktree_remove_desc: (path) => `Supprimer le git worktree à ${path} du disque`,
session_worktree_remove_confirm: (path) => `Supprimer le git worktree du disque ?\n\nChemin : ${path}\n\nTout le répertoire worktree sera supprimé. Les données de session restent dans WebUI.`,
session_worktree_remove_not_exists: (path) => `Le worktree à ${path} n'existe plus sur le disque.`,
session_worktree_remove_confirm_label: 'Supprimer',
session_worktree_removed: 'Worktree supprimé.',
session_worktree_remove_failed: 'Échec de la suppression du worktree : ',
session_worktree_remove_status_failed: 'Échec de la lecture du statut du worktree : ',
session_worktree_remove_locked_by_stream: 'Impossible de supprimer — une session de streaming active utilise ce worktree.',
session_worktree_remove_locked_by_terminal: 'Impossible de supprimer — une session de terminal active utilise ce worktree.',
session_worktree_remove_unsafe_blocked: 'Résolvez les modifications locales ou les commits non poussés avant de supprimer ce worktree.',
session_worktree_remove_dirty_warning: 'ATTENTION : Ce worktree a des modifications non validées qui seront perdues.',
session_worktree_remove_untracked_warning: (count) => `${count} fichier(s) non suivi(s) seront définitivement supprimés.`,
session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit(s) non poussé(s) seront perdus.`,
session_select_mode: 'Sélectionner',
session_select_mode_desc: 'Sélectionnez les conversations à gérer par lots',
session_select_all: 'Tout sélectionner',
+87 -3
View File
@@ -654,10 +654,7 @@ async function loadSession(sid){
updateQueueBadge(sid);
syncTopbar();renderMessages();
if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid);
// Kick off loadDir first (issues network requests), then highlight code.
// The fetch is dispatched before the CPU-bound Prism pass begins.
const _dirP=loadDir('.');
highlightCode();
await _dirP;
}
}
@@ -1666,6 +1663,18 @@ function _openSessionActionMenu(session, anchorEl){
));
}
if(!isExternalSession){
if(session.worktree_path){
menu.appendChild(_buildSessionAction(
t('session_worktree_remove'),
t('session_worktree_remove_desc', session.worktree_path),
ICONS.trash,
async()=>{
closeSessionActionMenu();
await removeWorktree(session);
},
'danger'
));
}
menu.appendChild(_buildSessionAction(
t('session_delete'),
_sessionDeleteDescription(session),
@@ -3152,6 +3161,81 @@ if(typeof window!=='undefined'){
});
}
async function removeWorktree(session){
// Fetch status first
let status=null;
try{
const statusResp=await api('/api/session/worktree/status?session_id='+encodeURIComponent(session.session_id));
status=statusResp.status;
}catch(e){
showToast(t('session_worktree_remove_status_failed')+e.message,0,'error');
return;
}
if(!status){
showToast(t('session_worktree_remove_status_failed'),0,'error');
return;
}
// Build confirm message
let details='';
if(!status.exists){
details=t('session_worktree_remove_not_exists',status.path);
}else{
details=t('session_worktree_remove_confirm',status.path);
if(status.locked_by_stream){
showToast(t('session_worktree_remove_locked_by_stream'),0,'error');
return;
}
if(status.locked_by_terminal){
showToast(t('session_worktree_remove_locked_by_terminal'),0,'error');
return;
}
if(status.dirty){
details+='\n\n'+t('session_worktree_remove_dirty_warning');
}
if(status.untracked_count>0){
details+='\n'+t('session_worktree_remove_untracked_warning',status.untracked_count);
}
if(status.ahead_behind&&status.ahead_behind.ahead>0){
details+='\n'+t('session_worktree_remove_ahead_warning',status.ahead_behind.ahead);
}
if(status.dirty||status.untracked_count>0||(status.ahead_behind&&status.ahead_behind.ahead>0)){
showToast(t('session_worktree_remove_failed')+t('session_worktree_remove_unsafe_blocked'),0,'error');
await showConfirmDialog({
message:details,
confirmLabel:t('dialog_confirm_btn'),
danger:true,
focusCancel:true
});
return;
}
}
const ok=await showConfirmDialog({
message:details,
confirmLabel:t('session_worktree_remove_confirm_label'),
danger:true
});
if(!ok)return;
try{
const result=await api('/api/session/worktree/remove',{
method:'POST',
body:JSON.stringify({session_id:session.session_id, force:false})
});
const warn=result.warnings&&result.warnings.length?(' '+result.warnings.join(' ')):'';
showToast(t('session_worktree_removed')+warn);
// Clear the worktree_path from cached session so menu doesn't show stale remove action
if(session.worktree_path){
session.worktree_path=null;
}
// Re-render the list if this is the active session
if(S.session&&S.session.session_id===session.session_id&&S.session.worktree_path){
S.session.worktree_path=null;
}
await renderSessionList();
}catch(e){
showToast(t('session_worktree_remove_failed')+e.message,0,'error');
}
}
async function deleteSession(sid){
const session=_sessionSnapshotById(sid);
const ok=await showConfirmDialog({
+39 -20
View File
@@ -4851,8 +4851,7 @@ function renderMessages(options){
_wireMessageWindowLoadEarlierButton();
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>postProcessRenderedMessages(inner));
if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver();
if(typeof loadTodos==='function'&&document.getElementById('panelTodos')&&document.getElementById('panelTodos').classList.contains('active')){loadTodos();}
return;
@@ -5409,8 +5408,7 @@ function renderMessages(options){
// scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up.
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
// Apply syntax highlighting after DOM is built
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>postProcessRenderedMessages(inner));
// Refresh todo panel if it's currently open
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
loadTodos();
@@ -5721,6 +5719,19 @@ async function regenerateResponse(btn) {
} catch(e) { setStatus(t('regen_failed') + e.message); }
}
function postProcessRenderedMessages(container) {
highlightCode(container);
addCopyButtons(container);
loadDiffInline(container);
loadCsvInline(container);
loadExcalidrawInline(container);
loadPdfInline(container);
loadHtmlInline(container);
renderMermaidBlocks(container);
renderKatexBlocks(container);
initTreeViews(container);
}
function highlightCode(container) {
// Apply Prism.js syntax highlighting to all code blocks in container (or whole messages area)
if(typeof Prism === 'undefined' || !Prism.highlightAllUnder) return;
@@ -5744,8 +5755,9 @@ function _loadJsyamlThen(cb){
document.head.appendChild(s);
}
function initTreeViews(){
document.querySelectorAll('.code-tree-wrap:not([data-tree-init])').forEach(wrap=>{
function initTreeViews(container){
const root=container||document;
root.querySelectorAll('.code-tree-wrap:not([data-tree-init])').forEach(wrap=>{
const rawText=wrap.dataset.raw;
const lang=wrap.dataset.lang;
let parsed=null;
@@ -5902,9 +5914,10 @@ function addCopyButtons(container){
let _mermaidLoading=false;
let _mermaidReady=false;
function loadDiffInline(){
function loadDiffInline(container){
const DIFF_MAX_SIZE=512*1024; // 512 KB cap for inline diff rendering
document.querySelectorAll('.diff-inline-load:not([data-loaded])').forEach(el=>{
const root=container||document;
root.querySelectorAll('.diff-inline-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
fetch('api/media?path='+encodeURIComponent(path))
@@ -5929,9 +5942,10 @@ function loadDiffInline(){
});
}
function loadCsvInline(){
function loadCsvInline(container){
const CSV_MAX_SIZE=256*1024; // 256 KB cap for inline CSV rendering
document.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{
const root=container||document;
root.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
fetch('api/media?path='+encodeURIComponent(path))
@@ -5964,9 +5978,10 @@ function loadCsvInline(){
});
}
function loadExcalidrawInline(){
function loadExcalidrawInline(container){
const EXCALIDRAW_MAX_SIZE=512*1024; // 512 KB cap
document.querySelectorAll('.excalidraw-inline-load:not([data-loaded])').forEach(el=>{
const root=container||document;
root.querySelectorAll('.excalidraw-inline-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
fetch('api/media?path='+encodeURIComponent(path))
@@ -6090,9 +6105,10 @@ function _renderExcalidrawCanvases(){
// the full buffer is received — ideally the server would enforce it before
// streaming (out of scope for this client-side PR).
let _pdfjsReady=false, _pdfjsLoading=false;
function loadPdfInline(){
function loadPdfInline(container){
const PDF_MAX_SIZE=4*1024*1024; // 4 MB cap for inline PDF preview
document.querySelectorAll('.pdf-preview-load:not([data-loaded])').forEach(el=>{
const root=container||document;
root.querySelectorAll('.pdf-preview-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
const fname=path.split('/').pop()||path;
@@ -6164,9 +6180,10 @@ function loadPdfInline(){
}
// ── HTML inline preview (sandboxed iframe) ─────────────────────────────────
function loadHtmlInline(){
function loadHtmlInline(container){
const HTML_MAX_SIZE=256*1024; // 256 KB cap for inline HTML preview
document.querySelectorAll('.html-preview-load:not([data-loaded])').forEach(el=>{
const root=container||document;
root.querySelectorAll('.html-preview-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
const fname=path.split('/').pop()||path;
@@ -6189,8 +6206,9 @@ function loadHtmlInline(){
});
}
function renderMermaidBlocks(){
const blocks=document.querySelectorAll('.mermaid-block:not([data-rendered])');
function renderMermaidBlocks(container){
const root=container||document;
const blocks=root.querySelectorAll('.mermaid-block:not([data-rendered])');
if(!blocks.length) return;
if(!_mermaidReady){
if(!_mermaidLoading){
@@ -6239,8 +6257,9 @@ function renderMermaidBlocks(){
let _katexLoading=false;
let _katexReady=false;
function renderKatexBlocks(){
const blocks=document.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])');
function renderKatexBlocks(container){
const root=container||document;
const blocks=root.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])');
if(!blocks.length) return;
if(!_katexReady){
if(!_katexLoading){
+10 -8
View File
@@ -53,14 +53,14 @@ def test_loadCsvInline_function():
"""Verify loadCsvInline lazy-load function exists."""
with open('static/ui.js') as f:
src = f.read()
assert 'function loadCsvInline()' in src, "Missing loadCsvInline function"
assert 'function loadCsvInline' in src, "Missing loadCsvInline function"
def test_csv_inline_max_size():
"""Verify CSV inline rendering has a size cap."""
with open('static/ui.js') as f:
src = f.read()
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
assert 'CSV_MAX_SIZE' in csv_section, "Should have CSV_MAX_SIZE constant"
assert 'csv_too_large' in csv_section, "Should use csv_too_large i18n for oversized files"
@@ -69,7 +69,7 @@ def test_csv_auto_detect_separator():
"""Verify CSV handler auto-detects separator."""
with open('static/ui.js') as f:
src = f.read()
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
assert 'separators' in csv_section, "Should have separator detection"
assert ';' in csv_section, "Should detect semicolon separator"
assert 'tab' in csv_section.lower() or '\\t' in csv_section, "Should detect tab separator"
@@ -86,24 +86,26 @@ def test_csv_error_handling():
"""Verify CSV error and empty data handling."""
with open('static/ui.js') as f:
src = f.read()
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2500]
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2500]
assert 'csv_error' in csv_section, "Should use csv_error i18n on fetch failure"
assert 'csv_no_data' in csv_section, "Should use csv_no_data i18n for insufficient data"
def test_csv_loadCsvInline_called_after_render():
"""Verify loadCsvInline is called in requestAnimationFrame after rendering."""
"""Verify loadCsvInline is called by the consolidated post-render pass."""
with open('static/ui.js') as f:
src = f.read()
assert src.count('loadCsvInline()') >= 2, \
"loadCsvInline should be called at least twice (initial render + cache restore)"
assert 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))' in src
idx = src.find('function postProcessRenderedMessages')
body = src[idx:idx + 500]
assert 'loadCsvInline(container)' in body, "post-process should call loadCsvInline once per render"
def test_csv_line_ending_normalization():
"""Verify CSV handler normalizes line endings."""
with open('static/ui.js') as f:
src = f.read()
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
assert '\\r\\n' in csv_section, "Should handle \\r\\n line endings"
assert '\\r' in csv_section, "Should handle \\r line endings"
+13 -2
View File
@@ -75,6 +75,17 @@ def wait_for_pid_file(pid_file: Path, timeout: float = 3.0) -> int:
raise AssertionError(f"PID file was not written: {pid_file}")
def wait_for_file_text(path: Path, timeout: float = 3.0) -> str:
deadline = time.time() + timeout
while time.time() < deadline:
if path.exists():
text = path.read_text(encoding="utf-8")
if text:
return text
time.sleep(0.05)
raise AssertionError(f"File was not written: {path}")
def assert_process_exits(pid: int, timeout: float = 3.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
@@ -110,7 +121,7 @@ def test_start_writes_pid_under_hermes_home_runs_foreground_no_browser_and_logs(
try:
assert pid > 1
assert log_file.exists()
fake_output = fake_log.read_text(encoding="utf-8")
fake_output = wait_for_file_text(fake_log)
assert "bootstrap.py --no-browser --foreground" in fake_output
assert "host=0.0.0.0 port=18991" in fake_output
assert str(hermes_home / "webui") in fake_output
@@ -154,7 +165,7 @@ def test_start_loads_dotenv_but_inline_overrides_win(tmp_path):
assert result.returncode == 0, result.stderr + result.stdout
pid = wait_for_pid_file(tmp_path / ".hermes" / "webui.pid")
try:
fake_output = fake_log.read_text(encoding="utf-8")
fake_output = wait_for_file_text(fake_log)
assert "fake-python args:" in fake_output
assert "host=0.0.0.0 port=18888" in fake_output
finally:
+12 -8
View File
@@ -22,14 +22,14 @@ def test_loadExcalidrawInline_function():
"""Verify loadExcalidrawInline lazy-load function exists."""
with open('static/ui.js') as f:
src = f.read()
assert 'function loadExcalidrawInline()' in src, "Missing loadExcalidrawInline function"
assert 'function loadExcalidrawInline' in src, "Missing loadExcalidrawInline function"
def test_excalidraw_json_validation():
"""Verify Excalidraw handler validates JSON format."""
with open('static/ui.js') as f:
src = f.read()
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 2000]
assert 'JSON.parse' in func, "Should parse JSON"
assert 'excalidraw_invalid' in func, "Should handle invalid format"
assert "data.type!=='excalidraw'" in func, "Should validate type field is 'excalidraw'"
@@ -39,7 +39,7 @@ def test_excalidraw_size_cap():
"""Verify Excalidraw inline rendering has a size cap."""
with open('static/ui.js') as f:
src = f.read()
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 2000]
assert 'EXCALIDRAW_MAX_SIZE' in func, "Should have EXCALIDRAW_MAX_SIZE constant"
assert 'excalidraw_too_large' in func, "Should use excalidraw_too_large i18n for oversized files"
@@ -48,7 +48,7 @@ def test_excalidraw_error_handling():
"""Verify Excalidraw error handling."""
with open('static/ui.js') as f:
src = f.read()
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 3500]
func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 3500]
assert 'excalidraw_error' in func, "Should use excalidraw_error i18n on fetch failure"
@@ -114,17 +114,21 @@ def test_excalidraw_download_link():
"""Verify Excalidraw embed includes download link."""
with open('static/ui.js') as f:
src = f.read()
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 2000]
assert 'excalidraw-open-link' in func, "Should include open/download link"
assert 'excalidraw_download' in func, "Should use excalidraw_download i18n"
def test_excalidraw_called_after_render():
"""Verify loadExcalidrawInline is called after message rendering."""
"""Verify loadExcalidrawInline is called by the consolidated post-render pass."""
with open('static/ui.js') as f:
src = f.read()
assert src.count('loadExcalidrawInline()') >= 2, \
"loadExcalidrawInline should be called at least twice"
assert 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))' in src
idx = src.find('function postProcessRenderedMessages')
body = src[idx:idx + 500]
assert 'loadExcalidrawInline(container)' in body, (
"post-process should call loadExcalidrawInline once per render"
)
def test_excalidraw_embed_wrap_structure():
+117
View File
@@ -1,7 +1,11 @@
"""Regression tests for #1909 CSP report-only security header."""
import io
import json
from http.server import BaseHTTPRequestHandler
from types import SimpleNamespace
import api.routes as routes
from server import Handler
@@ -15,12 +19,20 @@ def test_handler_adds_content_security_policy_report_only(monkeypatch):
headers = dict(sent_headers)
assert "Content-Security-Policy-Report-Only" in headers
assert "Report-To" in headers
assert "Content-Security-Policy" not in headers
policy = headers["Content-Security-Policy-Report-Only"]
assert "default-src 'self'" in policy
assert "object-src 'none'" in policy
assert "frame-ancestors 'self'" in policy
assert "base-uri 'self'" in policy
assert "report-uri /api/csp-report" in policy
assert "report-to csp-endpoint" in policy
assert json.loads(headers["Report-To"]) == {
"group": "csp-endpoint",
"max_age": 10886400,
"endpoints": [{"url": "/api/csp-report"}],
}
def test_csp_report_only_keeps_legacy_inline_allowances_for_current_ui():
@@ -33,3 +45,108 @@ def test_csp_report_only_keeps_legacy_inline_allowances_for_current_ui():
assert "'unsafe-eval'" not in policy
assert "img-src 'self' data: blob:" in policy
assert "connect-src 'self'" in policy
class _FakeHandler:
def __init__(self, body=b"{}", headers=None, client_ip="203.0.113.10"):
self.headers = {
"Content-Length": str(len(body)),
"Content-Type": "application/csp-report",
**(headers or {}),
}
self.rfile = io.BytesIO(body)
self.wfile = io.BytesIO()
self.client_address = (client_ip, 54321)
self.status = None
self.sent_headers = {}
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.sent_headers[key] = value
def end_headers(self):
pass
def test_csp_report_endpoint_accepts_report_uri_payload_without_csrf(monkeypatch, caplog):
routes._CSP_REPORT_RATE_LIMIT.clear()
payload = {
"csp-report": {
"document-uri": "http://127.0.0.1:8787/",
"violated-directive": "script-src-elem",
"blocked-uri": "inline",
}
}
handler = _FakeHandler(json.dumps(payload).encode("utf-8"))
def fail_if_called(_handler):
raise AssertionError("CSP reports must bypass the normal CSRF gate")
monkeypatch.setattr(routes, "_check_csrf", fail_if_called)
with caplog.at_level("INFO", logger="csp_report"):
assert routes.handle_post(handler, SimpleNamespace(path="/api/csp-report")) is True
assert handler.status == 204
assert handler.sent_headers["Content-Length"] == "0"
assert "violated-directive" in caplog.text
def test_csp_report_endpoint_accepts_report_to_array_payload():
routes._CSP_REPORT_RATE_LIMIT.clear()
payload = [
{
"type": "csp-violation",
"url": "http://127.0.0.1:8787/",
"body": {"blockedURL": "https://example.invalid/script.js"},
}
]
handler = _FakeHandler(
json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/reports+json"},
)
assert routes.handle_post(handler, SimpleNamespace(path="/api/csp-report")) is True
assert handler.status == 204
assert handler.sent_headers["Content-Length"] == "0"
def test_csp_report_endpoint_rate_limits_by_client_ip(monkeypatch):
routes._CSP_REPORT_RATE_LIMIT.clear()
monkeypatch.setattr(routes, "_CSP_REPORT_RATE_LIMIT_MAX", 1)
first = _FakeHandler(b"{}", client_ip="203.0.113.11")
second = _FakeHandler(b"{}", client_ip="203.0.113.11")
assert routes.handle_post(first, SimpleNamespace(path="/api/csp-report")) is True
assert routes.handle_post(second, SimpleNamespace(path="/api/csp-report")) is True
assert first.status == 204
assert second.status == 204
assert second.rfile.tell() == 0
def test_server_bypasses_auth_for_csp_report(monkeypatch):
handler = Handler.__new__(Handler)
handler.path = "/api/csp-report"
handler.command = "POST"
handler._req_t0 = 0
def fail_auth(_handler, _parsed):
raise AssertionError("CSP report collector must not require auth")
called = {}
def fake_route(_handler, parsed):
called["path"] = parsed.path
return True
monkeypatch.setattr("server.check_auth", fail_auth)
monkeypatch.setattr("server.clear_request_profile", lambda: None)
monkeypatch.setattr("server.get_profile_cookie", lambda _handler: None)
Handler._handle_write(handler, fake_route)
assert called == {"path": "/api/csp-report"}
@@ -68,3 +68,15 @@ def test_worktree_archive_delete_api_responses_are_explicit():
assert '"worktree_retained": True' in src
assert '{"ok": True, **worktree_retained}' in src
assert '{"ok": True, "session": s.compact(), **_worktree_retained_payload(s)}' in src
def test_remove_worktree_ui_does_not_force_unsafe_status_by_default():
src = read("static/sessions.js")
i18n = read("static/i18n.js")
assert "async function removeWorktree(session)" in src
assert "status.dirty||status.untracked_count>0||(status.ahead_behind&&status.ahead_behind.ahead>0)" in src
assert "session_worktree_remove_unsafe_blocked" in src
assert "session_worktree_remove_unsafe_blocked" in i18n
assert "Resolve local changes or unpushed commits before removing this worktree." in i18n
assert "JSON.stringify({session_id:session.session_id, force:false})" in src
assert "const force=(status.dirty||status.untracked_count>0)" not in src
+119
View File
@@ -0,0 +1,119 @@
import re
from pathlib import Path
I18N_PATH = Path(__file__).resolve().parent.parent / "static" / "i18n.js"
LOGS_FILTER_KEYS = {
"ja": {
"logs_severity": "重大度",
"logs_severity_all": "すべて",
"logs_severity_errors": "エラー",
"logs_severity_warnings": "警告+",
"logs_filter_active": "表示中(フィルター有効)",
},
"ru": {
"logs_severity": "Уровень",
"logs_severity_all": "Все",
"logs_severity_errors": "Ошибки",
"logs_severity_warnings": "Предупреждения+",
"logs_filter_active": "показано (фильтр активен)",
},
"es": {
"logs_severity": "Severidad",
"logs_severity_all": "Todo",
"logs_severity_errors": "Errores",
"logs_severity_warnings": "Advertencias+",
"logs_filter_active": "mostrados (filtro activo)",
},
"de": {
"logs_severity": "Schweregrad",
"logs_severity_all": "Alle",
"logs_severity_errors": "Fehler",
"logs_severity_warnings": "Warnungen+",
"logs_filter_active": "angezeigt (Filter aktiv)",
},
"zh": {
"logs_severity": "严重性",
"logs_severity_all": "全部",
"logs_severity_errors": "错误",
"logs_severity_warnings": "警告+",
"logs_filter_active": "已显示(筛选器已启用)",
},
"zh-Hant": {
"logs_severity": "嚴重性",
"logs_severity_all": "全部",
"logs_severity_errors": "錯誤",
"logs_severity_warnings": "警告+",
"logs_filter_active": "已顯示(篩選器已啟用)",
},
"pt": {
"logs_severity": "Severidade",
"logs_severity_all": "Todos",
"logs_severity_errors": "Erros",
"logs_severity_warnings": "Avisos+",
"logs_filter_active": "exibidos (filtro ativo)",
},
"ko": {
"logs_severity": "심각도",
"logs_severity_all": "전체",
"logs_severity_errors": "오류",
"logs_severity_warnings": "경고+",
"logs_filter_active": "표시됨(필터 활성)",
},
}
def _i18n_locale_block(locale: str) -> str:
src = I18N_PATH.read_text(encoding="utf-8")
if "-" in locale:
head = re.compile(rf"^ '{re.escape(locale)}':\s*\{{", re.M)
else:
head = re.compile(rf"^ {re.escape(locale)}:\s*\{{", re.M)
match = head.search(src)
assert match, f"locale {locale!r} not found"
body_start = match.end()
depth = 1
i = body_start
while i < len(src) and depth > 0:
ch = src[i]
if ch == "/" and i + 1 < len(src) and src[i + 1] == "/":
newline = src.find("\n", i)
i = len(src) if newline < 0 else newline + 1
continue
if ch in ("'", '"'):
quote = ch
i += 1
while i < len(src) and src[i] != quote:
i += 2 if src[i] == "\\" else 1
i += 1
continue
if ch == "`":
i += 1
while i < len(src) and src[i] != "`":
i += 2 if src[i] == "\\" else 1
i += 1
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[body_start:i]
i += 1
raise AssertionError(f"locale {locale!r} block never closed")
def _string_value(block: str, key: str) -> str:
match = re.search(rf"^\s+{re.escape(key)}:\s+'([^']*)',(?P<tail>[^\n]*)$", block, re.M)
assert match, f"{key} missing"
assert "TODO: translate" not in match.group("tail")
return match.group(1)
def test_logs_severity_filter_keys_are_translated_for_non_english_locales():
for locale, expected in LOGS_FILTER_KEYS.items():
block = _i18n_locale_block(locale)
for key, value in expected.items():
assert _string_value(block, key) == value
@@ -0,0 +1,104 @@
import io
import json
from urllib.parse import urlparse
import api.profiles as profiles
import api.routes as routes
class _FakeHandler:
def __init__(self):
self.status = None
self.headers = {}
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.headers[key] = value
def end_headers(self):
pass
def json_body(self):
return json.loads(self.wfile.getvalue().decode("utf-8"))
def test_sessions_list_reconciles_stale_stream_state_before_serializing(monkeypatch):
repaired = {"value": False}
all_sessions_calls = {"count": 0}
class _Session:
def __init__(self):
self.session_id = "stale-session"
self.active_stream_id = "stale-stream"
def fake_all_sessions(diag=None):
all_sessions_calls["count"] += 1
if repaired["value"]:
active_stream_id = None
is_streaming = False
else:
active_stream_id = "stale-stream"
is_streaming = False
return [
{
"session_id": "stale-session",
"title": "Stale Session",
"profile": "default",
"active_stream_id": active_stream_id,
"is_streaming": is_streaming,
"updated_at": 1,
"last_message_at": 1,
}
]
def fake_get_session(session_id, metadata_only=False):
assert session_id == "stale-session"
assert metadata_only is True
return _Session()
def fake_clear_stale_stream_state(session):
repaired["value"] = True
session.active_stream_id = None
return True
monkeypatch.setattr(routes, "all_sessions", fake_all_sessions)
monkeypatch.setattr(routes, "get_session", fake_get_session)
monkeypatch.setattr(routes, "_clear_stale_stream_state", fake_clear_stale_stream_state)
monkeypatch.setattr(routes, "load_settings", lambda: {"show_cli_sessions": False})
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
handler = _FakeHandler()
parsed = urlparse("http://example.com/api/sessions")
routes.handle_get(handler, parsed)
assert handler.status == 200
payload = handler.json_body()
sessions = payload["sessions"]
assert all_sessions_calls["count"] == 2
assert repaired["value"] is True
assert sessions[0]["active_stream_id"] is None
assert sessions[0]["is_streaming"] is False
def test_reconcile_stale_stream_state_skips_live_stream_rows(monkeypatch):
loaded = []
def fake_get_session(session_id, metadata_only=False):
loaded.append((session_id, metadata_only))
raise AssertionError("live stream rows should not be loaded for cleanup")
monkeypatch.setattr(routes, "get_session", fake_get_session)
changed = routes._reconcile_stale_stream_state_for_session_rows([
{
"session_id": "live-session",
"active_stream_id": "live-stream",
"is_streaming": True,
}
])
assert changed is False
assert loaded == []
+9 -12
View File
@@ -166,7 +166,7 @@ def test_data_katex_attribute_present():
def test_render_katex_blocks_function_exists():
"""renderKatexBlocks() function must exist in ui.js."""
assert 'function renderKatexBlocks()' in UI_JS, \
assert 'function renderKatexBlocks' in UI_JS, \
'renderKatexBlocks() function not found in ui.js'
@@ -202,21 +202,18 @@ def test_katex_throw_on_error_false():
def test_render_katex_blocks_wired_into_raf():
"""renderKatexBlocks() must be called in the same requestAnimationFrame as renderMermaidBlocks()."""
# Check that renderKatexBlocks appears somewhere near requestAnimationFrame
raf_idx = UI_JS.find('requestAnimationFrame')
# Find the rAF call that also contains renderKatexBlocks
has_katex_in_raf = any(
'renderKatexBlocks' in UI_JS[m.start():m.start()+200]
for m in re.finditer(r'requestAnimationFrame', UI_JS)
)
assert has_katex_in_raf, \
'renderKatexBlocks() not found in any requestAnimationFrame call — math will not render'
"""renderKatexBlocks() must run from the post-render requestAnimationFrame pass."""
raf_call = 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))'
assert raf_call in UI_JS, 'post-render requestAnimationFrame pass not found'
idx = UI_JS.find('function postProcessRenderedMessages')
body = UI_JS[idx:idx + 500]
assert 'renderMermaidBlocks(container)' in body
assert 'renderKatexBlocks(container)' in body
def test_mermaid_render_failure_removes_temporary_error_dom():
"""Failed Mermaid renders must not leave Mermaid's body-level syntax-error SVG visible."""
fn_start = UI_JS.find('function renderMermaidBlocks()')
fn_start = UI_JS.find('function renderMermaidBlocks')
assert fn_start != -1, 'renderMermaidBlocks() function not found in ui.js'
fn = UI_JS[fn_start:fn_start + 2200]
cleanup = "const tmp=document.getElementById('d'+id);\n if(tmp) tmp.remove();"
+7 -4
View File
@@ -56,14 +56,17 @@ class TestMediaDiffInline:
"""loadDiffInline() function should be defined."""
with open("static/ui.js", "r", encoding="utf-8") as f:
content = f.read()
assert "function loadDiffInline()" in content
assert "function loadDiffInline" in content
def test_loadDiffInline_called_in_post_render(self):
"""loadDiffInline() should be called in post-render (after addCopyButtons)."""
"""loadDiffInline() should be called by the consolidated post-render pass."""
with open("static/ui.js", "r", encoding="utf-8") as f:
content = f.read()
count = content.count("loadDiffInline()")
assert count >= 2, f"loadDiffInline() called {count} times, expected >= 2 (cached + fresh render)"
assert "requestAnimationFrame(()=>postProcessRenderedMessages(inner))" in content
start = content.find("function postProcessRenderedMessages")
body = content[start:start + 500]
assert "addCopyButtons(container)" in body
assert "loadDiffInline(container)" in body
def test_diff_inline_error_class(self):
"""Should have error state class."""
+5 -3
View File
@@ -20,7 +20,7 @@ class TestTreeRenderer:
def test_initTreeViews_function_exists(self):
with open("static/ui.js", "r", encoding="utf-8") as f:
content = f.read()
assert "function initTreeViews()" in content
assert "function initTreeViews" in content
def test_buildTreeDOM_function_exists(self):
with open("static/ui.js", "r", encoding="utf-8") as f:
@@ -30,8 +30,10 @@ class TestTreeRenderer:
def test_initTreeViews_called_in_post_render(self):
with open("static/ui.js", "r", encoding="utf-8") as f:
content = f.read()
count = content.count("initTreeViews()")
assert count >= 2, f"initTreeViews() called {count} times, expected >= 2"
assert "requestAnimationFrame(()=>postProcessRenderedMessages(inner))" in content
start = content.find("function postProcessRenderedMessages")
body = content[start:start + 500]
assert "initTreeViews(container)" in body
def test_tree_handles_all_value_types(self):
"""_buildTreeDOM should handle null, boolean, number, string, array, object."""
+11 -15
View File
@@ -59,15 +59,14 @@ class TestLoadDirParallelPrefetch:
)
# ── 2. sessions.js: loadSession idle path overlaps loadDir and highlightCode
# ── 2. sessions.js: loadSession idle path avoids duplicate highlighting
class TestLoadSessionIdleOverlap:
"""The idle path in loadSession() must start loadDir() before running
highlightCode() so the network request is in-flight during the CPU-bound
Prism.js pass."""
"""The idle path in loadSession() should rely on renderMessages() for the
post-render transcript pass instead of running another Prism.js pass."""
def test_idle_path_starts_loaddir_before_highlightcode(self):
def test_idle_path_does_not_repeat_highlight_after_render_messages(self):
idle_marker = "S.busy=false"
positions = []
start = 0
@@ -81,25 +80,22 @@ class TestLoadSessionIdleOverlap:
found = False
for pos in positions:
block = SESSIONS_JS[pos : pos + 600]
has_highlight = "highlightCode()" in block
has_loaddir = "loadDir('.')" in block
if has_highlight and has_loaddir:
has_render = "renderMessages()" in block
if has_loaddir and has_render:
found = True
loaddir_idx = block.find("loadDir(")
highlight_idx = block.find("highlightCode()")
assert loaddir_idx < highlight_idx, (
"In the idle path, loadDir() should be started before "
"highlightCode() so the network request is dispatched first."
assert "highlightCode()" not in block, (
"The idle path should rely on renderMessages()'s consolidated "
"post-render pass instead of running a second highlight pass."
)
assert "await" in block and "_dirP" in block, (
"loadDir() result should be stored and awaited after "
"highlightCode() completes."
"loadDir() result should still be stored and awaited."
)
break
assert found, (
"Could not find the idle path in loadSession that calls both "
"loadDir and highlightCode."
"renderMessages and loadDir."
)
+32 -29
View File
@@ -104,37 +104,37 @@ class TestLoadPdfInlineFunction:
def test_function_exists(self):
ui = _read_js('ui.js')
assert 'function loadPdfInline()' in ui, 'loadPdfInline() function must exist'
assert 'function loadPdfInline' in ui, 'loadPdfInline() function must exist'
def test_selects_pdf_preview_load_elements(self):
ui = _read_js('ui.js')
idx = ui.find('function loadPdfInline()')
idx = ui.find('function loadPdfInline')
body = ui[idx:idx + 500]
assert 'pdf-preview-load' in body, 'Must query .pdf-preview-load elements'
assert 'data-loaded' in body, 'Must use data-loaded attribute to prevent double-processing'
def test_fetches_via_api_media(self):
ui = _read_js('ui.js')
idx = ui.find('function loadPdfInline()')
idx = ui.find('function loadPdfInline')
body = ui[idx:idx + 1500]
assert 'api/media?path=' in body, 'Must fetch PDF via api/media endpoint'
def test_has_size_cap(self):
ui = _read_js('ui.js')
idx = ui.find('function loadPdfInline()')
idx = ui.find('function loadPdfInline')
body = ui[idx:idx + 1500]
assert 'MAX_SIZE' in body or 'byteLength' in body, 'Must enforce a size cap on PDF files'
def test_fallback_on_error(self):
ui = _read_js('ui.js')
idx = ui.find('function loadPdfInline()')
idx = ui.find('function loadPdfInline')
body = ui[idx:idx + 3000]
assert 'pdf_error' in body, 'Must show error fallback on failure'
assert 'pdf_download' in body or 'download=' in body, 'Error fallback must include download link'
def test_lazy_loads_pdfjs_from_cdn(self):
ui = _read_js('ui.js')
idx = ui.find('function loadPdfInline()')
idx = ui.find('function loadPdfInline')
body = ui[idx:idx + 3000]
assert 'pdfjs' in body, 'Must lazy-load PDF.js from CDN'
@@ -149,44 +149,44 @@ class TestLoadHtmlInlineFunction:
def test_function_exists(self):
ui = _read_js('ui.js')
assert 'function loadHtmlInline()' in ui, 'loadHtmlInline() function must exist'
assert 'function loadHtmlInline' in ui, 'loadHtmlInline() function must exist'
def test_selects_html_preview_load_elements(self):
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline()')
idx = ui.find('function loadHtmlInline')
body = ui[idx:idx + 500]
assert 'html-preview-load' in body, 'Must query .html-preview-load elements'
assert 'data-loaded' in body, 'Must use data-loaded attribute'
def test_fetches_via_api_media(self):
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline()')
idx = ui.find('function loadHtmlInline')
body = ui[idx:idx + 1000]
assert 'api/media?path=' in body, 'Must fetch HTML via api/media endpoint'
def test_has_size_cap(self):
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline()')
idx = ui.find('function loadHtmlInline')
body = ui[idx:idx + 1000]
assert 'MAX_SIZE' in body or 'html.length' in body, 'Must enforce a size cap on HTML files'
def test_fallback_on_error(self):
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline()')
idx = ui.find('function loadHtmlInline')
body = ui[idx:idx + 2000]
assert 'html_error' in body, 'Must show error fallback on failure'
def test_uses_srcdoc_attribute(self):
"""Must use srcdoc (not src) for HTML content to keep it same-origin sandboxed."""
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline()')
idx = ui.find('function loadHtmlInline')
body = ui[idx:idx + 1500]
assert 'srcdoc=' in body, 'Must use srcdoc attribute for inline HTML rendering'
def test_escapes_html_for_srcdoc(self):
"""HTML content must be escaped before embedding in srcdoc to prevent attribute injection."""
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline()')
idx = ui.find('function loadHtmlInline')
body = ui[idx:idx + 1500]
# Must escape &, <, >, " to prevent breaking out of srcdoc attribute
assert '&amp;' in body or 'replace' in body, 'Must escape HTML entities for srcdoc'
@@ -195,31 +195,34 @@ class TestLoadHtmlInlineFunction:
# ── requestAnimationFrame integration ──────────────────────────────────────
class TestRAFIntegration:
"""Both lazy-load functions must be called in the requestAnimationFrame blocks."""
"""Lazy-load functions must be called by the consolidated post-render pass."""
def test_loadPdfInline_called_after_render(self):
ui = _read_js('ui.js')
raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui)
load_blocks = [b for b in raf_blocks if 'loadDiffInline' in b]
assert len(load_blocks) >= 2, 'Expected at least 2 rAF blocks with loadDiffInline'
for block in load_blocks:
assert 'loadPdfInline()' in block, 'loadPdfInline() must be called alongside loadDiffInline'
idx = ui.find('function postProcessRenderedMessages')
body = ui[idx:idx + 500]
assert 'loadDiffInline(container)' in body, 'post-process must call loadDiffInline'
assert 'loadPdfInline(container)' in body, 'post-process must call loadPdfInline alongside loadDiffInline'
def test_loadHtmlInline_called_after_render(self):
ui = _read_js('ui.js')
raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui)
load_blocks = [b for b in raf_blocks if 'loadDiffInline' in b]
for block in load_blocks:
assert 'loadHtmlInline()' in block, 'loadHtmlInline() must be called alongside loadDiffInline'
idx = ui.find('function postProcessRenderedMessages')
body = ui[idx:idx + 500]
assert 'loadDiffInline(container)' in body, 'post-process must call loadDiffInline'
assert 'loadHtmlInline(container)' in body, 'post-process must call loadHtmlInline alongside loadDiffInline'
def test_initTreeViews_blocks_also_call_loaders(self):
"""rAF blocks with initTreeViews (not loadDiffInline) must also call PDF/HTML loaders."""
"""Tree views and inline loaders must share the same post-process pass."""
ui = _read_js('ui.js')
raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui)
tree_blocks = [b for b in raf_blocks if 'initTreeViews' in b and 'loadDiffInline' not in b]
for block in tree_blocks:
assert 'loadPdfInline()' in block, 'initTreeViews rAF block must also call loadPdfInline'
assert 'loadHtmlInline()' in block, 'initTreeViews rAF block must also call loadHtmlInline'
idx = ui.find('function postProcessRenderedMessages')
body = ui[idx:idx + 500]
assert 'initTreeViews(container)' in body, 'post-process must initialize tree views'
assert 'loadPdfInline(container)' in body, 'post-process must also call loadPdfInline'
assert 'loadHtmlInline(container)' in body, 'post-process must also call loadHtmlInline'
def test_message_render_uses_single_post_process_raf(self):
ui = _read_js('ui.js')
assert ui.count('requestAnimationFrame(()=>postProcessRenderedMessages(inner))') == 2
# ── CSS classes ────────────────────────────────────────────────────────────
+27
View File
@@ -532,3 +532,30 @@ def test_lineage_segment_locale_keys_are_defined_for_sidebar_locales():
locale_count = i18n.count("session_meta_messages:")
for key in required:
assert i18n.count(key) >= locale_count, f"{key} missing from one or more locale blocks"
def test_session_meta_segments_softened_label_no_literal_segment_in_english():
"""Regression: the sidebar badge for compressed/lineage rows must not visibly
say 'X segments' by default the technical internal term should be replaced
with softer user-facing copy (#2155).
This verifies the English base locale's session_meta_segments key so that
t() fallback for untranslated locales also produces softened copy.
"""
import re
i18n_text = (REPO_ROOT / 'static' / 'i18n.js').read_text(encoding='utf-8')
# Locate the English base-locale block (first occurrence, before any _lang guard).
first_lang = i18n_text.index('_lang: \'en\'')
second_lang = i18n_text.index('_lang:', first_lang + 1)
english_slice = i18n_text[first_lang:second_lang]
assert 'session_meta_segments:' in english_slice, 'session_meta_segments missing from English locale'
# Capture only the arrow-function value (not the key name which also contains 'segment').
match = re.search(
r"session_meta_segments:\s*(\(\w+\)\s*=>\s*[^,]+)",
english_slice,
)
assert match, 'session_meta_segments value not found in English locale'
rendered = match.group(1)
assert 'segment' not in rendered, (
f"session_meta_segments English value still contains the technical word 'segment': {rendered}. "
"Expected softened copy like 'prior turn(s)' instead. See #2155."
)
+104
View File
@@ -0,0 +1,104 @@
from types import SimpleNamespace
from unittest.mock import patch
from urllib.parse import urlparse
class _FakeSession:
def __init__(self, *, is_cli_session=False, session_source=None, source_tag=None):
self.session_id = "native_webui_001"
self.title = "Native WebUI"
self.workspace = "/tmp"
self.model = "gpt-test"
self.model_provider = None
self.messages = []
self.tool_calls = []
self.input_tokens = 0
self.output_tokens = 0
self.estimated_cost = 0
self.context_length = 1
self.threshold_tokens = 0
self.last_prompt_tokens = 0
self.active_stream_id = None
self.pending_user_message = None
self.pending_attachments = []
self.pending_started_at = None
self.composer_draft = {}
self.is_cli_session = is_cli_session
self.session_source = session_source
self.source_tag = source_tag
self.raw_source = source_tag
self.source_label = source_tag
def compact(self):
return {
"session_id": self.session_id,
"title": self.title,
"workspace": self.workspace,
"model": self.model,
"model_provider": self.model_provider,
"message_count": 0,
"context_length": self.context_length,
"threshold_tokens": self.threshold_tokens,
"last_prompt_tokens": self.last_prompt_tokens,
"active_stream_id": self.active_stream_id,
"pending_user_message": self.pending_user_message,
"composer_draft": self.composer_draft,
"is_cli_session": self.is_cli_session,
"session_source": self.session_source,
"source_tag": self.source_tag,
"raw_source": self.raw_source,
"source_label": self.source_label,
}
def _invoke_api_session(session_obj, *, lookup_cli):
import api.routes as routes
captured = {}
def fake_j(_handler, data, status=200, extra_headers=None):
captured["data"] = data
captured["status"] = status
return data
parsed = urlparse("/api/session?session_id=native_webui_001&messages=0&resolve_model=0")
with patch("api.routes.get_session", return_value=session_obj), \
patch("api.routes._clear_stale_stream_state", return_value=False), \
patch("api.routes._lookup_cli_session_metadata", side_effect=lookup_cli) as lookup, \
patch("api.routes.j", side_effect=fake_j):
routes.handle_get(SimpleNamespace(), parsed)
return captured, lookup
def test_api_session_metadata_skips_cli_lookup_for_native_webui_session():
"""Native WebUI sessions should not scan Agent state.db on every metadata load."""
session = _FakeSession()
def fail_lookup(_sid):
raise AssertionError("native WebUI metadata should not query CLI sessions")
captured, lookup = _invoke_api_session(session, lookup_cli=fail_lookup)
assert captured["status"] == 200
assert captured["data"]["session"]["session_id"] == "native_webui_001"
lookup.assert_not_called()
def test_api_session_metadata_keeps_cli_lookup_for_imported_cli_session():
"""Imported CLI/messaging sessions still need Agent metadata for overlap handling."""
session = _FakeSession(is_cli_session=True, session_source="messaging", source_tag="telegram")
captured, lookup = _invoke_api_session(
session,
lookup_cli=lambda sid: {
"session_id": sid,
"session_source": "messaging",
"source_tag": "telegram",
"raw_source": "telegram",
"source_label": "Telegram",
},
)
assert captured["status"] == 200
assert captured["data"]["session"]["source_tag"] == "telegram"
lookup.assert_called_once_with("native_webui_001")
+26
View File
@@ -87,3 +87,29 @@ def test_success_path_checks_stream_ownership_before_persisting_result():
assert compression_pos != -1
assert guard_pos < result_merge_pos
assert guard_pos < compression_pos
def test_self_heal_retry_success_checks_stream_ownership_before_writeback():
src = Path("api/streaming.py").read_text(encoding="utf-8")
start = src.index("logger.info('[webui] self-heal (except path): retrying stream")
end = src.index("logger.info('[webui] self-heal (except path): retry succeeded')", start)
block = src[start:end]
guard = "if not ephemeral and not _stream_writeback_is_current(s, stream_id):"
assert guard in block
assert block.index(guard) < block.index("_result_messages = _heal_result.get('messages') or _previous_context_messages")
assert block.index(guard) < block.index("s.save()")
def test_outer_exception_path_checks_stream_ownership_before_error_writeback():
src = Path("api/streaming.py").read_text(encoding="utf-8")
outer_error_payload = src.index("_error_payload = _provider_error_payload(err_str, _exc_type, _exc_hint)")
start = src.index("# Persist the error so it survives page reload.", outer_error_payload)
end = src.index("put('apperror', _error_payload)", start)
block = src[start:end]
guard = "if not ephemeral and not _stream_writeback_is_current(s, stream_id):"
assert guard in block
assert block.index(guard) < block.index("_materialize_pending_user_turn_before_error(s)")
assert block.index(guard) < block.index("s.active_stream_id = None")
assert block.index(guard) < block.index("s.messages.append(_error_message)")
+335
View File
@@ -0,0 +1,335 @@
"""Tests for the worktree remove functionality (Issue #2057 Slice 2)."""
from types import SimpleNamespace
from pathlib import Path
import pytest
import api.models as models
import api.routes as routes
import api.worktrees as worktrees
def _capture_post(monkeypatch, body):
captured = {}
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
monkeypatch.setattr(routes, "read_body", lambda handler: body)
# Monkeypatch both helpers.j and routes.j — bad() lives in helpers but calls the module-global j
import api.helpers as helpers
def _fake_j(handler, payload, status=200, extra_headers=None):
captured.update(payload=payload, status=status)
return True
monkeypatch.setattr(routes, "j", _fake_j)
monkeypatch.setattr(helpers, "j", _fake_j)
return captured
def _isolate_session_store(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
monkeypatch.setattr(routes, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
return session_dir
def _make_minimal_git_repo(tmp_path):
import subprocess
main = tmp_path / "main"
main.mkdir()
subprocess.run(["git", "init", "-b", "main", str(main)], check=True, capture_output=True)
subprocess.run(["git", "-C", str(main), "config", "user.email", "test@test.test"], check=True, capture_output=True)
subprocess.run(["git", "-C", str(main), "config", "user.name", "Test"], check=True, capture_output=True)
(main / "file.txt").write_text("content")
subprocess.run(["git", "-C", str(main), "add", "file.txt"], check=True, capture_output=True)
subprocess.run(["git", "-C", str(main), "commit", "-m", "init"], check=True, capture_output=True)
return main
# ── Function-level tests ─────────────────────────────────────────────────────
def test_remove_clean_worktree_succeeds(tmp_path):
import subprocess
from api.models import Session
main = _make_minimal_git_repo(tmp_path)
wt_path = tmp_path / "wt_clean"
subprocess.run(
["git", "-C", str(main), "worktree", "add", str(wt_path), "-b", "hermes/testclean"],
check=True, capture_output=True,
)
assert wt_path.exists()
s = Session(
session_id="testclean",
title="Clean",
workspace=str(wt_path),
worktree_path=str(wt_path),
worktree_branch="hermes/testclean",
worktree_repo_root=str(main),
)
result = worktrees.remove_worktree_for_session(s, force=False)
assert result["ok"] is True
assert result["removed_path"] == str(wt_path.resolve())
assert not wt_path.exists()
def test_remove_clean_worktree_does_not_force(tmp_path, monkeypatch):
from api.models import Session
worktree_path = tmp_path / "wt_clean"
worktree_path.mkdir()
repo_root = tmp_path / "repo"
repo_root.mkdir()
s = Session(
session_id="testcleanforce",
title="Clean",
workspace=str(worktree_path),
worktree_path=str(worktree_path),
worktree_branch="hermes/testcleanforce",
worktree_repo_root=str(repo_root),
)
monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: {
"exists": True,
"dirty": False,
"untracked_count": 0,
"ahead_behind": {"ahead": 0, "behind": 0, "available": False, "upstream": None},
"locked_by_stream": False,
"locked_by_terminal": False,
})
calls = []
def fake_run_git(args, cwd, timeout=2):
calls.append(args)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(worktrees, "_run_git", fake_run_git)
result = worktrees.remove_worktree_for_session(s, force=False)
assert result["ok"] is True
assert calls[0] == ["worktree", "remove", str(worktree_path.resolve())]
def test_remove_dirty_worktree_without_force_is_rejected(tmp_path, monkeypatch):
from api.models import Session
worktree_path = tmp_path / "wt_dirty"
worktree_path.mkdir()
repo_root = tmp_path / "repo"
repo_root.mkdir()
s = Session(
session_id="testdirty",
title="Dirty",
workspace=str(worktree_path),
worktree_path=str(worktree_path),
worktree_branch="hermes/testdirty",
worktree_repo_root=str(repo_root),
)
monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: {
"exists": True,
"dirty": True,
"untracked_count": 0,
"ahead_behind": {"ahead": 0, "behind": 0, "available": False, "upstream": None},
"locked_by_stream": False,
"locked_by_terminal": False,
})
monkeypatch.setattr(worktrees, "_run_git", lambda *args, **kwargs: pytest.fail("git remove should not run"))
with pytest.raises(ValueError, match="uncommitted changes"):
worktrees.remove_worktree_for_session(s, force=False)
def test_remove_untracked_worktree_without_force_is_rejected(tmp_path, monkeypatch):
from api.models import Session
worktree_path = tmp_path / "wt_untracked"
worktree_path.mkdir()
repo_root = tmp_path / "repo"
repo_root.mkdir()
s = Session(
session_id="testuntracked",
title="Untracked",
workspace=str(worktree_path),
worktree_path=str(worktree_path),
worktree_branch="hermes/testuntracked",
worktree_repo_root=str(repo_root),
)
monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: {
"exists": True,
"dirty": False,
"untracked_count": 2,
"ahead_behind": {"ahead": 0, "behind": 0, "available": False, "upstream": None},
"locked_by_stream": False,
"locked_by_terminal": False,
})
monkeypatch.setattr(worktrees, "_run_git", lambda *args, **kwargs: pytest.fail("git remove should not run"))
with pytest.raises(ValueError, match="untracked"):
worktrees.remove_worktree_for_session(s, force=False)
def test_remove_ahead_worktree_without_force_is_rejected(tmp_path, monkeypatch):
from api.models import Session
worktree_path = tmp_path / "wt_ahead"
worktree_path.mkdir()
repo_root = tmp_path / "repo"
repo_root.mkdir()
s = Session(
session_id="testahead",
title="Ahead",
workspace=str(worktree_path),
worktree_path=str(worktree_path),
worktree_branch="hermes/testahead",
worktree_repo_root=str(repo_root),
)
monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: {
"exists": True,
"dirty": False,
"untracked_count": 0,
"ahead_behind": {"ahead": 1, "behind": 0, "available": True, "upstream": "origin/main"},
"locked_by_stream": False,
"locked_by_terminal": False,
})
monkeypatch.setattr(worktrees, "_run_git", lambda *args, **kwargs: pytest.fail("git remove should not run"))
with pytest.raises(ValueError, match="unpushed"):
worktrees.remove_worktree_for_session(s, force=False)
def test_remove_force_warns_and_uses_git_force(tmp_path, monkeypatch):
from api.models import Session
worktree_path = tmp_path / "wt_force"
worktree_path.mkdir()
repo_root = tmp_path / "repo"
repo_root.mkdir()
s = Session(
session_id="testforce",
title="Force",
workspace=str(worktree_path),
worktree_path=str(worktree_path),
worktree_branch="hermes/testforce",
worktree_repo_root=str(repo_root),
)
monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: {
"exists": True,
"dirty": True,
"untracked_count": 3,
"ahead_behind": {"ahead": 2, "behind": 0, "available": True, "upstream": "origin/main"},
"locked_by_stream": False,
"locked_by_terminal": False,
})
calls = []
def fake_run_git(args, cwd, timeout=2):
calls.append(args)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(worktrees, "_run_git", fake_run_git)
result = worktrees.remove_worktree_for_session(s, force=True)
assert result["ok"] is True
assert calls[0] == ["worktree", "remove", "--force", str(worktree_path.resolve())]
assert "untracked file" in " ".join(result["warnings"])
assert "unpushed commit" in " ".join(result["warnings"])
def test_remove_worktree_not_exists(tmp_path):
from api.models import Session
s = Session(
session_id="testgone",
title="Gone",
workspace=str(tmp_path / "gone"),
worktree_path=str(tmp_path / "gone"),
worktree_branch="hermes/gone",
worktree_repo_root=str(tmp_path / "repo"),
)
result = worktrees.remove_worktree_for_session(s, force=False)
assert result["ok"] is True
assert len(result.get("warnings", [])) >= 1
def test_remove_worktree_no_path_raises(tmp_path):
from api.models import Session
s = Session(
session_id="testnowt",
title="No worktree",
workspace=str(tmp_path),
)
try:
worktrees.remove_worktree_for_session(s, force=False)
assert False, "should have raised ValueError"
except ValueError as e:
assert "not worktree-backed" in str(e)
# ── Route-level tests ────────────────────────────────────────────────────────
def test_remove_worktree_route_succeeds(tmp_path, monkeypatch):
import subprocess
from api.models import Session
main = _make_minimal_git_repo(tmp_path)
wt_path = tmp_path / "wt_route"
subprocess.run(
["git", "-C", str(main), "worktree", "add", str(wt_path), "-b", "hermes/testroute"],
check=True, capture_output=True,
)
_isolate_session_store(tmp_path, monkeypatch)
s = Session(
session_id="testroute1",
title="Route",
workspace=str(wt_path),
worktree_path=str(wt_path),
worktree_branch="hermes/testroute",
worktree_repo_root=str(main),
)
s.save()
body = {"session_id": "testroute1"}
captured = _capture_post(monkeypatch, body)
assert routes.handle_post(object(), SimpleNamespace(path="/api/session/worktree/remove")) is True
assert captured["status"] == 200
assert captured["payload"]["ok"] is True
assert captured["payload"]["removed_path"] == str(wt_path.resolve())
assert not wt_path.exists()
def test_remove_missing_session_returns_404(tmp_path, monkeypatch):
from api.models import Session
_isolate_session_store(tmp_path, monkeypatch)
s = Session(
session_id="someother",
title="Other",
workspace=str(tmp_path),
)
s.save()
body = {"session_id": "nonexistent"}
captured = _capture_post(monkeypatch, body)
routes.handle_post(object(), SimpleNamespace(path="/api/session/worktree/remove"))
assert captured["status"] == 404
assert "not found" in captured["payload"].get("error", "").lower()
def test_post_router_does_not_expose_read_only_worktree_or_compress_status():
src = Path("api/routes.py").read_text(encoding="utf-8")
post_body = src[src.index("def handle_post"):src.index('if parsed.path == "/api/session/worktree/remove"')]
assert '"/api/session/worktree/status"' not in post_body
assert '"/api/session/compress/status"' not in post_body