diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..79d0f0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **PR TBD** by @franksong2702 (refs #2376, refs #1925, refs #2283, refs #2361, refs #2363) — Adds a focused run-journal replay parity check for long tool-heavy turns. The frontend regression coverage now pins that replayed `reasoning`, `interim_assistant`, `tool`, `tool_complete`, `compressing`, `compressed`, `metering`, and terminal events enter the same EventSource timeline handlers as live streaming, and that each user-visible long-task event advances the replay cursor to avoid duplicate replay. The run-state consistency RFC now calls out live/replay timeline parity explicitly. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added 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..02d1b1e5 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 : cursor_loop_pos + 700] + 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" + )