mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-30 13:40:27 +00:00
Keep recovered pending turns in context
This commit is contained in:
@@ -59,6 +59,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stale stream recovery now keeps a recovered pending user turn in the model context as well as the visible transcript. Before this fix, a server restart during an in-flight turn could restore the user's message in WebUI while omitting it from `context_messages`, so the next agent turn could forget a prompt that was visibly present just above it.
|
||||
|
||||
- **PR #2315** by @Michaelyklam (closes #2305, refs #749) — WebUI profile creation now seeds bundled profile skills for newly-created non-cloned profiles, matching the CLI's `hermes profile create` behaviour. Pre-fix, creating a profile via Settings → New Profile (without checking "Clone from active profile") left the profile's `skills/` directory empty, which was inconsistent with CLI-created profiles that get the full bundled-skills overlay. The fix calls `seed_profile_skills(profile_path, quiet=True)` after `profile_path.mkdir()` when `clone_from is None`. Cloned profiles still inherit skills from their source — they don't get a second bundled-skills overlay. Seed failures (e.g. `hermes_cli` unavailable in Docker fallback) are logged as warnings, not fatal — profile creation still succeeds.
|
||||
|
||||
- **PR #2317** by @Michaelyklam (refs #2312 follow-up #2) — Appearance boot reconciliation now treats explicit `light`, `dark`, and `system` localStorage theme values as user selections when a prior Settings autosave failed. Pre-fix, the predicate `lsHasExplicitTheme = lsTheme === 'system'` only treated 'system' as explicit, so a user who picked `light` on a server defaulted to `dark` (or vice versa) with a failed autosave still reverted to the server default on refresh. Now broadened to `['system','light','dark'].includes(lsTheme)`. Skin handling was already correct (`lsSkin !== 'default'`). Closes follow-up item #2 from the v0.51.66 review (#2312).
|
||||
|
||||
+40
-11
@@ -203,6 +203,42 @@ def _active_stream_ids():
|
||||
return set(STREAMS.keys())
|
||||
|
||||
|
||||
def _append_recovered_turn_to_context(session, recovered: dict) -> None:
|
||||
context_messages = getattr(session, 'context_messages', None)
|
||||
if not isinstance(context_messages, list) or not context_messages:
|
||||
return
|
||||
recovered_text = " ".join(str(recovered.get('content') or '').split())
|
||||
if recovered_text:
|
||||
for existing in reversed(context_messages[-8:]):
|
||||
if not isinstance(existing, dict) or existing.get('role') != 'user':
|
||||
continue
|
||||
existing_text = " ".join(str(existing.get('content') or '').split())
|
||||
if existing_text == recovered_text:
|
||||
return
|
||||
context_entry = {k: v for k, v in recovered.items() if k != 'timestamp'}
|
||||
context_messages.append(context_entry)
|
||||
|
||||
|
||||
def _append_recovered_pending_turn(session, *, timestamp: int | None = None) -> dict | None:
|
||||
pending_text = str(session.pending_user_message or '')
|
||||
if not pending_text:
|
||||
return None
|
||||
recovered_ts = int(time.time())
|
||||
if isinstance(timestamp, (int, float)) and timestamp > 0:
|
||||
recovered_ts = int(timestamp)
|
||||
recovered: dict = {
|
||||
'role': 'user',
|
||||
'content': session.pending_user_message,
|
||||
'timestamp': recovered_ts,
|
||||
'_recovered': True,
|
||||
}
|
||||
if session.pending_attachments:
|
||||
recovered['attachments'] = list(session.pending_attachments)
|
||||
session.messages.append(recovered)
|
||||
_append_recovered_turn_to_context(session, recovered)
|
||||
return recovered
|
||||
|
||||
|
||||
def _is_streaming_session(active_stream_id, active_stream_ids):
|
||||
return bool(active_stream_id and active_stream_id in active_stream_ids)
|
||||
|
||||
@@ -695,15 +731,16 @@ def _apply_core_sync_or_error_marker(
|
||||
if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0:
|
||||
_recovered_ts = int(session.pending_started_at)
|
||||
if not _already_checkpointed:
|
||||
_append_recovered_pending_turn(session, timestamp=_recovered_ts)
|
||||
else:
|
||||
recovered = {
|
||||
'role': 'user',
|
||||
'content': session.pending_user_message,
|
||||
'timestamp': _recovered_ts,
|
||||
'_recovered': True,
|
||||
}
|
||||
if session.pending_attachments:
|
||||
recovered['attachments'] = list(session.pending_attachments)
|
||||
session.messages.append(recovered)
|
||||
_append_recovered_turn_to_context(session, recovered)
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
@@ -752,15 +789,7 @@ def _apply_core_sync_or_error_marker(
|
||||
_recovered_ts = int(time.time())
|
||||
if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0:
|
||||
_recovered_ts = int(session.pending_started_at)
|
||||
recovered: dict = {
|
||||
'role': 'user',
|
||||
'content': session.pending_user_message,
|
||||
'timestamp': _recovered_ts,
|
||||
'_recovered': True,
|
||||
}
|
||||
if session.pending_attachments:
|
||||
recovered['attachments'] = list(session.pending_attachments)
|
||||
session.messages.append(recovered)
|
||||
_append_recovered_pending_turn(session, timestamp=_recovered_ts)
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
|
||||
@@ -257,6 +257,44 @@ class TestDraftRecovery:
|
||||
f"got {user_msgs[0]['timestamp']}"
|
||||
)
|
||||
|
||||
def test_pending_message_recovered_into_context_messages(self, hermes_home, monkeypatch):
|
||||
"""A recovered pending prompt must remain visible to the next agent turn.
|
||||
|
||||
Sessions that have been auto-compressed feed context_messages to the
|
||||
model, not the full display transcript. If stale-stream repair appends
|
||||
the recovered user prompt only to messages, the user can see the prompt
|
||||
in WebUI but the next agent turn cannot.
|
||||
"""
|
||||
s = _make_session(
|
||||
messages=[{"role": "user", "content": "older visible turn"}],
|
||||
context_messages=[
|
||||
{"role": "user", "content": "older context turn"},
|
||||
{"role": "assistant", "content": "older context answer"},
|
||||
],
|
||||
)
|
||||
s.pending_user_message = "Clip this article https://example.com/post"
|
||||
s.active_stream_id = "stream_1"
|
||||
lock = config._get_session_agent_lock(s.session_id)
|
||||
|
||||
with lock:
|
||||
core_path = hermes_home / "sessions" / f"session_{s.session_id}.json"
|
||||
result = _apply_core_sync_or_error_marker(
|
||||
s, core_path, stream_id_for_recheck="stream_1",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert any(
|
||||
m.get("role") == "user"
|
||||
and m.get("content") == "Clip this article https://example.com/post"
|
||||
and m.get("_recovered") is True
|
||||
for m in s.messages
|
||||
)
|
||||
assert any(
|
||||
m.get("role") == "user"
|
||||
and m.get("content") == "Clip this article https://example.com/post"
|
||||
for m in s.context_messages
|
||||
), "Recovered pending user turn must be included in model context."
|
||||
|
||||
def test_error_marker_no_preserved_as_draft(self, hermes_home, monkeypatch):
|
||||
"""Error marker text must NOT say 'preserved as a draft'."""
|
||||
s = _make_stale_session()
|
||||
|
||||
Reference in New Issue
Block a user