diff --git a/static/sessions.js b/static/sessions.js index 935de3e8..2261135a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2339,7 +2339,11 @@ function _stripForcedSkillEnvelope(text){ } function _normalizeUserTranscriptText(text){ - let value=_stripAttachedFilesMarker(_stripForcedSkillEnvelope(text)); + const value=_stripAttachedFilesMarker(_stripForcedSkillEnvelope(text)); + // ui.js is loaded before sessions.js in index.html and owns the canonical + // workspace-sentinel parser used by rendering. Keep a small fallback for + // static/helper tests and defensive partial loads, but prefer the renderer's + // parser whenever it is available. if(typeof _stripWorkspaceDisplayPrefix==='function'){ return _stripWorkspaceDisplayPrefix(value); } diff --git a/tests/test_run_journal_frontend_static.py b/tests/test_run_journal_frontend_static.py index 5ce2121a..447a2180 100644 --- a/tests/test_run_journal_frontend_static.py +++ b/tests/test_run_journal_frontend_static.py @@ -1,9 +1,12 @@ +import json +import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[1] MESSAGES_SRC = (ROOT / "static" / "messages.js").read_text() SESSIONS_SRC = (ROOT / "static" / "sessions.js").read_text() +UI_SRC = (ROOT / "static" / "ui.js").read_text() def _function_body(src: str, signature: str) -> str: @@ -21,6 +24,53 @@ def _function_body(src: str, signature: str) -> str: raise AssertionError(f"could not extract function body for {signature!r}") +def _run_session_identity_probe() -> dict: + prompt = "same submitted prompt\nwith a second line" + workspace_prompt = f"[Workspace::v1: /tmp/hermes-webui]\n{prompt}" + legacy_workspace_prompt = f"[Workspace: /tmp/hermes-webui]\n{prompt}" + attached_prompt = f"{prompt}\n\n[Attached files: /tmp/a.txt]" + forced_prompt = ( + "[USER OVERRIDE] You MUST follow the skill 'hermes-webui-coordinator' " + "content provided below before responding to the next message.\n\n" + "[FORCED SKILL CONTEXT: hermes-webui-coordinator]\n" + "skill body that should not make a second user bubble\n" + "[/FORCED SKILL CONTEXT]\n\n" + f"{prompt}" + ) + helpers = "\n".join( + [ + _function_body(UI_SRC, "function _stripWorkspaceDisplayPrefix"), + _function_body(SESSIONS_SRC, "function _messageComparableText"), + _function_body(SESSIONS_SRC, "function _stripAttachedFilesMarker"), + _function_body(SESSIONS_SRC, "function _stripForcedSkillEnvelope"), + _function_body(SESSIONS_SRC, "function _normalizeUserTranscriptText"), + _function_body(SESSIONS_SRC, "function _sameTranscriptMessage"), + _function_body(SESSIONS_SRC, "function _inflightHasVisibleLiveState"), + ] + ) + script = f""" +{helpers} +const plain = {{role:'user', content:{json.dumps(prompt)}}}; +const workspace = {{role:'user', content:{json.dumps(workspace_prompt)}}}; +const legacyWorkspace = {{role:'user', content:{json.dumps(legacy_workspace_prompt)}}}; +const attached = {{role:'user', content:{json.dumps(attached_prompt)}}}; +const forced = {{role:'user', content:{json.dumps(forced_prompt)}}}; +const different = {{role:'user', content:'a different submitted prompt'}}; +process.stdout.write(JSON.stringify({{ + workspaceDedupe: _sameTranscriptMessage(plain, workspace), + legacyWorkspaceDedupe: _sameTranscriptMessage(plain, legacyWorkspace), + attachedDedupe: _sameTranscriptMessage(plain, attached), + forcedSkillDedupe: _sameTranscriptMessage(plain, forced), + differentUserNotDedupe: !_sameTranscriptMessage(plain, different), + roleMismatchNotDedupe: !_sameTranscriptMessage(plain, {{role:'assistant', content:{json.dumps(prompt)}}}), + userOnlyInflightVisible: _inflightHasVisibleLiveState({{messages:[plain]}}), + emptyUserOnlyInflightNotVisible: !_inflightHasVisibleLiveState({{messages:[{{role:'user', content:' '}}]}}), +}})); +""" + proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(proc.stdout) + + def test_reattach_path_uses_replay_when_status_reports_journal(): reattach_pos = MESSAGES_SRC.index("let replayOnly=false;") block = MESSAGES_SRC[reattach_pos : reattach_pos + 1200] @@ -150,31 +200,26 @@ def test_active_reload_keeps_user_only_inflight_visible_until_pending_dedupe(): The browser must not discard the user-only optimistic INFLIGHT entry as a cursor-only snapshot before pending/live replay reconciliation runs. """ - helper = _function_body(SESSIONS_SRC, "function _inflightHasVisibleLiveState") + result = _run_session_identity_probe() - assert "msg.role === 'user'" in helper or "msg.role==='user'" in helper - assert "_messageComparableText(msg)" in helper or "msg.content" in helper + assert result["userOnlyInflightVisible"] is True + assert result["emptyUserOnlyInflightNotVisible"] is True -def test_pending_user_merge_dedupes_workspace_prefixed_replay_rows(): +def test_pending_user_merge_dedupes_user_turn_variants_by_behavior(): """Pending user rows and replayed/checkpointed user rows share one turn. - The model-facing current user message may carry the WebUI workspace sentinel, - while pending_user_message stores only the human prompt. Frontend transcript - equality must normalize the same way the renderer and backend context - identity do, otherwise active reload/reconnect can show two user bubbles for - one submitted turn. + Execute the same JavaScript helpers the browser uses so the regression test + catches regex/order/trim mistakes, not just identifier wiring. """ - same = _function_body(SESSIONS_SRC, "function _sameTranscriptMessage") - normalizer = _function_body(SESSIONS_SRC, "function _normalizeUserTranscriptText") - forced = _function_body(SESSIONS_SRC, "function _stripForcedSkillEnvelope") + result = _run_session_identity_probe() - assert "_normalizeUserTranscriptText" in same - assert "_stripWorkspaceDisplayPrefix" in normalizer - assert "_stripAttachedFilesMarker" in normalizer - assert "Workspace::v1" in normalizer - assert "FORCED SKILL CONTEXT" in forced - assert "USER OVERRIDE" in forced + assert result["workspaceDedupe"] is True + assert result["legacyWorkspaceDedupe"] is True + assert result["attachedDedupe"] is True + assert result["forcedSkillDedupe"] is True + assert result["differentUserNotDedupe"] is True + assert result["roleMismatchNotDedupe"] is True def test_live_tool_matching_uses_the_same_aliases_as_live_card_dedup(): diff --git a/tests/test_session_save_mode.py b/tests/test_session_save_mode.py index 166cb109..e086b8a3 100644 --- a/tests/test_session_save_mode.py +++ b/tests/test_session_save_mode.py @@ -119,32 +119,34 @@ def test_eager_checkpointed_user_is_removed_from_model_context(): assert [m["content"] for m in context] == ["older", "prior"] -def test_submitted_turn_journal_does_not_duplicate_current_model_context(tmp_path): - from api.turn_journal import append_turn_journal_event +def test_active_pending_current_user_is_removed_from_model_context(): + """Current pending user text must not appear in conversation_history twice. + The provider receives the active turn as `user_message`; if an eager + checkpoint/recovery path has already put the same current user text at the + end of `context_messages`, `_context_messages_for_new_turn` must strip it + before building the model-facing history. Turn-journal `submitted` records + are intentionally not a context source for this helper. + """ session = Session( session_id="journal_context_current_turn", title="journal context", - messages=[{"role": "user", "content": "older"}], - context_messages=[{"role": "user", "content": "older"}], + messages=[ + {"role": "user", "content": "older"}, + {"role": "assistant", "content": "prior"}, + ], + context_messages=[ + {"role": "user", "content": "older"}, + {"role": "assistant", "content": "prior"}, + {"role": "user", "content": "[Workspace::v1: /tmp/hermes]\ncurrent prompt"}, + ], active_stream_id="stream-current", pending_user_message="current prompt", ) - append_turn_journal_event( - session.session_id, - { - "event": "submitted", - "stream_id": "stream-current", - "role": "user", - "content": "current prompt", - "created_at": 123.0, - }, - session_dir=tmp_path, - ) context = streaming._context_messages_for_new_turn(session, "current prompt") - assert [m["content"] for m in context] == ["older"] + assert [m["content"] for m in context] == ["older", "prior"] def test_eager_checkpointed_user_is_not_duplicated_after_agent_result():