mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 03:30:36 +00:00
Merge pull request #2947 from nesquena/release/stage-batch19
Release DI — stage-batch19 — 6-PR medium-risk batch (v0.51.137)
This commit is contained in:
@@ -3,6 +3,21 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.137] — 2026-05-25 — Release DI (stage-batch19 — 6-PR medium-risk batch)
|
||||
|
||||
### Added
|
||||
|
||||
- Operators can now set `HERMES_WEBUI_CSP_CONNECT_EXTRA` to append validated extra origins to the report-only CSP `connect-src` directive for reverse-proxy or tunnel deployments.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Trim session-level tool call payloads to the returned message window for paginated `/api/session` loads, so long tool-heavy sessions do not send historical tool call summaries during ordinary session switching.
|
||||
- Sidebar compression lineage collapse now prefers the current continuation tip over a preserved parent snapshot when both rows share the same backend segment count. This keeps reloads after context compression from reopening the older parent transcript and making the active conversation appear to disappear.
|
||||
- Reloading a stale `/session/<parent>` compression URL now resolves to the visible continuation tip from the sidebar payload instead of reopening the archived parent snapshot.
|
||||
- Undo, retry, and explicit session truncation now persist a sidecar truncation watermark, preventing older `state.db` rows from reappearing after the WebUI transcript was intentionally shortened.
|
||||
- Chat uploads with the same filename in one session now keep distinct attachment files instead of overwriting the earlier upload.
|
||||
- Compression reference card no longer disappears behind the "Load earlier messages" cutoff after subsequent turns. The post-compression anchor is now calculated from the position of the last `[CONTEXT COMPACTION]` marker in the transcript instead of pointing at the visible tail, so the anchor stays at the compression boundary regardless of how many turns have been added since.
|
||||
|
||||
## [v0.51.136] — 2026-05-25 — Release DH (stage-batch18 — 5-PR streaming + session index batch)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -279,6 +279,7 @@ Full list of environment variables:
|
||||
| `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace |
|
||||
| `HERMES_WEBUI_DEFAULT_MODEL` | *(provider default)* | Optional model override; leave unset to use the active Hermes provider default |
|
||||
| `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication |
|
||||
| `HERMES_WEBUI_CSP_CONNECT_EXTRA` | *(unset)* | Optional space-separated `http(s)://` or `ws(s)://` origins to append to the report-only CSP `connect-src` directive for reverse-proxy or tunnel deployments |
|
||||
| `HERMES_WEBUI_EXTENSION_DIR` | *(unset)* | Optional local directory served at `/extensions/`; must point to an existing directory before extension injection is enabled |
|
||||
| `HERMES_WEBUI_EXTENSION_SCRIPT_URLS` | *(unset)* | Optional comma-separated same-origin script URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) |
|
||||
| `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` | *(unset)* | Optional comma-separated same-origin stylesheet URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) |
|
||||
|
||||
+43
-2
@@ -443,6 +443,7 @@ class Session:
|
||||
context_engine_state=None,
|
||||
context_length=None, threshold_tokens=None,
|
||||
last_prompt_tokens=None,
|
||||
truncation_watermark=None,
|
||||
gateway_routing=None, gateway_routing_history=None,
|
||||
llm_title_generated: bool=False,
|
||||
parent_session_id: str=None,
|
||||
@@ -489,6 +490,7 @@ class Session:
|
||||
self.context_length = context_length
|
||||
self.threshold_tokens = threshold_tokens
|
||||
self.last_prompt_tokens = last_prompt_tokens
|
||||
self.truncation_watermark = truncation_watermark
|
||||
self.gateway_routing = gateway_routing if isinstance(gateway_routing, dict) else None
|
||||
self.gateway_routing_history = gateway_routing_history if isinstance(gateway_routing_history, list) else []
|
||||
self.llm_title_generated = bool(llm_title_generated)
|
||||
@@ -518,6 +520,18 @@ class Session:
|
||||
def path(self):
|
||||
return SESSION_DIR / f'{self.session_id}.json'
|
||||
|
||||
def _maybe_clear_truncation_watermark(self) -> None:
|
||||
watermark = _message_timestamp_as_float({"timestamp": self.truncation_watermark})
|
||||
if watermark is None:
|
||||
return
|
||||
max_message_timestamp = None
|
||||
for msg in self.messages or []:
|
||||
timestamp = _message_timestamp_as_float(msg)
|
||||
if timestamp is not None:
|
||||
max_message_timestamp = timestamp if max_message_timestamp is None else max(max_message_timestamp, timestamp)
|
||||
if max_message_timestamp is not None and max_message_timestamp > watermark:
|
||||
self.truncation_watermark = None
|
||||
|
||||
def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
|
||||
# ── #1558 P0 guard ──────────────────────────────────────────────
|
||||
# Refuse to save a session that was loaded with metadata_only=True.
|
||||
@@ -538,6 +552,7 @@ class Session:
|
||||
)
|
||||
if touch_updated_at:
|
||||
self.updated_at = time.time()
|
||||
self._maybe_clear_truncation_watermark()
|
||||
# Write metadata fields first so load_metadata_only() can read them
|
||||
# without parsing the full messages array (which may be 400KB+).
|
||||
# Fields are listed in the order they should appear in the JSON file.
|
||||
@@ -553,6 +568,7 @@ class Session:
|
||||
'context_engine', 'compression_anchor_engine', 'compression_anchor_mode',
|
||||
'compression_anchor_details', 'context_engine_state',
|
||||
'context_length', 'threshold_tokens', 'last_prompt_tokens',
|
||||
'truncation_watermark',
|
||||
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
|
||||
'parent_session_id',
|
||||
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
|
||||
@@ -3316,13 +3332,27 @@ def state_db_delta_after_context(sidecar_context: list, state_messages: list) ->
|
||||
return state_messages[best_len:]
|
||||
|
||||
|
||||
def merge_session_messages_append_only(sidecar_messages: list, state_messages: list) -> list:
|
||||
def merge_session_messages_append_only(
|
||||
sidecar_messages: list,
|
||||
state_messages: list,
|
||||
*,
|
||||
truncation_watermark=None,
|
||||
) -> list:
|
||||
"""Merge sidecar/context and state.db messages without deleting local rows."""
|
||||
sidecar_messages = list(sidecar_messages or [])
|
||||
state_messages = list(state_messages or [])
|
||||
watermark_timestamp = _message_timestamp_as_float({"timestamp": truncation_watermark})
|
||||
if not state_messages:
|
||||
return sidecar_messages
|
||||
if not sidecar_messages:
|
||||
if watermark_timestamp is not None:
|
||||
return [
|
||||
msg for msg in state_messages
|
||||
if (
|
||||
(timestamp := _message_timestamp_as_float(msg)) is not None
|
||||
and timestamp <= watermark_timestamp
|
||||
)
|
||||
]
|
||||
return state_messages
|
||||
|
||||
merged_messages = []
|
||||
@@ -3367,6 +3397,13 @@ def merge_session_messages_append_only(sidecar_messages: list, state_messages: l
|
||||
skipped_state_visible_counts.get(matched_visible_key, 0) + 1
|
||||
)
|
||||
continue
|
||||
if (
|
||||
watermark_timestamp is not None
|
||||
and timestamp is not None
|
||||
and timestamp > watermark_timestamp
|
||||
and key not in seen_message_keys
|
||||
):
|
||||
continue
|
||||
if max_sidecar_timestamp is not None and timestamp is not None and timestamp <= max_sidecar_timestamp:
|
||||
if key in seen_message_keys:
|
||||
continue
|
||||
@@ -3423,7 +3460,11 @@ def reconciled_state_db_messages_for_session(
|
||||
state_messages = get_state_db_session_messages(getattr(session, 'session_id', None))
|
||||
if prefer_context and local_messages:
|
||||
state_messages = state_db_delta_after_context(local_messages, state_messages)
|
||||
return merge_session_messages_append_only(local_messages, state_messages)
|
||||
return merge_session_messages_append_only(
|
||||
local_messages,
|
||||
state_messages,
|
||||
truncation_watermark=getattr(session, "truncation_watermark", None),
|
||||
)
|
||||
|
||||
|
||||
def get_cli_session_messages(sid) -> list:
|
||||
|
||||
+52
-8
@@ -2146,6 +2146,23 @@ def _messages_include_tool_metadata(messages) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _tool_calls_for_message_window(tool_calls, start_idx: int, message_count: int) -> list:
|
||||
"""Keep session-level tool calls that point into a returned message window."""
|
||||
if not isinstance(tool_calls, list) or message_count <= 0:
|
||||
return []
|
||||
end_idx = start_idx + message_count
|
||||
filtered = []
|
||||
for tool_call in tool_calls:
|
||||
if not isinstance(tool_call, dict):
|
||||
continue
|
||||
assistant_idx = tool_call.get("assistant_msg_idx")
|
||||
if isinstance(assistant_idx, bool) or not isinstance(assistant_idx, int):
|
||||
continue
|
||||
if start_idx <= assistant_idx < end_idx:
|
||||
filtered.append(tool_call)
|
||||
return filtered
|
||||
|
||||
|
||||
def _merged_session_messages_for_display(session, cli_messages=None) -> list:
|
||||
"""Return the message coordinate space exposed by ``GET /api/session``.
|
||||
|
||||
@@ -2202,7 +2219,11 @@ def _metadata_only_message_summary(sid: str, profile: str | None = None) -> dict
|
||||
sidecar_messages = getattr(sidecar_session, "messages", []) or []
|
||||
state_db_messages = get_state_db_session_messages(sid, profile=profile)
|
||||
return _message_summary(
|
||||
merge_session_messages_append_only(sidecar_messages, state_db_messages)
|
||||
merge_session_messages_append_only(
|
||||
sidecar_messages,
|
||||
state_db_messages,
|
||||
truncation_watermark=getattr(sidecar_session, "truncation_watermark", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -4033,7 +4054,11 @@ def handle_get(handler, parsed) -> bool:
|
||||
# them chronologically and dedupe exact repeats.
|
||||
_all_msgs = _merged_session_messages_for_display(s, cli_messages)
|
||||
else:
|
||||
_all_msgs = merge_session_messages_append_only(s.messages, state_db_messages)
|
||||
_all_msgs = merge_session_messages_append_only(
|
||||
s.messages,
|
||||
state_db_messages,
|
||||
truncation_watermark=getattr(s, "truncation_watermark", None),
|
||||
)
|
||||
else:
|
||||
if is_messaging_session and cli_messages:
|
||||
sidecar_messages = getattr(s, "messages", []) or []
|
||||
@@ -4080,6 +4105,19 @@ def handle_get(handler, parsed) -> bool:
|
||||
_truncated_msgs = _all_msgs
|
||||
else:
|
||||
_truncated_msgs = []
|
||||
# Index of the first returned message in the full message array.
|
||||
# Frontend uses this as cursor for scroll-to-top paging.
|
||||
if load_messages and msg_before is not None:
|
||||
_messages_offset = max(0, _before_idx - len(_truncated_msgs))
|
||||
elif load_messages:
|
||||
_messages_offset = max(0, len(_all_msgs) - len(_truncated_msgs))
|
||||
else:
|
||||
_messages_offset = 0
|
||||
_windowed_messages = (
|
||||
load_messages
|
||||
and msg_limit is not None
|
||||
and (msg_before is not None or len(_truncated_msgs) < len(_all_msgs))
|
||||
)
|
||||
# Resolve effective context_length with model-metadata fallback so
|
||||
# older sessions (pre-#1318) that have context_length=0 persisted
|
||||
# still render a meaningful indicator on load. Mirrors the
|
||||
@@ -4119,6 +4157,12 @@ def handle_get(handler, parsed) -> bool:
|
||||
# messages already carry per-message tool metadata. Avoid sending
|
||||
# the full historical list with a small tail window.
|
||||
_session_tool_calls = []
|
||||
elif _windowed_messages:
|
||||
_session_tool_calls = _tool_calls_for_message_window(
|
||||
_session_tool_calls,
|
||||
_messages_offset,
|
||||
len(_truncated_msgs),
|
||||
)
|
||||
_merged_message_count = _summary_message_count if _summary_message_count is not None else len(_all_msgs)
|
||||
_merged_last_message_at = _summary_last_message_at if _summary_last_message_at is not None else 0
|
||||
if _summary_last_message_at is None and _all_msgs:
|
||||
@@ -4179,12 +4223,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
else:
|
||||
_truncated = load_messages and msg_limit is not None and len(_all_msgs) > msg_limit
|
||||
raw["_messages_truncated"] = _truncated
|
||||
# Index of the first returned message in the full message array.
|
||||
# Frontend uses this as cursor for scroll-to-top paging.
|
||||
if msg_before is not None:
|
||||
raw["_messages_offset"] = max(0, _before_idx - len(_truncated_msgs))
|
||||
else:
|
||||
raw["_messages_offset"] = max(0, len(_all_msgs) - len(_truncated_msgs))
|
||||
raw["_messages_offset"] = _messages_offset
|
||||
_t4 = _time.monotonic()
|
||||
if effective_model:
|
||||
raw["model"] = effective_model
|
||||
@@ -5433,6 +5472,11 @@ def handle_post(handler, parsed) -> bool:
|
||||
keep = int(body["keep_count"])
|
||||
with _get_session_agent_lock(body["session_id"]):
|
||||
s.messages = s.messages[:keep]
|
||||
try:
|
||||
from api.session_ops import _truncation_watermark_for
|
||||
s.truncation_watermark = _truncation_watermark_for(s.messages)
|
||||
except Exception:
|
||||
s.truncation_watermark = 0.0
|
||||
s.save()
|
||||
return j(
|
||||
handler, {"ok": True, "session": s.compact() | {"messages": s.messages}}
|
||||
|
||||
@@ -27,6 +27,16 @@ def _truncate_at_last_user(messages):
|
||||
return history[:last_user_idx]
|
||||
|
||||
|
||||
def _truncation_watermark_for(messages):
|
||||
history = list(messages or [])
|
||||
if not history:
|
||||
return 0.0
|
||||
try:
|
||||
return float(history[-1].get('timestamp') or 0)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def retry_last(session_id: str) -> dict[str, Any]:
|
||||
"""Truncate the session to before the last user message, return its text.
|
||||
|
||||
@@ -75,6 +85,7 @@ def retry_last(session_id: str) -> dict[str, Any]:
|
||||
last_user_text = _extract_text(history[last_user_idx].get('content', ''))
|
||||
removed_count = len(history) - last_user_idx
|
||||
s.messages = history[:last_user_idx]
|
||||
s.truncation_watermark = _truncation_watermark_for(s.messages)
|
||||
if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages:
|
||||
truncated_context = _truncate_at_last_user(s.context_messages)
|
||||
if truncated_context is not None:
|
||||
@@ -114,6 +125,7 @@ def undo_last(session_id: str) -> dict[str, Any]:
|
||||
removed_text = _extract_text(history[last_user_idx].get('content', ''))
|
||||
removed_count = len(history) - last_user_idx
|
||||
s.messages = history[:last_user_idx]
|
||||
s.truncation_watermark = _truncation_watermark_for(s.messages)
|
||||
if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages:
|
||||
truncated_context = _truncate_at_last_user(s.context_messages)
|
||||
if truncated_context is not None:
|
||||
|
||||
+49
-4
@@ -5060,11 +5060,56 @@ def _run_agent_streaming(
|
||||
# Notify the frontend that compression happened
|
||||
if _compressed:
|
||||
visible_after = visible_messages_for_anchor(s.messages, auto_compression=True)
|
||||
s.compression_anchor_visible_idx = (
|
||||
max(0, len(visible_after) - 1) if visible_after else None
|
||||
)
|
||||
# Find the LAST [CONTEXT COMPACTION] marker in s.messages
|
||||
# and count visible messages before it. This is the correct
|
||||
# anchor — it points to the compression boundary regardless
|
||||
# of how many turns have been added since the boundary was
|
||||
# established. Using len(visible_before)-1 is fragile when
|
||||
# _previous_messages doesn't include markers or when extra
|
||||
# messages accumulate between compression and the done event.
|
||||
_last_marker_raw_idx = None
|
||||
for _mi, _m in enumerate(s.messages):
|
||||
if _is_context_compression_marker(_m):
|
||||
_last_marker_raw_idx = _mi
|
||||
if _last_marker_raw_idx is not None:
|
||||
_visible_before_marker = visible_messages_for_anchor(
|
||||
s.messages[:_last_marker_raw_idx], auto_compression=True,
|
||||
)
|
||||
s.compression_anchor_visible_idx = max(0, len(_visible_before_marker) - 1)
|
||||
logger.info(
|
||||
'[ANCHOR-MARKER] session=%s marker_raw=%d vis_before=%d anchor=%d',
|
||||
getattr(s, 'session_id', '?'),
|
||||
_last_marker_raw_idx,
|
||||
len(_visible_before_marker),
|
||||
s.compression_anchor_visible_idx,
|
||||
)
|
||||
else:
|
||||
# Fallback: use pre-turn display messages
|
||||
visible_before = visible_messages_for_anchor(
|
||||
_previous_messages, auto_compression=True,
|
||||
)
|
||||
if visible_before:
|
||||
s.compression_anchor_visible_idx = max(0, len(visible_before) - 1)
|
||||
elif visible_after:
|
||||
s.compression_anchor_visible_idx = 0
|
||||
else:
|
||||
s.compression_anchor_visible_idx = None
|
||||
logger.info(
|
||||
'[ANCHOR-FALLBACK] session=%s vis_before=%d anchor=%d',
|
||||
getattr(s, 'session_id', '?'),
|
||||
len(visible_before) if visible_before else 0,
|
||||
s.compression_anchor_visible_idx if s.compression_anchor_visible_idx is not None else -1,
|
||||
)
|
||||
# Pick anchor_msg for _compression_anchor_message_key
|
||||
_anchor_vis_idx = s.compression_anchor_visible_idx
|
||||
if _anchor_vis_idx is not None and visible_after and _anchor_vis_idx < len(visible_after):
|
||||
anchor_msg = visible_after[_anchor_vis_idx]
|
||||
elif visible_after:
|
||||
anchor_msg = visible_after[-1]
|
||||
else:
|
||||
anchor_msg = None
|
||||
s.compression_anchor_message_key = (
|
||||
_compression_anchor_message_key(visible_after[-1]) if visible_after else None
|
||||
_compression_anchor_message_key(anchor_msg) if anchor_msg else None
|
||||
)
|
||||
s.compression_anchor_summary = _compact_summary_text(
|
||||
_compression_summary_from_messages(s.messages)
|
||||
|
||||
@@ -81,6 +81,16 @@ def _upload_destination(session_id: str, safe_name: str) -> Path:
|
||||
dest = (dest_dir / safe_name).resolve()
|
||||
if not dest.is_relative_to(dest_dir):
|
||||
raise ValueError('Invalid upload destination')
|
||||
if dest.exists():
|
||||
stem = dest.stem
|
||||
suffix = dest.suffix
|
||||
for idx in range(1, 1000):
|
||||
candidate = (dest_dir / f'{stem}-{idx}{suffix}').resolve()
|
||||
if not candidate.is_relative_to(dest_dir):
|
||||
raise ValueError('Invalid upload destination')
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
raise ValueError('Too many uploads with the same filename')
|
||||
return dest
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ All business logic lives in api/*.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
@@ -111,6 +112,55 @@ from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CSP_CONNECT_BASE = (
|
||||
"'self' http://127.0.0.1:* http://localhost:* "
|
||||
"ws://127.0.0.1:* ws://localhost:*"
|
||||
)
|
||||
_CSP_EXTRA_CONNECT_RE = re.compile(
|
||||
r"^(?:https?|wss?)://(?:\*\.)?[A-Za-z0-9._~-]+(?::(?P<port>\d{1,5}|\*))?$"
|
||||
)
|
||||
|
||||
|
||||
def _valid_csp_extra_connect_source(source: str) -> bool:
|
||||
match = _CSP_EXTRA_CONNECT_RE.fullmatch(source)
|
||||
if not match:
|
||||
return False
|
||||
port = match.group("port")
|
||||
if not port or port == "*":
|
||||
return True
|
||||
try:
|
||||
return 1 <= int(port) <= 65535
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _csp_extra_connect_src() -> str:
|
||||
raw = os.getenv("HERMES_WEBUI_CSP_CONNECT_EXTRA", "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
sources = raw.split()
|
||||
if not sources or any(not _valid_csp_extra_connect_source(src) for src in sources):
|
||||
logger.warning("Ignoring invalid HERMES_WEBUI_CSP_CONNECT_EXTRA value")
|
||||
return ""
|
||||
return " " + " ".join(sources)
|
||||
|
||||
|
||||
def _build_csp_report_only_policy() -> str:
|
||||
connect_src = _CSP_CONNECT_BASE + _csp_extra_connect_src()
|
||||
return (
|
||||
"default-src 'self'; "
|
||||
"base-uri 'self'; "
|
||||
"object-src 'none'; "
|
||||
"frame-ancestors 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self' data:; "
|
||||
"media-src 'self' data: blob:; "
|
||||
f"connect-src {connect_src}; "
|
||||
"report-uri /api/csp-report; report-to csp-endpoint"
|
||||
)
|
||||
|
||||
from api.auth import check_auth
|
||||
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||
from api.helpers import j, get_profile_cookie
|
||||
@@ -207,24 +257,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
_ver_suffix = WEBUI_VERSION.removeprefix('v')
|
||||
server_version = ('HermesWebUI/' + _ver_suffix) if _ver_suffix != 'unknown' else 'HermesWebUI'
|
||||
_CSP_REPORT_ONLY = (
|
||||
"default-src 'self'; "
|
||||
"base-uri 'self'; "
|
||||
"object-src 'none'; "
|
||||
"frame-ancestors 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"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:*; "
|
||||
"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:
|
||||
return cls._CSP_REPORT_ONLY
|
||||
return _build_csp_report_only_policy()
|
||||
|
||||
def end_headers(self) -> None:
|
||||
self.send_header("Content-Security-Policy-Report-Only", self.csp_report_only_policy())
|
||||
|
||||
@@ -543,6 +543,10 @@ async function newSession(flash, options={}){
|
||||
|
||||
async function loadSession(sid){
|
||||
const opts = arguments[1] || {};
|
||||
if(!opts.skipLineageResolve && typeof _resolveSessionIdFromSidebarLineage==='function'){
|
||||
const resolvedSid=_resolveSessionIdFromSidebarLineage(sid);
|
||||
if(resolvedSid&&resolvedSid!==sid) sid=resolvedSid;
|
||||
}
|
||||
const forceReload = !!opts.force;
|
||||
const currentSid = S.session ? S.session.session_id : null;
|
||||
// Clicking the already-open session in the sidebar is a no-op. Reloading it
|
||||
@@ -2661,6 +2665,39 @@ function _sessionLineageContainsSession(s, sid){
|
||||
return false;
|
||||
}
|
||||
|
||||
function _resolveSessionIdFromSidebarLineage(sid){
|
||||
sid=String(sid||'').trim();
|
||||
if(!sid||!Array.isArray(_allSessions)||!_allSessions.length) return sid||null;
|
||||
const visibleRows=_collapseSessionLineageForSidebar(_allSessions).filter(row=>row&&!_isChildSession(row));
|
||||
if(visibleRows.some(row=>row&&row.session_id===sid)) return sid;
|
||||
const candidates=[];
|
||||
for(const row of visibleRows){
|
||||
if(!row||!row.session_id) continue;
|
||||
if(row.session_source==='fork'||row.relationship_type==='child_session') continue;
|
||||
const lineageLike=!!(
|
||||
row._lineage_key||row._lineage_root_id||row.lineage_root_id||
|
||||
row._compression_segment_count||row.pre_compression_snapshot||
|
||||
(Array.isArray(row._lineage_segments)&&row._lineage_segments.length>1)
|
||||
);
|
||||
if(!lineageLike) continue;
|
||||
const key=_sidebarLineageKeyForRow(row);
|
||||
if(key===sid||row.parent_session_id===sid||row._lineage_root_id===sid||row.lineage_root_id===sid||_sessionLineageContainsSession(row,sid)){
|
||||
candidates.push(row);
|
||||
}
|
||||
}
|
||||
if(!candidates.length) return sid;
|
||||
candidates.sort((a,b)=>{
|
||||
const bSeg=Number(b&&b._compression_segment_count||b&&b._lineage_collapsed_count||0);
|
||||
const aSeg=Number(a&&a._compression_segment_count||a&&a._lineage_collapsed_count||0);
|
||||
if(bSeg!==aSeg) return bSeg-aSeg;
|
||||
const bSnapshot=!!(b&&b.pre_compression_snapshot);
|
||||
const aSnapshot=!!(a&&a.pre_compression_snapshot);
|
||||
if(bSnapshot!==aSnapshot) return aSnapshot-bSnapshot;
|
||||
return _sessionTimestampMs(b)-_sessionTimestampMs(a);
|
||||
});
|
||||
return candidates[0].session_id||sid;
|
||||
}
|
||||
|
||||
function _sessionSegmentCount(s){
|
||||
if(!s) return 0;
|
||||
const counts=[];
|
||||
@@ -2838,6 +2875,13 @@ function _collapseSessionLineageForSidebar(sessions){
|
||||
if(bSeg||aSeg){
|
||||
if(bSeg!==aSeg) return bSeg-aSeg;
|
||||
}
|
||||
// Preserved pre-compression parents can share the same backend segment
|
||||
// count as the continuation. Prefer the non-snapshot tip before falling
|
||||
// back to timestamps, otherwise a recently-polled parent reopens the
|
||||
// older transcript and makes the active continuation look lost.
|
||||
const bSnapshot=!!(b&&b.pre_compression_snapshot);
|
||||
const aSnapshot=!!(a&&a.pre_compression_snapshot);
|
||||
if(bSnapshot!==aSnapshot) return aSnapshot-bSnapshot;
|
||||
return _sessionTimestampMs(b)-_sessionTimestampMs(a);
|
||||
});
|
||||
const chosen=sorted[0];
|
||||
|
||||
@@ -97,3 +97,101 @@ def test_compression_summary_ignores_tool_output_that_mentions_compression():
|
||||
|
||||
assert _compression_summary_from_messages([marker, skill_tool_output]) == marker["content"]
|
||||
assert _compression_summary_from_messages([skill_tool_output]) is None
|
||||
|
||||
|
||||
def test_marker_based_anchor_calculation():
|
||||
"""Verify that the marker-based anchor logic used in streaming.py's done
|
||||
handler correctly computes compression_anchor_visible_idx from the last
|
||||
[CONTEXT COMPACTION] marker position. This prevents the reference card
|
||||
from being pushed behind the render window after subsequent turns."""
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "first reply"},
|
||||
{"role": "tool", "content": "tool output"},
|
||||
{"role": "user", "content": "second question"},
|
||||
{"role": "assistant", "content": "second reply"},
|
||||
{"role": "assistant", "content": "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] summary"},
|
||||
{"role": "user", "content": "third question"},
|
||||
{"role": "assistant", "content": "third reply"},
|
||||
{"role": "tool", "content": "more tool output"},
|
||||
{"role": "user", "content": "fourth question"},
|
||||
{"role": "assistant", "content": "fourth reply"},
|
||||
]
|
||||
|
||||
# Simulate the fix logic from streaming.py
|
||||
_last_marker_raw_idx = None
|
||||
for _mi, _m in enumerate(messages):
|
||||
if is_context_compression_marker(_m):
|
||||
_last_marker_raw_idx = _mi
|
||||
assert _last_marker_raw_idx == 5, "expected marker at index 5"
|
||||
|
||||
_visible_before_marker = visible_messages_for_anchor(
|
||||
messages[:_last_marker_raw_idx], auto_compression=True,
|
||||
)
|
||||
anchor = max(0, len(_visible_before_marker) - 1)
|
||||
# Visible before marker: 4 messages (hello, first reply, second question, second reply)
|
||||
# tool at index 2 is filtered out. Anchor = 3 (last visible before marker)
|
||||
assert anchor == 3, f"expected anchor 3, got {anchor}"
|
||||
|
||||
# Verify the anchor points to the right message
|
||||
full_vis = visible_messages_for_anchor(messages, auto_compression=True)
|
||||
# full_vis: hello, first reply, second question, second reply, third question, third reply, fourth question, fourth reply = 8
|
||||
assert len(full_vis) == 8, f"expected 8, got {len(full_vis)}"
|
||||
assert full_vis[anchor]["content"] == "second reply"
|
||||
|
||||
# After additional turns, anchor stays at marker boundary
|
||||
messages_extended = messages + [
|
||||
{"role": "user", "content": "fifth question"},
|
||||
{"role": "assistant", "content": "fifth reply"},
|
||||
{"role": "tool", "content": "hidden"},
|
||||
]
|
||||
full_vis_ext = visible_messages_for_anchor(messages_extended, auto_compression=True)
|
||||
# The anchor should still point to the same position in full_vis_ext
|
||||
assert full_vis_ext[anchor]["content"] == "second reply"
|
||||
assert anchor < len(full_vis_ext) - 3 # anchor is not at the end
|
||||
|
||||
|
||||
def test_marker_based_anchor_multiple_compressions():
|
||||
"""With multiple compression markers, the anchor uses the LAST marker."""
|
||||
messages = [
|
||||
{"role": "user", "content": "A"},
|
||||
{"role": "assistant", "content": "B"},
|
||||
{"role": "assistant", "content": "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] first"},
|
||||
{"role": "user", "content": "C"},
|
||||
{"role": "assistant", "content": "D"},
|
||||
{"role": "assistant", "content": "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] second"},
|
||||
{"role": "user", "content": "E"},
|
||||
{"role": "assistant", "content": "F"},
|
||||
]
|
||||
# Find last marker
|
||||
_last_marker_raw_idx = None
|
||||
for _mi, _m in enumerate(messages):
|
||||
if is_context_compression_marker(_m):
|
||||
_last_marker_raw_idx = _mi
|
||||
assert _last_marker_raw_idx == 5
|
||||
|
||||
_visible_before_marker = visible_messages_for_anchor(
|
||||
messages[:_last_marker_raw_idx], auto_compression=True,
|
||||
)
|
||||
anchor = max(0, len(_visible_before_marker) - 1)
|
||||
# Visible before last marker at raw[5]: messages[0:5] = [A, B, marker, C, D]
|
||||
# After filtering: A, B, C, D = 4 visible. Anchor = 3 (last visible = D)
|
||||
assert anchor == 3, f"expected anchor 3, got {anchor}"
|
||||
assert _visible_before_marker[anchor]["content"] == "D"
|
||||
|
||||
|
||||
def test_marker_based_anchor_fallback_when_no_marker():
|
||||
"""When no compression marker exists, fall back to old behavior."""
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
]
|
||||
_last_marker_raw_idx = None
|
||||
for _mi, _m in enumerate(messages):
|
||||
if is_context_compression_marker(_m):
|
||||
_last_marker_raw_idx = _mi
|
||||
assert _last_marker_raw_idx is None
|
||||
|
||||
# Should fall through (not crash) - verified by the old path
|
||||
visible_after = visible_messages_for_anchor(messages, auto_compression=True)
|
||||
assert len(visible_after) > 0
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Regression coverage for configurable CSP connect-src extras (#2901)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_csp_connect_src_default_header_unchanged(monkeypatch):
|
||||
from server import Handler
|
||||
|
||||
monkeypatch.delenv("HERMES_WEBUI_CSP_CONNECT_EXTRA", raising=False)
|
||||
|
||||
policy = Handler.csp_report_only_policy()
|
||||
|
||||
assert (
|
||||
"connect-src 'self' http://127.0.0.1:* http://localhost:* "
|
||||
"ws://127.0.0.1:* ws://localhost:*; "
|
||||
) in policy
|
||||
|
||||
|
||||
def test_csp_connect_src_includes_valid_extra_origins(monkeypatch):
|
||||
from server import Handler
|
||||
|
||||
monkeypatch.setenv(
|
||||
"HERMES_WEBUI_CSP_CONNECT_EXTRA",
|
||||
"https://metrics.example.com wss://events.example.com:443",
|
||||
)
|
||||
|
||||
policy = Handler.csp_report_only_policy()
|
||||
|
||||
assert (
|
||||
"connect-src 'self' http://127.0.0.1:* http://localhost:* "
|
||||
"ws://127.0.0.1:* ws://localhost:* "
|
||||
"https://metrics.example.com wss://events.example.com:443; "
|
||||
) in policy
|
||||
|
||||
|
||||
def test_csp_connect_src_rejects_directive_injection(monkeypatch, caplog):
|
||||
from server import Handler
|
||||
|
||||
monkeypatch.setenv(
|
||||
"HERMES_WEBUI_CSP_CONNECT_EXTRA",
|
||||
"https://metrics.example.com; script-src *",
|
||||
)
|
||||
|
||||
policy = Handler.csp_report_only_policy()
|
||||
|
||||
assert "https://metrics.example.com" not in policy
|
||||
assert "script-src *" not in policy
|
||||
assert "Ignoring invalid HERMES_WEBUI_CSP_CONNECT_EXTRA" in caplog.text
|
||||
|
||||
|
||||
def test_csp_connect_src_rejects_paths(monkeypatch):
|
||||
from server import Handler
|
||||
|
||||
monkeypatch.setenv(
|
||||
"HERMES_WEBUI_CSP_CONNECT_EXTRA",
|
||||
"https://metrics.example.com/api",
|
||||
)
|
||||
|
||||
policy = Handler.csp_report_only_policy()
|
||||
|
||||
assert "https://metrics.example.com/api" not in policy
|
||||
|
||||
|
||||
def test_csp_connect_src_rejects_invalid_ports(monkeypatch):
|
||||
from server import Handler
|
||||
|
||||
monkeypatch.setenv(
|
||||
"HERMES_WEBUI_CSP_CONNECT_EXTRA",
|
||||
"https://metrics.example.com:99999",
|
||||
)
|
||||
|
||||
policy = Handler.csp_report_only_policy()
|
||||
|
||||
assert "https://metrics.example.com:99999" not in policy
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Regression tests for #2914 state.db tail replay after undo/retry/edit."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _msg(role: str, content: str, ts: float, mid: str) -> dict:
|
||||
return {"id": mid, "role": role, "content": content, "timestamp": ts}
|
||||
|
||||
|
||||
def test_reconciled_messages_skip_state_tail_after_sidecar_truncation():
|
||||
from api.models import Session, reconciled_state_db_messages_for_session
|
||||
|
||||
sidecar = [
|
||||
_msg("user", "first", 1.0, "sidecar-u1"),
|
||||
_msg("assistant", "reply first", 2.0, "sidecar-a1"),
|
||||
]
|
||||
state_db = [
|
||||
_msg("user", "first", 1.0, "state-u1"),
|
||||
_msg("assistant", "reply first", 2.0, "state-a1"),
|
||||
_msg("user", "second", 3.0, "state-u2"),
|
||||
_msg("assistant", "reply second", 4.0, "state-a2"),
|
||||
]
|
||||
session = Session(
|
||||
session_id="issue2914",
|
||||
messages=sidecar,
|
||||
truncation_watermark=2.0,
|
||||
)
|
||||
|
||||
merged = reconciled_state_db_messages_for_session(session, state_messages=state_db)
|
||||
|
||||
assert [m["content"] for m in merged] == ["first", "reply first"]
|
||||
|
||||
|
||||
def test_empty_sidecar_truncation_watermark_blocks_state_replay():
|
||||
from api.models import Session, reconciled_state_db_messages_for_session
|
||||
|
||||
state_db = [
|
||||
_msg("user", "only prompt", 1.0, "state-u1"),
|
||||
_msg("assistant", "only reply", 2.0, "state-a1"),
|
||||
]
|
||||
session = Session(
|
||||
session_id="issue2914empty",
|
||||
messages=[],
|
||||
truncation_watermark=0.0,
|
||||
)
|
||||
|
||||
assert reconciled_state_db_messages_for_session(session, state_messages=state_db) == []
|
||||
|
||||
|
||||
def test_undo_persists_truncation_watermark_at_new_tail(monkeypatch, tmp_path):
|
||||
import api.models as models
|
||||
from api.models import Session
|
||||
from api.session_ops import undo_last
|
||||
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
models.SESSIONS.clear()
|
||||
|
||||
session = Session(
|
||||
session_id="issue2914undo",
|
||||
messages=[
|
||||
_msg("user", "first", 1.0, "u1"),
|
||||
_msg("assistant", "reply first", 2.0, "a1"),
|
||||
_msg("user", "second", 3.0, "u2"),
|
||||
_msg("assistant", "reply second", 4.0, "a2"),
|
||||
],
|
||||
)
|
||||
session.save()
|
||||
|
||||
undo_last("issue2914undo")
|
||||
|
||||
loaded = Session.load("issue2914undo")
|
||||
assert loaded is not None
|
||||
assert [m["content"] for m in loaded.messages] == ["first", "reply first"]
|
||||
assert loaded.truncation_watermark == 2.0
|
||||
@@ -253,6 +253,89 @@ console.log(JSON.stringify(collapsed));
|
||||
|
||||
|
||||
|
||||
def test_sidebar_lineage_collapse_prefers_current_tip_over_same_segment_snapshot():
|
||||
"""A preserved parent snapshot can share the child's backend segment count.
|
||||
|
||||
Loading/polling the parent refreshes its timestamp, but the collapsed row
|
||||
must still open the non-snapshot continuation tip. Otherwise a reload after
|
||||
compression jumps back to the older parent transcript and looks like the
|
||||
active conversation disappeared.
|
||||
"""
|
||||
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
||||
source = f"""
|
||||
const src = {js!r};
|
||||
function extractFunc(name) {{
|
||||
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
eval(extractFunc('_sessionTimestampMs'));
|
||||
eval(extractFunc('_isChildSession'));
|
||||
eval(extractFunc('_sessionLineageKey'));
|
||||
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
||||
const sessions = [
|
||||
{{session_id:'parent', title:'Duplicate Assistant Text Blocks', message_count:64, updated_at:300, last_message_at:300, pre_compression_snapshot:true, _lineage_root_id:'parent', _compression_segment_count:2}},
|
||||
{{session_id:'child', title:'Duplicate Assistant Text Blocks', parent_session_id:'parent', message_count:86, updated_at:200, last_message_at:200, _lineage_root_id:'parent', _compression_segment_count:2}},
|
||||
];
|
||||
const collapsed = _collapseSessionLineageForSidebar(sessions);
|
||||
console.log(JSON.stringify(collapsed));
|
||||
"""
|
||||
collapsed = json.loads(_run_node(source))
|
||||
assert [row["session_id"] for row in collapsed] == ["child"]
|
||||
assert collapsed[0]["_lineage_collapsed_count"] == 2
|
||||
assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["child", "parent"]
|
||||
|
||||
|
||||
|
||||
def test_direct_parent_restore_resolves_to_visible_compression_tip():
|
||||
"""A stale /session/<parent> URL should reopen the visible continuation tip.
|
||||
|
||||
The sidebar payload may omit the archived pre-compression parent but still
|
||||
include the latest continuation with lineage metadata pointing back to the
|
||||
parent. Boot restore should use that visible tip instead of loading the old
|
||||
parent transcript and making the continuation look lost.
|
||||
"""
|
||||
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
||||
source = f"""
|
||||
const src = {js!r};
|
||||
function extractFunc(name) {{
|
||||
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
var _allSessions = [
|
||||
{{session_id:'child', title:'Duplicate Assistant Text Blocks', parent_session_id:'parent', message_count:86, updated_at:200, last_message_at:200, _lineage_root_id:'parent', _compression_segment_count:2}},
|
||||
{{session_id:'other', title:'Other', message_count:4, updated_at:100, last_message_at:100}},
|
||||
];
|
||||
eval(extractFunc('_sessionTimestampMs'));
|
||||
eval(extractFunc('_isChildSession'));
|
||||
eval(extractFunc('_sessionLineageKey'));
|
||||
eval(extractFunc('_sessionLineageContainsSession'));
|
||||
eval(extractFunc('_sidebarLineageKeyForRow'));
|
||||
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
||||
eval(extractFunc('_resolveSessionIdFromSidebarLineage'));
|
||||
console.log(JSON.stringify({{parent:_resolveSessionIdFromSidebarLineage('parent'), child:_resolveSessionIdFromSidebarLineage('child'), other:_resolveSessionIdFromSidebarLineage('other')}}));
|
||||
"""
|
||||
result = json.loads(_run_node(source))
|
||||
assert result == {"parent": "child", "child": "child", "other": "other"}
|
||||
|
||||
|
||||
def test_sidebar_attaches_child_sessions_to_collapsed_hidden_parent_lineage():
|
||||
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
||||
source = f"""
|
||||
|
||||
@@ -12,7 +12,8 @@ class _FakeSession:
|
||||
self.model_provider = None
|
||||
self.messages = messages
|
||||
self.tool_calls = [
|
||||
{"name": "old-tool", "snippet": "historical snippet", "assistant_msg_idx": 0}
|
||||
{"name": "old-tool", "snippet": "historical snippet", "assistant_msg_idx": 0},
|
||||
{"name": "visible-tool", "snippet": "visible snippet", "assistant_msg_idx": 1},
|
||||
]
|
||||
self.input_tokens = 0
|
||||
self.output_tokens = 0
|
||||
@@ -43,7 +44,7 @@ class _FakeSession:
|
||||
}
|
||||
|
||||
|
||||
def _invoke(session):
|
||||
def _invoke(session, query=None):
|
||||
import api.routes as routes
|
||||
|
||||
captured = {}
|
||||
@@ -53,7 +54,9 @@ def _invoke(session):
|
||||
captured["status"] = status
|
||||
return data
|
||||
|
||||
parsed = urlparse("/api/session?session_id=tail_payload_001&messages=1&resolve_model=0&msg_limit=1")
|
||||
if query is None:
|
||||
query = "session_id=tail_payload_001&messages=1&resolve_model=0&msg_limit=1"
|
||||
parsed = urlparse(f"/api/session?{query}")
|
||||
with patch("api.routes.get_session", return_value=session), \
|
||||
patch("api.routes._clear_stale_stream_state", return_value=False), \
|
||||
patch("api.routes._lookup_cli_session_metadata", return_value={}), \
|
||||
@@ -80,7 +83,7 @@ def test_tail_window_omits_historical_tool_calls_when_messages_have_tool_metadat
|
||||
assert payload["_messages_truncated"] is True
|
||||
|
||||
|
||||
def test_tail_window_keeps_session_tool_calls_for_legacy_messages_without_metadata():
|
||||
def test_tail_window_keeps_only_visible_session_tool_calls_for_legacy_messages_without_metadata():
|
||||
session = _FakeSession([
|
||||
{"role": "user", "content": "older"},
|
||||
{"role": "assistant", "content": "visible legacy message"},
|
||||
@@ -89,4 +92,42 @@ def test_tail_window_keeps_session_tool_calls_for_legacy_messages_without_metada
|
||||
payload = _invoke(session)
|
||||
|
||||
assert payload["messages"] == [session.messages[-1]]
|
||||
assert payload["tool_calls"] == [session.tool_calls[-1]]
|
||||
|
||||
|
||||
def test_full_load_keeps_all_session_tool_calls_for_legacy_messages_without_metadata():
|
||||
session = _FakeSession([
|
||||
{"role": "user", "content": "older"},
|
||||
{"role": "assistant", "content": "visible legacy message"},
|
||||
])
|
||||
|
||||
payload = _invoke(
|
||||
session,
|
||||
query="session_id=tail_payload_001&messages=1&resolve_model=0",
|
||||
)
|
||||
|
||||
assert payload["messages"] == session.messages
|
||||
assert payload["tool_calls"] == session.tool_calls
|
||||
|
||||
|
||||
def test_msg_before_window_keeps_only_that_page_session_tool_calls():
|
||||
session = _FakeSession([
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "second legacy message"},
|
||||
{"role": "user", "content": "third"},
|
||||
{"role": "assistant", "content": "fourth legacy message"},
|
||||
])
|
||||
session.tool_calls = [
|
||||
{"name": "first-page-tool", "snippet": "kept", "assistant_msg_idx": 1},
|
||||
{"name": "tail-tool", "snippet": "not in page", "assistant_msg_idx": 3},
|
||||
{"name": "unindexed-tool", "snippet": "cannot place"},
|
||||
]
|
||||
|
||||
payload = _invoke(
|
||||
session,
|
||||
query="session_id=tail_payload_001&messages=1&resolve_model=0&msg_before=3&msg_limit=2",
|
||||
)
|
||||
|
||||
assert payload["messages"] == session.messages[1:3]
|
||||
assert payload["tool_calls"] == [session.tool_calls[0]]
|
||||
assert payload["_messages_offset"] == 1
|
||||
|
||||
@@ -352,6 +352,24 @@ def test_upload_respects_attachment_dir_env(monkeypatch, tmp_path):
|
||||
assert _session_attachment_dir("session-123") == inbox.resolve() / "session-123"
|
||||
|
||||
|
||||
def test_upload_destination_does_not_overwrite_same_filename(monkeypatch, tmp_path):
|
||||
"""Repeated uploads with the same filename in one session keep distinct paths."""
|
||||
from api.upload import _upload_destination
|
||||
|
||||
inbox = tmp_path / "attachment-inbox"
|
||||
monkeypatch.setenv("HERMES_WEBUI_ATTACHMENT_DIR", str(inbox))
|
||||
|
||||
first = _upload_destination("session-123", "photo.png")
|
||||
first.write_bytes(b"first")
|
||||
second = _upload_destination("session-123", "photo.png")
|
||||
second.write_bytes(b"second")
|
||||
|
||||
assert first.name == "photo.png"
|
||||
assert second.name == "photo-1.png"
|
||||
assert first.read_bytes() == b"first"
|
||||
assert second.read_bytes() == b"second"
|
||||
|
||||
|
||||
def test_upload_too_large(cleanup_test_sessions):
|
||||
"""Uploading a file over MAX_UPLOAD_BYTES is rejected (413 or connection closed)."""
|
||||
sid, _ = make_session_tracked(cleanup_test_sessions)
|
||||
|
||||
Reference in New Issue
Block a user