diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index 96d9ff3b..b3329a25 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -78,7 +78,10 @@ while WebUI still has multiple overlapping state stores. assistant just acted. 5. **Replay is idempotent.** Replaying a run from a cursor must not duplicate transcript rows, thinking content, interim assistant text, tool cards, or - compression cards. + compression cards. Replayed long-task events should enter the same + browser-facing timeline renderer as live SSE events so recovery does not + downgrade a structured Thinking / progress / tool / compression turn into a + separate flattened presentation. 6. **Compression is not current intent.** Automatic compression summaries and reference cards are recovery/handoff material. They must not be treated as a new user request, active-turn content, or the default visible explanation for @@ -102,6 +105,8 @@ context reconstruction, or session metadata: - What happens after browser refresh, session switch, SSE reconnect, and WebUI restart? - Does replay rebuild the same scene without duplicates? +- Does replay use the same timeline-rendering path as live SSE for thinking, + interim assistant text, tool cards, compression cards, and terminal states? - Can this change move a session in the sidebar without meaningful user or assistant activity? - Can automatic compression or recovery text become visible active-turn content? @@ -147,4 +152,3 @@ The two documents should be read together: 4. If #1925 introduces a new adapter-backed runtime layer, update this RFC or replace it with the accepted implementation contract so these invariants do not live only in historical discussion. - diff --git a/tests/test_run_journal_frontend_static.py b/tests/test_run_journal_frontend_static.py index aaee3048..773b6618 100644 --- a/tests/test_run_journal_frontend_static.py +++ b/tests/test_run_journal_frontend_static.py @@ -35,3 +35,60 @@ def test_frontend_replay_cursor_uses_eventsource_last_event_id(): assert "source.addEventListener(_runJournalEventName,_rememberRunJournalCursor)" in MESSAGES_SRC 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" + ) + + assert "updateThinking(" in wire_block, "reasoning replay should use the live Thinking card path" + assert "appendLiveToolCard(tc)" in wire_block, "tool replay should use live tool-card rendering" + assert "setCompressionUi({" 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 [") + 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" + )