HERMES-GIELIE/tests/test_run_journal_frontend_static.py

421 lines
20 KiB
Python
Raw Normal View History

2026-06-24 08:31:45 +00:00
import json
import subprocess
2026-05-15 01:37:54 +00:00
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MESSAGES_SRC = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
SESSIONS_SRC = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
UI_SRC = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
2026-05-15 01:37:54 +00:00
2026-06-24 07:22:56 +00:00
def _function_body(src: str, signature: str) -> str:
start = src.index(signature)
brace = src.index("{", start)
depth = 0
for idx in range(brace, len(src)):
char = src[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return src[start : idx + 1]
raise AssertionError(f"could not extract function body for {signature!r}")
2026-06-24 08:31:45 +00:00
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 _currentTailUserMessage"),
_function_body(SESSIONS_SRC, "function _hasCurrentTailUserDuplicate"),
2026-06-24 08:31:45 +00:00
_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 _run_current_turn_scope_probe() -> dict:
prompt = "repeat me"
historical_workspace_prompt = f"[Workspace::v1: /tmp/old]\n{prompt}"
current_workspace_prompt = f"[Workspace::v1: /tmp/current]\n{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 _currentTailUserMessage"),
_function_body(SESSIONS_SRC, "function _hasCurrentTailUserDuplicate"),
_function_body(SESSIONS_SRC, "function _mergePendingSessionMessage"),
_function_body(SESSIONS_SRC, "function _mergeInflightTailMessages"),
]
)
script = f"""
{helpers}
function getPendingSessionMessage(session, messages){{
const text=String(session&&session.pending_user_message||'').trim();
if(!text) return null;
return {{
role:'user',
content:text,
_ts:session.pending_started_at||10,
_pending:true,
}};
}}
const historical = {{role:'user', content:{json.dumps(historical_workspace_prompt)}, _ts:1}};
const historicalAnswer = {{role:'assistant', content:'done', _ts:2}};
const liveAssistant = {{role:'assistant', content:'working', _live:true, _ts:4}};
const pendingSession = {{pending_user_message:{json.dumps(prompt)}, pending_started_at:3}};
const pendingAfterHistory = [historical, historicalAnswer];
const insertedAfterHistory = _mergePendingSessionMessage(pendingSession, pendingAfterHistory);
const pendingBeforeLive = [historical, historicalAnswer, liveAssistant];
const insertedBeforeLive = _mergePendingSessionMessage(pendingSession, pendingBeforeLive);
const optimisticCurrent = {{role:'user', content:{json.dumps(current_workspace_prompt)}, _ts:3}};
const pendingWithCurrent = [historical, historicalAnswer, optimisticCurrent, liveAssistant];
const insertedWithCurrent = _mergePendingSessionMessage(pendingSession, pendingWithCurrent);
const inflightAfterHistory = _mergeInflightTailMessages(
[historical, historicalAnswer],
[{{role:'user', content:{json.dumps(prompt)}, _ts:3}}, liveAssistant]
);
const inflightWithCurrent = _mergeInflightTailMessages(
[historical, historicalAnswer, optimisticCurrent],
[{{role:'user', content:{json.dumps(prompt)}, _ts:3}}, liveAssistant]
);
process.stdout.write(JSON.stringify({{
insertedAfterHistory,
pendingAfterHistoryRoles: pendingAfterHistory.map(m=>m.role),
insertedBeforeLive,
pendingBeforeLiveRoles: pendingBeforeLive.map(m=>m.role),
insertedWithCurrent,
pendingWithCurrentRoles: pendingWithCurrent.map(m=>m.role),
inflightAfterHistoryRoles: inflightAfterHistory.map(m=>m.role),
inflightWithCurrentRoles: inflightWithCurrent.map(m=>m.role),
}}));
"""
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(proc.stdout)
def _run_pending_session_message_probe() -> dict:
prompt = "repeat me"
historical_workspace_prompt = f"[Workspace::v1: /tmp/old]\n{prompt}"
current_workspace_prompt = f"[Workspace::v1: /tmp/current]\n{prompt}"
helpers = "\n".join(
[
_function_body(UI_SRC, "function _stripWorkspaceDisplayPrefix"),
_function_body(UI_SRC, "function msgContent"),
_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(UI_SRC, "function _pendingCurrentTailUserMessage"),
_function_body(UI_SRC, "function getPendingSessionMessage"),
]
)
script = f"""
{helpers}
const prompt = {json.dumps(prompt)};
const historical = {{role:'user', content:prompt, _ts:1}};
const historicalWorkspace = {{role:'user', content:{json.dumps(historical_workspace_prompt)}, _ts:1}};
const historicalAnswer = {{role:'assistant', content:'done', _ts:2}};
const currentTail = {{role:'user', content:prompt, _ts:3}};
const currentWorkspaceTail = {{role:'user', content:{json.dumps(current_workspace_prompt)}, _ts:3}};
const liveAssistant = {{role:'assistant', content:'working', _live:true, _ts:4}};
const attachments = [{{name:'note.txt', path:'note.txt', mime:'text/plain'}}];
const fromHistoricalSameText = getPendingSessionMessage(
{{pending_user_message:prompt, pending_started_at:3}},
[historical, historicalAnswer]
);
const fromHistoricalWorkspace = getPendingSessionMessage(
{{pending_user_message:prompt, pending_started_at:3}},
[historicalWorkspace, historicalAnswer]
);
const exactCurrentMessages = [historical, historicalAnswer, currentTail];
const exactCurrentResult = getPendingSessionMessage(
{{pending_user_message:prompt, pending_started_at:4, pending_attachments:attachments}},
exactCurrentMessages
);
const workspaceCurrentResult = getPendingSessionMessage(
{{pending_user_message:prompt, pending_started_at:4}},
[historical, historicalAnswer, currentWorkspaceTail]
);
const liveAfterCurrentResult = getPendingSessionMessage(
{{pending_user_message:prompt, pending_started_at:4}},
[historical, historicalAnswer, currentWorkspaceTail, liveAssistant]
);
const differentTailResult = getPendingSessionMessage(
{{pending_user_message:prompt, pending_started_at:4}},
[historical, historicalAnswer, {{role:'user', content:'different prompt', _ts:3}}]
);
process.stdout.write(JSON.stringify({{
historicalSameTextSurvives: !!fromHistoricalSameText && fromHistoricalSameText.content===prompt && fromHistoricalSameText._pending===true,
historicalWorkspaceSurvives: !!fromHistoricalWorkspace && fromHistoricalWorkspace.content===prompt && fromHistoricalWorkspace._pending===true,
exactCurrentTailDedupe: exactCurrentResult===null,
exactCurrentTailAttachmentsCopied: Array.isArray(currentTail.attachments) && currentTail.attachments[0].name==='note.txt',
workspaceCurrentTailDedupe: workspaceCurrentResult===null,
liveAfterCurrentTailDedupe: liveAfterCurrentResult===null,
differentCurrentTailSurvives: !!differentTailResult && differentTailResult.content===prompt && differentTailResult._pending===true,
}}));
"""
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(proc.stdout)
2026-05-15 01:37:54 +00:00
def test_reattach_path_uses_replay_when_status_reports_journal():
reattach_pos = MESSAGES_SRC.index("let replayOnly=false;")
fix(webui): keep a following reader pinned through streaming growth and SSE recovery Two related 'jump back' classes where a reader who is following the live stream gets stranded mid-transcript, both verified live (OLD stranded the viewport 470-580px from bottom; FIX lands at 1px). 1. Content-grew-beneath-a-pinned-viewport (static/ui.js scroll listener). While streaming on a tall transcript (especially on mobile, where chunks land fast), new content increases scrollHeight under a stationary viewport. The reader never scrolled (top did not move up, _messageUserUnpinned is false), but bottomDistance crosses the nearBottom threshold, so the code fell through to _scrollPinned=false, killing auto-follow mid-stream. The follow writer and the scroll listener then fought frame-by-frame; the viewport stalled while content kept growing and was progressively stranded. Fix: in the !_messageUserUnpinned branch, when the viewport did NOT move up and auto-follow is on, keep the pin and re-snap to the true bottom via _setMessageScrollToBottom() instead of unpinning. The genuine scroll-away fallthrough is unchanged. 2. SSE-recovery follow-restore (static/messages.js). _handleStreamError (SSE drop), the Task-cancelled apply + fallback paths, and the reconnect-stream-dead cleanup all push/replace S.messages then renderMessages({preserveScroll:true}). preserveScroll's restore path keys on the pre-render snapshot's bottom-distance, which during a live stream can read large (content grew under a followed viewport), so it yanked a following reader up to a stale historical position on a process restart / SSE drop / cancel. Fix: capture follow-intent (_isMessagePaneNearBottom) BEFORE mutating S.messages, and after the recovery render, scrollToBottom() if the reader was following — so they see the interruption/cancellation notice in place. Readers who scrolled up to read history are left where they were. Structural source-lock tests in tests/test_sse_recovery_scroll_stranding.py; behavioral A/B verified via Playwright on a local source build.
2026-06-29 16:44:52 +00:00
# Window widened to 2200: the SSE-recovery follow-restore fix (the
# _wasFollowingAtReconnectDead guard + its sticky-unpin check) inserted lines
# into the reconnect-dead cleanup block between this anchor and the
# replay-params assertion below, pushing the target string past the old slice.
block = MESSAGES_SRC[reattach_pos : reattach_pos + 2200]
2026-05-15 01:37:54 +00:00
assert "st.replay_available" in block
assert "replayOnly=true" in block
[HELD — independent review pending] Release v0.51.294 — stage-3401 (live-to-final redesign #3401 + 4 deep-review fixes) (#3741) * Harden interrupted recovery control filtering * Redesign live-to-final assistant replies * Fix live activity anchor test fixture * Fix CI lint issues for live reply tests * Strengthen live progress prompt contract * Recover PR #3401 refresh on origin/master * Repair live-to-final refresh regressions * Fix live worklog refresh regressions * Show live footer timer on initial stream start * Restore live stream shell after reload * Preserve per-frame live SSE replay cursors * Preserve reasoning as Worklog Thinking cards * Quiet Worklog Thinking card styling * Align Worklog Thinking card styling * Scope live Worklog Thinking cards by segment * Suppress exact duplicate settled Thinking * Close #3401 merge review test gaps * fix(#3401): resolve 4 deep-review regressions (inline-think, reconnect-dup, neon skin, busy-gate worklog) Deep review (Codex diff-vs-master + live-browser drive) of the live-to-final refactor surfaced 4 regressions vs master that the rewritten suite no longer guarded: 1. Inline <think>…</think>answer reasoning vanished — _assistantReasoningPayloadText used $-anchored regexes so a leading think block + visible answer extracted nothing and the Thinking card never rendered. Removed the 3 $ anchors to match the (non-anchored) display stripper. Live: inline-think thinking-only turn now renders. 2. (CORE) reconnect/reload duplicated the live reply — _rememberRunJournalCursor advanced a closure-local seq but never wrote INFLIGHT[activeSid].lastRunJournalSeq, so a reload replayed the journal from after_seq=0 over restored lastAssistantText. Now mirrors the cursor onto INFLIGHT + schedules a throttled persist. 3. Neon skin silently broke — PR deleted the :root[data-skin="neon"] CSS but left Neon in the picker. Restored the neon CSS block from master. 4. Settled tool-worklog rebuild gated purely on !S.busy — dropped every prior settled turn's worklog when renderMessages re-ran during an active stream (switch-back to an in-progress session). Restored master's !S.busy || (S.toolCalls && S.toolCalls.length). Live: busy re-render now preserves tool cards (4→4, was 4→0). Live-verified all 4 + confirmed #3709/#3592 invariants still hold (1 thinking card, none below footer; distinct siblings preserved). + tests/test_issue3401_deep_review_fixes.py (7). * test(#3401): realign 3 stale source-shape assertions to the deep-review fixes Fix commit changed two source literals that existing stage tests scanned for: - test_live_activity_timeline.py (x2): split anchor 'if(!S.busy){' → the restored 'if(!S.busy || (S.toolCalls&&S.toolCalls.length)){' guard (fix 4). - test_run_journal_frontend_static.py: 'after_seq=0' not in source — fix 2's comment contained that literal; rephrased the comment to 'the zero floor (after_seq of 0)'. Intent of all three assertions unchanged; only the matched string updated. No code behavior change. * docs(changelog): v0.51.294 — Release JJ (stage-3401, #3401 live-to-final redesign) --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: Nathan-Hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 19:12:37 +00:00
assert "(reconnecting||replayOnly)?_runJournalReplayParams():''" in block
2026-05-15 01:37:54 +00:00
assert "_clearOwnerInflightState()" in block
def test_error_reconnect_path_can_restore_from_journal():
# Anchor on the reconnect block's stable entry point rather than the exact
# composer-status string: the first status was changed to a template literal
# `Reconnecting… (1/${_retryDelays.length})` (staged-probe counter), so the
# old single-quoted "setComposerStatus('Reconnecting" anchor no longer exists.
reconnect_pos = MESSAGES_SRC.index("_reconnectAttempted=true;")
block = MESSAGES_SRC[reconnect_pos : reconnect_pos + 1100]
2026-05-15 01:37:54 +00:00
assert "st.active" in block
assert "st.replay_available" in block
assert "Restoring stream" in block
2026-05-15 14:41:08 +00:00
assert "_runJournalReplayParams()" in block
def test_frontend_replay_cursor_uses_eventsource_last_event_id():
cursor_pos = MESSAGES_SRC.index("function _rememberRunJournalCursor")
block = MESSAGES_SRC[cursor_pos : cursor_pos + 1000]
assert "e.lastEventId" in block
assert "lastIndexOf(':')" in block
assert "_lastRunJournalSeq=seq" in block
2026-05-15 14:46:57 +00:00
assert "source.addEventListener(_runJournalEventName,_rememberRunJournalCursor)" in MESSAGES_SRC
2026-05-15 14:41:08 +00:00
assert "after_seq=${encodeURIComponent(String(_runJournalReplayAfterSeq()))}" in MESSAGES_SRC
assert "after_seq=0" not in MESSAGES_SRC
def test_replayed_long_task_events_enter_the_same_live_timeline_handlers():
"""Run-journal replay must not grow a parallel long-task renderer.
The run-state consistency contract depends on replayed journal events
flowing through the same EventSource handlers as live streams. Otherwise a
live long task can render as Thinking -> progress text -> tool cards, while
the same journaled event sequence replays as a flattened or reordered scene.
"""
wire_pos = MESSAGES_SRC.index("function _wireSSE(source)")
wire_block = MESSAGES_SRC[wire_pos : MESSAGES_SRC.index("async function _restoreSettledSession", wire_pos)]
replay_events = [
"reasoning",
"interim_assistant",
"tool",
"tool_complete",
"compressing",
"compressed",
"metering",
"done",
"apperror",
]
for event_name in replay_events:
assert f"source.addEventListener('{event_name}'" in wire_block, (
f"{event_name} must be handled by the shared live/replay SSE pipeline"
)
[HELD — independent review pending] Release v0.51.294 — stage-3401 (live-to-final redesign #3401 + 4 deep-review fixes) (#3741) * Harden interrupted recovery control filtering * Redesign live-to-final assistant replies * Fix live activity anchor test fixture * Fix CI lint issues for live reply tests * Strengthen live progress prompt contract * Recover PR #3401 refresh on origin/master * Repair live-to-final refresh regressions * Fix live worklog refresh regressions * Show live footer timer on initial stream start * Restore live stream shell after reload * Preserve per-frame live SSE replay cursors * Preserve reasoning as Worklog Thinking cards * Quiet Worklog Thinking card styling * Align Worklog Thinking card styling * Scope live Worklog Thinking cards by segment * Suppress exact duplicate settled Thinking * Close #3401 merge review test gaps * fix(#3401): resolve 4 deep-review regressions (inline-think, reconnect-dup, neon skin, busy-gate worklog) Deep review (Codex diff-vs-master + live-browser drive) of the live-to-final refactor surfaced 4 regressions vs master that the rewritten suite no longer guarded: 1. Inline <think>…</think>answer reasoning vanished — _assistantReasoningPayloadText used $-anchored regexes so a leading think block + visible answer extracted nothing and the Thinking card never rendered. Removed the 3 $ anchors to match the (non-anchored) display stripper. Live: inline-think thinking-only turn now renders. 2. (CORE) reconnect/reload duplicated the live reply — _rememberRunJournalCursor advanced a closure-local seq but never wrote INFLIGHT[activeSid].lastRunJournalSeq, so a reload replayed the journal from after_seq=0 over restored lastAssistantText. Now mirrors the cursor onto INFLIGHT + schedules a throttled persist. 3. Neon skin silently broke — PR deleted the :root[data-skin="neon"] CSS but left Neon in the picker. Restored the neon CSS block from master. 4. Settled tool-worklog rebuild gated purely on !S.busy — dropped every prior settled turn's worklog when renderMessages re-ran during an active stream (switch-back to an in-progress session). Restored master's !S.busy || (S.toolCalls && S.toolCalls.length). Live: busy re-render now preserves tool cards (4→4, was 4→0). Live-verified all 4 + confirmed #3709/#3592 invariants still hold (1 thinking card, none below footer; distinct siblings preserved). + tests/test_issue3401_deep_review_fixes.py (7). * test(#3401): realign 3 stale source-shape assertions to the deep-review fixes Fix commit changed two source literals that existing stage tests scanned for: - test_live_activity_timeline.py (x2): split anchor 'if(!S.busy){' → the restored 'if(!S.busy || (S.toolCalls&&S.toolCalls.length)){' guard (fix 4). - test_run_journal_frontend_static.py: 'after_seq=0' not in source — fix 2's comment contained that literal; rephrased the comment to 'the zero floor (after_seq of 0)'. Intent of all three assertions unchanged; only the matched string updated. No code behavior change. * docs(changelog): v0.51.294 — Release JJ (stage-3401, #3401 live-to-final redesign) --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: Nathan-Hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 19:12:37 +00:00
thinking_helper = MESSAGES_SRC[
MESSAGES_SRC.index("function _updateLiveThinkingCard") :
MESSAGES_SRC.index("// Split a content string", MESSAGES_SRC.index("function _updateLiveThinkingCard"))
]
assert "_updateLiveThinkingCard(" in wire_block, "reasoning replay should use the live Thinking card path"
assert "updateThinking(text, opts)" in thinking_helper and "appendThinking(text, opts)" in thinking_helper, (
"the shared Thinking helper should still route replay/live reasoning into the Worklog Thinking card path"
)
assert "appendLiveToolCard(tc" in wire_block, "tool replay should use live tool-card rendering"
# Compression replay must dispatch through setCompressionUi(...). The handler
# body may build the state object inline (`setCompressionUi({...})`) or hoist
# it into a `state` variable first (`setCompressionUi(state)`) — both forms
# use the same compression-card path, so accept either. Pinning the literal
# `{` after the open-paren was over-specific and broke in v0.51.76 when
# PR #2347 hoisted the state object to share it with `appendLiveCompressionCard`.
assert ("setCompressionUi({" in wire_block) or ("setCompressionUi(state)" in wire_block), (
"compression replay should use the compression card path"
)
assert "_runJournalReplayParams()" in MESSAGES_SRC, "replay attachments should enter _wireSSE via EventSource"
def test_run_journal_cursor_tracks_every_long_task_timeline_event():
"""Every user-visible long-task event needs cursor tracking for parity replay."""
cursor_loop_pos = MESSAGES_SRC.index("for(const _runJournalEventName of [")
2026-05-16 12:05:19 +00:00
cursor_loop = MESSAGES_SRC[cursor_loop_pos : MESSAGES_SRC.index("]", cursor_loop_pos)]
timeline_events = [
"token",
"interim_assistant",
"reasoning",
"tool",
"tool_complete",
"compressing",
"compressed",
"metering",
"done",
"apperror",
"cancel",
]
for event_name in timeline_events:
assert f"'{event_name}'" in cursor_loop, (
f"{event_name} must advance the replay cursor to avoid duplicate timeline replay"
)
def test_server_runtime_journal_snapshot_restores_structured_inflight_state():
helper_pos = SESSIONS_SRC.index("function _serverLiveSnapshotToolId")
helper_block = SESSIONS_SRC[helper_pos : helper_pos + 3600]
load_pos = SESSIONS_SRC.index("async function loadSession")
load_end = SESSIONS_SRC.index("// ── Handoff hint logic", load_pos)
load_block = SESSIONS_SRC[load_pos:load_end]
assert "runtime_journal_snapshot" in load_block
assert "_serverLiveSnapshotInflight(S.session.runtime_journal_snapshot" in load_block
assert "!_inflightHasVisibleLiveState(INFLIGHT[sid])" in load_block
assert "journalSnapshot:true" in helper_block
assert "lastRunJournalSeq" in helper_block
assert "last_assistant_text" in helper_block
assert "activity_burst_anchors" in helper_block
for key in ("tid", "id", "tool_call_id", "tool_use_id", "call_id"):
assert key in helper_block
2026-06-24 07:22:56 +00:00
def test_active_reload_keeps_user_only_inflight_visible_until_pending_dedupe():
"""A just-submitted user row is visible live state before first assistant text.
On an active first-turn reload, the sidecar can still have messages=[] while
pending_user_message and the submitted turn journal record the same prompt.
The browser must not discard the user-only optimistic INFLIGHT entry as a
cursor-only snapshot before pending/live replay reconciliation runs.
"""
2026-06-24 08:31:45 +00:00
result = _run_session_identity_probe()
2026-06-24 07:22:56 +00:00
2026-06-24 08:31:45 +00:00
assert result["userOnlyInflightVisible"] is True
assert result["emptyUserOnlyInflightNotVisible"] is True
2026-06-24 07:22:56 +00:00
2026-06-24 08:31:45 +00:00
def test_pending_user_merge_dedupes_user_turn_variants_by_behavior():
2026-06-24 07:22:56 +00:00
"""Pending user rows and replayed/checkpointed user rows share one turn.
2026-06-24 08:31:45 +00:00
Execute the same JavaScript helpers the browser uses so the regression test
catches regex/order/trim mistakes, not just identifier wiring.
2026-06-24 07:22:56 +00:00
"""
2026-06-24 08:31:45 +00:00
result = _run_session_identity_probe()
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
2026-06-24 07:22:56 +00:00
def test_user_turn_dedupe_is_scoped_to_current_turn_by_behavior():
result = _run_current_turn_scope_probe()
assert result["insertedAfterHistory"] is True
assert result["pendingAfterHistoryRoles"] == ["user", "assistant", "user"]
assert result["insertedBeforeLive"] is True
assert result["pendingBeforeLiveRoles"] == ["user", "assistant", "user", "assistant"]
assert result["insertedWithCurrent"] is False
assert result["pendingWithCurrentRoles"] == ["user", "assistant", "user", "assistant"]
assert result["inflightAfterHistoryRoles"] == ["user", "assistant", "user", "assistant"]
assert result["inflightWithCurrentRoles"] == ["user", "assistant", "user", "assistant"]
def test_get_pending_session_message_keeps_deferred_repeat_prompt_by_behavior():
"""Deferred active reload must not hide a current repeat prompt.
In the default deferred save mode, chat start persists only
pending_user_message before the worker appends the display row. If an older
turn has the same visible user text, getPendingSessionMessage still has to
return the current pending row; downstream merge code then dedupes only
against the current tail.
"""
result = _run_pending_session_message_probe()
assert result["historicalSameTextSurvives"] is True
assert result["historicalWorkspaceSurvives"] is True
assert result["exactCurrentTailDedupe"] is True
assert result["exactCurrentTailAttachmentsCopied"] is True
assert result["workspaceCurrentTailDedupe"] is True
assert result["liveAfterCurrentTailDedupe"] is True
assert result["differentCurrentTailSurvives"] is True
def test_live_tool_matching_uses_the_same_aliases_as_live_card_dedup():
live_tid_pos = MESSAGES_SRC.index("function _liveToolTid")
live_tid_block = MESSAGES_SRC[live_tid_pos : live_tid_pos + 450]
find_pos = MESSAGES_SRC.index("function _findPendingLiveToolCallIndex")
find_block = MESSAGES_SRC[find_pos : find_pos + 900]
upsert_pos = MESSAGES_SRC.index("function upsertLiveToolCall")
upsert_block = MESSAGES_SRC[upsert_pos : upsert_pos + 600]
for key in ("tid", "id", "tool_call_id", "tool_use_id", "call_id"):
assert key in live_tid_block
assert key in find_block
assert key in upsert_block