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>
597 lines
27 KiB
Python
597 lines
27 KiB
Python
"""Geometric duplication of recovered reasoning-only rows at turn settle.
|
|
|
|
Refs #7388 / #7416 (linear journal replay). This is the INDEPENDENT geometric
|
|
leg: each settled turn re-appended every empty recovered anchor once more.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import os
|
|
import pathlib
|
|
|
|
import pytest
|
|
|
|
from api.models import Session
|
|
from api.streaming import (
|
|
_assign_stable_message_ids,
|
|
_merge_display_messages_after_agent_result,
|
|
_restore_display_reasoning_metadata,
|
|
_settle_result_messages,
|
|
)
|
|
from api import streaming as _streaming
|
|
|
|
STREAM_ID = "34d53038e2d3485cb67cd90ef8408faf"
|
|
|
|
|
|
def _recovered_anchor(reasoning: str, ts: int = 1788356394) -> dict:
|
|
return {
|
|
"role": "assistant",
|
|
"content": "",
|
|
"timestamp": ts,
|
|
"_recovered_from_run_journal": True,
|
|
"_recovered_stream_id": STREAM_ID,
|
|
"reasoning": reasoning,
|
|
}
|
|
|
|
|
|
def _is_clone(message) -> bool:
|
|
return bool(
|
|
isinstance(message, dict)
|
|
and message.get("_recovered_from_run_journal")
|
|
and message.get("role") == "assistant"
|
|
and not (message.get("content") or "")
|
|
)
|
|
|
|
|
|
def _clone_blocks(messages) -> list[int]:
|
|
blocks, run = [], 0
|
|
for message in messages:
|
|
if _is_clone(message):
|
|
run += 1
|
|
elif run:
|
|
blocks.append(run)
|
|
run = 0
|
|
if run:
|
|
blocks.append(run)
|
|
return blocks
|
|
|
|
|
|
def _compacted_history() -> tuple[list[dict], list[dict]]:
|
|
"""Display transcript with a recovered anchor + a COMPACTED model context.
|
|
|
|
The anchor's turn was compressed out of the context, so its API-safe
|
|
position lies beyond the end of ``result_messages`` (production shape).
|
|
"""
|
|
display = [
|
|
{"role": "user", "content": "reload the webui", "timestamp": 1788356170},
|
|
{"role": "assistant", "content": "Restarting the WebUI unit.", "timestamp": 1788356390},
|
|
_recovered_anchor("The command was killed with SIGTERM"),
|
|
{"role": "user", "content": "did it come back?", "timestamp": 1788357000},
|
|
{"role": "assistant", "content": "Yes, it is up.", "timestamp": 1788357010},
|
|
]
|
|
context = [
|
|
{"role": "user", "content": "did it come back?", "timestamp": 1788357000},
|
|
{"role": "assistant", "content": "Yes, it is up.", "timestamp": 1788357010},
|
|
]
|
|
return display, context
|
|
|
|
|
|
def _settle_turn(display, context, turn: int):
|
|
msg_text = f"follow-up {turn}"
|
|
ts = 1788440000 + turn * 10
|
|
result = copy.deepcopy(context) + [
|
|
{"role": "user", "content": msg_text, "timestamp": ts},
|
|
{"role": "assistant", "content": f"reply {turn}", "timestamp": ts + 5},
|
|
]
|
|
restored = _restore_display_reasoning_metadata(copy.deepcopy(display), copy.deepcopy(result))
|
|
merged = _merge_display_messages_after_agent_result(
|
|
copy.deepcopy(display), copy.deepcopy(context), restored, msg_text, source="webui",
|
|
)
|
|
return merged, result
|
|
|
|
|
|
def test_restore_display_reasoning_does_not_append_unaligned_rows_past_result_end():
|
|
display, context = _compacted_history()
|
|
result = copy.deepcopy(context) + [
|
|
{"role": "user", "content": "follow-up 1"},
|
|
{"role": "assistant", "content": "reply 1"},
|
|
]
|
|
|
|
restored = _restore_display_reasoning_metadata(display, copy.deepcopy(result))
|
|
|
|
# The anchor's API-safe slot (2) is beyond the compacted result's own
|
|
# rows, so nothing may be appended after the new reply.
|
|
assert [m.get("content") for m in restored] == [m.get("content") for m in result]
|
|
assert not any(_is_clone(m) for m in restored)
|
|
|
|
|
|
def test_settled_turns_do_not_double_recovered_anchors():
|
|
display, context = _compacted_history()
|
|
assert sum(_is_clone(m) for m in display) == 1
|
|
for turn in range(1, 5):
|
|
display, context = _settle_turn(display, context, turn)
|
|
assert sum(_is_clone(m) for m in display) == 1, (
|
|
f"turn {turn}: clone blocks {_clone_blocks(display)}"
|
|
)
|
|
# Original anchor stays where it was, right after its own reply.
|
|
assert _is_clone(display[2])
|
|
assert display[-1]["content"] == "reply 4"
|
|
|
|
|
|
def test_reasoning_only_row_still_restored_when_its_successor_is_present():
|
|
previous = [
|
|
{"role": "user", "content": "old turn", "timestamp": 1},
|
|
{"role": "assistant", "content": "", "timestamp": 2, "reasoning": "visible thinking card"},
|
|
{"role": "assistant", "content": "old answer", "timestamp": 3},
|
|
]
|
|
updated = [
|
|
{"role": "user", "content": "old turn"},
|
|
{"role": "assistant", "content": "old answer"},
|
|
{"role": "user", "content": "new turn"},
|
|
{"role": "assistant", "content": "new answer"},
|
|
]
|
|
|
|
restored = _restore_display_reasoning_metadata(previous, updated)
|
|
|
|
assert [m.get("content") for m in restored] == [
|
|
"old turn", "", "old answer", "new turn", "new answer",
|
|
]
|
|
assert restored[1]["reasoning"] == "visible thinking card"
|
|
|
|
|
|
def _repeated_prompt_history(n_anchors: int) -> tuple[list[dict], list[dict]]:
|
|
display = [
|
|
{"role": "user", "content": "older turn", "timestamp": 1788356100, "id": 1},
|
|
{"role": "assistant", "content": "older answer", "timestamp": 1788356200, "id": 2},
|
|
*[_recovered_anchor(f"recovered thinking {i}", 1788356394 + i) for i in range(n_anchors)],
|
|
{"role": "user", "content": "continue", "timestamp": 1788357000, "id": 3},
|
|
{"role": "assistant", "content": "first answer", "timestamp": 1788357010, "id": 4},
|
|
]
|
|
context = [copy.deepcopy(display[-2]), copy.deepcopy(display[-1])]
|
|
return display, context
|
|
|
|
|
|
def _settle_session_turn(session, turn: int, prompt: str, answer: str):
|
|
"""Production order: ids minted on result rows, then restore -> merge."""
|
|
previous = list(session.messages)
|
|
previous_context = list(session.context_messages)
|
|
ts = 1788440000 + turn * 10
|
|
result = copy.deepcopy(previous_context) + [
|
|
{"role": "user", "content": prompt, "timestamp": ts},
|
|
{"role": "assistant", "content": answer, "timestamp": ts + 5},
|
|
]
|
|
_settle_result_messages(session, previous, previous_context, result, prompt, "webui", None)
|
|
|
|
|
|
def test_repeated_prompt_with_distinct_stable_id_does_not_realign_anchors(monkeypatch):
|
|
monkeypatch.setattr(_streaming, "_annotate_media_snapshots_for_settled_messages", lambda m: None)
|
|
display, context = _repeated_prompt_history(64)
|
|
session = Session(session_id="a" * 12, title="t", messages=copy.deepcopy(display))
|
|
session.context_messages = copy.deepcopy(context)
|
|
assert sum(_is_clone(m) for m in session.messages) == 64
|
|
|
|
# Guard the fixture: the new "continue" row is a DIFFERENT turn (id 5) that
|
|
# is content-identical to the historical successor (id 3).
|
|
probe = copy.deepcopy(context) + [
|
|
{"role": "user", "content": "continue", "timestamp": 1788440000},
|
|
{"role": "assistant", "content": "a different new answer", "timestamp": 1788440005},
|
|
]
|
|
_assign_stable_message_ids(probe, display, context)
|
|
assert (display[-2]["id"], probe[2]["id"]) == (3, 5)
|
|
assert _streaming._message_identity(display[-2]) == _streaming._message_identity(probe[2])
|
|
|
|
for turn in range(1, 4):
|
|
_settle_session_turn(session, turn, "continue", f"a different new answer {turn}")
|
|
assert sum(_is_clone(m) for m in session.messages) == 64, (
|
|
f"turn {turn}: clone blocks {_clone_blocks(session.messages)}"
|
|
)
|
|
assert session.messages[-1]["content"] == f"a different new answer {turn}"
|
|
# Anchors stay in their original block, right after "older answer".
|
|
assert _clone_blocks(session.messages) == [64]
|
|
assert session.messages[1]["content"] == "older answer" and _is_clone(session.messages[2])
|
|
|
|
|
|
def test_successor_alignment_prefers_stable_ids_over_content():
|
|
previous = [
|
|
{"role": "user", "content": "older turn", "timestamp": 1, "id": 1},
|
|
{"role": "assistant", "content": "older answer", "timestamp": 2, "id": 2},
|
|
{"role": "assistant", "content": "", "timestamp": 3, "reasoning": "card"},
|
|
{"role": "user", "content": "continue", "timestamp": 4, "id": 3},
|
|
{"role": "assistant", "content": "first answer", "timestamp": 5, "id": 4},
|
|
]
|
|
|
|
def restore(prev, successor_id, prompt="continue"):
|
|
row = {"role": "user", "content": prompt}
|
|
if successor_id is not None:
|
|
row["id"] = successor_id
|
|
updated = [
|
|
{"role": "user", "content": "older turn", "id": 1},
|
|
{"role": "assistant", "content": "older answer", "id": 2},
|
|
row,
|
|
{"role": "assistant", "content": "new answer"},
|
|
]
|
|
restored = _restore_display_reasoning_metadata(copy.deepcopy(prev), updated)
|
|
return [m.get("content") for m in restored]
|
|
|
|
restored_shape = ["older turn", "older answer", "", "continue", "new answer"]
|
|
skipped_shape = ["older turn", "older answer", "continue", "new answer"]
|
|
assert restore(previous, 3) == restored_shape
|
|
assert restore(previous, 5) == skipped_shape
|
|
# Exactly one side carries an id -> rejected, never a content fallback.
|
|
# (A workspace-prefixed prompt is identity-equal but escapes id carry-forward.)
|
|
prefixed = "[Workspace::v1: /tmp/ws] continue"
|
|
assert _streaming._message_identity({"role": "user", "content": prefixed}) == (
|
|
_streaming._message_identity(previous[3])
|
|
)
|
|
assert restore(previous, None, prefixed) == [
|
|
"older turn", "older answer", prefixed, "new answer",
|
|
]
|
|
idless = copy.deepcopy(previous)
|
|
del idless[3]["id"]
|
|
assert restore(idless, 3) == skipped_shape
|
|
# Neither side carries an id -> legacy content identity still restores.
|
|
assert restore(idless, None) == restored_shape
|
|
|
|
|
|
_REAL_SIDECAR = os.environ.get("HERMES_WEBUI_REAL_CLONE_SIDECAR", "")
|
|
|
|
|
|
def _assistant_successor_history(n_anchors: int) -> tuple[list[dict], list[dict]]:
|
|
"""64 anchors before a historical assistant ``"Done."`` (id 2); compacted context."""
|
|
display = [
|
|
{"role": "user", "content": "do it", "timestamp": 1788356100, "id": 1},
|
|
*[_recovered_anchor(f"recovered thinking {i}", 1788356394 + i) for i in range(n_anchors)],
|
|
{"role": "assistant", "content": "Done.", "timestamp": 1788357010, "id": 2},
|
|
]
|
|
context = [copy.deepcopy(display[0]), copy.deepcopy(display[-1])]
|
|
return display, context
|
|
|
|
|
|
def _settle_replacing_result(monkeypatch, display, context, result, prompt):
|
|
"""Result that REPLACES the compacted context (does not extend it)."""
|
|
monkeypatch.setattr(_streaming, "_annotate_media_snapshots_for_settled_messages", lambda m: None)
|
|
session = Session(session_id="d" * 12, title="t", messages=copy.deepcopy(display))
|
|
session.context_messages = copy.deepcopy(context)
|
|
_settle_result_messages(
|
|
session, list(session.messages), list(session.context_messages), result, prompt, "webui", None,
|
|
)
|
|
return session
|
|
|
|
|
|
def test_historical_assistant_successor_with_same_content_does_not_forge_current_id(monkeypatch):
|
|
display, context = _assistant_successor_history(64)
|
|
assert sum(_is_clone(m) for m in display) == 64
|
|
result = [
|
|
{"role": "user", "content": "do it again", "timestamp": 1788440000},
|
|
{"role": "assistant", "content": "Done.", "timestamp": 1788440005},
|
|
]
|
|
session = _settle_replacing_result(monkeypatch, display, context, result, "do it again")
|
|
assert sum(_is_clone(m) for m in session.messages) == 64, _clone_blocks(session.messages)
|
|
# The distinct current "Done." must NOT inherit the historical successor's id 2.
|
|
assert result[-1]["id"] != 2 and _streaming._is_stable_message_id(result[-1]["id"])
|
|
assert _clone_blocks(session.messages) == [64]
|
|
assert session.messages[-1]["content"] == "Done." and session.messages[-2]["content"] == "do it again"
|
|
|
|
|
|
def test_whole_repeated_user_assistant_pair_does_not_forge_or_double(monkeypatch):
|
|
display, context = _assistant_successor_history(64)
|
|
result = [
|
|
{"role": "user", "content": "do it", "timestamp": 1788440000},
|
|
{"role": "assistant", "content": "Done.", "timestamp": 1788440005},
|
|
]
|
|
session = _settle_replacing_result(monkeypatch, display, context, result, "do it")
|
|
assert sum(_is_clone(m) for m in session.messages) == 64, _clone_blocks(session.messages)
|
|
assert [m["id"] for m in result] != [1, 2]
|
|
assert result[-1]["id"] != 2
|
|
assert _clone_blocks(session.messages) == [64]
|
|
|
|
|
|
def test_assistant_only_result_has_boundary_zero_and_inherits_nothing(monkeypatch):
|
|
display, context = _assistant_successor_history(4)
|
|
result = [{"role": "assistant", "content": "Done.", "timestamp": 1788440005}]
|
|
session = _settle_replacing_result(monkeypatch, display, [context[-1]], result, "do it again")
|
|
assert result[0]["id"] != 2
|
|
assert sum(_is_clone(m) for m in session.messages) == 4
|
|
|
|
|
|
def _current_only_collision_result() -> list[dict]:
|
|
"""Compacted CURRENT-only result whose text echoes the old context."""
|
|
return [
|
|
{"role": "user", "content": "do it", "timestamp": 1788440000},
|
|
{"role": "assistant", "content": "Done.", "timestamp": 1788440005},
|
|
{"role": "assistant", "content": "Verified.", "timestamp": 1788440010},
|
|
]
|
|
|
|
|
|
def test_current_only_collision_without_authority_fails_closed_to_boundary_zero(monkeypatch):
|
|
display, context = _assistant_successor_history(64)
|
|
result = _current_only_collision_result()
|
|
assert _streaming._messages_have_prefix(result, context) # identity ignores ids/ts
|
|
assert _streaming._active_turn_boundary(copy.deepcopy(result), context, None, "do it") == 0
|
|
session = _settle_replacing_result(monkeypatch, display, context, result, "do it")
|
|
assert sum(_is_clone(m) for m in session.messages) == 64, _clone_blocks(session.messages)
|
|
assert _clone_blocks(session.messages) == [64]
|
|
assert session.messages[-1]["content"] == "Verified."
|
|
|
|
|
|
def test_current_only_collision_with_exact_authority_keeps_one_block(monkeypatch):
|
|
display, context = _assistant_successor_history(64)
|
|
result = _current_only_collision_result()
|
|
identity = _streaming._resolve_active_turn_authority(
|
|
{"token": None, "text": "do it", "current_turn_user_idx": None, "turn_id": ""},
|
|
result={"messages": result, "current_turn_user_idx": 0, "turn_id": "turn-1"},
|
|
)
|
|
assert _streaming._active_turn_boundary_is_valid(identity)
|
|
assert _streaming._active_turn_boundary(copy.deepcopy(result), context, identity, "do it") == 0
|
|
monkeypatch.setattr(_streaming, "_annotate_media_snapshots_for_settled_messages", lambda m: None)
|
|
session = Session(session_id="f" * 12, title="t", messages=copy.deepcopy(display))
|
|
session.context_messages = copy.deepcopy(context)
|
|
_settle_result_messages(
|
|
session, list(session.messages), list(session.context_messages), result, "do it", "webui", identity,
|
|
)
|
|
assert _clone_blocks(session.messages) == [64]
|
|
|
|
|
|
def test_append_only_result_control_keeps_boundary_after_prefix(monkeypatch):
|
|
display, context = _assistant_successor_history(64)
|
|
result = copy.deepcopy(context) + [
|
|
{"role": "user", "content": "do it", "timestamp": 1788440000},
|
|
{"role": "assistant", "content": "Verified.", "timestamp": 1788440010},
|
|
]
|
|
assert _streaming._active_turn_boundary(copy.deepcopy(result), context, None, "do it") == 2
|
|
session = _settle_replacing_result(monkeypatch, display, context, result, "do it")
|
|
assert _clone_blocks(session.messages) == [64]
|
|
assert session.messages[-1]["content"] == "Verified."
|
|
|
|
|
|
def test_prefix_without_current_user_at_or_after_it_fails_closed():
|
|
boundary = _streaming._active_turn_boundary
|
|
prev = [{"role": "user", "content": "do it"}, {"role": "assistant", "content": "Done."}]
|
|
# Historical user matches the prompt, but no current user row follows the prefix.
|
|
assert boundary(prev + [{"role": "assistant", "content": "Verified."}], prev, None, "do it") == 0
|
|
# A prompt-matching user AFTER the prefix is the current turn.
|
|
assert boundary(prev + [{"role": "user", "content": "do it"}], prev, None, "do it") == 2
|
|
|
|
|
|
def test_active_turn_boundary_contract():
|
|
boundary = _streaming._active_turn_boundary
|
|
prev = [{"role": "user", "content": "a"}, {"role": "assistant", "content": "b"}]
|
|
assert boundary([], prev, None, "x") == 0
|
|
assert boundary([{"role": "assistant", "content": "Done."}], prev, None, "x") == 0
|
|
extended = prev + [{"role": "user", "content": "x"}, {"role": "assistant", "content": "y"}]
|
|
assert boundary(extended, prev, None, "x") == 2
|
|
replaced = [{"role": "user", "content": "x"}, {"role": "assistant", "content": "y"}]
|
|
assert boundary(replaced, prev, None, "x") == 0
|
|
tokened = [{"role": "user", "content": "x", "_active_turn_token": "tok"}, {"role": "assistant", "content": "y"}]
|
|
assert boundary(prev + tokened, prev, {"token": "tok"}, "x") == 2
|
|
|
|
|
|
def _identity_hole_history(successor_id) -> tuple[list[dict], list[dict]]:
|
|
"""Historical successor carries ``successor_id``; anchor sits right before it."""
|
|
display = [
|
|
{"role": "user", "content": "older turn", "timestamp": 1},
|
|
{"role": "assistant", "content": "older answer", "timestamp": 2},
|
|
{"role": "assistant", "content": "", "timestamp": 3, "reasoning": "historical card"},
|
|
{"role": "user", "content": "continue", "timestamp": 4, "id": successor_id},
|
|
{"role": "assistant", "content": "first answer", "timestamp": 5},
|
|
]
|
|
context = [copy.deepcopy(display[-2]), copy.deepcopy(display[-1])]
|
|
return display, context
|
|
|
|
|
|
def _reasoning_cards(messages) -> list:
|
|
return [m.get("reasoning") for m in messages]
|
|
|
|
|
|
def _settled_session(monkeypatch, display, context, result_ids=()):
|
|
monkeypatch.setattr(_streaming, "_annotate_media_snapshots_for_settled_messages", lambda m: None)
|
|
session = Session(session_id="b" * 12, title="t", messages=copy.deepcopy(display))
|
|
session.context_messages = copy.deepcopy(context)
|
|
result = copy.deepcopy(context) + [
|
|
{"role": "user", "content": "continue", "timestamp": 10},
|
|
{"role": "assistant", "content": "new answer", "timestamp": 15},
|
|
]
|
|
for row, rid in zip(result, result_ids, strict=False):
|
|
if rid is not None:
|
|
row["id"] = rid
|
|
_settle_result_messages(
|
|
session, list(session.messages), list(session.context_messages), result, "continue", "webui", None,
|
|
)
|
|
return session, result
|
|
|
|
|
|
# The new "continue" is a distinct turn: exactly one historical card may survive.
|
|
_ONE_CARD = [None, None, "historical card", None, None, None, None]
|
|
|
|
|
|
def test_bool_successor_id_never_matches_integer_one(monkeypatch):
|
|
display, context = _identity_hole_history(True)
|
|
session, result = _settled_session(monkeypatch, display, context, result_ids=(None, None, 1, 2))
|
|
assert True == 1 and result[2]["id"] == 1 # noqa: E712 - the hole being closed
|
|
assert _reasoning_cards(session.messages) == _ONE_CARD
|
|
assert all(type(m["id"]) is int for m in result) # replayed True row re-minted
|
|
|
|
|
|
def test_bool_successor_id_never_matches_minted_integer_one(monkeypatch):
|
|
display, context = _identity_hole_history(True)
|
|
session, result = _settled_session(monkeypatch, display, context)
|
|
assert _reasoning_cards(session.messages) == _ONE_CARD
|
|
assert all(type(m["id"]) is int for m in result)
|
|
|
|
|
|
def test_float_successor_id_never_matches_integer_one(monkeypatch):
|
|
display, context = _identity_hole_history(1.0)
|
|
session, result = _settled_session(monkeypatch, display, context, result_ids=(None, None, 1, 2))
|
|
assert 1.0 == 1 and result[2]["id"] == 1
|
|
assert _reasoning_cards(session.messages) == _ONE_CARD
|
|
assert all(type(m["id"]) is int for m in result)
|
|
|
|
|
|
def test_reused_positive_id_cannot_establish_successor_ownership(monkeypatch):
|
|
display, context = _identity_hole_history(7)
|
|
display[0]["id"] = 7 # the same id is owned by two API-safe historical rows
|
|
session, result = _settled_session(monkeypatch, display, context, result_ids=(7, 8, 7, 9))
|
|
assert [m["id"] for m in result] == [7, 8, 7, 9]
|
|
assert _reasoning_cards(session.messages) == _ONE_CARD
|
|
|
|
|
|
def test_unique_stable_id_control_still_restores_before_its_own_successor(monkeypatch):
|
|
display, context = _identity_hole_history(3)
|
|
display[0]["id"], display[1]["id"], display[4]["id"] = 1, 2, 4
|
|
context[1]["id"] = 4
|
|
session, result = _settled_session(monkeypatch, display, context)
|
|
assert [m["id"] for m in result] == [3, 4, 5, 6]
|
|
assert _reasoning_cards(session.messages) == _ONE_CARD
|
|
assert session.messages[2]["reasoning"] == "historical card"
|
|
|
|
|
|
def test_bool_ids_do_not_survive_save_load_into_minted_collision(tmp_path, monkeypatch):
|
|
from api import models, run_journal
|
|
|
|
sessions = tmp_path / "sessions"
|
|
sessions.mkdir()
|
|
monkeypatch.setattr(run_journal, "_default_session_dir", lambda: sessions)
|
|
monkeypatch.setattr(models, "SESSION_DIR", sessions)
|
|
monkeypatch.setattr(models, "SESSION_INDEX_FILE", sessions / "_index.json")
|
|
monkeypatch.setattr(models, "SESSIONS", type(models.SESSIONS)())
|
|
|
|
display, context = _identity_hole_history(True)
|
|
persisted = Session(session_id="c" * 12, title="t", messages=copy.deepcopy(display))
|
|
persisted.context_messages = copy.deepcopy(context)
|
|
persisted.save()
|
|
loaded = Session.load("c" * 12)
|
|
assert loaded is not None and loaded.messages[3]["id"] is True # survives save/load
|
|
|
|
session, result = _settled_session(
|
|
monkeypatch, loaded.messages, loaded.context_messages, result_ids=(None, None, 1, 2),
|
|
)
|
|
assert result[2]["id"] == 1 and _reasoning_cards(session.messages) == _ONE_CARD
|
|
session.save()
|
|
reloaded = Session.load("b" * 12)
|
|
assert reloaded is not None
|
|
assert _reasoning_cards(reloaded.messages) == _ONE_CARD
|
|
assert [m["id"] for m in reloaded.messages[-2:]] == [1, 2]
|
|
assert all(type(m["id"]) is int for m in result) # replayed True row re-minted
|
|
|
|
|
|
def _isolated_session_store(tmp_path, monkeypatch):
|
|
from api import models, run_journal
|
|
|
|
sessions = tmp_path / "sessions"
|
|
sessions.mkdir()
|
|
monkeypatch.setattr(run_journal, "_default_session_dir", lambda: sessions)
|
|
monkeypatch.setattr(models, "SESSION_DIR", sessions)
|
|
monkeypatch.setattr(models, "SESSION_INDEX_FILE", sessions / "_index.json")
|
|
monkeypatch.setattr(models, "SESSIONS", type(models.SESSIONS)())
|
|
return sessions
|
|
|
|
|
|
def test_idless_successor_card_survives_settle_save_load(tmp_path, monkeypatch):
|
|
"""Persisted-display pin: the helper-level id skip never drops the card."""
|
|
_isolated_session_store(tmp_path, monkeypatch)
|
|
display, context = _identity_hole_history(None)
|
|
del display[3]["id"] # legacy transcript: successor predates stable ids
|
|
context = [copy.deepcopy(display[-2]), copy.deepcopy(display[-1])]
|
|
persisted = Session(session_id="e" * 12, title="t", messages=copy.deepcopy(display))
|
|
persisted.context_messages = copy.deepcopy(context)
|
|
persisted.save()
|
|
loaded = Session.load("e" * 12)
|
|
assert loaded is not None and "id" not in loaded.messages[3]
|
|
|
|
session, result = _settled_session(monkeypatch, loaded.messages, loaded.context_messages)
|
|
assert result[0]["id"] is not None # minted before restore: helper-level skip applies
|
|
session.save()
|
|
reloaded = Session.load("b" * 12)
|
|
assert reloaded is not None
|
|
assert _reasoning_cards(reloaded.messages) == _ONE_CARD
|
|
assert reloaded.messages[2]["reasoning"] == "historical card"
|
|
assert [m["content"] for m in reloaded.messages[-2:]] == ["continue", "new answer"]
|
|
|
|
|
|
class _FakePostHandler:
|
|
def __init__(self):
|
|
self.status, self.headers, self.body, self.wfile = None, {}, bytearray(), self
|
|
|
|
def send_response(self, status):
|
|
self.status = status
|
|
|
|
def send_header(self, name, value):
|
|
self.headers[name] = value
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
def write(self, data):
|
|
self.body.extend(data)
|
|
|
|
|
|
def test_handle_chat_sync_passes_result_turn_authority_to_settlement(tmp_path, monkeypatch):
|
|
import sys
|
|
from types import SimpleNamespace
|
|
|
|
from api import models, routes
|
|
|
|
_isolated_session_store(tmp_path, monkeypatch)
|
|
monkeypatch.setattr(routes, "SESSION_INDEX_FILE", models.SESSION_INDEX_FILE)
|
|
monkeypatch.setattr(routes, "get_session", models.get_session)
|
|
monkeypatch.setattr(routes, "title_from", models.title_from)
|
|
monkeypatch.setattr(routes, "get_config", lambda: {"model": "m", "provider": "p"})
|
|
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path)
|
|
monkeypatch.setattr(routes, "load_settings", lambda: {})
|
|
monkeypatch.setattr(routes, "_resolve_cli_toolsets", lambda: [])
|
|
monkeypatch.setattr(routes, "_agent_runtime_barrier_response", lambda **_k: None)
|
|
monkeypatch.setattr(_streaming, "_annotate_media_snapshots_for_settled_messages", lambda m: None)
|
|
|
|
display, context = _assistant_successor_history(4)
|
|
session = Session(
|
|
session_id="g" * 12, workspace=str(tmp_path), messages=copy.deepcopy(display),
|
|
context_messages=copy.deepcopy(context), model="m", model_provider="p",
|
|
)
|
|
session.save(touch_updated_at=False)
|
|
# Whole history re-sent as CURRENT rows; the Agent says the turn starts at 0.
|
|
result_rows = copy.deepcopy(context) + [
|
|
{"role": "user", "content": "do it"}, {"role": "assistant", "content": "Verified."},
|
|
]
|
|
for row in result_rows:
|
|
row.pop("id", None)
|
|
row.pop("timestamp", None)
|
|
|
|
class FakeAgent:
|
|
def __init__(self, **_kwargs):
|
|
pass
|
|
|
|
def run_conversation(self, **_kwargs):
|
|
return {
|
|
"messages": result_rows, "final_response": "Verified.", "completed": True,
|
|
"current_turn_user_idx": 0, "turn_id": "turn-sync-1",
|
|
}
|
|
|
|
monkeypatch.setitem(sys.modules, "run_agent", SimpleNamespace(AIAgent=FakeAgent))
|
|
handler = _FakePostHandler()
|
|
routes._handle_chat_sync(
|
|
handler, {"session_id": "g" * 12, "message": "do it", "workspace": str(tmp_path)},
|
|
)
|
|
assert handler.status == 200
|
|
# Boundary 0 from exact authority: no historical id (1/2) forged onto current rows.
|
|
assert result_rows[0]["id"] != 1 and result_rows[1]["id"] != 2
|
|
reloaded = Session.load("g" * 12)
|
|
assert reloaded is not None
|
|
assert _clone_blocks(reloaded.messages) == [4]
|
|
assert reloaded.messages[-1]["content"] == "Verified."
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not _REAL_SIDECAR or not pathlib.Path(_REAL_SIDECAR).is_file(),
|
|
reason="set HERMES_WEBUI_REAL_CLONE_SIDECAR to a captured sidecar to run",
|
|
)
|
|
def test_real_captured_sidecar_does_not_double_per_settled_turn():
|
|
data = json.loads(pathlib.Path(_REAL_SIDECAR).read_text())
|
|
display = copy.deepcopy(data["messages"])
|
|
context = copy.deepcopy(data.get("context_messages") or [])
|
|
before = sum(_is_clone(m) for m in display)
|
|
assert before > 0
|
|
for turn in range(1, 3):
|
|
display, context = _settle_turn(display, context, turn)
|
|
assert sum(_is_clone(m) for m in display) == before, _clone_blocks(display)
|