Files
hermes-webui/tests/test_issue6611_regeneration_transaction.py
T
alfred-rootson b71c88f292 fix(workspace): preserve remote POSIX paths across Session & streaming callsites (#7131) (#7168)
Closes the remote POSIX workspace path preservation gap across Session and streaming. Profile-aware runtime path resolution.

Co-authored-by: alfred-rootson <alfred.wojtasordzik@gmail.com>
2026-09-15 15:37:11 -07:00

574 lines
22 KiB
Python

import copy
import queue
import threading
from types import SimpleNamespace
from pathlib import Path
import pytest
from api.models import Session
from api.session_ops import (
RegenerationUnavailable,
RegenerationPlan,
apply_regeneration_plan,
plan_regeneration,
restore_regeneration_state,
snapshot_regeneration_state,
)
from tests._issue6611_fixture import load_issue6611_fixture
def _session():
rows = [
{"role": "user", "content": "prompt", "id": "u1", "_source": "webui"},
{"role": "assistant", "content": "failed"},
]
return Session(
session_id="transaction6611",
messages=copy.deepcopy(rows),
context_messages=copy.deepcopy(rows),
workspace="C:/workspace",
)
def test_plan_installs_rows_and_context_as_one_prepared_pair():
session = _session()
plan = plan_regeneration(session)
assert isinstance(plan, RegenerationPlan)
assert plan.canonical_rows == session.messages
assert plan.canonical_context == session.context_messages
applied, retained_context_user = apply_regeneration_plan(
session,
plan,
return_context_user=True,
)
assert applied
assert retained_context_user is session.context_messages[0]
assert session.messages == plan.canonical_rows[: plan.truncation_boundary]
assert session.context_messages == plan.canonical_context[: plan.truncation_boundary]
assert any(row.get("content") == "prompt" for row in session.context_messages)
assert [row["role"] for row in session.messages] == ["user"]
def test_apply_consumes_prepared_pair_without_ambient_authority_read(monkeypatch):
session = _session()
plan = plan_regeneration(session)
monkeypatch.setattr(
"api.session_ops.regeneration_state",
lambda _session: (_ for _ in ()).throw(AssertionError("ambient read")),
)
session.messages[0]["content"] = "changed"
assert apply_regeneration_plan(session, plan)
assert session.messages == plan.canonical_rows[: plan.truncation_boundary]
def test_complete_session_snapshot_restores_every_attribute():
session = _session()
session.compression_state = {"marker": "kept"}
session._anchor_scene_index = 7
before = copy.deepcopy(session.__dict__)
snapshot = snapshot_regeneration_state(session)
session.messages.clear()
session.context_messages.clear()
session.compression_state["marker"] = "changed"
session._anchor_scene_index = 99
restore_regeneration_state(session, snapshot)
assert session.__dict__ == before
def test_persisted_preacceptance_rollback_restores_exact_snapshot(monkeypatch):
session = _session()
snapshot = snapshot_regeneration_state(session)
persisted = []
monkeypatch.setattr(Session, "save", lambda self, **_kwargs: persisted.append(copy.deepcopy(self.__dict__)))
session.active_stream_id = "stale"
session.pending_user_message = "changed"
restore_regeneration_state(session, snapshot)
session.save(touch_updated_at=False)
assert persisted == [snapshot]
def test_noop_rejection_does_not_need_persisted_rollback():
session = _session()
snapshot = snapshot_regeneration_state(session)
restore_regeneration_state(session, snapshot)
assert session.__dict__ == snapshot
def test_locked_stale_plan_does_not_restore_a_request_time_snapshot(monkeypatch):
from api import routes
session = _session()
session.active_stream_id = "accepted-after-browser-validation"
current_state = copy.deepcopy(session.__dict__)
def stale_plan(*_args, **_kwargs):
raise RegenerationUnavailable("stale_regeneration_revision")
monkeypatch.setattr("api.session_ops.plan_regeneration", stale_plan)
result = routes._start_regeneration_stream_locked(
session,
turn=SimpleNamespace(revision="old-revision"),
workspace="C:/workspace",
model="model",
model_provider="provider",
normalized_model=False,
diag=None,
goal_related=False,
source="webui",
moa_config=None,
backend_is_gateway=False,
)
assert result["code"] == "stale_regeneration_revision"
assert session.__dict__ == current_state
def test_locked_unexpected_plan_error_does_not_restore_a_request_time_snapshot(monkeypatch):
from api import routes
session = _session()
session.active_stream_id = "accepted-after-browser-validation"
current_state = copy.deepcopy(session.__dict__)
def unexpected_plan(*_args, **_kwargs):
raise RuntimeError("plan failed")
monkeypatch.setattr("api.session_ops.plan_regeneration", unexpected_plan)
with pytest.raises(RuntimeError, match="plan failed"):
routes._start_regeneration_stream_locked(
session,
turn=SimpleNamespace(revision="old-revision"),
workspace="C:/workspace",
model="model",
model_provider="provider",
normalized_model=False,
diag=None,
goal_related=False,
source="webui",
moa_config=None,
backend_is_gateway=False,
)
assert session.__dict__ == current_state
def test_locked_preacceptance_exception_restores_the_transaction_snapshot(monkeypatch):
from api import routes
session = _session()
plan = plan_regeneration(session)
before = copy.deepcopy(session.__dict__)
def fail_prepare(*_args, **_kwargs):
raise RuntimeError("prepare failed")
monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", fail_prepare)
with pytest.raises(RuntimeError, match="prepare failed"):
routes._start_regeneration_stream_locked(
session,
turn=plan.turn,
workspace="C:/workspace",
model="model",
model_provider="provider",
normalized_model=False,
diag=None,
goal_related=False,
source="webui",
moa_config=None,
backend_is_gateway=False,
)
assert session.__dict__ == before
def test_locked_postacceptance_workspace_exception_does_not_restore_turn(monkeypatch):
from api import routes
import api.turn_journal as turn_journal
session = _session()
plan = plan_regeneration(session)
monkeypatch.setattr(routes, "register_session_writeback_owner", lambda *_args: None)
monkeypatch.setattr(routes, "clear_session_writeback_owner_if_owned", lambda *_args: None)
monkeypatch.setattr(routes, "register_stream_owner", lambda *_args: None)
monkeypatch.setattr(routes, "unregister_stream_owner", lambda *_args: None)
monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue())
monkeypatch.setattr(turn_journal, "append_turn_journal_event", lambda *_args, **_kwargs: {"turn_id": "turn-6611"})
monkeypatch.setattr(Session, "save", lambda *_args, **_kwargs: None)
monkeypatch.setattr(routes, "set_last_workspace", lambda *_args, **_kw: (_ for _ in ()).throw(RuntimeError("workspace failed")))
class FakeThread:
def __init__(self, *args, **kwargs):
pass
def start(self):
return None
def join(self, timeout=None):
return None
monkeypatch.setattr(routes.threading, "Thread", FakeThread)
before = copy.deepcopy(session.__dict__)
with pytest.raises(RuntimeError, match="workspace failed") as raised:
routes._start_regeneration_stream_locked(
session,
turn=plan.turn,
workspace="C:/workspace",
model="model",
model_provider="provider",
normalized_model=False,
diag=None,
goal_related=False,
source="webui",
moa_config=None,
backend_is_gateway=False,
)
assert raised.value._regeneration_accepted is True
assert session.active_stream_id is not None
assert session.__dict__ != before
def test_chat_start_losing_regeneration_preserves_locked_send_winner(monkeypatch, tmp_path):
from api import routes
from api import models as models_api
import api.runtime_adapter as runtime_adapter
session = _session()
session.session_id = "route-race-6611"
session.model_explicit_pick_signature = "before-regeneration"
revision = plan_regeneration(session).revision
monkeypatch.setattr(models_api, "SESSION_DIR", tmp_path)
monkeypatch.setattr(models_api, "SESSION_INDEX_FILE", tmp_path / "_index.json")
monkeypatch.setattr(runtime_adapter, "runtime_adapter_runner_enabled", lambda: False)
monkeypatch.setattr(routes, "_get_or_materialize_session", lambda *_args, **_kwargs: session)
monkeypatch.setattr(routes, "_agent_runtime_barrier_response", lambda **_kwargs: None)
monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *_args: True)
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
monkeypatch.setattr(routes, "_read_profile_model_config", lambda *_args: (None, None, {}))
monkeypatch.setattr(routes, "_resolve_chat_workspace_for_regeneration", lambda *_args: "C:/workspace")
monkeypatch.setattr(routes, "get_config_snapshot", lambda: {})
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda *_args: False)
monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", lambda *_args, **_kwargs: ("model", "provider", False))
monkeypatch.setattr(routes, "_repair_foreign_session_model_provider", lambda *_args, **_kwargs: "provider")
monkeypatch.setattr(routes, "compression_recovery_payload_for_session", lambda *_args: None)
winner_started = threading.Event()
winner_finished = threading.Event()
def lose_after_locked_send(*_args, **_kwargs):
def normal_send_winner():
with routes._get_session_agent_lock(session.session_id):
session.active_stream_id = "winner-stream"
session.pending_user_message = "winner prompt"
session.pending_started_at = 222.0
session.pending_user_source = "webui"
session.save(touch_updated_at=False)
winner_started.set()
winner_finished.set()
thread = threading.Thread(target=normal_send_winner)
thread.start()
assert winner_started.wait(1)
assert winner_finished.wait(1)
thread.join(timeout=1)
return {
"error": "stale regeneration revision",
"code": "stale_regeneration_revision",
"_status": 409,
}
captured = {}
monkeypatch.setattr(routes, "_start_run", lose_after_locked_send)
monkeypatch.setattr(
routes,
"j",
lambda _handler, data, status=200, **_kwargs: captured.update(data=data, status=status),
)
routes._handle_chat_start(
None,
{
"session_id": session.session_id,
"regenerate": True,
"regeneration_revision": revision,
"explicit_model_pick": True,
},
)
assert captured["status"] == 409
assert (
session.active_stream_id,
session.pending_user_message,
session.pending_started_at,
session.pending_user_source,
) == ("winner-stream", "winner prompt", 222.0, "webui")
assert session.model_explicit_pick_signature == "before-regeneration"
reloaded = Session.load(session.session_id)
assert (
reloaded.active_stream_id,
reloaded.pending_user_message,
reloaded.pending_started_at,
reloaded.pending_user_source,
) == ("winner-stream", "winner prompt", 222.0, "webui")
def test_regeneration_lock_winner_blocks_a_later_normal_send(monkeypatch):
from api import routes
session = _session()
accepted = threading.Event()
normal_checked = threading.Event()
normal_result = {}
monkeypatch.setattr(routes, "_agent_runtime_barrier_response", lambda **_kwargs: None)
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args: None)
def accepted_regeneration(value, **_kwargs):
value.active_stream_id = "accepted-regeneration"
value.pending_user_message = "regenerated prompt"
accepted.set()
def ordinary_send_observer():
with routes._get_session_agent_lock(value.session_id):
normal_result["active_stream_id"] = value.active_stream_id
normal_result["pending_user_message"] = value.pending_user_message
normal_checked.set()
thread = threading.Thread(target=ordinary_send_observer)
thread.start()
assert not normal_checked.wait(0.05)
return {"stream_id": "accepted-regeneration", "session_id": value.session_id}
monkeypatch.setattr(routes, "_start_regeneration_stream_locked", accepted_regeneration)
result = routes._start_chat_stream_for_session(
session,
msg="ignored",
attachments=[],
workspace="C:/workspace",
model="model",
model_provider="provider",
external_runtime_owned=False,
regeneration=SimpleNamespace(revision="revision"),
)
assert accepted.is_set()
assert result["stream_id"] == "accepted-regeneration"
assert normal_checked.wait(1)
assert normal_result == {
"active_stream_id": "accepted-regeneration",
"pending_user_message": "regenerated prompt",
}
def test_prepare_mirrors_active_turn_token_to_context_before_timestamp_mutation(monkeypatch):
from api import routes
session = _session()
retained_user = session.messages[0]
session.context_messages = [
{"role": "user", "content": "prompt"},
{"role": "user", "content": "prompt"},
]
retained_context_user = session.context_messages[1]
monkeypatch.setattr(routes, "register_session_writeback_owner", lambda *_args: None)
routes._prepare_chat_start_session_for_stream(
session,
msg="prompt",
attachments=[],
workspace="C:/workspace",
model="model",
model_provider="provider",
stream_id="token-parity-stream",
started_at=99.0,
retained_user=retained_user,
retained_context_user=retained_context_user,
defer_save=True,
)
assert retained_user["timestamp"] == 99.0
assert "timestamp" not in session.context_messages[0]
assert "_active_turn_token" not in session.context_messages[0]
assert retained_context_user["timestamp"] == 99.0
assert retained_context_user["_active_turn_token"] == retained_user["_active_turn_token"]
def test_prepare_marks_new_fork_turn_in_eager_materialization(monkeypatch):
from api import routes
from api.streaming import _materialize_active_turn_user
session = Session(
session_id="fork-prepare-6611",
messages=[],
context_messages=[],
session_source="fork",
parent_session_id="parent-6611",
)
monkeypatch.setattr(routes, "register_session_writeback_owner", lambda *_args: None)
monkeypatch.setattr(routes, "get_webui_session_save_mode", lambda: "eager")
routes._prepare_chat_start_session_for_stream(
session,
msg="new fork prompt",
attachments=[],
workspace="C:/workspace",
model="model",
model_provider="provider",
stream_id="fork-prepare-stream",
started_at=99.0,
defer_save=True,
)
assert len(session.messages) == 1
assert session.messages[0]["_fork_child_turn"] == session.session_id
deferred = Session(
session_id="fork-deferred-6611",
messages=[],
context_messages=[],
session_source="fork",
parent_session_id="parent-6611",
)
routes._prepare_chat_start_session_for_stream(
deferred,
msg="deferred fork prompt",
attachments=[],
workspace="C:/workspace",
model="model",
model_provider="provider",
stream_id="fork-deferred-stream",
started_at=100.0,
defer_save=True,
)
materialized = _materialize_active_turn_user(
{
"text": deferred.pending_user_message,
"source": deferred.pending_user_source,
"timestamp": deferred.pending_started_at,
"session_id": deferred.session_id,
},
deferred.pending_user_message,
deferred.pending_user_source,
)
assert materialized["_fork_child_turn"] == deferred.session_id
def test_issue_artifact_rows_follow_production_regeneration_and_error_settlement(monkeypatch, tmp_path):
"""Exercise production plan/apply/prepare/materializer/save-reload helpers, not HTTP integration."""
from api import routes
from api import models as models_api
from api.session_ops import apply_regeneration_plan, plan_regeneration
from api.streaming import _materialize_pending_user_turn_before_error
rows = load_issue6611_fixture()["rows"]
session = Session(
session_id="artifact-production-6611",
messages=copy.deepcopy(rows),
context_messages=copy.deepcopy(rows),
workspace="C:/workspace",
)
plan = plan_regeneration(session)
assert apply_regeneration_plan(session, plan)
monkeypatch.setattr(routes, "register_session_writeback_owner", lambda *_args: None)
routes._prepare_chat_start_session_for_stream(
session,
msg=rows[0]["content"],
attachments=[],
workspace="C:/workspace",
model="model",
model_provider="provider",
stream_id="artifact-production-stream",
started_at=99.0,
retained_user=session.messages[-1],
defer_save=True,
)
assert [row["role"] for row in session.messages].count("user") == 1
assert session.messages[0]["content"] == rows[0]["content"]
assert _materialize_pending_user_turn_before_error(session) is False
session.active_stream_id = None
session.pending_user_message = None
session.pending_attachments = []
session.pending_started_at = None
session.pending_user_source = None
session.messages.append(copy.deepcopy(rows[1]))
assert [row["role"] for row in session.messages] == ["user", "assistant"]
assert [row["content"] for row in session.messages if row["role"] == "user"] == [rows[0]["content"]]
monkeypatch.setattr(models_api, "SESSION_DIR", tmp_path)
monkeypatch.setattr(models_api, "SESSION_INDEX_FILE", tmp_path / "_index.json")
session.save(touch_updated_at=False)
reloaded = Session.load(session.session_id)
assert reloaded is not None
assert [row["role"] for row in reloaded.messages] == ["user", "assistant"]
assert [row["content"] for row in reloaded.messages if row["role"] == "user"] == [rows[0]["content"]]
def test_locked_start_always_replans_after_browser_validation():
source = (Path(__file__).parents[1] / "api" / "routes.py").read_text(encoding="utf-8")
start = source.index("def _start_regeneration_stream_locked(")
end = source.index("def _active_run_stream_for_session", start)
body = source[start:end]
assert "plan = plan_regeneration(" in body
assert "expected_revision=turn.revision" in body
assert "lock_held=True" in body
def test_regeneration_preview_has_no_request_snapshot_or_outer_restore_owner():
source = (Path(__file__).parents[1] / "api" / "routes.py").read_text(encoding="utf-8")
chat_start = source.index("def _handle_chat_start(")
chat_end = source.index("def _resolve_chat_workspace_with_recovery", chat_start)
body = source[chat_start:chat_end]
assert "regeneration_snapshot = snapshot_regeneration_state" not in body
assert "transaction_snapshot" not in body
assert "_restore_regeneration_preacceptance" not in body
regeneration_preview = body[body.index('if body.get("regenerate") is True:'):body.index('diag.stage("normalize_message")')]
assert "_clear_stale_stream_state" not in regeneration_preview
assert "model_explicit_pick_signature" not in regeneration_preview
def test_concurrent_normal_winner_survives_regeneration_409_in_memory_and_after_reload(monkeypatch, tmp_path):
from api import routes
from api import models as models_api
session = _session()
session.session_id = "race-6611"
session.active_stream_id = "stale-stream"
session.pending_user_message = "stale prompt"
session.pending_started_at = 111.0
session.pending_user_source = "webui"
monkeypatch.setattr(models_api, "SESSION_DIR", tmp_path)
monkeypatch.setattr(models_api, "SESSION_INDEX_FILE", tmp_path / "_index.json")
session.save(touch_updated_at=False)
winner_started = threading.Event()
winner_finished = threading.Event()
def stale_plan(*_args, **_kwargs):
def normal_winner():
winner_started.set()
session.active_stream_id = "winner-stream"
session.pending_user_message = "winner prompt"
session.pending_started_at = 222.0
session.pending_user_source = "webui"
session.save(touch_updated_at=False)
winner_finished.set()
threading.Thread(target=normal_winner).start()
assert winner_started.wait(1)
assert winner_finished.wait(1)
raise RegenerationUnavailable("stale_regeneration_revision")
monkeypatch.setattr("api.session_ops.plan_regeneration", stale_plan)
result = routes._start_regeneration_stream_locked(
session,
turn=SimpleNamespace(revision="old-revision"),
workspace="C:/workspace",
model="model",
model_provider="provider",
normalized_model=False,
diag=None,
goal_related=False,
source="webui",
moa_config=None,
backend_is_gateway=False,
)
assert result["_status"] == 409
assert (session.active_stream_id, session.pending_user_message, session.pending_started_at, session.pending_user_source) == (
"winner-stream", "winner prompt", 222.0, "webui"
)
reloaded = Session.load(session.session_id)
assert (reloaded.active_stream_id, reloaded.pending_user_message, reloaded.pending_started_at, reloaded.pending_user_source) == (
"winner-stream", "winner prompt", 222.0, "webui"
)