This commit is contained in:
nesquena-hermes committed 2026-07-05 20:51:59 +00:00
commit 5d81a317ec
12 files changed
+1014 -6

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
"""Compression-exhausted recovery metadata and intent helpers."""
from __future__ import annotations
import re
import time
from typing import Any
COMPRESSION_RECOVERY_TERMINAL_STATE = "compression_exhausted"
COMPRESSION_RECOVERY_ACTION_START_FOCUSED = "start_focused_continuation"
_GENERIC_CONTINUATION_INTENTS = frozenset(
{
"continue",
"continue please",
"go on",
"keep going",
"resume",
"proceed",
"carry on",
"继续",
"继续吧",
"接着",
"接着做",
"继续做",
"继续执行",
}
)
def _positive_int(value: Any) -> int:
try:
parsed = int(value or 0)
except (TypeError, ValueError):
return 0
return parsed if parsed > 0 else 0
def _normalize_intent_text(text: str) -> str:
raw = str(text or "").strip().lower()
if not raw:
return ""
# Keep CJK letters while dropping punctuation/emoji/noise around short intents.
return re.sub(r"[\W_]+", " ", raw, flags=re.UNICODE).strip()
def is_generic_continuation_intent(text: str) -> bool:
"""Return True only for short, content-free continuation requests."""
normalized = _normalize_intent_text(text)
if normalized in _GENERIC_CONTINUATION_INTENTS:
return True
# A single repeated word such as "continue continue" is still generic, but
# longer prompts like "continue by summarizing file X" are substantive.
parts = normalized.split()
return bool(parts) and len(parts) <= 2 and all(part in _GENERIC_CONTINUATION_INTENTS for part in parts)
def build_compression_recovery_payload(session, *, message: str = "", details: str = "") -> dict:
"""Build the durable UI/route payload for a compression-exhausted turn."""
source_sid = str(getattr(session, "session_id", "") or "")
last_prompt_tokens = _positive_int(getattr(session, "last_prompt_tokens", None))
threshold_tokens = _positive_int(getattr(session, "threshold_tokens", None))
context_length = _positive_int(getattr(session, "context_length", None))
payload = {
"terminal_state": COMPRESSION_RECOVERY_TERMINAL_STATE,
"recommended_action": COMPRESSION_RECOVERY_ACTION_START_FOCUSED,
"source_session_id": source_sid,
"created_at": time.time(),
"title": "Context compression exhausted",
"summary": (
"This run could not safely shrink the conversation enough to continue in place. "
"Start a focused continuation, then describe the next narrow task."
),
"action_label": "Start focused continuation",
"message": str(message or "").strip(),
"details": str(details or "").strip()[:1200],
"last_prompt_tokens": last_prompt_tokens,
"threshold_tokens": threshold_tokens,
"context_length": context_length,
}
return payload
def stamp_compression_exhausted_recovery(session, *, message: str = "", details: str = "") -> dict:
"""Persist recovery metadata on a session and return the message payload."""
payload = build_compression_recovery_payload(session, message=message, details=details)
session.recommended_recovery_action = payload["recommended_action"]
session.compression_recovery = payload
return payload
def compression_recovery_payload_for_session(session) -> dict | None:
payload = getattr(session, "compression_recovery", None)
if not isinstance(payload, dict):
return None
if payload.get("terminal_state") != COMPRESSION_RECOVERY_TERMINAL_STATE:
return None
action = str(payload.get("recommended_action") or getattr(session, "recommended_recovery_action", "") or "")
if action != COMPRESSION_RECOVERY_ACTION_START_FOCUSED:
return None
return payload
def clear_compression_recovery(session) -> None:
session.recommended_recovery_action = None
session.compression_recovery = {}
+115
View File
@@ -1051,6 +1051,10 @@ class Session:
context_engine_state=None,
context_length=None, threshold_tokens=None,
last_prompt_tokens=None,
compression_recovery=None,
recommended_recovery_action=None,
compression_recovery_source_session_id=None,
compression_recovery_action=None,
truncation_watermark=None,
truncation_boundary=None,
clear_generation=None,
@@ -1103,6 +1107,18 @@ class Session:
self.context_length = context_length
self.threshold_tokens = threshold_tokens
self.last_prompt_tokens = last_prompt_tokens
self.compression_recovery = compression_recovery if isinstance(compression_recovery, dict) else {}
self.recommended_recovery_action = recommended_recovery_action
self.compression_recovery_source_session_id = (
str(compression_recovery_source_session_id).strip()
if compression_recovery_source_session_id
else None
)
self.compression_recovery_action = (
str(compression_recovery_action).strip()
if compression_recovery_action
else None
)
self.truncation_watermark = truncation_watermark
self.truncation_boundary = truncation_boundary
self.clear_generation = clear_generation
@@ -1174,6 +1190,8 @@ class Session:
'context_engine', 'compression_anchor_engine', 'compression_anchor_mode',
'compression_anchor_details', 'context_engine_state',
'context_length', 'threshold_tokens', 'last_prompt_tokens',
'compression_recovery', 'recommended_recovery_action',
'compression_recovery_source_session_id', 'compression_recovery_action',
'truncation_watermark',
'truncation_boundary',
'clear_generation',
@@ -1422,12 +1440,18 @@ class Session:
'context_length': self.context_length,
'threshold_tokens': self.threshold_tokens,
'last_prompt_tokens': self.last_prompt_tokens,
'compression_recovery': self.compression_recovery,
'recommended_recovery_action': self.recommended_recovery_action,
'gateway_routing': self.gateway_routing,
'gateway_routing_history': self.gateway_routing_history,
'manual_title': self.manual_title,
# Only emit 'parent_session_id' when set (the /branch fork link, #1342).
# Sessions without a fork must not leak None — see test_session_lineage_metadata_api.
**({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}),
**({
'compression_recovery_source_session_id': self.compression_recovery_source_session_id,
'compression_recovery_action': self.compression_recovery_action,
} if (self.compression_recovery_source_session_id or self.compression_recovery_action) else {}),
**({
'worktree_path': self.worktree_path,
'worktree_branch': self.worktree_branch,
@@ -3269,6 +3293,97 @@ def get_session(sid, metadata_only=False):
return s
raise KeyError(sid)
_COMPRESSION_RECOVERY_PROFILE_UNSET = object()
def _compression_recovery_child_matches(
session,
source_session_id: str,
action: str,
source_profile=_COMPRESSION_RECOVERY_PROFILE_UNSET,
) -> bool:
if source_profile is not _COMPRESSION_RECOVERY_PROFILE_UNSET:
try:
from api.profiles import _profiles_match
except (ImportError, AttributeError):
logger.debug("Failed to profile-check compression recovery session", exc_info=True)
return False
if not _profiles_match(getattr(session, "profile", None), source_profile):
return False
return (
str(getattr(session, "compression_recovery_source_session_id", "") or "").strip() == source_session_id
and str(getattr(session, "compression_recovery_action", "") or "").strip() == action
)
def find_compression_recovery_session(
source_session_id: str,
action: str,
source_profile=_COMPRESSION_RECOVERY_PROFILE_UNSET,
):
"""Return an existing focused recovery child for ``source_session_id``.
The recovery-start endpoint is a retryable UI action. A persisted marker on
the child session makes double-clicks, repeated calls, and cache reloads
converge on the same continuation instead of creating duplicate siblings.
"""
source_sid = str(source_session_id or "").strip()
recovery_action = str(action or "").strip()
if not source_sid or not recovery_action:
return None
matches = []
seen_ids: set[str] = set()
try:
with LOCK:
memory_sessions = list(SESSIONS.values())
for session in memory_sessions:
sid = str(getattr(session, "session_id", "") or "").strip()
if sid:
seen_ids.add(sid)
if _compression_recovery_child_matches(session, source_sid, recovery_action, source_profile):
matches.append(session)
except Exception:
logger.debug("Failed to scan cached compression recovery sessions", exc_info=True)
try:
persisted_ids = _persisted_session_ids_snapshot()
except Exception:
persisted_ids = frozenset()
for sid in persisted_ids:
if sid in seen_ids:
continue
try:
meta = Session.load_metadata_only(sid)
except Exception:
logger.debug("Failed to inspect compression recovery session %s", sid, exc_info=True)
continue
if not meta or not _compression_recovery_child_matches(meta, source_sid, recovery_action, source_profile):
continue
try:
matches.append(get_session(sid))
except Exception:
matches.append(meta)
if not matches:
return None
def _sort_key(session):
try:
created_at = float(getattr(session, "created_at", 0) or 0)
except (TypeError, ValueError):
created_at = 0.0
try:
updated_at = float(getattr(session, "updated_at", 0) or 0)
except (TypeError, ValueError):
updated_at = 0.0
return (created_at, updated_at, str(getattr(session, "session_id", "") or ""))
return sorted(matches, key=_sort_key)[0]
def _profile_default_model_state(profile=None):
"""Return the default model/provider configured for *profile*."""
default_model = ""
+159 -4
View File
@@ -42,6 +42,12 @@ from api.agent_sessions import (
read_session_lineage_report,
)
from api.compression_anchor import visible_messages_for_anchor
from api.compression_recovery import (
COMPRESSION_RECOVERY_ACTION_START_FOCUSED,
clear_compression_recovery,
compression_recovery_payload_for_session,
is_generic_continuation_intent,
)
from api.session_events import (
add_session_list_changed_listener,
publish_session_list_changed,
@@ -8144,6 +8150,11 @@ def _merged_webui_lineage_messages_for_display(session, messages=None) -> list:
parent_id = str(getattr(session, "parent_session_id", "") or "").strip()
if not parent_id:
return primary_messages
if (
str(getattr(session, "compression_recovery_source_session_id", "") or "").strip()
and str(getattr(session, "compression_recovery_action", "") or "").strip()
):
return primary_messages
source = str(getattr(session, "session_source", "") or "").strip().lower()
relationship = str(getattr(session, "relationship_type", "") or "").strip().lower()
if source == "fork" or relationship == "child_session":
@@ -8602,6 +8613,7 @@ def _keep_latest_messaging_session_per_source(
from api.models import (
Session,
get_session,
find_compression_recovery_session,
get_session_for_file_ops,
new_session,
all_sessions,
@@ -8643,6 +8655,9 @@ from api.models import (
)
_COMPRESSION_RECOVERY_START_LOCK = threading.Lock()
def _pre_compression_continuation_session_id(session) -> str | None:
"""Return the newest visible descendant for a hidden compression snapshot.
@@ -13185,6 +13200,9 @@ def handle_post(handler, parsed) -> bool:
)
return j(handler, {"session": s.compact() | {"messages": s.messages}})
if parsed.path == "/api/session/compression-recovery/start":
return _handle_session_compression_recovery_start(handler, body)
if parsed.path == "/api/session/duplicate":
try:
sid = body.get("session_id")
@@ -19873,6 +19891,103 @@ def _handle_bg_task_complete_ack(handler, body):
)
def _handle_session_compression_recovery_start(handler, body):
try:
require(body, "session_id")
except ValueError as e:
return bad(handler, str(e))
sid = str(body.get("session_id") or "").strip()
if not sid:
return bad(handler, "session_id is required")
if _session_is_subagent_view_only(sid):
return bad(handler, "Subagent sessions are view-only and cannot start compression recovery from WebUI", 400)
try:
source = get_session(sid)
except KeyError:
return bad(handler, "Session not found", 404)
if not _session_visible_to_active_profile(getattr(source, "profile", None), handler):
return bad(handler, "Session not found", 404)
recovery = compression_recovery_payload_for_session(source)
if not recovery:
return bad(handler, "Session does not have a compression recovery action.", 409)
action = str(recovery.get("recommended_action") or "")
if action != COMPRESSION_RECOVERY_ACTION_START_FOCUSED:
return bad(handler, "Unsupported compression recovery action.", 409)
created = False
with _COMPRESSION_RECOVERY_START_LOCK:
source_profile = getattr(source, "profile", None)
copied_session = find_compression_recovery_session(sid, action, source_profile=source_profile)
if copied_session is None:
title = str(getattr(source, "title", None) or "Untitled").strip() or "Untitled"
if not title.endswith(" (focused continuation)"):
title = f"{title} (focused continuation)"
copied_session = Session(
session_id=uuid.uuid4().hex[:12],
title=title,
workspace=getattr(source, "workspace", get_last_workspace()),
model=getattr(source, "model", None),
model_provider=getattr(source, "model_provider", None),
messages=[],
tool_calls=[],
pinned=False,
archived=False,
project_id=getattr(source, "project_id", None),
profile=getattr(source, "profile", None),
session_source="fork",
personality=getattr(source, "personality", None),
enabled_toolsets=copy.deepcopy(getattr(source, "enabled_toolsets", None)),
context_length=getattr(source, "context_length", None),
threshold_tokens=getattr(source, "threshold_tokens", None),
gateway_routing=copy.deepcopy(getattr(source, "gateway_routing", None)),
gateway_routing_history=copy.deepcopy(getattr(source, "gateway_routing_history", None) or []),
parent_session_id=getattr(source, "session_id", sid),
worktree_path=getattr(source, "worktree_path", None),
worktree_branch=getattr(source, "worktree_branch", None),
worktree_repo_root=getattr(source, "worktree_repo_root", None),
worktree_created_at=getattr(source, "worktree_created_at", None),
compression_recovery_source_session_id=sid,
compression_recovery_action=action,
)
# Preserve the workspace/model/profile lane, but intentionally start with an
# empty model-facing transcript so a focused follow-up does not replay the
# exhausted state.db/context tail.
copied_session.context_messages = []
copied_session.composer_draft = {"text": "", "files": []}
try:
copied_session.save()
except Exception as e:
logger.exception("failed to persist compression recovery session for %s", sid)
return bad(handler, f"Failed to start compression recovery: {_sanitize_error(e)}", 500)
with LOCK:
SESSIONS[copied_session.session_id] = copied_session
SESSIONS.move_to_end(copied_session.session_id)
_evict_sessions_over_cap()
created = True
if created:
publish_session_list_changed(
"session_compression_recovery",
profile=getattr(copied_session, "profile", None),
session_id=getattr(copied_session, "session_id", None),
)
session_payload = redact_session_data(copied_session.compact() | {"messages": copied_session.messages})
return j(
handler,
{
"ok": True,
"session": session_payload,
"source_session_id": sid,
"recommended_recovery_action": action,
"message": (
"Started a focused continuation. Describe the next narrow task to continue."
if created
else "Opened the existing focused continuation for this exhausted session."
),
},
)
def _handle_goal_command(handler, body):
"""Handle WebUI /goal command controls and optional kickoff stream."""
try:
@@ -20128,6 +20243,19 @@ def _handle_chat_start(handler, body, diag=None):
return bad(handler, "message is required")
diag.stage("normalize_attachments") if diag else None
attachments = _normalize_chat_attachments(body.get("attachments") or [])[:20]
recovery = compression_recovery_payload_for_session(s)
if recovery and not attachments and is_generic_continuation_intent(msg):
return j(
handler,
{
"error": "This session exhausted context compression. Start a focused continuation, then describe the next narrow task.",
"type": "compression_recovery_required",
"recommended_recovery_action": recovery.get("recommended_action"),
"compression_recovery": recovery,
"session_id": getattr(s, "session_id", body["session_id"]),
},
status=409,
)
diag.stage("resolve_workspace") if diag else None
try:
workspace = _resolve_chat_workspace_with_recovery(s, body.get("workspace"))
@@ -20187,16 +20315,43 @@ def _handle_chat_start(handler, body, diag=None):
}
if not gateway_chat_enabled and moa_config is not None:
start_run_kwargs["moa_config"] = moa_config
response = _start_run(
s,
**start_run_kwargs,
)
recovery_cleared_for_start = None
def _restore_cleared_recovery():
if recovery_cleared_for_start is None:
return None
s.compression_recovery = recovery_cleared_for_start
s.recommended_recovery_action = recovery_cleared_for_start.get("recommended_action")
try:
s.save()
except Exception as restore_err:
logger.exception("failed to restore compression recovery after chat start rejection for %s", getattr(s, "session_id", None))
return restore_err
return None
if recovery:
recovery_cleared_for_start = copy.deepcopy(recovery)
clear_compression_recovery(s)
try:
response = _start_run(
s,
**start_run_kwargs,
)
except Exception:
_restore_cleared_recovery()
raise
# Map adapter-selection NotImplementedError (501) onto the legacy
# bad-request response shape that this route exposed historically
# before the helper extraction.
if response.get("_status") == 501 and "error" in response:
restore_err = _restore_cleared_recovery()
if restore_err is not None:
return bad(handler, f"failed to restore compression recovery: {_sanitize_error(restore_err)}", 500)
return j(handler, {"error": response["error"]}, status=501)
status = int(response.pop("_status", 200) or 200)
if status >= 400 and recovery_cleared_for_start is not None:
restore_err = _restore_cleared_recovery()
if restore_err is not None:
return bad(handler, f"failed to restore compression recovery: {_sanitize_error(restore_err)}", 500)
diag.stage("response_write") if diag else None
return j(handler, response, status=status)
finally:
+7
View File
@@ -536,6 +536,9 @@ def _state_db_row_to_sidecar(row: dict) -> dict:
messages = row.get('messages') if isinstance(row.get('messages'), list) else []
last_ts = messages[-1].get('timestamp') if messages and isinstance(messages[-1], dict) else started_at
workspace_value = row.get('workspace') or ''
compression_recovery = row.get('compression_recovery')
if not isinstance(compression_recovery, dict):
compression_recovery = {}
return {
'session_id': row.get('id'),
'title': row.get('title') or 'Recovered WebUI Session',
@@ -567,6 +570,10 @@ def _state_db_row_to_sidecar(row: dict) -> dict:
'context_length': None,
'threshold_tokens': None,
'last_prompt_tokens': None,
'compression_recovery': compression_recovery,
'recommended_recovery_action': row.get('recommended_recovery_action') or None,
'compression_recovery_source_session_id': row.get('compression_recovery_source_session_id') or None,
'compression_recovery_action': row.get('compression_recovery_action') or None,
'gateway_routing': None,
'gateway_routing_history': [],
'llm_title_generated': False,
+24
View File
@@ -44,6 +44,7 @@ from api.config import (
)
from api.helpers import redact_session_data, _redact_text
from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor
from api.compression_recovery import stamp_compression_exhausted_recovery
from api.metering import meter
from api.run_journal import RunJournalWriter
from api.todo_state import attach_todo_state, emit_todo_state
@@ -8680,6 +8681,15 @@ def _run_agent_streaming(
'timestamp': int(time.time()),
'_error': True,
}
if _err_type == 'compression_exhausted':
_recovery = stamp_compression_exhausted_recovery(
s,
message=_error_payload.get('message') or _err_label,
details=_error_payload.get('details') or '',
)
_error_message['_compressionRecovery'] = _recovery
_error_payload['compression_recovery'] = _recovery
_error_payload['recommended_recovery_action'] = _recovery.get('recommended_action')
if _error_payload.get('details'):
_error_message['provider_details'] = _error_payload['details']
if _err_type == 'cancelled':
@@ -9573,6 +9583,7 @@ def _run_agent_streaming(
_exc_is_not_found = _classification['type'] == 'model_not_found' # detects '404', 'not found', 'does not exist', and 'invalid model'.
_exc_is_cancelled = _classification['type'] == 'cancelled'
_exc_is_interrupted = _classification['type'] == 'interrupted'
_exc_is_compression_exhausted = _classification['type'] == 'compression_exhausted'
# The user hint still points to Settings / `hermes model` from _classify_provider_error().
if _exc_is_quota:
@@ -9703,6 +9714,10 @@ def _run_agent_streaming(
_exc_label, _exc_type, _exc_hint = (
_classification['label'], _classification['type'], _classification['hint'],
)
elif _exc_is_compression_exhausted:
_exc_label, _exc_type, _exc_hint = (
_classification['label'], _classification['type'], _classification['hint'],
)
else:
_exc_label, _exc_type, _exc_hint = 'Error', 'error', ''
@@ -9742,6 +9757,15 @@ def _run_agent_streaming(
'timestamp': int(time.time()),
'_error': True,
}
if _exc_type == 'compression_exhausted':
_recovery = stamp_compression_exhausted_recovery(
s,
message=_error_payload.get('message') or err_str,
details=_error_payload.get('details') or '',
)
_error_message['_compressionRecovery'] = _recovery
_error_payload['compression_recovery'] = _recovery
_error_payload['recommended_recovery_action'] = _recovery.get('recommended_action')
if _error_payload.get('details'):
_error_message['provider_details'] = _error_payload['details']
if _exc_type == 'cancelled':
+4
View File
@@ -51,6 +51,10 @@ _METADATA_FIELDS = frozenset(
"context_length",
"threshold_tokens",
"last_prompt_tokens",
"compression_recovery",
"recommended_recovery_action",
"compression_recovery_source_session_id",
"compression_recovery_action",
"truncation_watermark",
"truncation_boundary",
"gateway_routing",
+38
View File
@@ -121,6 +121,44 @@ The on-disk locations below assume the default `~/.hermes/webui` state directory
---
## "Context compression exhausted" after a long-running turn
**Symptom.** A long-running session, often with many tool calls or a small
context-window model, ends with a `Context compression exhausted` error instead
of a final answer. The message includes a recovery action labeled `Start focused
continuation`.
**Why.** Automatic compression could not shrink the current conversation enough
to continue safely in the same model-facing context. The exhausted session is
terminal: sending a bare "continue", "go on", or "继续" would usually replay the
same oversized state and fail again, so the WebUI points the user to a focused
linked continuation instead.
**Diagnostic.**
1. Open the session JSON under your WebUI state directory, for example:
```bash
jq '.recommended_recovery_action, .compression_recovery' \
~/.hermes/webui/sessions/<session_id>.json
```
2. A recoverable exhausted turn should report:
- `recommended_recovery_action: "start_focused_continuation"`
- `compression_recovery.terminal_state: "compression_exhausted"`
- the final assistant error message carrying `_compressionRecovery`
**Fix.** Use the `Start focused continuation` action in the exhausted message.
The new linked session preserves the workspace, model, profile, project, and
toolset lane, but intentionally starts with an empty model-facing transcript so
the oversized exhausted tail is not replayed. After the new session opens,
describe the next narrow task explicitly instead of sending a bare continuation.
**When to file a bug.** File a bug if the exhausted message has no recovery
action, the action creates a session with the old oversized context/messages
replayed into the model-facing transcript, or a bare "continue" starts another
turn in the exhausted session instead of being blocked with recovery guidance.
---
## Other troubleshooting
This document grows over time. If a recurring failure mode isn't covered here yet, add it via PR. The format for each entry: **Symptom → Why → Diagnostic commands → Fix → When to file a bug**.
+7 -1
View File
@@ -1326,6 +1326,11 @@ async function send(){
_flushSelectionBlocksToComposer();
text=$('msg').value.trim();
if(!text&&!S.pendingFiles.length){_sendInProgress=false;_sendInProgressSid=null;return;}
if(typeof shouldInterceptCompressionRecoveryContinuation==='function'&&shouldInterceptCompressionRecoveryContinuation(text,S.pendingFiles)){
if(typeof showCompressionRecoveryContinuationHint==='function') showCompressionRecoveryContinuationHint();
_sendInProgress=false;_sendInProgressSid=null;
return;
}
// #5472: snapshot the ORIGINAL user-typed composer state now — before slash
// rewrites (/moa, bundles) mutate `text` and before uploadPendingFiles()
@@ -5707,7 +5712,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
}
} else {
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`,provider_details:details,provider_details_label:detailsLabel});
const recovery=(d.compression_recovery&&typeof d.compression_recovery==='object')?d.compression_recovery:null;
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`,provider_details:details,provider_details_label:detailsLabel,_compressionRecovery:recovery||undefined});
_attachProjectedAnchorSceneToLastAssistant(S.messages);
}
}catch(_){
+8
View File
@@ -5786,6 +5786,14 @@ main.main > #mainPlugin{display:none;}
.status-card-label{color:var(--muted);font-weight:550;background:rgba(0,0,0,.015);}
.status-card-value{color:var(--text);word-break:break-word;font-family:var(--font-mono);}
@media (max-width:700px){.status-card{margin-left:0;}.status-card-head{flex-direction:column;}.status-card-session-copy{max-width:100%;}.status-card-grid{grid-template-columns:1fr;}.status-card-label{padding-bottom:2px;border-top:1px solid var(--border-subtle);}.status-card-value{padding-top:2px;border-top:0;}}
.compression-recovery-card{display:flex;align-items:center;justify-content:space-between;gap:14px;margin:12px 0 4px;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-card);background:var(--surface-subtle);box-shadow:0 8px 20px rgba(0,0,0,.04);}
.compression-recovery-copy{display:flex;flex-direction:column;gap:3px;min-width:0;}
.compression-recovery-title{color:var(--text);font-size:14px;font-weight:650;line-height:1.35;}
.compression-recovery-summary{color:var(--muted);font-size:12px;line-height:1.45;}
.compression-recovery-action{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:32px;padding:6px 11px;border:1px solid var(--accent-bg-strong);border-radius:7px;background:var(--accent-bg);color:var(--accent-text);font:inherit;font-size:12px;font-weight:650;white-space:nowrap;cursor:pointer;}
.compression-recovery-action:hover{background:var(--accent-bg-strong);color:var(--accent-text);}
.compression-recovery-action:disabled{opacity:.65;cursor:progress;}
@media (max-width:700px){.compression-recovery-card{flex-direction:column;align-items:stretch;margin-left:0;}.compression-recovery-action{width:100%;}}
.assistant-segment-anchor { display: none; }
/* ── Classic conversation layout: user right, half-width; assistant left ── */
+88 -1
View File
@@ -418,6 +418,91 @@ function _statusCardHtml(card){
</div>`;
}
function _compressionRecoveryHtml(recovery, sessionId){
if(!recovery||typeof recovery!=='object') return '';
if(String(recovery.terminal_state||'')!=='compression_exhausted') return '';
const action=String(recovery.recommended_action||'');
if(action!=='start_focused_continuation') return '';
const sid=String(recovery.source_session_id||sessionId||'');
const title=String(recovery.title||'Context compression exhausted');
const summary=String(recovery.summary||'Start a focused continuation, then describe the next narrow task.');
const actionLabel=String(recovery.action_label||'Start focused continuation');
const icon=(typeof li==='function')?li('git-branch',14):'';
return `<div class="compression-recovery-card" data-compression-recovery-card="1">
<div class="compression-recovery-copy">
<div class="compression-recovery-title">${esc(title)}</div>
<div class="compression-recovery-summary">${esc(summary)}</div>
</div>
<button class="compression-recovery-action" type="button" data-recovery-session-id="${esc(sid)}" onclick="startCompressionRecovery(this);event.stopPropagation()">${icon}<span>${esc(actionLabel)}</span></button>
</div>`;
}
function _activeCompressionRecoveryPayload(){
if(!S||!S.session) return null;
const recovery=S.session.compression_recovery;
if(recovery&&typeof recovery==='object'&&String(recovery.terminal_state||'')==='compression_exhausted') return recovery;
// A cleared session-level recovery payload is authoritative. Only scan
// message metadata for older sessions that never exposed this field.
if(Object.prototype.hasOwnProperty.call(S.session,'compression_recovery')) return null;
const messages=Array.isArray(S.messages)?S.messages:[];
for(let i=messages.length-1;i>=0;i--){
const msg=messages[i];
const msgRecovery=msg&&msg._compressionRecovery;
if(msgRecovery&&typeof msgRecovery==='object'&&String(msgRecovery.terminal_state||'')==='compression_exhausted') return msgRecovery;
}
return null;
}
function isGenericCompressionContinuationIntent(text){
const raw=String(text||'').trim().toLowerCase();
if(!raw) return false;
const normalized=raw.replace(/[^\p{L}\p{N}]+/gu,' ').trim();
const generic=new Set(['continue','continue please','go on','keep going','resume','proceed','carry on','继续','继续吧','接着','接着做','继续做','继续执行']);
if(generic.has(normalized)) return true;
const parts=normalized.split(/\s+/).filter(Boolean);
return !!parts.length&&parts.length<=2&&parts.every(part=>generic.has(part));
}
function shouldInterceptCompressionRecoveryContinuation(text, files){
const hasFiles=Array.isArray(files)&&files.length>0;
if(hasFiles||!isGenericCompressionContinuationIntent(text)) return false;
const recovery=_activeCompressionRecoveryPayload();
return !!(recovery&&String(recovery.recommended_action||'')==='start_focused_continuation');
}
function showCompressionRecoveryContinuationHint(){
const card=document.querySelector('[data-compression-recovery-card="1"]');
if(card&&typeof card.scrollIntoView==='function'){
try{card.scrollIntoView({block:'center',behavior:'smooth'});}catch(_){card.scrollIntoView();}
const btn=card.querySelector('.compression-recovery-action');
if(btn&&typeof btn.focus==='function') setTimeout(()=>btn.focus(),120);
}
if(typeof showToast==='function') showToast('This session exhausted context compression. Start a focused continuation, then describe the next narrow task.',4500,'warning');
}
async function startCompressionRecovery(btn){
const sourceSid=String((btn&&btn.dataset&&btn.dataset.recoverySessionId)||(S.session&&S.session.session_id)||'').trim();
if(!sourceSid) return;
if(btn){btn.disabled=true;btn.classList.add('loading');}
try{
const data=await api('/api/session/compression-recovery/start',{method:'POST',body:JSON.stringify({session_id:sourceSid})});
const sid=data&&data.session&&data.session.session_id;
if(!sid) throw new Error('Compression recovery did not return a session.');
try{localStorage.setItem('hermes-webui-session',sid);}catch(_){}
if(typeof loadSession==='function') await loadSession(sid,{preserveActiveInput:false});
else if(data.session){S.session=data.session;S.messages=data.session.messages||[];syncTopbar();renderMessages();}
if(typeof renderSessionList==='function') await renderSessionList();
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(sid);
if(typeof showToast==='function') showToast((data&&data.message)||'Started focused continuation.',3000,'success');
const composer=$('msg');
if(composer&&typeof composer.focus==='function') composer.focus();
}catch(e){
if(typeof showToast==='function') showToast('Compression recovery failed: '+(e&&e.message||e),5000,'error');
}finally{
if(btn){btn.disabled=false;btn.classList.remove('loading');}
}
}
const MESSAGE_RENDER_WINDOW_DEFAULT=50;
const MESSAGE_VIRTUAL_THRESHOLD_ROWS=80;
const MESSAGE_VIRTUAL_BUFFER_PX=900;
@@ -13493,6 +13578,8 @@ function renderMessages(options){
const summary=m.provider_details_label||'Provider details';
bodyHtml += `<details class="provider-error-details"><summary>${esc(String(summary))}</summary><pre><code>${esc(String(m.provider_details))}</code></pre></details>`;
}
const recoveryHtml=(!isUser&&m._compressionRecovery) ? _compressionRecoveryHtml(m._compressionRecovery, (S.session&&S.session.session_id)||'') : '';
if(recoveryHtml) bodyHtml += recoveryHtml;
const statusHtml = (!isUser&&m._statusCard) ? _statusCardHtml(m._statusCard) : '';
const isEditableUser=isUser&&rawIdx===lastUserRawIdx;
const editBtn = isEditableUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
@@ -13687,7 +13774,7 @@ function renderMessages(options){
if((isCompactWorklogMode()||isTransparentStream())&&_assistantThinkingBelongsInWorklog(m, rawIdx, toolCallAssistantIdxs)) assistantThinking.set(rawIdx, thinkingText);
else if(window._showThinking!==false) seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText));
}
const hasVisibleBody=!!(String(content||'').trim()||filesHtml||statusHtml);
const hasVisibleBody=!!(String(content||'').trim()||filesHtml||statusHtml||recoveryHtml);
if(statusHtml){
seg.insertAdjacentHTML('beforeend', statusHtml);
}else if(hasVisibleBody){
@@ -135,6 +135,9 @@ def test_compression_exhausted_after_session_rotation_preserves_snapshot_and_err
assert payload["session"]["session_id"] == new_sid
assert payload["old_session_id"] == old_sid
assert payload["new_session_id"] == new_sid
assert payload["recommended_recovery_action"] == "start_focused_continuation"
assert payload["compression_recovery"]["terminal_state"] == "compression_exhausted"
assert payload["compression_recovery"]["source_session_id"] == new_sid
old_payload = json.loads((session_dir / f"{old_sid}.json").read_text(encoding="utf-8"))
new_payload = json.loads((session_dir / f"{new_sid}.json").read_text(encoding="utf-8"))
@@ -144,7 +147,10 @@ def test_compression_exhausted_after_session_rotation_preserves_snapshot_and_err
assert new_payload["session_id"] == new_sid
assert new_payload["parent_session_id"] == old_sid
assert new_payload["pre_compression_snapshot"] is False
assert new_payload["recommended_recovery_action"] == "start_focused_continuation"
assert new_payload["compression_recovery"]["recommended_action"] == "start_focused_continuation"
assert new_payload["messages"][-1]["_error"] is True
assert new_payload["messages"][-1]["_compressionRecovery"]["recommended_action"] == "start_focused_continuation"
assert "Context compression exhausted" in new_payload["messages"][-1]["content"]
assert old_sid not in streaming.SESSIONS
assert streaming.SESSIONS[new_sid].session_id == new_sid
@@ -429,6 +435,8 @@ def test_apperror_payload_enriched_before_enqueue(tmp_path, monkeypatch):
assert payload_after["session_id"] == new_sid
assert payload_after["old_session_id"] == old_sid
assert payload_after["new_session_id"] == new_sid
assert payload_after["recommended_recovery_action"] == "start_focused_continuation"
assert payload_after["compression_recovery"]["recommended_action"] == "start_focused_continuation"
def test_exception_apperror_payload_includes_session_id_before_enqueue():
+445
View File
@@ -0,0 +1,445 @@
import io
import json
from pathlib import Path
from api import models, routes
from api.compression_recovery import (
compression_recovery_payload_for_session,
is_generic_continuation_intent,
stamp_compression_exhausted_recovery,
)
from api.models import Session
from api.session_recovery import _state_db_row_to_sidecar
from api.webui_session_db import WebUIJsonSessionDB
ROOT = Path(__file__).resolve().parents[1]
class _JSONHandler:
headers = {}
def __init__(self):
self.status = None
self.wfile = io.BytesIO()
self.headers_sent = {}
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.headers_sent[key] = value
def end_headers(self):
pass
def _payload(handler):
raw = handler.wfile.getvalue().decode("utf-8")
return json.loads(raw) if raw else {}
def _isolate_sessions(monkeypatch, tmp_path):
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")
models.SESSIONS.clear()
routes.SESSIONS.clear()
return session_dir
def test_generic_continuation_intent_is_scoped_to_empty_continue_words():
assert is_generic_continuation_intent("continue")
assert is_generic_continuation_intent("继续吧。")
assert is_generic_continuation_intent("go on")
assert not is_generic_continuation_intent("continue by summarizing the workspace changes")
assert not is_generic_continuation_intent("继续修复 4685 的恢复卡")
def test_chat_start_blocks_generic_continue_after_compression_exhausted(monkeypatch, tmp_path):
_isolate_sessions(monkeypatch, tmp_path)
sid = "recoverychat1"
session = Session(
session_id=sid,
title="Recovery",
workspace=str(tmp_path),
model="gpt-4o",
messages=[{"role": "user", "content": "long task"}],
)
stamp_compression_exhausted_recovery(session, message="Context length exceeded.")
models.SESSIONS[sid] = session
routes.SESSIONS[sid] = session
handler = _JSONHandler()
routes._handle_chat_start(handler, {"session_id": sid, "message": "继续"})
payload = _payload(handler)
assert handler.status == 409
assert payload["type"] == "compression_recovery_required"
assert payload["recommended_recovery_action"] == "start_focused_continuation"
assert payload["compression_recovery"]["terminal_state"] == "compression_exhausted"
def test_chat_start_keeps_recovery_when_substantive_prompt_fails_validation(monkeypatch, tmp_path):
session_dir = _isolate_sessions(monkeypatch, tmp_path)
sid = "recoverychat2"
session = Session(
session_id=sid,
title="Recovery",
workspace=str(tmp_path),
model="gpt-4o",
messages=[{"role": "user", "content": "long task"}],
)
stamp_compression_exhausted_recovery(session, message="Context length exceeded.")
session.save()
models.SESSIONS[sid] = session
routes.SESSIONS[sid] = session
monkeypatch.setattr(
routes,
"_resolve_chat_workspace_with_recovery",
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("bad workspace")),
)
handler = _JSONHandler()
routes._handle_chat_start(handler, {"session_id": sid, "message": "continue by checking the repo"})
saved = json.loads((session_dir / f"{sid}.json").read_text(encoding="utf-8"))
assert handler.status == 400
assert saved["recommended_recovery_action"] == "start_focused_continuation"
assert saved["compression_recovery"]["terminal_state"] == "compression_exhausted"
def test_chat_start_clears_recovery_when_substantive_prompt_starts(monkeypatch, tmp_path):
session_dir = _isolate_sessions(monkeypatch, tmp_path)
sid = "recoverychat3"
session = Session(
session_id=sid,
title="Recovery",
workspace=str(tmp_path),
model="gpt-4o",
messages=[{"role": "user", "content": "long task"}],
)
stamp_compression_exhausted_recovery(session, message="Context length exceeded.")
session.save()
models.SESSIONS[sid] = session
routes.SESSIONS[sid] = session
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda *_args, **_kwargs: str(tmp_path))
monkeypatch.setattr(routes, "_read_profile_model_config", lambda *_args, **_kwargs: (None, None, {}))
monkeypatch.setattr(
routes,
"_resolve_compatible_session_model_state",
lambda requested_model, requested_provider, **_kwargs: (requested_model or "gpt-4o", requested_provider or "openai", False),
)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _config: False)
def _fake_start_run(run_session, **_kwargs):
assert compression_recovery_payload_for_session(run_session) is None
run_session.save()
return {"session_id": sid, "stream_id": "stream1", "_status": 200}
monkeypatch.setattr(routes, "_start_run", _fake_start_run)
handler = _JSONHandler()
routes._handle_chat_start(handler, {"session_id": sid, "message": "continue by checking the repo"})
payload = _payload(handler)
saved = json.loads((session_dir / f"{sid}.json").read_text(encoding="utf-8"))
assert handler.status == 200
assert payload["stream_id"] == "stream1"
assert saved["compression_recovery"] == {}
assert saved["recommended_recovery_action"] is None
def test_chat_start_restores_recovery_when_substantive_prompt_start_is_rejected(monkeypatch, tmp_path):
session_dir = _isolate_sessions(monkeypatch, tmp_path)
sid = "recoverychat4"
session = Session(
session_id=sid,
title="Recovery",
workspace=str(tmp_path),
model="gpt-4o",
messages=[{"role": "user", "content": "long task"}],
)
stamp_compression_exhausted_recovery(session, message="Context length exceeded.")
session.save()
models.SESSIONS[sid] = session
routes.SESSIONS[sid] = session
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda *_args, **_kwargs: str(tmp_path))
monkeypatch.setattr(routes, "_read_profile_model_config", lambda *_args, **_kwargs: (None, None, {}))
monkeypatch.setattr(
routes,
"_resolve_compatible_session_model_state",
lambda requested_model, requested_provider, **_kwargs: (requested_model or "gpt-4o", requested_provider or "openai", False),
)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _config: False)
def _fake_start_run(run_session, **_kwargs):
assert compression_recovery_payload_for_session(run_session) is None
run_session.save()
return {"error": "session already has an active stream", "_status": 409}
monkeypatch.setattr(routes, "_start_run", _fake_start_run)
handler = _JSONHandler()
routes._handle_chat_start(handler, {"session_id": sid, "message": "continue by checking the repo"})
saved = json.loads((session_dir / f"{sid}.json").read_text(encoding="utf-8"))
assert handler.status == 409
assert saved["recommended_recovery_action"] == "start_focused_continuation"
assert saved["compression_recovery"]["terminal_state"] == "compression_exhausted"
def test_recovery_start_creates_focused_linked_session(monkeypatch, tmp_path):
session_dir = _isolate_sessions(monkeypatch, tmp_path)
sid = "recoverysrc1"
session = Session(
session_id=sid,
title="Long task",
workspace=str(tmp_path),
model="gpt-4o",
model_provider="openai",
profile="default",
project_id="proj_1",
messages=[{"role": "user", "content": "long task"}],
context_messages=[{"role": "user", "content": "large context"}],
)
stamp_compression_exhausted_recovery(session, message="Context length exceeded.")
session.save()
models.SESSIONS[sid] = session
routes.SESSIONS[sid] = session
handler = _JSONHandler()
routes._handle_session_compression_recovery_start(handler, {"session_id": sid})
payload = _payload(handler)
assert handler.status == 200
new_session = payload["session"]
assert new_session["session_id"] != sid
assert new_session["parent_session_id"] == sid
assert new_session["workspace"] == str(tmp_path)
assert new_session["model"] == "gpt-4o"
assert new_session["model_provider"] == "openai"
assert new_session["messages"] == []
assert new_session["session_source"] == "fork"
saved = json.loads((session_dir / f"{new_session['session_id']}.json").read_text(encoding="utf-8"))
assert saved["parent_session_id"] == sid
assert saved["session_source"] == "fork"
assert saved["context_messages"] == []
assert saved["compression_recovery_source_session_id"] == sid
assert saved["compression_recovery_action"] == "start_focused_continuation"
assert compression_recovery_payload_for_session(session)["recommended_action"] == "start_focused_continuation"
def test_recovery_child_does_not_merge_parent_transcript(monkeypatch, tmp_path):
_isolate_sessions(monkeypatch, tmp_path)
sid = "recoverysrcisolate"
session = Session(
session_id=sid,
title="Long task",
workspace=str(tmp_path),
model="gpt-4o",
messages=[
{"role": "user", "content": "long task"},
{"role": "assistant", "content": "compression exhausted"},
],
)
stamp_compression_exhausted_recovery(session, message="Context length exceeded.")
session.save()
models.SESSIONS[sid] = session
routes.SESSIONS[sid] = session
handler = _JSONHandler()
routes._handle_session_compression_recovery_start(handler, {"session_id": sid})
payload = _payload(handler)
child_id = payload["session"]["session_id"]
child = models.SESSIONS[child_id]
assert child.messages == []
assert routes._merged_webui_lineage_messages_for_display(child) == []
def test_recovery_start_reuses_existing_focused_session(monkeypatch, tmp_path):
session_dir = _isolate_sessions(monkeypatch, tmp_path)
sid = "recoverysrc2"
session = Session(
session_id=sid,
title="Long task",
workspace=str(tmp_path),
model="gpt-4o",
messages=[{"role": "user", "content": "long task"}],
)
stamp_compression_exhausted_recovery(session, message="Context length exceeded.")
session.save()
models.SESSIONS[sid] = session
routes.SESSIONS[sid] = session
first_handler = _JSONHandler()
routes._handle_session_compression_recovery_start(first_handler, {"session_id": sid})
first_payload = _payload(first_handler)
first_child_id = first_payload["session"]["session_id"]
second_handler = _JSONHandler()
routes._handle_session_compression_recovery_start(second_handler, {"session_id": sid})
second_payload = _payload(second_handler)
assert second_handler.status == 200
assert second_payload["session"]["session_id"] == first_child_id
assert second_payload["message"].startswith("Opened the existing")
models.SESSIONS.clear()
routes.SESSIONS.clear()
third_handler = _JSONHandler()
routes._handle_session_compression_recovery_start(third_handler, {"session_id": sid})
third_payload = _payload(third_handler)
assert third_handler.status == 200
assert third_payload["session"]["session_id"] == first_child_id
recovery_children = []
for path in session_dir.glob("*.json"):
if path.name.startswith("_"):
continue
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict) and data.get("compression_recovery_source_session_id") == sid:
recovery_children.append(data)
assert len(recovery_children) == 1
def test_recovery_start_ignores_existing_child_from_other_profile(monkeypatch, tmp_path):
session_dir = _isolate_sessions(monkeypatch, tmp_path)
sid = "recoverysrcprofile"
source = Session(
session_id=sid,
title="Long task",
workspace=str(tmp_path),
model="gpt-4o",
profile="default",
messages=[{"role": "user", "content": "long task"}],
)
stamp_compression_exhausted_recovery(source, message="Context length exceeded.")
source.save()
foreign_child = Session(
session_id="foreignchild1",
title="Foreign focused continuation",
workspace=str(tmp_path),
model="gpt-4o",
profile="other-profile",
messages=[],
parent_session_id=sid,
compression_recovery_source_session_id=sid,
compression_recovery_action="start_focused_continuation",
)
foreign_child.save()
models.SESSIONS.clear()
routes.SESSIONS.clear()
models.SESSIONS[sid] = source
routes.SESSIONS[sid] = source
handler = _JSONHandler()
routes._handle_session_compression_recovery_start(handler, {"session_id": sid})
payload = _payload(handler)
assert handler.status == 200
assert payload["session"]["session_id"] != "foreignchild1"
assert payload["session"]["profile"] == "default"
recovery_children = []
for path in session_dir.glob("*.json"):
if path.name.startswith("_"):
continue
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict) and data.get("compression_recovery_source_session_id") == sid:
recovery_children.append(data)
assert {child["profile"] for child in recovery_children} == {"default", "other-profile"}
def test_recovery_metadata_is_persisted_and_exposed_in_compact_session():
session = Session(session_id="recovermeta1", title="x", messages=[])
recovery = stamp_compression_exhausted_recovery(session, message="Context length exceeded.")
compact = session.compact()
assert recovery["terminal_state"] == "compression_exhausted"
assert compact["recommended_recovery_action"] == "start_focused_continuation"
assert compact["compression_recovery"]["recommended_action"] == "start_focused_continuation"
def test_recovery_child_markers_round_trip_through_state_db_sidecar_rebuild(tmp_path):
db = WebUIJsonSessionDB(tmp_path)
db.write_session(
{
"session_id": "recoverychild1",
"title": "Focused continuation",
"model": "gpt-4o",
"started_at": 1700000000,
"messages": [],
"parent_session_id": "recoverysrc3",
"compression_recovery_source_session_id": "recoverysrc3",
"compression_recovery_action": "start_focused_continuation",
}
)
row = db.list_sessions()[0]
assert row["compression_recovery_source_session_id"] == "recoverysrc3"
assert row["compression_recovery_action"] == "start_focused_continuation"
sidecar = _state_db_row_to_sidecar({"id": "recoverychild1", **row, "messages": []})
assert sidecar["compression_recovery_source_session_id"] == "recoverysrc3"
assert sidecar["compression_recovery_action"] == "start_focused_continuation"
def test_recovery_source_metadata_round_trips_through_state_db_sidecar_rebuild(tmp_path):
recovery = {
"type": "compression_recovery_required",
"terminal_state": "compression_exhausted",
"recommended_action": "start_focused_continuation",
"source_session_id": "recoverysrc4",
}
db = WebUIJsonSessionDB(tmp_path)
db.write_session(
{
"session_id": "recoverysrc4",
"title": "Exhausted source",
"model": "gpt-4o",
"started_at": 1700000000,
"messages": [{"role": "user", "content": "long task"}],
"compression_recovery": recovery,
"recommended_recovery_action": "start_focused_continuation",
}
)
row = db.list_sessions()[0]
sidecar = _state_db_row_to_sidecar({"id": "recoverysrc4", **row, "messages": []})
assert sidecar["compression_recovery"] == recovery
assert sidecar["recommended_recovery_action"] == "start_focused_continuation"
def test_compression_recovery_ui_wires_card_action_and_send_intercept():
ui = (ROOT / "static/ui.js").read_text(encoding="utf-8")
messages = (ROOT / "static/messages.js").read_text(encoding="utf-8")
assert "function _compressionRecoveryHtml" in ui
assert "data-compression-recovery-card=\"1\"" in ui
assert "api('/api/session/compression-recovery/start'" in ui
assert "Compression recovery did not return a session." in ui
assert "const sid=String(recovery.source_session_id||sessionId||'')" in ui
assert "function shouldInterceptCompressionRecoveryContinuation" in ui
assert "shouldInterceptCompressionRecoveryContinuation(text,S.pendingFiles)" in messages
assert "_compressionRecovery:recovery||undefined" in messages
def test_compression_recovery_ui_skips_message_fallback_after_session_clear():
ui = (ROOT / "static/ui.js").read_text(encoding="utf-8")
start = ui.index("function _activeCompressionRecoveryPayload(){")
end = ui.index("function isGenericCompressionContinuationIntent", start)
body = ui[start:end]
session_guard = "Object.prototype.hasOwnProperty.call(S.session,'compression_recovery')"
message_scan = "const messages=Array.isArray(S.messages)?S.messages:[]"
assert session_guard in body
assert message_scan in body
assert body.index(session_guard) < body.index(message_scan)