mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-22 18:30:28 +00:00
245 lines
8.5 KiB
Python
245 lines
8.5 KiB
Python
"""Regression tests for config-driven first-turn session persistence (#1406)."""
|
|
import json
|
|
|
|
import pytest
|
|
|
|
import api.config as config
|
|
import api.models as models
|
|
import api.routes as routes
|
|
import api.streaming as streaming
|
|
from api.models import Session, new_session
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_state(tmp_path, monkeypatch):
|
|
session_dir = tmp_path / "sessions"
|
|
session_dir.mkdir()
|
|
index_file = session_dir / "_index.json"
|
|
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
|
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
|
|
monkeypatch.setattr(config, "SESSION_INDEX_FILE", index_file, raising=False)
|
|
models.SESSIONS.clear()
|
|
config.STREAMS.clear()
|
|
config.CANCEL_FLAGS.clear()
|
|
config.AGENT_INSTANCES.clear()
|
|
config.SESSION_AGENT_LOCKS.clear()
|
|
monkeypatch.setattr(config, "cfg", {})
|
|
monkeypatch.setattr(config, "_cfg_cache", {})
|
|
yield session_dir
|
|
models.SESSIONS.clear()
|
|
config.STREAMS.clear()
|
|
config.CANCEL_FLAGS.clear()
|
|
config.AGENT_INSTANCES.clear()
|
|
config.SESSION_AGENT_LOCKS.clear()
|
|
|
|
|
|
def test_session_save_mode_defaults_to_deferred_for_missing_config():
|
|
assert config.get_webui_session_save_mode({}) == "deferred"
|
|
assert config.get_webui_session_save_mode({"webui": {}}) == "deferred"
|
|
|
|
|
|
@pytest.mark.parametrize("raw", ["bogus", "", None, 42, {"mode": "eager"}])
|
|
def test_invalid_session_save_mode_falls_back_to_deferred(raw):
|
|
assert config.get_webui_session_save_mode({"webui": {"session_save_mode": raw}}) == "deferred"
|
|
|
|
|
|
def test_eager_session_save_mode_is_accepted():
|
|
assert config.get_webui_session_save_mode({"webui": {"session_save_mode": "eager"}}) == "eager"
|
|
|
|
|
|
def test_eager_mode_still_does_not_save_empty_new_sessions(_isolate_state, monkeypatch):
|
|
monkeypatch.setattr(config, "cfg", {"webui": {"session_save_mode": "eager"}})
|
|
s = new_session()
|
|
assert not s.path.exists(), "eager mode must not recreate empty Untitled session files"
|
|
|
|
|
|
def test_deferred_chat_start_persists_pending_only_before_thread(_isolate_state, monkeypatch):
|
|
monkeypatch.setattr(config, "cfg", {"webui": {"session_save_mode": "deferred"}})
|
|
s = new_session(workspace=str(_isolate_state.parent))
|
|
routes._prepare_chat_start_session_for_stream(
|
|
s,
|
|
msg="hello deferred",
|
|
attachments=[],
|
|
workspace=str(_isolate_state.parent),
|
|
model=s.model,
|
|
model_provider=s.model_provider,
|
|
stream_id="stream_deferred",
|
|
started_at=123.0,
|
|
)
|
|
on_disk = json.loads(s.path.read_text(encoding="utf-8"))
|
|
assert on_disk["messages"] == []
|
|
assert on_disk["pending_user_message"] == "hello deferred"
|
|
|
|
|
|
def test_eager_chat_start_checkpoints_first_user_message_before_thread(_isolate_state, monkeypatch):
|
|
monkeypatch.setattr(config, "cfg", {"webui": {"session_save_mode": "eager"}})
|
|
s = new_session(workspace=str(_isolate_state.parent))
|
|
routes._prepare_chat_start_session_for_stream(
|
|
s,
|
|
msg="hello eager",
|
|
attachments=[{"name": "note.txt", "path": "", "mime": "text/plain"}],
|
|
workspace=str(_isolate_state.parent),
|
|
model=s.model,
|
|
model_provider=s.model_provider,
|
|
stream_id="stream_eager",
|
|
started_at=456.0,
|
|
)
|
|
on_disk = json.loads(s.path.read_text(encoding="utf-8"))
|
|
assert [m["role"] for m in on_disk["messages"]] == ["user"]
|
|
assert on_disk["messages"][0]["content"] == "hello eager"
|
|
assert on_disk["messages"][0]["attachments"][0]["name"] == "note.txt"
|
|
assert on_disk["pending_user_message"] == "hello eager"
|
|
|
|
|
|
def test_eager_wal_repair_does_not_duplicate_checkpointed_user_message(_isolate_state, monkeypatch):
|
|
s = Session(session_id="eager_repair", messages=[{"role": "user", "content": "survive"}])
|
|
s.pending_user_message = "survive"
|
|
s.active_stream_id = "dead_stream"
|
|
s.pending_started_at = 789.0
|
|
s.save()
|
|
|
|
repaired = models._repair_stale_pending(s)
|
|
|
|
assert repaired is True
|
|
user_messages = [m for m in s.messages if m.get("role") == "user" and m.get("content") == "survive"]
|
|
assert len(user_messages) == 1
|
|
assert s.pending_user_message is None
|
|
assert any(m.get("_error") for m in s.messages if m.get("role") == "assistant")
|
|
|
|
|
|
def test_eager_checkpointed_user_is_removed_from_model_context():
|
|
context = streaming._drop_checkpointed_current_user_from_context(
|
|
[
|
|
{"role": "user", "content": "older"},
|
|
{"role": "assistant", "content": "prior"},
|
|
{"role": "user", "content": "current"},
|
|
],
|
|
"current",
|
|
)
|
|
assert [m["content"] for m in context] == ["older", "prior"]
|
|
|
|
|
|
def test_eager_checkpointed_user_is_not_duplicated_after_agent_result():
|
|
merged = streaming._merge_display_messages_after_agent_result(
|
|
previous_display=[{"role": "user", "content": "repeat me"}],
|
|
previous_context=[],
|
|
result_messages=[
|
|
{"role": "user", "content": "repeat me"},
|
|
{"role": "assistant", "content": "ok"},
|
|
],
|
|
msg_text="repeat me",
|
|
)
|
|
assert [m["role"] for m in merged] == ["user", "assistant"]
|
|
|
|
|
|
def test_deferred_turn_is_materialized_when_agent_returns_assistant_only_delta():
|
|
merged = streaming._merge_display_messages_after_agent_result(
|
|
previous_display=[
|
|
{"role": "user", "content": "older prompt"},
|
|
{"role": "assistant", "content": "older answer"},
|
|
],
|
|
previous_context=[
|
|
{"role": "user", "content": "older prompt"},
|
|
{"role": "assistant", "content": "older answer"},
|
|
],
|
|
result_messages=[
|
|
{"role": "user", "content": "older prompt"},
|
|
{"role": "assistant", "content": "older answer"},
|
|
{"role": "assistant", "content": "current answer"},
|
|
],
|
|
msg_text="latest prompt",
|
|
)
|
|
|
|
assert [m["role"] for m in merged] == [
|
|
"user",
|
|
"assistant",
|
|
"user",
|
|
"assistant",
|
|
]
|
|
assert [m["content"] for m in merged[-2:]] == ["latest prompt", "current answer"]
|
|
|
|
|
|
def test_duplicate_assistant_delta_is_not_persisted_twice():
|
|
"""Provider/result merge replay must not duplicate the same assistant bubble."""
|
|
previous_display = [
|
|
{"role": "user", "content": "older prompt"},
|
|
{"role": "assistant", "content": "older answer"},
|
|
]
|
|
previous_context = list(previous_display)
|
|
result_messages = previous_context + [
|
|
{"role": "user", "content": "latest prompt"},
|
|
{"role": "assistant", "content": "current answer"},
|
|
{"role": "assistant", "content": "current answer"},
|
|
]
|
|
|
|
merged = streaming._merge_display_messages_after_agent_result(
|
|
previous_display=previous_display,
|
|
previous_context=previous_context,
|
|
result_messages=result_messages,
|
|
msg_text="latest prompt",
|
|
)
|
|
|
|
assert [m["role"] for m in merged] == [
|
|
"user",
|
|
"assistant",
|
|
"user",
|
|
"assistant",
|
|
]
|
|
assert [m["content"] for m in merged[-2:]] == ["latest prompt", "current answer"]
|
|
assert (
|
|
sum(
|
|
1
|
|
for m in merged
|
|
if m.get("role") == "assistant" and m.get("content") == "current answer"
|
|
)
|
|
== 1
|
|
)
|
|
|
|
|
|
def test_same_assistant_text_across_different_turns_is_preserved():
|
|
previous_display = [
|
|
{"role": "user", "content": "first prompt"},
|
|
{"role": "assistant", "content": "same answer"},
|
|
]
|
|
previous_context = list(previous_display)
|
|
result_messages = previous_context + [
|
|
{"role": "user", "content": "second prompt"},
|
|
{"role": "assistant", "content": "same answer"},
|
|
]
|
|
|
|
merged = streaming._merge_display_messages_after_agent_result(
|
|
previous_display=previous_display,
|
|
previous_context=previous_context,
|
|
result_messages=result_messages,
|
|
msg_text="second prompt",
|
|
)
|
|
|
|
assert [m["content"] for m in merged] == [
|
|
"first prompt",
|
|
"same answer",
|
|
"second prompt",
|
|
"same answer",
|
|
]
|
|
|
|
|
|
def test_llm_title_generated_survives_save_and_load(_isolate_state):
|
|
s = Session(
|
|
session_id="generated_title",
|
|
title="Useful generated title",
|
|
messages=[{"role": "user", "content": "first prompt"}],
|
|
llm_title_generated=True,
|
|
)
|
|
s.save()
|
|
|
|
loaded = Session.load("generated_title")
|
|
|
|
assert loaded.llm_title_generated is True
|
|
on_disk = json.loads(s.path.read_text(encoding="utf-8"))
|
|
assert on_disk["llm_title_generated"] is True
|
|
|
|
|
|
def test_session_constructor_preserves_loaded_llm_title_generated_kwarg():
|
|
s = Session(session_id="loaded_generated_title", llm_title_generated=True)
|
|
|
|
assert s.llm_title_generated is True
|