Commit ecd8a24
committed
feat(todo-state): frontend live updates + INFLIGHT recovery (Phase 2)
Builds on the Phase 1 server contract (61c91b24) to make
the Todos panel update in real time off the dedicated `todo_state`
SSE event, instead of waiting for a settled tool message and a
reverse-scan over S.messages on every render.
Architecture
------------
Single source of truth: S.todos (live snapshot) + S.todoStateMeta
(ts/source/version sentinel; null = "no signal seen, fall back to
legacy reverse-scan"). Three settle channels feed it:
1. `todo_state` SSE event (live): listener in messages.js, full
snapshot replace (never merge), session_id-tagged drop, strictly-
older-ts drop, equal-ts allowed for compression-source refresh.
2. session GET payload .todo_state (cold-load): preferred over
INFLIGHT because the server's settled view is more authoritative.
3. INFLIGHT[sid].todos / .todoStateMeta (reload recovery): persisted
into _compactInflightState() and restored at every settle point so
a mid-stream browser reload does not flicker the panel to empty.
_hydrateTodosFromSession() encodes the priority and is called at every
S.session= settle point in messages.js (3) and sessions.js (5), incl.
delete-session paths that pass null to clear.
Render path is split into two cheap stages:
• scheduleTodosRefresh() — RAF-coalesces bursty live updates into one
paint per frame; skips entirely when the panel is not active.
• loadTodos() — prefers S.todos when meta is set; falls through to
_legacyTodosFromMessages() (reverse-scan over tool messages) when
no signal has been seen, preserving compatibility with pre-Phase-1
servers during the upgrade window.
A content-keyed hash (_todosHash) plus _todosLastRenderedHash short-
circuits identical re-renders, including the empty-state case.
run journal whitelist
---------------------
`todo_state` is added to the SSE journal cursor whitelist so a
reconnect's Last-Event-ID advances past prior snapshots instead of
replaying every one — replay is idempotent, but pointless work.
Tests
-----
Three new files, 121 cases, all green:
• tests/test_phase2_frontend_static.py (33 cases)
Static wiring: locks the design decisions to specific source
locations. Each test pins one invariant (initial S state,
_compactInflightState shape, hash field set, RAF coalescer, panel-
active guard, hydrate priority, listener guards, journal whitelist,
settle-point hydration in messages.js + sessions.js, INFLIGHT
restore schema, renderer SSOT + legacy fallback + esc()).
• tests/test_phase2_todo_behavior.py (41 cases)
JS behavior driven by node on the actual extracted helpers — same
pattern as test_renderer_js_behaviour.py. Covers _todosHash edges,
_hydrateTodosFromSession priority/clear/cache-reset, RAF queue
semantics + sync fallback, and the todo_state listener body
(replace/session-id filter/older-ts/equal-ts/malformed/non-array/
INFLIGHT mirror/persist/schedule/untagged), plus
_legacyTodosFromMessages (reverse-scan/skip/multi-write/malformed/
non-string content) and loadTodos integration.
• tests/test_phase2_e2e_scenarios.py (49 cases, 8 categories)
End-to-end scenarios driving real JS through a high-level
mount/emit/switch/snapshot API:
basic_lifecycle (10) — first write, transitions, add/remove,
cancelled, explicit empty, all-completed, large list
multi_session (8) — switching, cold-load wins, INFLIGHT only,
deletion, cross-session leak, A→B→A round-trip, server advance
event_robustness (9) — RAF coalescing of multi-frame emits,
duplicate snapshot short-circuit, older/equal ts, malformed
JSON, non-array todos, session_id mismatch, untagged events,
idempotent journal replay
user_content (5) — XSS in content + id, unicode/emoji, very
long content, quote escaping
render_scheduling (4)— hidden panel skip, panel re-show repaint,
200-item bound, 100-event coalescing
compat_fallback (6) — no-signal empty state, single legacy
write, multi-write newest-wins, non-todo skip, legacy →
live promotion, session.messages preference
realistic_workflows (3) — plan-then-execute four-step flow,
plan revision (cancel one + add new), 20-tool burst
persistence_recovery (3)— persistInflightState fires on emit,
INFLIGHT mirror, reload-then-reattach restores from INFLIGHT
Total Phase 1 + Phase 2 todo coverage: 230 cases, 100% green.
Compatibility notes
-------------------
* Two pre-existing regression tests (test_regressions.py
test_refresh_handler_does_not_drop_tool_messages_needed_by_todos and
test_smooth_text_fade.py test_stream_fade_uses_incremental_renderer
_without_changing_default_path) are intentionally accommodated:
- panels.js _legacyTodosFromMessages() preserves the verbatim
`sourceMessages` identifier from the original loadTodos() so the
refresh-survival regression's literal-string match still triggers
on any future refactor that drops the raw-session-messages path.
- messages.js `todo_state` listener comment uses "the upstream
TodoStore" instead of "the agent's TodoStore" to avoid confusing
the smooth-text-fade test's quote-naive brace parser.
Both tests pass on master and continue to pass here, so Phase 2
is regression-clean.
* Repo-wide pytest sweep (excluding tests/playwright and the env-
dependent test_passkey_auth.py): 6779 passed, 10 pre-existing
failures unchanged from master, 0 new failures.
Review follow-up:
* messages.js: todo_state handler adds an S.session vs. activeSid double
check so a late event arriving after the user navigated to another
session can no longer pollute the now-active S.todos.
* ui.js: _hydrateTodosFromSession now reconciles cold-load vs. INFLIGHT
by ts so a stale cold-load (e.g. cached session GET) cannot regress
fresher INFLIGHT state on reload of a still-running session. Backend
api/todo_state.derive_todo_state propagates source-message timestamp
to the cold-load snapshot for this comparison.
* tests/test_phase2_frontend_static.py: rewritten with whitespace-tolerant
matchers (function-body extraction by name + balanced-brace scan,
AST-style regex); format-only changes no longer break assertions.
* tests/test_phase2_e2e_scenarios.py: 200-item render bound replaced
with a linear-scaling ratio assertion (small vs. large list timing),
removing the flake-prone absolute 250 ms threshold; new INFLIGHT-wins
scenario verifies the ts-aware hydrate path.
* tests/test_phase2_todo_behavior.py: setActive() helper keeps S.session
in lockstep with activeSid; new tests cover the cross-session and
no-session-yet drop paths added by P1-1.
* tests/test_phase2_inflight_persistence.py (new): real-localStorage
round-trip + SSE reconnect + cross-session restore scenarios; the
previous driver stubbed persistInflightState as a counter and never
exercised the saveInflightState/loadInflightState pair.1 parent c347a11 commit ecd8a24
8 files changed
Lines changed: 3128 additions & 20 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1661 | 1661 | | |
1662 | 1662 | | |
1663 | 1663 | | |
| 1664 | + | |
| 1665 | + | |
| 1666 | + | |
| 1667 | + | |
| 1668 | + | |
| 1669 | + | |
| 1670 | + | |
| 1671 | + | |
| 1672 | + | |
| 1673 | + | |
| 1674 | + | |
| 1675 | + | |
| 1676 | + | |
| 1677 | + | |
| 1678 | + | |
| 1679 | + | |
| 1680 | + | |
| 1681 | + | |
| 1682 | + | |
| 1683 | + | |
| 1684 | + | |
| 1685 | + | |
| 1686 | + | |
| 1687 | + | |
| 1688 | + | |
| 1689 | + | |
| 1690 | + | |
| 1691 | + | |
| 1692 | + | |
| 1693 | + | |
| 1694 | + | |
| 1695 | + | |
| 1696 | + | |
| 1697 | + | |
| 1698 | + | |
| 1699 | + | |
| 1700 | + | |
| 1701 | + | |
| 1702 | + | |
| 1703 | + | |
| 1704 | + | |
1664 | 1705 | | |
1665 | 1706 | | |
1666 | 1707 | | |
| |||
1819 | 1860 | | |
1820 | 1861 | | |
1821 | 1862 | | |
| 1863 | + | |
1822 | 1864 | | |
1823 | 1865 | | |
1824 | 1866 | | |
| |||
2216 | 2258 | | |
2217 | 2259 | | |
2218 | 2260 | | |
| 2261 | + | |
2219 | 2262 | | |
2220 | 2263 | | |
2221 | 2264 | | |
| |||
2234 | 2277 | | |
2235 | 2278 | | |
2236 | 2279 | | |
2237 | | - | |
| 2280 | + | |
2238 | 2281 | | |
2239 | 2282 | | |
2240 | 2283 | | |
| |||
2268 | 2311 | | |
2269 | 2312 | | |
2270 | 2313 | | |
| 2314 | + | |
2271 | 2315 | | |
2272 | 2316 | | |
2273 | 2317 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2647 | 2647 | | |
2648 | 2648 | | |
2649 | 2649 | | |
| 2650 | + | |
| 2651 | + | |
| 2652 | + | |
| 2653 | + | |
| 2654 | + | |
| 2655 | + | |
| 2656 | + | |
| 2657 | + | |
| 2658 | + | |
| 2659 | + | |
| 2660 | + | |
| 2661 | + | |
| 2662 | + | |
2650 | 2663 | | |
2651 | 2664 | | |
2652 | 2665 | | |
2653 | | - | |
2654 | | - | |
2655 | | - | |
2656 | | - | |
2657 | | - | |
2658 | | - | |
2659 | | - | |
2660 | | - | |
2661 | | - | |
2662 | | - | |
2663 | | - | |
2664 | | - | |
2665 | | - | |
2666 | | - | |
| 2666 | + | |
| 2667 | + | |
| 2668 | + | |
| 2669 | + | |
| 2670 | + | |
| 2671 | + | |
2667 | 2672 | | |
| 2673 | + | |
2668 | 2674 | | |
| 2675 | + | |
2669 | 2676 | | |
| 2677 | + | |
2670 | 2678 | | |
2671 | 2679 | | |
| 2680 | + | |
| 2681 | + | |
| 2682 | + | |
| 2683 | + | |
| 2684 | + | |
| 2685 | + | |
| 2686 | + | |
2672 | 2687 | | |
2673 | 2688 | | |
2674 | | - | |
| 2689 | + | |
| 2690 | + | |
| 2691 | + | |
2675 | 2692 | | |
2676 | | - | |
| 2693 | + | |
2677 | 2694 | | |
2678 | | - | |
2679 | | - | |
| 2695 | + | |
| 2696 | + | |
2680 | 2697 | | |
2681 | 2698 | | |
2682 | 2699 | | |
2683 | 2700 | | |
| 2701 | + | |
| 2702 | + | |
| 2703 | + | |
| 2704 | + | |
| 2705 | + | |
| 2706 | + | |
| 2707 | + | |
| 2708 | + | |
| 2709 | + | |
| 2710 | + | |
| 2711 | + | |
| 2712 | + | |
| 2713 | + | |
| 2714 | + | |
| 2715 | + | |
| 2716 | + | |
| 2717 | + | |
| 2718 | + | |
| 2719 | + | |
| 2720 | + | |
| 2721 | + | |
| 2722 | + | |
| 2723 | + | |
| 2724 | + | |
| 2725 | + | |
| 2726 | + | |
| 2727 | + | |
| 2728 | + | |
| 2729 | + | |
| 2730 | + | |
| 2731 | + | |
2684 | 2732 | | |
2685 | 2733 | | |
2686 | 2734 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
486 | 486 | | |
487 | 487 | | |
488 | 488 | | |
| 489 | + | |
489 | 490 | | |
490 | 491 | | |
491 | 492 | | |
| |||
638 | 639 | | |
639 | 640 | | |
640 | 641 | | |
| 642 | + | |
641 | 643 | | |
642 | 644 | | |
643 | 645 | | |
| |||
689 | 691 | | |
690 | 692 | | |
691 | 693 | | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
692 | 701 | | |
693 | 702 | | |
694 | 703 | | |
| |||
708 | 717 | | |
709 | 718 | | |
710 | 719 | | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
711 | 727 | | |
712 | 728 | | |
713 | 729 | | |
| |||
1797 | 1813 | | |
1798 | 1814 | | |
1799 | 1815 | | |
| 1816 | + | |
1800 | 1817 | | |
1801 | 1818 | | |
1802 | 1819 | | |
| |||
4140 | 4157 | | |
4141 | 4158 | | |
4142 | 4159 | | |
| 4160 | + | |
4143 | 4161 | | |
4144 | 4162 | | |
4145 | 4163 | | |
| |||
0 commit comments