diff --git a/api/models.py b/api/models.py index 6312a5e3..ebf1e508 100644 --- a/api/models.py +++ b/api/models.py @@ -391,10 +391,13 @@ 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 + role = str(recovered.get('role') or '') recovered_text = " ".join(str(recovered.get('content') or '').split()) + if not recovered_text and not recovered.get('tool_call_id') and not recovered.get('tool_calls'): + return if recovered_text: for existing in reversed(context_messages[-8:]): - if not isinstance(existing, dict) or existing.get('role') != 'user': + if not isinstance(existing, dict) or existing.get('role') != role: continue existing_text = " ".join(str(existing.get('content') or '').split()) if existing_text == recovered_text: @@ -1420,15 +1423,19 @@ def _append_journaled_partial_output( if existing_idx is not None: current_assistant_idx = existing_idx assistant_started_at = None + if 0 <= existing_idx < len(session.messages): + _append_recovered_turn_to_context(session, session.messages[existing_idx]) return existing_idx timestamp = int(assistant_started_at or time.time()) - session.messages.append({ + recovered_assistant = { 'role': 'assistant', 'content': content, 'timestamp': timestamp, '_recovered_from_run_journal': True, '_recovered_stream_id': stream_id, - }) + } + session.messages.append(recovered_assistant) + _append_recovered_turn_to_context(session, recovered_assistant) current_assistant_idx = len(session.messages) - 1 assistant_started_at = None appended_any = True diff --git a/tests/test_recovered_journal_context.py b/tests/test_recovered_journal_context.py new file mode 100644 index 00000000..5dc67c31 --- /dev/null +++ b/tests/test_recovered_journal_context.py @@ -0,0 +1,113 @@ +"""Regression: run-journal recovery must feed the next model turn. + +A WebUI restart can interrupt an in-flight turn after visible assistant progress +has already streamed to the browser and run journal. Recovery restores that text +to the visible transcript (`session.messages`). It must also restore it to the +model-facing history (`session.context_messages`), otherwise the next user turn +sees a stale pre-restart context and the agent "forgets" the recovered work. +""" +from __future__ import annotations + +import pytest + +import api.profiles as profiles +from api.models import ( + Session, + _append_journaled_partial_output, + _append_recovered_pending_turn, +) +from api.run_journal import append_run_event +from api.streaming import _context_messages_for_new_turn + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + home = tmp_path / "hermes_home" + home.mkdir() + (home / "sessions").mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", home) + return home + + +def test_recovered_journal_text_is_in_next_turn_context(hermes_home): + sid = "recovered_context_repro" + stream_id = "stream-upgrade" + append_run_event( + sid, + stream_id, + "interim_assistant", + {"text": "升级代码层面已经完成并通过关键校验:现在本地是 v0.51.554-1-gd9bd39c0。"}, + ) + append_run_event( + sid, + stream_id, + "interim_assistant", + {"text": "重启条件满足:现在触发延迟重启脚本。"}, + ) + + session = Session( + session_id=sid, + title="repro", + messages=[ + {"role": "user", "content": "查一下上游有没有修复"}, + {"role": "assistant", "content": "上游已合并 v0.51.554,但本地还未升级。"}, + ], + context_messages=[ + {"role": "user", "content": "查一下上游有没有修复"}, + {"role": "assistant", "content": "上游已合并 v0.51.554,但本地还未升级。"}, + ], + pending_user_message="帮我升级", + ) + + _append_recovered_pending_turn(session, timestamp=123) + assert _append_journaled_partial_output(session, stream_id, dedupe_existing=True) is True + + visible_text = "\n".join(m.get("content", "") for m in session.messages) + assert "v0.51.554-1-gd9bd39c0" in visible_text + + next_context = _context_messages_for_new_turn(session, "升级完成了吗?") + context_text = "\n".join(m.get("content", "") for m in next_context) + + assert "帮我升级" in context_text + assert "v0.51.554-1-gd9bd39c0" in context_text + assert "重启条件满足" in context_text + + +def test_deduped_existing_recovered_assistant_repairs_missing_context(hermes_home): + """If visible recovery already happened but context was missing, rerunning + recovery with dedupe_existing=True should backfill context instead of + deciding the existing visible row is enough. + """ + sid = "recovered_context_dedupe" + stream_id = "stream-upgrade" + append_run_event( + sid, + stream_id, + "token", + {"text": "升级代码层面已经完成并通过关键校验。"}, + ) + + recovered_assistant = { + "role": "assistant", + "content": "升级代码层面已经完成并通过关键校验。", + "_recovered_from_run_journal": True, + "_recovered_stream_id": stream_id, + } + session = Session( + session_id=sid, + title="repro", + messages=[ + {"role": "user", "content": "帮我升级", "_recovered": True}, + recovered_assistant, + ], + context_messages=[ + {"role": "user", "content": "帮我升级", "_recovered": True}, + ], + ) + + assert _append_journaled_partial_output(session, stream_id, dedupe_existing=True) is False + + next_context = _context_messages_for_new_turn(session, "升级完成了吗?") + context_text = "\n".join(m.get("content", "") for m in next_context) + assert "升级代码层面已经完成" in context_text