mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-18 13:40:22 +00:00
468 lines
18 KiB
Python
468 lines
18 KiB
Python
"""Tests for session field propagation on duplicate and branch.
|
|
|
|
Verifies that critical Session fields are copied when duplicating or
|
|
branching a session, preventing data loss scenarios like:
|
|
- truncation_watermark not carried → merge with state.db drops messages
|
|
- context_messages not carried → agent sees different context
|
|
- gateway_routing not carried → routing customization lost
|
|
- enabled_toolsets not carried → toolset override lost
|
|
- context_engine_state not carried → context engine state lost
|
|
|
|
These are static-analysis tests (source inspection) matching the
|
|
conventions of the existing test suite.
|
|
"""
|
|
import re
|
|
|
|
|
|
def _extract_duplicate_block():
|
|
"""Extract the duplicate handler block from routes.py."""
|
|
with open('api/routes.py') as f:
|
|
src = f.read()
|
|
match = re.search(
|
|
r'parsed\.path == "/api/session/duplicate"(.*?)(?=\n if parsed\.path|$)',
|
|
src, re.DOTALL
|
|
)
|
|
assert match, "Could not find /api/session/duplicate handler block"
|
|
return match.group(1), src
|
|
|
|
|
|
def _extract_branch_block():
|
|
"""Extract the branch handler block from routes.py."""
|
|
with open('api/routes.py') as f:
|
|
src = f.read()
|
|
match = re.search(
|
|
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
|
|
src, re.DOTALL
|
|
)
|
|
assert match, "Could not find /api/session/branch handler block"
|
|
return match.group(1), src
|
|
|
|
|
|
def _find_session_ctor(block, var_name='copied_session'):
|
|
"""Find the Session() constructor call in a block and return its body."""
|
|
# Match from "var_name = Session(" to the closing ")"
|
|
pattern = rf'{var_name}\s*=\s*Session\((.*?)\)\s*$'
|
|
match = re.search(pattern, block, re.DOTALL)
|
|
assert match, f"Could not find {var_name} = Session() constructor"
|
|
return match.group(1)
|
|
|
|
|
|
def _has_field(ctor_body, field_name):
|
|
"""Check if a field is present in the constructor body."""
|
|
return f'{field_name}=' in ctor_body
|
|
|
|
|
|
def _has_deepcopy_for(ctor_body, field_name):
|
|
"""Check if a field uses deepcopy in the constructor."""
|
|
# Find the line containing the field assignment
|
|
lines = ctor_body.split('\n')
|
|
for line in lines:
|
|
if f'{field_name}=' in line:
|
|
return 'deepcopy' in line
|
|
return False
|
|
|
|
|
|
# ── Duplicate: critical fields MUST be copied ────────────────────────────────
|
|
|
|
def test_duplicate_copies_truncation_watermark():
|
|
"""truncation_watermark must be copied to prevent state.db merge from
|
|
dropping messages after edit in a duplicated session (#2914)."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'truncation_watermark'), \
|
|
"Duplicate must copy truncation_watermark"
|
|
|
|
|
|
def test_duplicate_copies_context_messages():
|
|
"""context_messages is the authoritative model-facing prefix. Without it,
|
|
the duplicate's agent context diverges from what the user sees."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'context_messages'), \
|
|
"Duplicate must copy context_messages"
|
|
assert _has_deepcopy_for(ctor, 'context_messages'), \
|
|
"context_messages must be deepcopied (mutable list)"
|
|
|
|
|
|
def test_duplicate_copies_gateway_routing():
|
|
"""gateway_routing customization must survive duplication."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'gateway_routing'), \
|
|
"Duplicate must copy gateway_routing"
|
|
assert _has_deepcopy_for(ctor, 'gateway_routing'), \
|
|
"gateway_routing must be deepcopied (mutable dict)"
|
|
|
|
|
|
def test_duplicate_copies_gateway_routing_history():
|
|
"""gateway_routing_history must survive duplication."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'gateway_routing_history'), \
|
|
"Duplicate must copy gateway_routing_history"
|
|
assert _has_deepcopy_for(ctor, 'gateway_routing_history'), \
|
|
"gateway_routing_history must be deepcopied (mutable list)"
|
|
|
|
|
|
def test_duplicate_copies_cache_tokens():
|
|
"""Cache token counters should be preserved for accurate usage tracking."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'cache_read_tokens'), \
|
|
"Duplicate must copy cache_read_tokens"
|
|
assert _has_field(ctor, 'cache_write_tokens'), \
|
|
"Duplicate must copy cache_write_tokens"
|
|
|
|
|
|
def test_duplicate_copies_enabled_toolsets():
|
|
"""Per-session toolset override must survive duplication."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'enabled_toolsets'), \
|
|
"Duplicate must copy enabled_toolsets"
|
|
|
|
|
|
def test_duplicate_copies_llm_title_generated():
|
|
"""llm_title_generated flag should be preserved to avoid regenerating title."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'llm_title_generated'), \
|
|
"Duplicate must copy llm_title_generated"
|
|
|
|
|
|
def test_duplicate_copies_composer_draft():
|
|
"""Per-session composer draft should be preserved."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'composer_draft'), \
|
|
"Duplicate must copy composer_draft"
|
|
assert _has_deepcopy_for(ctor, 'composer_draft'), \
|
|
"composer_draft must be deepcopied (mutable dict)"
|
|
|
|
|
|
def test_duplicate_copies_context_engine():
|
|
"""Context engine must be preserved so duplicate's context engine starts correctly."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'context_engine'), \
|
|
"Duplicate must copy context_engine"
|
|
assert _has_field(ctor, 'context_engine_state'), \
|
|
"Duplicate must copy context_engine_state"
|
|
assert _has_deepcopy_for(ctor, 'context_engine_state'), \
|
|
"context_engine_state must be deepcopied (mutable dict)"
|
|
|
|
|
|
def test_duplicate_copies_model_provider():
|
|
"""model_provider must survive duplication."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'model_provider'), \
|
|
"Duplicate must copy model_provider"
|
|
|
|
|
|
def test_duplicate_copies_personality():
|
|
"""personality must survive duplication."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'personality'), \
|
|
"Duplicate must copy personality"
|
|
|
|
|
|
def test_duplicate_copies_project_id():
|
|
"""project_id must survive duplication."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'project_id'), \
|
|
"Duplicate must copy project_id"
|
|
|
|
|
|
def test_duplicate_copies_profile():
|
|
"""profile must survive duplication."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'profile'), \
|
|
"Duplicate must copy profile"
|
|
|
|
|
|
def test_duplicate_copies_context_length():
|
|
"""context_length must survive duplication."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'context_length'), \
|
|
"Duplicate must copy context_length"
|
|
|
|
|
|
def test_duplicate_copies_threshold_tokens():
|
|
"""threshold_tokens must survive duplication."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'threshold_tokens'), \
|
|
"Duplicate must copy threshold_tokens"
|
|
|
|
|
|
def test_duplicate_copies_workspace():
|
|
"""workspace must survive duplication."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'workspace'), \
|
|
"Duplicate must copy workspace"
|
|
|
|
|
|
def test_duplicate_copies_model():
|
|
"""model must survive duplication."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'model'), \
|
|
"Duplicate must copy model"
|
|
|
|
|
|
def test_duplicate_copies_usage_counters():
|
|
"""Token usage counters must be preserved for accurate billing tracking."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_field(ctor, 'input_tokens'), \
|
|
"Duplicate must copy input_tokens"
|
|
assert _has_field(ctor, 'output_tokens'), \
|
|
"Duplicate must copy output_tokens"
|
|
assert _has_field(ctor, 'estimated_cost'), \
|
|
"Duplicate must copy estimated_cost"
|
|
|
|
|
|
# ── Duplicate: mutable fields MUST use deepcopy ──────────────────────────────
|
|
|
|
def test_duplicate_deepcopies_messages():
|
|
"""messages must be deepcopied to prevent shared list mutation."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_deepcopy_for(ctor, 'messages'), \
|
|
"messages must be deepcopied (mutable list of dicts)"
|
|
|
|
|
|
def test_duplicate_deepcopies_tool_calls():
|
|
"""tool_calls must be deepcopied to prevent shared list mutation."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert _has_deepcopy_for(ctor, 'tool_calls'), \
|
|
"tool_calls must be deepcopied (mutable list)"
|
|
|
|
|
|
# ── Duplicate: intentionally NOT copied (ephemeral / re-derived) ────────────
|
|
|
|
def test_duplicate_resets_pinned():
|
|
"""Pinned status should NOT transfer to duplicate."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert 'pinned=False' in ctor, \
|
|
"Duplicate should reset pinned to False"
|
|
|
|
|
|
def test_duplicate_resets_archived():
|
|
"""Archived status should NOT transfer to duplicate."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert 'archived=False' in ctor, \
|
|
"Duplicate should reset archived to False"
|
|
|
|
|
|
def test_duplicate_does_not_copy_compression_anchor():
|
|
"""Compression anchor fields are intentionally NOT carried — they
|
|
re-derive on the next turn (per existing comment)."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert not _has_field(ctor, 'compression_anchor_visible_idx'), \
|
|
"compression_anchor_visible_idx should NOT be copied (re-derives)"
|
|
assert not _has_field(ctor, 'compression_anchor_message_key'), \
|
|
"compression_anchor_message_key should NOT be copied (re-derives)"
|
|
assert not _has_field(ctor, 'compression_anchor_summary'), \
|
|
"compression_anchor_summary should NOT be copied (re-derives)"
|
|
assert not _has_field(ctor, 'compression_anchor_details'), \
|
|
"compression_anchor_details should NOT be copied (re-derives)"
|
|
assert not _has_field(ctor, 'compression_anchor_mode'), \
|
|
"compression_anchor_mode should NOT be copied (re-derives)"
|
|
assert not _has_field(ctor, 'compression_anchor_engine'), \
|
|
"compression_anchor_engine should NOT be copied (re-derives)"
|
|
|
|
|
|
def test_duplicate_does_not_copy_worktree():
|
|
"""Worktree fields must NOT transfer — they are per-session-instance."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert not _has_field(ctor, 'worktree_path'), \
|
|
"worktree_path should NOT be copied (per-instance)"
|
|
assert not _has_field(ctor, 'worktree_branch'), \
|
|
"worktree_branch should NOT be copied (per-instance)"
|
|
assert not _has_field(ctor, 'worktree_repo_root'), \
|
|
"worktree_repo_root should NOT be copied (per-instance)"
|
|
|
|
|
|
def test_duplicate_does_not_copy_last_prompt_tokens():
|
|
"""last_prompt_tokens is intentionally NOT carried — re-derives on next turn."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert not _has_field(ctor, 'last_prompt_tokens'), \
|
|
"last_prompt_tokens should NOT be copied (re-derives)"
|
|
|
|
|
|
def test_duplicate_does_not_copy_ephemeral():
|
|
"""Ephemeral fields (active_stream_id, pending_*) must NOT transfer."""
|
|
block, _ = _extract_duplicate_block()
|
|
ctor = _find_session_ctor(block)
|
|
assert not _has_field(ctor, 'active_stream_id'), \
|
|
"active_stream_id should NOT be copied (ephemeral)"
|
|
assert not _has_field(ctor, 'pending_user_message'), \
|
|
"pending_user_message should NOT be copied (ephemeral)"
|
|
assert not _has_field(ctor, 'pending_attachments'), \
|
|
"pending_attachments should NOT be copied (ephemeral)"
|
|
assert not _has_field(ctor, 'pending_started_at'), \
|
|
"pending_started_at should NOT be copied (ephemeral)"
|
|
|
|
|
|
# ── Branch: critical fields MUST be copied ──────────────────────────────────
|
|
|
|
def test_branch_copies_model_provider():
|
|
"""Branch must inherit model_provider from source."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert _has_field(ctor, 'model_provider'), \
|
|
"Branch must copy model_provider"
|
|
|
|
|
|
def test_branch_copies_project_id():
|
|
"""Branch must inherit project_id from source."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert _has_field(ctor, 'project_id'), \
|
|
"Branch must copy project_id"
|
|
|
|
|
|
def test_branch_copies_personality():
|
|
"""Branch must inherit personality from source."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert _has_field(ctor, 'personality'), \
|
|
"Branch must copy personality"
|
|
|
|
|
|
def test_branch_copies_enabled_toolsets():
|
|
"""Branch must inherit enabled_toolsets from source."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert _has_field(ctor, 'enabled_toolsets'), \
|
|
"Branch must copy enabled_toolsets"
|
|
|
|
|
|
def test_branch_copies_context_messages():
|
|
"""Branch must deep-copy context_messages for independent context."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert _has_field(ctor, 'context_messages'), \
|
|
"Branch must copy context_messages"
|
|
assert _has_deepcopy_for(ctor, 'context_messages'), \
|
|
"context_messages must be deepcopied in branch"
|
|
|
|
|
|
def test_branch_copies_gateway_routing():
|
|
"""Branch must inherit gateway_routing from source."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert _has_field(ctor, 'gateway_routing'), \
|
|
"Branch must copy gateway_routing"
|
|
assert _has_deepcopy_for(ctor, 'gateway_routing'), \
|
|
"gateway_routing must be deepcopied in branch"
|
|
|
|
|
|
def test_branch_copies_context_length():
|
|
"""Branch must inherit context_length from source."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert _has_field(ctor, 'context_length'), \
|
|
"Branch must copy context_length"
|
|
|
|
|
|
def test_branch_copies_threshold_tokens():
|
|
"""Branch must inherit threshold_tokens from source."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert _has_field(ctor, 'threshold_tokens'), \
|
|
"Branch must copy threshold_tokens"
|
|
|
|
|
|
def test_branch_copies_context_engine():
|
|
"""Branch must inherit context_engine state from source."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert _has_field(ctor, 'context_engine'), \
|
|
"Branch must copy context_engine"
|
|
assert _has_field(ctor, 'context_engine_state'), \
|
|
"Branch must copy context_engine_state"
|
|
assert _has_deepcopy_for(ctor, 'context_engine_state'), \
|
|
"context_engine_state must be deepcopied in branch"
|
|
|
|
|
|
# ── Branch: intentionally NOT copied ────────────────────────────────────────
|
|
|
|
def test_branch_does_not_copy_compression_anchor():
|
|
"""Branch should not copy compression anchor fields (re-derive)."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert not _has_field(ctor, 'compression_anchor_visible_idx'), \
|
|
"compression_anchor_visible_idx should NOT be copied in branch"
|
|
|
|
|
|
def test_branch_does_not_copy_truncation_watermark():
|
|
"""truncation_watermark must NOT be copied in branch — the branch starts
|
|
with a fresh message slice and watermark from the original session would
|
|
cause state.db merge to incorrectly filter messages in the branch."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert not _has_field(ctor, 'truncation_watermark'), \
|
|
"truncation_watermark should NOT be copied in branch"
|
|
|
|
|
|
def test_branch_does_not_copy_usage_counters():
|
|
"""Branch starts a new conversation — token counters must NOT carry over."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert not _has_field(ctor, 'input_tokens'), \
|
|
"input_tokens should NOT be copied in branch"
|
|
assert not _has_field(ctor, 'output_tokens'), \
|
|
"output_tokens should NOT be copied in branch"
|
|
assert not _has_field(ctor, 'estimated_cost'), \
|
|
"estimated_cost should NOT be copied in branch"
|
|
|
|
|
|
def test_branch_does_not_copy_tool_calls():
|
|
"""Branch must NOT inherit tool_calls from source."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert not _has_field(ctor, 'tool_calls'), \
|
|
"tool_calls should NOT be copied in branch"
|
|
|
|
|
|
def test_branch_does_not_copy_gateway_routing_history():
|
|
"""Branch starts its own routing history."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert not _has_field(ctor, 'gateway_routing_history'), \
|
|
"gateway_routing_history should NOT be copied in branch"
|
|
|
|
|
|
def test_branch_sets_session_source():
|
|
"""Branch must set session_source='fork' for lineage tracking."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert 'session_source=' in ctor, \
|
|
"Branch must set session_source"
|
|
assert '"fork"' in ctor or "'fork'" in ctor, \
|
|
"session_source must be 'fork'"
|
|
|
|
|
|
def test_branch_does_not_copy_ephemeral():
|
|
"""Branch should not copy ephemeral fields."""
|
|
block, _ = _extract_branch_block()
|
|
ctor = _find_session_ctor(block, 'branch')
|
|
assert not _has_field(ctor, 'active_stream_id'), \
|
|
"active_stream_id should NOT be copied in branch"
|
|
assert not _has_field(ctor, 'pending_user_message'), \
|
|
"pending_user_message should NOT be copied in branch"
|