Keep recovered pending turns in context

This commit is contained in:
Frank Song
2026-05-16 11:53:13 +08:00
committed by Hermes Agent
parent 761cf550de
commit 40f69a2b75
3 changed files with 80 additions and 11 deletions
+2
View File
@@ -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
View File
@@ -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 = []
+38
View File
@@ -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()