diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bf05c1b..5cd3aab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/api/models.py b/api/models.py index 681df1c5..f448f7a3 100644 --- a/api/models.py +++ b/api/models.py @@ -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 = [] diff --git a/tests/test_session_sidecar_repair.py b/tests/test_session_sidecar_repair.py index e95efafb..4d575125 100644 --- a/tests/test_session_sidecar_repair.py +++ b/tests/test_session_sidecar_repair.py @@ -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()