Release v0.51.261 — Release IC (stage-r11) (#3616)
## Release v0.51.261 — Release IC (stage-r11) Live Todos panel via an explicit `todo_state` SSE contract. ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3373 follow-up (#3454) | @v2psv | The Todos side panel now tracks `todo` tool state **live during an active run** instead of staying stale until settle / rolling back on a mid-stream reload. A dedicated `todo_state` SSE event sends a full, redacted, idempotent snapshot on todo-tool completion (no more truncated `tool_complete.preview`); the same `api.todo_state` parser feeds live + cold-load; live snapshots persist into INFLIGHT so reload/reattach restores the panel; cold-load vs INFLIGHT reconciled by timestamp (incl. the `coldTs===0` compressed-session edge); legacy reverse-scan kept as fallback for old servers. | ### Gate - Full pytest suite: **7692 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): **SAFE TO SHIP** — verified the new `todo_state` SSE handler composes with existing dispatch (no double-subscribe), INFLIGHT persistence is cleared on terminal/cancel (composes with discard_session + turn-journal), timestamp reconciliation can't let a stale local snapshot win, redaction holds, the legacy reverse-scan fallback still works with no double-render, and the `models.py` change is todo-scoped (no CLI-classification interaction). Co-authored-by: v2psv <v2psv@users.noreply.github.com>
This commit is contained in:
1 parent
ba987040c7
commit
6703978c60
12 files changed
+863
-69
No files matched your search
@@ -3,6 +3,11 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [v0.51.261] — 2026-06-04 — Release IC (stage-r11 — live Todos panel via a todo_state SSE contract)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The Todos side panel now tracks `todo` tool state live during an active run**, instead of staying stale until the turn settled and occasionally rolling back on a reload/SSE-reattach mid-stream. A dedicated `todo_state` SSE event emits a full, redacted, idempotent todo snapshot when the `todo` tool completes (no longer relying on the truncated `tool_complete.preview`); the same `api.todo_state` parser feeds both live emission and cold-load hydration; live snapshots persist into INFLIGHT so reload/reattach restores the panel before the next event; and cold-load vs INFLIGHT snapshots are reconciled by timestamp (including the `coldTs===0` compressed-session edge). The legacy reverse-scan fallback is kept for old servers / upgrade windows. (#3373 follow-up, @v2psv)
|
||||||
|
|
||||||
## [v0.51.260] — 2026-06-04 — Release IB (stage-r8 — un-held safety fixes + cron/TTS/mcp/state-toast batch)
|
## [v0.51.260] — 2026-06-04 — Release IB (stage-r8 — un-held safety fixes + cron/TTS/mcp/state-toast batch)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -137,6 +137,14 @@ def _persisted_session_ids_snapshot() -> frozenset[str]:
|
|||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _session_dir_has_persisted_session_files() -> bool:
|
||||||
|
"""Return True when the current session dir has at least one session JSON file."""
|
||||||
|
try:
|
||||||
|
return any(not p.name.startswith('_') for p in SESSION_DIR.glob('*.json'))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _rebuild_session_index_background() -> None:
|
def _rebuild_session_index_background() -> None:
|
||||||
try:
|
try:
|
||||||
_write_session_index(updates=None)
|
_write_session_index(updates=None)
|
||||||
@@ -2825,6 +2833,8 @@ def all_sessions(diag=None):
|
|||||||
with LOCK:
|
with LOCK:
|
||||||
in_memory_ids = set(SESSIONS.keys())
|
in_memory_ids = set(SESSIONS.keys())
|
||||||
persisted_ids = _persisted_session_ids_snapshot()
|
persisted_ids = _persisted_session_ids_snapshot()
|
||||||
|
if not index and _session_dir_has_persisted_session_files():
|
||||||
|
raise ValueError("empty session index while session files exist")
|
||||||
index = [
|
index = [
|
||||||
s for s in index
|
s for s in index
|
||||||
if (
|
if (
|
||||||
@@ -2839,6 +2849,8 @@ def all_sessions(diag=None):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
if not index and _session_dir_has_persisted_session_files():
|
||||||
|
raise ValueError("session index has no live rows while session files exist")
|
||||||
backfilled = []
|
backfilled = []
|
||||||
for i, s in enumerate(index):
|
for i, s in enumerate(index):
|
||||||
if 'last_message_at' not in s:
|
if 'last_message_at' not in s:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from api.helpers import redact_session_data, _redact_text
|
|||||||
from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor
|
from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor
|
||||||
from api.metering import meter
|
from api.metering import meter
|
||||||
from api.run_journal import RunJournalWriter
|
from api.run_journal import RunJournalWriter
|
||||||
|
from api.todo_state import emit_todo_state
|
||||||
from api.turn_journal import append_turn_journal_event_for_stream
|
from api.turn_journal import append_turn_journal_event_for_stream
|
||||||
from api.usage import prompt_cache_hit_percent
|
from api.usage import prompt_cache_hit_percent
|
||||||
from api.models import (
|
from api.models import (
|
||||||
@@ -4996,6 +4997,35 @@ def _run_agent_streaming(
|
|||||||
'duration': cb_kwargs.get('duration'),
|
'duration': cb_kwargs.get('duration'),
|
||||||
'is_error': bool(cb_kwargs.get('is_error', False)),
|
'is_error': bool(cb_kwargs.get('is_error', False)),
|
||||||
})
|
})
|
||||||
|
# Mirror the todo tool's in-memory state into a
|
||||||
|
# dedicated SSE event so the Todos panel can update
|
||||||
|
# in real-time without waiting for the turn to
|
||||||
|
# settle. The helper guards on name=='todo', sends
|
||||||
|
# the full snapshot (idempotent under SSE replay)
|
||||||
|
# and swallows internal errors so emission never
|
||||||
|
# breaks tool delivery. Prefer the structured
|
||||||
|
# `result` kwarg from modern Hermes builds; fall
|
||||||
|
# back to the truncated `preview` only when the
|
||||||
|
# callback was invoked without one (older builds).
|
||||||
|
#
|
||||||
|
# Graceful degradation on old builds: `preview` is a
|
||||||
|
# truncated snippet, so its JSON is usually unparseable.
|
||||||
|
# parse_todo_tool_result() then returns None and NO
|
||||||
|
# todo_state event is emitted — live panel updates are
|
||||||
|
# silently unavailable on pre-`result` builds. This is
|
||||||
|
# intended: the panel still hydrates via cold-load on the
|
||||||
|
# next session GET; it just won't update mid-stream.
|
||||||
|
emit_todo_state(
|
||||||
|
put,
|
||||||
|
name=name,
|
||||||
|
function_result=(
|
||||||
|
cb_kwargs.get('result')
|
||||||
|
if cb_kwargs.get('result') is not None
|
||||||
|
else preview
|
||||||
|
),
|
||||||
|
session_id=session_id,
|
||||||
|
stream_id=stream_id,
|
||||||
|
)
|
||||||
_tool_stats = meter().get_stats()
|
_tool_stats = meter().get_stats()
|
||||||
_tool_stats['session_id'] = session_id
|
_tool_stats['session_id'] = session_id
|
||||||
_tool_stats['usage'] = _live_usage_snapshot()
|
_tool_stats['usage'] = _live_usage_snapshot()
|
||||||
@@ -5064,6 +5094,20 @@ def _run_agent_streaming(
|
|||||||
'tid': tool_call_id,
|
'tid': tool_call_id,
|
||||||
'is_error': False,
|
'is_error': False,
|
||||||
})
|
})
|
||||||
|
# Mirror the todo tool's in-memory state into
|
||||||
|
# a dedicated SSE event so the Todos panel can
|
||||||
|
# update in real-time without waiting for the
|
||||||
|
# turn to settle. See the legacy path above
|
||||||
|
# for the contract; the helper handles the
|
||||||
|
# name guard, payload shape, and swallow-all
|
||||||
|
# error policy.
|
||||||
|
emit_todo_state(
|
||||||
|
put,
|
||||||
|
name=name,
|
||||||
|
function_result=function_result,
|
||||||
|
session_id=session_id,
|
||||||
|
stream_id=stream_id,
|
||||||
|
)
|
||||||
_tool_stats = meter().get_stats()
|
_tool_stats = meter().get_stats()
|
||||||
_tool_stats['session_id'] = session_id
|
_tool_stats['session_id'] = session_id
|
||||||
_tool_stats['usage'] = _live_usage_snapshot()
|
_tool_stats['usage'] = _live_usage_snapshot()
|
||||||
|
|||||||
+246
-48
@@ -1,30 +1,91 @@
|
|||||||
"""Derive settled todo snapshots for session GET responses.
|
"""Derive ``todo_state`` snapshots from tool results and settled session messages.
|
||||||
|
|
||||||
The browser's Todos panel currently reconstructs state by reverse-scanning
|
The ``todo`` tool's in-memory store lives on the per-session AIAgent. The
|
||||||
loaded tool messages. That works only when the latest todo tool result is inside
|
WebUI bridge needs to mirror that state to the browser in two situations:
|
||||||
the returned message window. This helper gives ``/api/session`` a compact,
|
|
||||||
explicit ``todo_state`` sidecar derived from the full settled transcript.
|
|
||||||
|
|
||||||
The detector intentionally mirrors ``run_agent.AIAgent._hydrate_todo_store``:
|
1. **Live**: when the agent calls ``todo`` mid-stream, ``api.streaming``
|
||||||
walk messages newest-first and use the first tool message whose JSON content has
|
emits a dedicated ``todo_state`` SSE event so the Todos panel updates
|
||||||
a ``todos`` list. Empty ``todos`` is a valid snapshot so a cleared task list does
|
without waiting for the turn to finish. See :func:`emit_todo_state`.
|
||||||
not fall through to an older non-empty write.
|
|
||||||
|
2. **Cold-load**: when the browser opens a session (no live stream), the
|
||||||
|
session GET handler attaches ``todo_state`` derived from the most
|
||||||
|
recent ``role='tool'`` message whose JSON content carries a ``todos``
|
||||||
|
list. See :func:`attach_todo_state`.
|
||||||
|
|
||||||
|
Both paths normalize through :func:`_normalize_snapshot` so the frontend
|
||||||
|
has a single deserialization contract:
|
||||||
|
|
||||||
|
{
|
||||||
|
"todos": [{"id": ..., "content": ..., "status": ...}, ...],
|
||||||
|
"summary": {"total": N, "pending": N, "in_progress": N,
|
||||||
|
"completed": N, "cancelled": N},
|
||||||
|
"version": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
Live SSE payloads add ``session_id``, ``stream_id``, ``source`` and ``ts``
|
||||||
|
on top so the frontend can filter cross-session events and ignore
|
||||||
|
out-of-order replays.
|
||||||
|
|
||||||
|
**Detection symmetry with the agent.** The cold-load helper deliberately
|
||||||
|
uses the same loose detector as ``run_agent.AIAgent._hydrate_todo_store``
|
||||||
|
(``role='tool'`` + JSON content with ``todos: list``). If a future change
|
||||||
|
tightens or relaxes that detector, mirror it here so the WebUI panel
|
||||||
|
never disagrees with the agent's in-memory ``TodoStore``.
|
||||||
|
|
||||||
|
**Multimodal tool results.** Some tools return content as a list of
|
||||||
|
OpenAI/Anthropic content parts rather than a JSON string. The ``todo``
|
||||||
|
tool always returns a JSON string, so list-shaped content cannot be a
|
||||||
|
todo write — :func:`derive_todo_state` skips them by design.
|
||||||
|
|
||||||
|
This module is **side-effect free** by design — it only parses data and
|
||||||
|
calls a caller-supplied ``put`` callable for SSE. Routing/event-shape
|
||||||
|
decisions live here so the call sites stay one-liners.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Iterable, Optional, Sequence
|
import time
|
||||||
|
from typing import Any, Callable, Iterable, Optional, Sequence
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Bumped when the on-wire payload shape changes in a non-additive way.
|
||||||
|
# Additive fields (e.g. timestamps, tags) keep VERSION at 1.
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
|
# Single source of truth for the SSE event name and the session GET
|
||||||
|
# payload key. Any current or future caller must reuse these so a
|
||||||
|
# rename only happens in one place.
|
||||||
|
EVENT_NAME = "todo_state"
|
||||||
PAYLOAD_KEY = "todo_state"
|
PAYLOAD_KEY = "todo_state"
|
||||||
|
|
||||||
|
|
||||||
def _normalize_snapshot(data: Any) -> Optional[dict]:
|
def _normalize_snapshot(data: Any) -> Optional[dict]:
|
||||||
"""Return the canonical todo snapshot shape, or ``None`` for non-todo data."""
|
"""Return a normalized snapshot dict, or ``None`` if the payload is invalid.
|
||||||
|
|
||||||
|
Accepts the canonical ``{"todos": [...], "summary": {...}}`` shape
|
||||||
|
produced by ``tools.todo_tool.todo_tool``. Anything else returns
|
||||||
|
``None`` so callers can fall through to legacy paths or skip
|
||||||
|
emission.
|
||||||
|
|
||||||
|
The detector is intentionally loose so it stays symmetric with the
|
||||||
|
agent's hydration logic — see the module docstring.
|
||||||
|
|
||||||
|
**Empty list is a valid snapshot.** ``todos == []`` returns a normal
|
||||||
|
snapshot (not ``None``), so the latest write wins even when it cleared
|
||||||
|
the list. This is deliberately symmetric with the agent: its
|
||||||
|
``_hydrate_todo_store`` (run_agent.py) breaks at the most-recent todo
|
||||||
|
message and, because ``if last_todo_response:`` is falsy for ``[]``,
|
||||||
|
leaves its TodoStore empty — i.e. agent shows empty, panel shows empty.
|
||||||
|
Do NOT reintroduce a ``len(todos) > 0`` guard here or in the frontend
|
||||||
|
fallback (``_legacyTodosFromMessages``): that was the pre-Phase-2
|
||||||
|
behavior that kept scanning past an empty write to an older non-empty
|
||||||
|
list, diverging from the agent and showing a stale "cleared" list.
|
||||||
|
"""
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return None
|
return None
|
||||||
todos = data.get("todos")
|
todos = data.get("todos")
|
||||||
@@ -41,16 +102,86 @@ def _normalize_snapshot(data: Any) -> Optional[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def parse_todo_tool_result(function_result: Any) -> Optional[dict]:
|
def parse_todo_tool_result(function_result: Any) -> Optional[dict]:
|
||||||
"""Parse a todo tool result JSON string or pre-parsed dict into a snapshot."""
|
"""Parse a fresh ``todo`` tool call result into a snapshot dict.
|
||||||
|
|
||||||
|
The agent's ``todo`` handler returns a JSON string; this helper
|
||||||
|
accepts either that string or an already-parsed dict (defensive —
|
||||||
|
future callers may deserialize earlier in the pipeline).
|
||||||
|
|
||||||
|
Returns ``None`` on any parse/shape failure so the caller can
|
||||||
|
swallow the error without breaking the tool delivery path.
|
||||||
|
"""
|
||||||
data: Any = function_result
|
data: Any = function_result
|
||||||
if isinstance(function_result, str):
|
if isinstance(function_result, str):
|
||||||
try:
|
try:
|
||||||
data = json.loads(function_result)
|
data = json.loads(function_result)
|
||||||
except (TypeError, ValueError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
return _normalize_snapshot(data)
|
return _normalize_snapshot(data)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_todo_state(messages: Optional[Iterable[dict]]) -> Optional[dict]:
|
||||||
|
"""Derive the latest todo snapshot from settled conversation history.
|
||||||
|
|
||||||
|
Mirrors the agent-side ``_hydrate_todo_store`` logic: walk messages
|
||||||
|
in reverse, return the first ``role='tool'`` message whose JSON
|
||||||
|
content carries a ``todos`` list. Returns ``None`` when no such
|
||||||
|
message is found (fresh session, or a session that never invoked
|
||||||
|
``todo``).
|
||||||
|
|
||||||
|
Multimodal tool results — ``content`` as a list of content parts
|
||||||
|
rather than a JSON string — are skipped intentionally. The ``todo``
|
||||||
|
tool always returns a string, so list-shaped content cannot be a
|
||||||
|
todo write; non-string ``content`` is therefore correct to ignore.
|
||||||
|
|
||||||
|
The fast-path string check (``'"todos"' in content``) avoids parsing
|
||||||
|
JSON for every tool result — most sessions have many non-todo tool
|
||||||
|
calls but at most a handful of todo writes.
|
||||||
|
"""
|
||||||
|
if not messages:
|
||||||
|
return None
|
||||||
|
# ``reversed`` works on ``list`` and ``tuple`` natively; for any
|
||||||
|
# other iterable (e.g. a generator) we materialize once. Routes
|
||||||
|
# always pass a list, so this branch is normally a no-op.
|
||||||
|
if not isinstance(messages, (list, tuple)):
|
||||||
|
messages = list(messages)
|
||||||
|
for idx in range(len(messages) - 1, -1, -1):
|
||||||
|
msg = messages[idx]
|
||||||
|
if not isinstance(msg, dict) or msg.get("role") != "tool":
|
||||||
|
continue
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if not isinstance(content, str) or '"todos"' not in content:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(content)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
snapshot = _normalize_snapshot(data)
|
||||||
|
if snapshot is not None:
|
||||||
|
# Carry a timestamp so the frontend can reconcile cold-load
|
||||||
|
# vs. INFLIGHT snapshots by recency.
|
||||||
|
#
|
||||||
|
# Primary source: this message's own ``timestamp``. But a
|
||||||
|
# todo tool message can lose its timestamp during context
|
||||||
|
# compression/rebuild — the on-disk message ends up with
|
||||||
|
# ``timestamp=None``. If we emit a snapshot with no ``ts``,
|
||||||
|
# the frontend reads coldTs=0 and a STALE-but-timestamped
|
||||||
|
# INFLIGHT snapshot wins the recency comparison, so the panel
|
||||||
|
# renders a historical todo list. This is the latest-by-
|
||||||
|
# POSITION snapshot, so it must never lose recency to an
|
||||||
|
# earlier list. When this message has no usable timestamp,
|
||||||
|
# fall back to the max timestamp seen anywhere at or before
|
||||||
|
# this position — guaranteeing cold ts >= any earlier todo
|
||||||
|
# write's ts.
|
||||||
|
ts_val = _message_ts_float(msg.get("timestamp"))
|
||||||
|
if ts_val <= 0:
|
||||||
|
ts_val = _max_timestamp_through(messages, idx)
|
||||||
|
if ts_val > 0:
|
||||||
|
snapshot["ts"] = ts_val
|
||||||
|
return snapshot
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _message_ts_float(ts_raw: Any) -> float:
|
def _message_ts_float(ts_raw: Any) -> float:
|
||||||
"""Coerce a message ``timestamp`` field to a positive float, or 0.0."""
|
"""Coerce a message ``timestamp`` field to a positive float, or 0.0."""
|
||||||
try:
|
try:
|
||||||
@@ -59,55 +190,122 @@ def _message_ts_float(ts_raw: Any) -> float:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
def _max_timestamp_through(messages: Sequence[Any], upto_idx: int) -> float:
|
def _max_timestamp_through(messages: "Sequence[Any]", upto_idx: int) -> float:
|
||||||
"""Return the largest valid timestamp at or before ``upto_idx``."""
|
"""Largest valid ``timestamp`` among messages[0:upto_idx+1].
|
||||||
|
|
||||||
|
Used as a recency floor when the latest todo message itself lost its
|
||||||
|
timestamp during compression/rebuild. Scanning only up to the todo's
|
||||||
|
position keeps the floor causally correct — it never borrows a
|
||||||
|
timestamp from a message that came after the todo write.
|
||||||
|
"""
|
||||||
best = 0.0
|
best = 0.0
|
||||||
end = min(upto_idx, len(messages) - 1)
|
end = min(upto_idx, len(messages) - 1)
|
||||||
for i in range(end, -1, -1):
|
for i in range(end, -1, -1):
|
||||||
msg = messages[i]
|
m = messages[i]
|
||||||
if not isinstance(msg, dict):
|
if not isinstance(m, dict):
|
||||||
continue
|
continue
|
||||||
best = max(best, _message_ts_float(msg.get("timestamp")))
|
ts = _message_ts_float(m.get("timestamp"))
|
||||||
|
if ts > best:
|
||||||
|
best = ts
|
||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
def derive_todo_state(messages: Optional[Iterable[dict]]) -> Optional[dict]:
|
def _redact_snapshot(snapshot: dict) -> dict:
|
||||||
"""Derive the latest settled todo snapshot from conversation history.
|
"""Redact credential-shaped text from a todo snapshot before it leaves the process.
|
||||||
|
|
||||||
Returns ``None`` when the session has no todo writes. Malformed or
|
The live SSE path (:func:`emit_todo_state`) does NOT pass through
|
||||||
non-string tool contents are skipped so unrelated tool results never break
|
``redact_session_data`` (api/helpers.py) the way the cold-load session
|
||||||
session loading.
|
GET response does, so emission must redact the same content that path
|
||||||
|
would — otherwise the live Todos panel (and the run-journal replay that
|
||||||
|
persists every SSE event) becomes a redaction bypass for any credential
|
||||||
|
an agent wrote into a todo item's ``content``. The live event also
|
||||||
|
carries the FULL untruncated todos, a wider exposure surface than the
|
||||||
|
truncated ``preview`` the sibling ``tool``/``tool_complete`` events send.
|
||||||
|
|
||||||
|
``_redact_value`` is imported lazily to keep the dependency direction
|
||||||
|
one-way (helpers must never import todo_state) and to avoid paying the
|
||||||
|
import cost on the cold-load path, which redacts via ``redact_session_data``.
|
||||||
|
The redaction setting is read once per SSE snapshot and threaded through the
|
||||||
|
recursive helper so nested strings do not reload settings.json individually.
|
||||||
|
|
||||||
|
Returns a new, redacted snapshot. Raises on failure so the caller fails
|
||||||
|
closed (no emission) rather than leaking an unredacted payload.
|
||||||
"""
|
"""
|
||||||
if not messages:
|
from typing import cast
|
||||||
return None
|
from api.config import load_settings
|
||||||
if not isinstance(messages, (list, tuple)):
|
from api.helpers import _redact_value
|
||||||
messages = list(messages)
|
_enabled = bool(load_settings().get("api_redact_enabled", True))
|
||||||
|
# ``_redact_value`` preserves container shape (dict in → dict out); the
|
||||||
|
# cast narrows its broad recursive union back to dict for the type checker.
|
||||||
|
return cast(dict, _redact_value(snapshot, _enabled=_enabled))
|
||||||
|
|
||||||
for idx in range(len(messages) - 1, -1, -1):
|
|
||||||
msg = messages[idx]
|
def emit_todo_state(
|
||||||
if not isinstance(msg, dict) or msg.get("role") != "tool":
|
put: Callable[[str, dict], Any],
|
||||||
continue
|
*,
|
||||||
content = msg.get("content", "")
|
name: Optional[str],
|
||||||
if not isinstance(content, str) or '"todos"' not in content:
|
function_result: Any,
|
||||||
continue
|
session_id: Optional[str],
|
||||||
snapshot = parse_todo_tool_result(content)
|
stream_id: Optional[str],
|
||||||
|
source: str = "tool",
|
||||||
|
) -> bool:
|
||||||
|
"""Emit a ``todo_state`` SSE event when ``name == 'todo'``.
|
||||||
|
|
||||||
|
Returns ``True`` if an event was emitted, ``False`` otherwise.
|
||||||
|
Always swallows internal errors — emission must never break tool
|
||||||
|
delivery, which is the caller's primary contract.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
put: streaming queue callback; signature ``put(event, data)``.
|
||||||
|
name: tool name from the callback. Skipped when not ``'todo'``.
|
||||||
|
function_result: raw tool result (JSON string or dict).
|
||||||
|
session_id: tag so the frontend can filter cross-session events.
|
||||||
|
stream_id: tag so SSE replay can dedupe by stream.
|
||||||
|
source: emission origin tag. ``'tool'`` for live tool calls;
|
||||||
|
future callers may use ``'compression-refresh'`` etc.
|
||||||
|
|
||||||
|
The full snapshot is always sent — idempotent re-application is safe
|
||||||
|
under SSE replay through the run journal. The snapshot is redacted
|
||||||
|
before emission (see :func:`_redact_snapshot`); if redaction fails the
|
||||||
|
event is dropped (fail-closed) rather than leaking an unredacted payload.
|
||||||
|
"""
|
||||||
|
if name != "todo":
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
snapshot = parse_todo_tool_result(function_result)
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
continue
|
return False
|
||||||
|
snapshot = _redact_snapshot(snapshot)
|
||||||
ts_val = _message_ts_float(msg.get("timestamp"))
|
put(EVENT_NAME, {
|
||||||
if ts_val <= 0:
|
"session_id": session_id,
|
||||||
ts_val = _max_timestamp_through(messages, idx)
|
"stream_id": stream_id,
|
||||||
if ts_val > 0:
|
"source": source,
|
||||||
snapshot["ts"] = ts_val
|
"ts": time.time(),
|
||||||
return snapshot
|
**snapshot,
|
||||||
return None
|
})
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
# Per-call debug logging — a flood would mean the queue is
|
||||||
|
# broken, in which case the rest of the stream is already dead.
|
||||||
|
# Redaction failure also lands here and correctly drops the event.
|
||||||
|
logger.debug("todo_state emit failed (name=%s)", name, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def attach_todo_state(payload: dict, messages: Optional[Iterable[dict]]) -> bool:
|
def attach_todo_state(
|
||||||
"""Attach ``todo_state`` to a session payload when one can be derived.
|
payload: dict,
|
||||||
|
messages: Optional[Iterable[dict]],
|
||||||
|
) -> bool:
|
||||||
|
"""Attach a derived ``todo_state`` snapshot to a session GET response.
|
||||||
|
|
||||||
Mutates ``payload`` in place. Errors are swallowed deliberately: a malformed
|
Mutates ``payload`` in place when a snapshot can be derived.
|
||||||
historical tool message must not make ``/api/session`` fail.
|
Returns ``True`` if attached, ``False`` otherwise. Always swallows
|
||||||
|
errors — a malformed sidecar must never break the session GET
|
||||||
|
response.
|
||||||
|
|
||||||
|
The caller is responsible for any higher-level gating
|
||||||
|
(e.g. ``load_messages``); this helper is a no-op on empty/``None``
|
||||||
|
``messages`` so callers can hand it whatever message list they have.
|
||||||
"""
|
"""
|
||||||
if not messages:
|
if not messages:
|
||||||
return False
|
return False
|
||||||
|
|||||||
+47
-1
@@ -938,6 +938,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
messages:inflight.messages||[],
|
messages:inflight.messages||[],
|
||||||
uploaded:inflight.uploaded||[...uploaded],
|
uploaded:inflight.uploaded||[...uploaded],
|
||||||
toolCalls:inflight.toolCalls||[],
|
toolCalls:inflight.toolCalls||[],
|
||||||
|
todos:Array.isArray(inflight.todos)?inflight.todos:S.todos,
|
||||||
|
todoStateMeta:inflight.todoStateMeta||S.todoStateMeta||null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function snapshotLiveTurn(){
|
function snapshotLiveTurn(){
|
||||||
@@ -1920,6 +1922,47 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
scrollIfPinned();
|
scrollIfPinned();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Phase 2: dedicated `todo_state` event carries a full snapshot of
|
||||||
|
// the upstream TodoStore. We treat it as the single source of truth
|
||||||
|
// for the Todos panel — never merge, always replace. The handler
|
||||||
|
// is intentionally cheap: parse, validate, write S.todos, mirror to
|
||||||
|
// INFLIGHT, schedule a RAF render. Out-of-order events are filtered
|
||||||
|
// by ts; SSE journal replay is idempotent because snapshots are full.
|
||||||
|
// Cross-session protection mirrors every other live listener:
|
||||||
|
// payload.session_id must match activeSid or the event is dropped.
|
||||||
|
source.addEventListener('todo_state',e=>{
|
||||||
|
let d;
|
||||||
|
try{ d=JSON.parse(e.data||'{}'); }catch(_){ return; }
|
||||||
|
if(!d||typeof d!=='object') return;
|
||||||
|
// Cross-session double check: payload.session_id is the SSE-side
|
||||||
|
// filter (some legacy emissions omit it), and S.session.session_id
|
||||||
|
// is the UI-side filter (a late event that arrives after the user
|
||||||
|
// already navigated to another session must not pollute S.todos).
|
||||||
|
// Both must agree with activeSid before we touch global state.
|
||||||
|
if(d.session_id&&d.session_id!==activeSid) return;
|
||||||
|
if(!S.session||S.session.session_id!==activeSid) return;
|
||||||
|
if(!Array.isArray(d.todos)) return;
|
||||||
|
const incomingTs=Number(d.ts)||0;
|
||||||
|
const currentTs=(S.todoStateMeta&&Number(S.todoStateMeta.ts))||0;
|
||||||
|
// Strictly older snapshots are discarded; equal-ts events still
|
||||||
|
// apply so a compression-source refresh can land on the same
|
||||||
|
// second as the tool emit it follows.
|
||||||
|
if(incomingTs&¤tTs&&incomingTs<currentTs) return;
|
||||||
|
S.todos=d.todos;
|
||||||
|
S.todoStateMeta={
|
||||||
|
ts:incomingTs||(Date.now()/1000),
|
||||||
|
source:String(d.source||'tool'),
|
||||||
|
version:Number(d.version)||1,
|
||||||
|
};
|
||||||
|
const inflight=INFLIGHT[activeSid];
|
||||||
|
if(inflight){
|
||||||
|
inflight.todos=S.todos;
|
||||||
|
inflight.todoStateMeta=S.todoStateMeta;
|
||||||
|
}
|
||||||
|
if(typeof persistInflightState==='function') persistInflightState();
|
||||||
|
if(typeof scheduleTodosRefresh==='function') scheduleTodosRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
source.addEventListener('approval',e=>{
|
source.addEventListener('approval',e=>{
|
||||||
const d=JSON.parse(e.data);
|
const d=JSON.parse(e.data);
|
||||||
showApprovalForSession(activeSid, d, 1);
|
showApprovalForSession(activeSid, d, 1);
|
||||||
@@ -2093,6 +2136,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
const _prevCacheWrite=(S.session&&S.session.cache_write_tokens)||0;
|
const _prevCacheWrite=(S.session&&S.session.cache_write_tokens)||0;
|
||||||
S.session=d.session;S.messages=_carryForwardEphemeralTurnFields(S.messages||[], d.session.messages||[]);if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
|
S.session=d.session;S.messages=_carryForwardEphemeralTurnFields(S.messages||[], d.session.messages||[]);if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
|
||||||
S.messages=_filterRecoveryControlMessages(S.messages || []);
|
S.messages=_filterRecoveryControlMessages(S.messages || []);
|
||||||
|
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||||
if(S.session&&S.session.session_id){
|
if(S.session&&S.session.session_id){
|
||||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||||
@@ -2522,6 +2566,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
S.session=data.session;
|
S.session=data.session;
|
||||||
const _nextMsgs3018=(data.session.messages||[]).filter(m=>m&&m.role);
|
const _nextMsgs3018=(data.session.messages||[]).filter(m=>m&&m.role);
|
||||||
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
||||||
|
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||||
_markSessionViewed(activeSid, data.session.message_count ?? S.messages.length);
|
_markSessionViewed(activeSid, data.session.message_count ?? S.messages.length);
|
||||||
renderMessages({preserveScroll:true});
|
renderMessages({preserveScroll:true});
|
||||||
@@ -2540,7 +2585,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
_setActivePaneIdleIfOwner();
|
_setActivePaneIdleIfOwner();
|
||||||
});
|
});
|
||||||
|
|
||||||
for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','approval','clarify','state_saved','title','title_status','context_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){
|
for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','todo_state','approval','clarify','state_saved','title','title_status','context_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){
|
||||||
source.addEventListener(_runJournalEventName,_rememberRunJournalCursor);
|
source.addEventListener(_runJournalEventName,_rememberRunJournalCursor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2619,6 +2664,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role);
|
const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role);
|
||||||
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
||||||
S.messages=_filterRecoveryControlMessages(S.messages || []);
|
S.messages=_filterRecoveryControlMessages(S.messages || []);
|
||||||
|
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||||
if(S.session&&S.session.session_id){
|
if(S.session&&S.session.session_id){
|
||||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||||
|
|||||||
+43
-8
@@ -2666,34 +2666,69 @@ async function loadKanbanTask(taskId){
|
|||||||
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
|
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 2: Single-source-of-truth render.
|
||||||
|
//
|
||||||
|
// Reads `S.todos` (set by the `todo_state` SSE listener, INFLIGHT
|
||||||
|
// restore, or session cold-load — see _hydrateTodosFromSession in
|
||||||
|
// ui.js). When `S.todoStateMeta` is null we have never seen an
|
||||||
|
// explicit signal and fall through to the legacy reverse-scan over
|
||||||
|
// settled tool messages — this keeps the panel populated against
|
||||||
|
// pre-Phase-1 servers and during the upgrade window.
|
||||||
|
//
|
||||||
|
// The render is short-circuited via `_todosLastRenderedHash` (defined
|
||||||
|
// in ui.js): repeated emissions that yield identical DOM are no-ops.
|
||||||
|
// Coalescing of bursty live updates happens upstream in
|
||||||
|
// scheduleTodosRefresh().
|
||||||
function loadTodos() {
|
function loadTodos() {
|
||||||
const panel = $('todoPanel');
|
const panel = $('todoPanel');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
|
|
||||||
const sessionTodoState = S.session && S.session.todo_state;
|
|
||||||
let todos;
|
let todos;
|
||||||
if (sessionTodoState && Array.isArray(sessionTodoState.todos)) {
|
if (S.todoStateMeta) {
|
||||||
todos = sessionTodoState.todos;
|
todos = Array.isArray(S.todos) ? S.todos : [];
|
||||||
} else {
|
} else {
|
||||||
todos = _legacyTodosFromMessages();
|
todos = _legacyTodosFromMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!todos.length) {
|
if (!todos.length) {
|
||||||
|
if (typeof _todosLastRenderedHash !== 'undefined' && _todosLastRenderedHash === '__empty__') return;
|
||||||
panel.innerHTML = `<div style="color:var(--muted);font-size:12px;padding:4px 0">${esc(t('todos_no_active'))}</div>`;
|
panel.innerHTML = `<div style="color:var(--muted);font-size:12px;padding:4px 0">${esc(t('todos_no_active'))}</div>`;
|
||||||
|
if (typeof _todosLastRenderedHash !== 'undefined') _todosLastRenderedHash = '__empty__';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof _todosHash === 'function' && typeof _todosLastRenderedHash !== 'undefined') {
|
||||||
|
const hash = _todosHash(todos);
|
||||||
|
if (hash === _todosLastRenderedHash) return;
|
||||||
|
_todosLastRenderedHash = hash;
|
||||||
|
}
|
||||||
|
|
||||||
const statusIcon = {pending:li('square',14), in_progress:li('loader',14), completed:li('check',14), cancelled:li('x',14)};
|
const statusIcon = {pending:li('square',14), in_progress:li('loader',14), completed:li('check',14), cancelled:li('x',14)};
|
||||||
const statusColor = {pending:'var(--muted)', in_progress:'var(--blue)', completed:'rgba(100,200,100,.8)', cancelled:'rgba(200,100,100,.5)'};
|
const statusColor = {pending:'var(--muted)', in_progress:'var(--blue)', completed:'rgba(100,200,100,.8)', cancelled:'rgba(200,100,100,.5)'};
|
||||||
panel.innerHTML = todos.map(todo => `
|
// Single innerHTML join is the cheapest correct way to materialize
|
||||||
|
// ~10–50 leaf nodes. All user-controlled content goes through esc().
|
||||||
|
panel.innerHTML = todos.map(td => `
|
||||||
<div style="display:flex;align-items:flex-start;gap:10px;padding:6px 0;border-bottom:1px solid var(--border);">
|
<div style="display:flex;align-items:flex-start;gap:10px;padding:6px 0;border-bottom:1px solid var(--border);">
|
||||||
<span style="font-size:14px;display:inline-flex;align-items:center;flex-shrink:0;margin-top:1px;color:${statusColor[todo.status]||'var(--muted)'}">${statusIcon[todo.status]||li('square',14)}</span>
|
<span style="font-size:14px;display:inline-flex;align-items:center;flex-shrink:0;margin-top:1px;color:${statusColor[td.status]||'var(--muted)'}">${statusIcon[td.status]||li('square',14)}</span>
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<div style="font-size:13px;color:${todo.status==='completed'?'var(--muted)':todo.status==='in_progress'?'var(--text)':'var(--text)'};${todo.status==='completed'?'text-decoration:line-through;opacity:.5':''};line-height:1.4">${esc(todo.content)}</div>
|
<div style="font-size:13px;color:${td.status==='completed'?'var(--muted)':td.status==='in_progress'?'var(--text)':'var(--text)'};${td.status==='completed'?'text-decoration:line-through;opacity:.5':''};line-height:1.4">${esc(td.content)}</div>
|
||||||
<div style="font-size:10px;color:var(--muted);margin-top:2px;opacity:.6">${esc(todo.id)} · ${esc(todo.status)}</div>
|
<div style="font-size:10px;color:var(--muted);margin-top:2px;opacity:.6">${esc(td.id)} · ${esc(td.status)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy fallback: reverse-scan settled tool messages for the most
|
||||||
|
// recent {"todos":[...]} payload. Used only when no `todo_state`
|
||||||
|
// signal has been seen for the current session — primarily during
|
||||||
|
// upgrade windows where the server has not yet been redeployed with
|
||||||
|
// Phase 1. Once Phase 1 is universally deployed and a stabilization
|
||||||
|
// period has passed, this can be removed (Phase 3).
|
||||||
|
//
|
||||||
|
// Variable name `sourceMessages` is preserved verbatim from the
|
||||||
|
// original loadTodos() implementation so the matching regression
|
||||||
|
// test (R-todo-survive-refresh in tests/test_regressions.py) keeps
|
||||||
|
// catching any future refactor that drops the raw-session-messages
|
||||||
|
// fallback. See the test for the exact contract.
|
||||||
function _legacyTodosFromMessages() {
|
function _legacyTodosFromMessages() {
|
||||||
const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages;
|
const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages;
|
||||||
if (!Array.isArray(sourceMessages)) return [];
|
if (!Array.isArray(sourceMessages)) return [];
|
||||||
@@ -2707,7 +2742,7 @@ function _legacyTodosFromMessages() {
|
|||||||
if (!content || content.indexOf('"todos"') < 0) continue;
|
if (!content || content.indexOf('"todos"') < 0) continue;
|
||||||
try {
|
try {
|
||||||
const d = JSON.parse(content);
|
const d = JSON.parse(content);
|
||||||
if (d && Array.isArray(d.todos) && d.todos.length) return d.todos;
|
if (d && Array.isArray(d.todos)) return d.todos;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
+37
-2
@@ -611,6 +611,7 @@ async function newSession(flash, options={}){
|
|||||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)});
|
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)});
|
||||||
S.session=data.session;S.messages=data.session.messages||[];
|
S.session=data.session;S.messages=data.session.messages||[];
|
||||||
if(_sessionSourceFilter==='cli') _sessionSourceFilter='webui';
|
if(_sessionSourceFilter==='cli') _sessionSourceFilter='webui';
|
||||||
|
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||||
S.lastUsage={...(data.session.last_usage||{})};
|
S.lastUsage={...(data.session.last_usage||{})};
|
||||||
if(!(options&&options.worktree)) _rememberNewChatDraftSession(S.session);
|
if(!(options&&options.worktree)) _rememberNewChatDraftSession(S.session);
|
||||||
if(flash)S.session._flash=true;
|
if(flash)S.session._flash=true;
|
||||||
@@ -802,6 +803,7 @@ async function loadSession(sid){
|
|||||||
// Stale response? A newer loadSession() call has already started (#1060).
|
// Stale response? A newer loadSession() call has already started (#1060).
|
||||||
if (_loadingSessionId !== sid) return;
|
if (_loadingSessionId !== sid) return;
|
||||||
S.session=data.session;
|
S.session=data.session;
|
||||||
|
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||||
S.session._modelResolutionDeferred=true;
|
S.session._modelResolutionDeferred=true;
|
||||||
S.lastUsage={...(data.session.last_usage||{})};
|
S.lastUsage={...(data.session.last_usage||{})};
|
||||||
// Reset scroll-direction tracker on session switch so the new chat's
|
// Reset scroll-direction tracker on session switch so the new chat's
|
||||||
@@ -853,6 +855,13 @@ async function loadSession(sid){
|
|||||||
messages:Array.isArray(stored.messages)&&stored.messages.length?stored.messages:[],
|
messages:Array.isArray(stored.messages)&&stored.messages.length?stored.messages:[],
|
||||||
uploaded:Array.isArray(stored.uploaded)?stored.uploaded:[],
|
uploaded:Array.isArray(stored.uploaded)?stored.uploaded:[],
|
||||||
toolCalls:Array.isArray(stored.toolCalls)?stored.toolCalls:[],
|
toolCalls:Array.isArray(stored.toolCalls)?stored.toolCalls:[],
|
||||||
|
// Phase 2: restore the live todo snapshot from persisted INFLIGHT
|
||||||
|
// so the panel does not flicker to empty when a mid-stream
|
||||||
|
// browser reload reattaches before the next `todo_state` event
|
||||||
|
// fires. Both fields are optional; missing values fall back to
|
||||||
|
// cold-load via session.todo_state.
|
||||||
|
todos:Array.isArray(stored.todos)?stored.todos:null,
|
||||||
|
todoStateMeta:stored.todoStateMeta||null,
|
||||||
reattach:true,
|
reattach:true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -872,6 +881,8 @@ async function loadSession(sid){
|
|||||||
if(_mergePendingSessionMessage(S.session,S.messages)){
|
if(_mergePendingSessionMessage(S.session,S.messages)){
|
||||||
INFLIGHT[sid].messages=S.messages;
|
INFLIGHT[sid].messages=S.messages;
|
||||||
}
|
}
|
||||||
|
// Refresh todos from cold-load or persisted INFLIGHT before painting.
|
||||||
|
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||||
S.busy=true;
|
S.busy=true;
|
||||||
// appendLiveToolCard() is guarded by S.activeStreamId; restore it before
|
// appendLiveToolCard() is guarded by S.activeStreamId; restore it before
|
||||||
// replaying persisted live tools so the compact Activity count survives
|
// replaying persisted live tools so the compact Activity count survives
|
||||||
@@ -1569,9 +1580,31 @@ async function _ensureMessagesLoaded(sid) {
|
|||||||
S.messages = msgs;
|
S.messages = msgs;
|
||||||
if(S.session&&S.session.session_id===sid){
|
if(S.session&&S.session.session_id===sid){
|
||||||
S.session.message_count=Number(data.session.message_count || msgs.length);
|
S.session.message_count=Number(data.session.message_count || msgs.length);
|
||||||
if(Object.prototype.hasOwnProperty.call(data.session,'todo_state')) S.session.todo_state=data.session.todo_state;
|
|
||||||
else delete S.session.todo_state;
|
|
||||||
S.lastUsage={...(data.session.last_usage||S.lastUsage||{})};
|
S.lastUsage={...(data.session.last_usage||S.lastUsage||{})};
|
||||||
|
// Phase 2: the messages=1 response carries the canonical cold-load
|
||||||
|
// `todo_state` snapshot, derived server-side from the FULL untruncated
|
||||||
|
// message list (api/routes.py + api/todo_state.py). The earlier
|
||||||
|
// messages=0 fetch in loadSession() does not include this field —
|
||||||
|
// attach_todo_state is gated on `load_messages`. Without applying it
|
||||||
|
// here, long sessions whose latest todo write falls outside the
|
||||||
|
// _INITIAL_MSG_LIMIT tail would lose the panel on refresh: the
|
||||||
|
// legacy reverse-scan in _legacyTodosFromMessages() can only see the
|
||||||
|
// tail S.messages, while the authoritative snapshot was already
|
||||||
|
// computed by the server and is sitting in this very response.
|
||||||
|
// _hydrateTodosFromSession is idempotent and picks newer of
|
||||||
|
// cold-load vs INFLIGHT by timestamp, so calling it again here is
|
||||||
|
// safe even when an INFLIGHT snapshot was already restored.
|
||||||
|
if(data.session.todo_state !== undefined){
|
||||||
|
S.session.todo_state = data.session.todo_state;
|
||||||
|
}else{
|
||||||
|
delete S.session.todo_state;
|
||||||
|
}
|
||||||
|
if(typeof _hydrateTodosFromSession === 'function'){
|
||||||
|
_hydrateTodosFromSession(S.session);
|
||||||
|
}
|
||||||
|
if(typeof scheduleTodosRefresh === 'function'){
|
||||||
|
scheduleTodosRefresh();
|
||||||
|
}
|
||||||
_setSessionViewedCount(sid, Number(S.session.message_count || msgs.length));
|
_setSessionViewedCount(sid, Number(S.session.message_count || msgs.length));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2156,6 +2189,7 @@ function _renderBatchActionBar(){
|
|||||||
ids.forEach(_clearHandoffStorageForSession);
|
ids.forEach(_clearHandoffStorageForSession);
|
||||||
if(S.session&&ids.includes(S.session.session_id)){
|
if(S.session&&ids.includes(S.session.session_id)){
|
||||||
S.session=null;S.messages=[];S.entries=[];localStorage.removeItem('hermes-webui-session');
|
S.session=null;S.messages=[];S.entries=[];localStorage.removeItem('hermes-webui-session');
|
||||||
|
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(null);
|
||||||
const remaining=await api('/api/sessions');
|
const remaining=await api('/api/sessions');
|
||||||
if(remaining.sessions&&remaining.sessions.length){await loadSession(remaining.sessions[0].session_id);}
|
if(remaining.sessions&&remaining.sessions.length){await loadSession(remaining.sessions[0].session_id);}
|
||||||
else{$('msgInner').innerHTML='';$('emptyState').style.display='';}
|
else{$('msgInner').innerHTML='';$('emptyState').style.display='';}
|
||||||
@@ -5225,6 +5259,7 @@ async function deleteSession(sid, beforeDelete=null){
|
|||||||
}
|
}
|
||||||
if(S.session&&S.session.session_id===sid){
|
if(S.session&&S.session.session_id===sid){
|
||||||
S.session=null;S.messages=[];S.entries=[];
|
S.session=null;S.messages=[];S.entries=[];
|
||||||
|
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(null);
|
||||||
localStorage.removeItem('hermes-webui-session');
|
localStorage.removeItem('hermes-webui-session');
|
||||||
// load the most recent remaining session, or show blank if none left
|
// load the most recent remaining session, or show blank if none left
|
||||||
const remaining=await api('/api/sessions');
|
const remaining=await api('/api/sessions');
|
||||||
|
|||||||
+165
-1
@@ -1,4 +1,11 @@
|
|||||||
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default',showHiddenWorkspaceFiles:false};
|
// `todos` is the single source of truth for the Todos panel. Any update
|
||||||
|
// goes through the `todo_state` SSE event (live) or session.todo_state
|
||||||
|
// (cold-load). `todoStateMeta` doubles as a sentinel: while it is null
|
||||||
|
// no explicit signal has been seen, so loadTodos() falls back to the
|
||||||
|
// legacy reverse-scan over S.messages — that keeps new clients working
|
||||||
|
// against old servers (Phase 1 may not yet be deployed everywhere).
|
||||||
|
// See api/todo_state.py for the wire contract.
|
||||||
|
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default',showHiddenWorkspaceFiles:false,todos:[],todoStateMeta:null};
|
||||||
|
|
||||||
function assistantDisplayName(){
|
function assistantDisplayName(){
|
||||||
if(S.activeProfile&&S.activeProfile!=='default') return S.activeProfile.charAt(0).toUpperCase()+S.activeProfile.slice(1);
|
if(S.activeProfile&&S.activeProfile!=='default') return S.activeProfile.charAt(0).toUpperCase()+S.activeProfile.slice(1);
|
||||||
@@ -4797,11 +4804,20 @@ function _compactInflightState(state){
|
|||||||
const limits=_getInflightStateLimits();
|
const limits=_getInflightStateLimits();
|
||||||
const messages=Array.isArray(state.messages)?state.messages.slice(-limits.messages):[];
|
const messages=Array.isArray(state.messages)?state.messages.slice(-limits.messages):[];
|
||||||
const toolCalls=Array.isArray(state.toolCalls)?state.toolCalls.slice(-limits.toolCalls):[];
|
const toolCalls=Array.isArray(state.toolCalls)?state.toolCalls.slice(-limits.toolCalls):[];
|
||||||
|
// Phase 2: persist the live todo snapshot so reload / SSE reattach
|
||||||
|
// restores the panel without waiting for the next live `todo` write.
|
||||||
|
// The list is bounded by the agent (typically <20 items) and each
|
||||||
|
// item is small, so no per-list cap is needed beyond the existing
|
||||||
|
// stringChars truncation in _truncateInflightValue.
|
||||||
|
const todos=Array.isArray(state.todos)?state.todos:null;
|
||||||
|
const todoStateMeta=(state.todoStateMeta&&typeof state.todoStateMeta==='object')?state.todoStateMeta:null;
|
||||||
return _truncateInflightValue({
|
return _truncateInflightValue({
|
||||||
streamId:state.streamId||null,
|
streamId:state.streamId||null,
|
||||||
messages,
|
messages,
|
||||||
uploaded:Array.isArray(state.uploaded)?state.uploaded.slice(-20):[],
|
uploaded:Array.isArray(state.uploaded)?state.uploaded.slice(-20):[],
|
||||||
toolCalls,
|
toolCalls,
|
||||||
|
todos,
|
||||||
|
todoStateMeta,
|
||||||
}, limits.stringChars);
|
}, limits.stringChars);
|
||||||
}
|
}
|
||||||
function _writeInflightStateMap(all){
|
function _writeInflightStateMap(all){
|
||||||
@@ -4863,6 +4879,154 @@ function clearInflightState(sid){
|
|||||||
}catch(_){ }
|
}catch(_){ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Todo state: single source of truth + render scheduling ─────────────────
|
||||||
|
//
|
||||||
|
// Three concerns live together so they can share state cleanly:
|
||||||
|
//
|
||||||
|
// 1. _todosHash(items) — cheap content fingerprint; skips re-render when
|
||||||
|
// a snapshot would paint the same DOM. Used both as a short-circuit
|
||||||
|
// and as the hash that compares "rendered vs current" snapshots.
|
||||||
|
//
|
||||||
|
// 2. scheduleTodosRefresh() — coalesces multiple `todo_state` events that
|
||||||
|
// land in the same animation frame into a single loadTodos() call.
|
||||||
|
// Skips work entirely when the panel is not active.
|
||||||
|
//
|
||||||
|
// 3. _hydrateTodosFromSession(session) — applies cold-load todo_state
|
||||||
|
// from the session GET payload, or clears the panel when neither a
|
||||||
|
// cold-load nor an INFLIGHT signal is available. Called at every
|
||||||
|
// `S.session = ...` settle point so cross-session navigation never
|
||||||
|
// leaves a stale list visible.
|
||||||
|
//
|
||||||
|
// The hash is keyed on (id, content, status); the render itself uses
|
||||||
|
// `esc()` for any user-controlled string, so XSS surface is the same as
|
||||||
|
// any other innerHTML path in this file.
|
||||||
|
let _todosLastRenderedHash=null;
|
||||||
|
let _todosRenderRafId=0;
|
||||||
|
|
||||||
|
function _todosHash(items){
|
||||||
|
if(!Array.isArray(items)) return '';
|
||||||
|
// String concat outperforms JSON.stringify on small arrays in V8 (no
|
||||||
|
// intermediate object allocation) and is exact enough — the field set
|
||||||
|
// matches what the renderer reads, so any visible change in DOM
|
||||||
|
// implies a hash change. Field separators (\x1f, \x1e) are control
|
||||||
|
// chars unlikely to appear in real todo content, so collisions across
|
||||||
|
// boundaries are not realistic.
|
||||||
|
let h=items.length+'|';
|
||||||
|
for(let i=0;i<items.length;i++){
|
||||||
|
const t=items[i]||{};
|
||||||
|
h+=String(t.id==null?'':t.id)+'\x1f'+String(t.content==null?'':t.content)+'\x1f'+String(t.status==null?'':t.status)+'\x1e';
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _todosPanelIsActive(){
|
||||||
|
if(typeof document==='undefined') return false;
|
||||||
|
const panel=document.getElementById('panelTodos');
|
||||||
|
return !!(panel&&panel.classList&&panel.classList.contains('active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTodosRefresh(){
|
||||||
|
// Idempotent: many `todo_state` events fire on each tool result, but
|
||||||
|
// only the latest snapshot needs to paint. RAF lets us coalesce
|
||||||
|
// without timer drift.
|
||||||
|
if(_todosRenderRafId) return;
|
||||||
|
if(typeof requestAnimationFrame!=='function'){
|
||||||
|
if(typeof loadTodos==='function') loadTodos();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_todosRenderRafId=requestAnimationFrame(()=>{
|
||||||
|
_todosRenderRafId=0;
|
||||||
|
if(!_todosPanelIsActive()) return;
|
||||||
|
if(typeof loadTodos==='function') loadTodos();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _resetTodosRenderCache(){
|
||||||
|
// Clear after every cross-session navigation so the next render is
|
||||||
|
// never short-circuited against a hash from a different session.
|
||||||
|
_todosLastRenderedHash=null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hydrateTodosFromSession(session){
|
||||||
|
// Three input cases, three deterministic outcomes:
|
||||||
|
// a) cold-load AND inflight both present → pick newer by ts so a
|
||||||
|
// stale cold-load from the session GET cannot regress a fresher
|
||||||
|
// INFLIGHT snapshot persisted from a still-running stream
|
||||||
|
// (avoids visible rollback on reload).
|
||||||
|
// b) only one of cold-load / inflight is present → use it.
|
||||||
|
// c) neither → reset to empty + sentinel so loadTodos() falls
|
||||||
|
// through to the legacy reverse-scan or paints the empty state.
|
||||||
|
const sid=(session&&session.session_id)||'';
|
||||||
|
const inflight=(typeof INFLIGHT==='object'&&INFLIGHT&&sid)?INFLIGHT[sid]:null;
|
||||||
|
const cold=session&&session.todo_state;
|
||||||
|
const coldOk=!!(cold&&Array.isArray(cold.todos));
|
||||||
|
const inflightOk=!!(inflight&&Array.isArray(inflight.todos)&&inflight.todoStateMeta);
|
||||||
|
const coldTs=coldOk?(Number(cold.ts)||0):0;
|
||||||
|
const inflightTs=inflightOk?(Number(inflight.todoStateMeta&&inflight.todoStateMeta.ts)||0):0;
|
||||||
|
// Whether a live stream currently owns this session. This is the signal
|
||||||
|
// that disambiguates a ts-less cold-load (see below); it comes from the
|
||||||
|
// session GET payload (mirrors sessions.js `S.session.active_stream_id`).
|
||||||
|
const streamActive=!!(session&&session.active_stream_id);
|
||||||
|
if(coldOk&&inflightOk){
|
||||||
|
// Reconcile the server's settled cold-load snapshot against the
|
||||||
|
// locally-persisted INFLIGHT snapshot.
|
||||||
|
//
|
||||||
|
// coldTs===0 means the cold-load carries NO usable timestamp, so we
|
||||||
|
// cannot order it against INFLIGHT by recency. A todo tool message can
|
||||||
|
// legitimately lose its `timestamp` during context compression/rebuild
|
||||||
|
// (the on-disk message ends up timestamp=None), and derive_todo_state
|
||||||
|
// (api/todo_state.py) then returns the correct latest-by-POSITION todos
|
||||||
|
// but omits `ts`. The tie-break depends on who owns the INFLIGHT tail:
|
||||||
|
//
|
||||||
|
// - stream ACTIVE → INFLIGHT is the live tail. The most recent todo
|
||||||
|
// write may still be in flight and not yet settled into the message
|
||||||
|
// list derive_todo_state scans, so a ts-less cold-load can be an
|
||||||
|
// OLDER (pre-latest-write) view. Letting cold win here rolls the
|
||||||
|
// panel back to a stale list, and since the stream may have just
|
||||||
|
// ended on that very write there is no guaranteed forward SSE event
|
||||||
|
// to self-heal. So prefer INFLIGHT. If cold is in fact newer, the
|
||||||
|
// reattach replay (sessions.js attachLiveStream, reconnecting) re-
|
||||||
|
// emits the journaled `todo_state` events which reconcile forward by
|
||||||
|
// ts, so any transient discrepancy corrects itself.
|
||||||
|
//
|
||||||
|
// - stream IDLE → INFLIGHT is leftover from a finished/crashed stream
|
||||||
|
// (idle sessions purge it shortly after, sessions.js), and there is
|
||||||
|
// no replay to correct anything. The settled cold-load is the
|
||||||
|
// authoritative latest-by-position view, so prefer cold. This also
|
||||||
|
// preserves the original fix for the "shows an old todo list" bug,
|
||||||
|
// where a stale prior-turn INFLIGHT must not beat a ts-less cold-load.
|
||||||
|
//
|
||||||
|
// When coldTs>0 the original recency rule stands: strict ">", and on a
|
||||||
|
// tie prefer INFLIGHT for the freshest in-tab edits.
|
||||||
|
const coldWins=(coldTs===0)?(!streamActive):(coldTs>inflightTs);
|
||||||
|
if(coldWins){
|
||||||
|
S.todos=cold.todos;
|
||||||
|
S.todoStateMeta={
|
||||||
|
ts:coldTs,
|
||||||
|
source:'cold-load',
|
||||||
|
version:Number(cold.version)||1,
|
||||||
|
};
|
||||||
|
}else{
|
||||||
|
S.todos=inflight.todos;
|
||||||
|
S.todoStateMeta=inflight.todoStateMeta;
|
||||||
|
}
|
||||||
|
}else if(coldOk){
|
||||||
|
S.todos=cold.todos;
|
||||||
|
S.todoStateMeta={
|
||||||
|
ts:coldTs,
|
||||||
|
source:'cold-load',
|
||||||
|
version:Number(cold.version)||1,
|
||||||
|
};
|
||||||
|
}else if(inflightOk){
|
||||||
|
S.todos=inflight.todos;
|
||||||
|
S.todoStateMeta=inflight.todoStateMeta;
|
||||||
|
}else{
|
||||||
|
S.todos=[];
|
||||||
|
S.todoStateMeta=null;
|
||||||
|
}
|
||||||
|
_resetTodosRenderCache();
|
||||||
|
}
|
||||||
|
|
||||||
function snapshotLiveTurnHtmlForSession(sid){
|
function snapshotLiveTurnHtmlForSession(sid){
|
||||||
// Keep the DOM snapshot memory-only. Persisted INFLIGHT state intentionally
|
// Keep the DOM snapshot memory-only. Persisted INFLIGHT state intentionally
|
||||||
// stores structured stream state, not outerHTML, so a hard reload still uses
|
// stores structured stream state, not outerHTML, so a hard reload still uses
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
STREAMING_PY = Path(__file__).parent.parent / "api" / "streaming.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_todo_state_calls() -> list[ast.Call]:
|
||||||
|
tree = ast.parse(STREAMING_PY.read_text(encoding="utf-8"))
|
||||||
|
calls: list[ast.Call] = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "emit_todo_state":
|
||||||
|
calls.append(node)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_imports_todo_state_emitter():
|
||||||
|
tree = ast.parse(STREAMING_PY.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
isinstance(node, ast.ImportFrom)
|
||||||
|
and node.module == "api.todo_state"
|
||||||
|
and any(alias.name == "emit_todo_state" for alias in node.names)
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_emits_todo_state_on_both_tool_callback_shapes():
|
||||||
|
calls = _emit_todo_state_calls()
|
||||||
|
|
||||||
|
assert len(calls) >= 2
|
||||||
|
for call in calls[:2]:
|
||||||
|
kw_names = {kw.arg for kw in call.keywords}
|
||||||
|
assert {"name", "function_result", "session_id", "stream_id"}.issubset(kw_names)
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_prefers_full_tool_result_when_available():
|
||||||
|
src = STREAMING_PY.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "cb_kwargs.get('result')" in src
|
||||||
|
assert "else preview" in src
|
||||||
|
assert "function_result=function_result" in src
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _read_static(path: str) -> str:
|
||||||
|
return (REPO_ROOT / path).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_state_and_inflight_storage_include_todo_snapshot():
|
||||||
|
ui = _read_static("static/ui.js")
|
||||||
|
messages = _read_static("static/messages.js")
|
||||||
|
|
||||||
|
assert "todos:[],todoStateMeta:null" in ui
|
||||||
|
assert "const todos=Array.isArray(state.todos)?state.todos:null" in ui
|
||||||
|
assert "todoStateMeta" in ui[ui.find("function _compactInflightState"):ui.find("function _writeInflightStateMap")]
|
||||||
|
assert "todos:Array.isArray(inflight.todos)?inflight.todos:S.todos" in messages
|
||||||
|
assert "todoStateMeta:inflight.todoStateMeta||S.todoStateMeta||null" in messages
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_todo_state_listener_is_registered_and_journaled():
|
||||||
|
messages = _read_static("static/messages.js")
|
||||||
|
|
||||||
|
assert "source.addEventListener('todo_state'" in messages
|
||||||
|
assert "if(d.session_id&&d.session_id!==activeSid) return;" in messages
|
||||||
|
assert "if(!S.session||S.session.session_id!==activeSid) return;" in messages
|
||||||
|
assert "if(incomingTs&¤tTs&&incomingTs<currentTs) return;" in messages
|
||||||
|
assert "inflight.todos=S.todos" in messages
|
||||||
|
assert "'todo_state','approval'" in messages
|
||||||
|
|
||||||
|
|
||||||
|
def test_hydrate_todos_from_session_reconciles_cold_and_inflight_snapshots():
|
||||||
|
ui = _read_static("static/ui.js")
|
||||||
|
start = ui.find("function _hydrateTodosFromSession(session)")
|
||||||
|
end = ui.find("function snapshotLiveTurnHtmlForSession")
|
||||||
|
|
||||||
|
assert start != -1
|
||||||
|
assert end != -1
|
||||||
|
block = ui[start:end]
|
||||||
|
assert "const cold=session&&session.todo_state;" in block
|
||||||
|
assert "const streamActive=!!(session&&session.active_stream_id);" in block
|
||||||
|
assert "const coldWins=(coldTs===0)?(!streamActive):(coldTs>inflightTs);" in block
|
||||||
|
assert "S.todos=cold.todos" in block
|
||||||
|
assert "S.todos=inflight.todos" in block
|
||||||
|
assert "S.todoStateMeta=null" in block
|
||||||
|
assert "_resetTodosRenderCache();" in block
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node is required for frontend handler behavior test")
|
||||||
|
def test_todo_state_listener_replaces_snapshot_filters_session_and_rejects_older_ts(tmp_path):
|
||||||
|
script = r'''
|
||||||
|
const fs = require('fs');
|
||||||
|
const src = fs.readFileSync(process.argv[2], 'utf8');
|
||||||
|
function extractTodoStateHandler(source) {
|
||||||
|
const start = source.indexOf("source.addEventListener('todo_state'");
|
||||||
|
if (start < 0) throw new Error('todo_state listener not found');
|
||||||
|
const arrow = source.indexOf('=>', start);
|
||||||
|
const bodyStart = source.indexOf('{', arrow);
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = bodyStart; i < source.length; i++) {
|
||||||
|
const ch = source[i];
|
||||||
|
if (ch === '{') depth++;
|
||||||
|
else if (ch === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return source.slice(bodyStart + 1, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('todo_state listener body not closed');
|
||||||
|
}
|
||||||
|
const body = extractTodoStateHandler(src);
|
||||||
|
let S = {session:{session_id:'s1'}, todos:[], todoStateMeta:null};
|
||||||
|
let INFLIGHT = {s1:{messages:[]}};
|
||||||
|
let activeSid = 's1';
|
||||||
|
let persistCalls = 0;
|
||||||
|
function persistInflightState(){ persistCalls++; }
|
||||||
|
let refreshCalls = 0;
|
||||||
|
function scheduleTodosRefresh(){ refreshCalls++; }
|
||||||
|
const handler = new Function('e','S','INFLIGHT','activeSid','persistInflightState','scheduleTodosRefresh', body);
|
||||||
|
function fire(payload){ handler({data: JSON.stringify(payload)}, S, INFLIGHT, activeSid, persistInflightState, scheduleTodosRefresh); }
|
||||||
|
function assert(cond, msg){ if(!cond) throw new Error(msg); }
|
||||||
|
|
||||||
|
fire({session_id:'other', todos:[{id:'x', content:'wrong', status:'pending'}], ts:20});
|
||||||
|
assert(S.todos.length === 0, 'cross-session event must be ignored');
|
||||||
|
assert(persistCalls === 0 && refreshCalls === 0, 'ignored event must not persist or refresh');
|
||||||
|
|
||||||
|
fire({session_id:'s1', todos:[{id:'a', content:'current', status:'in_progress'}], ts:10, version:1});
|
||||||
|
assert(S.todos[0].id === 'a', 'valid event replaces S.todos');
|
||||||
|
assert(S.todoStateMeta.ts === 10, 'valid event stores timestamp');
|
||||||
|
assert(INFLIGHT.s1.todos[0].id === 'a', 'valid event mirrors into INFLIGHT');
|
||||||
|
assert(INFLIGHT.s1.todoStateMeta.ts === 10, 'valid event mirrors meta into INFLIGHT');
|
||||||
|
assert(persistCalls === 1 && refreshCalls === 1, 'valid event persists and schedules refresh');
|
||||||
|
|
||||||
|
fire({session_id:'s1', todos:[{id:'old', content:'old', status:'pending'}], ts:5, version:1});
|
||||||
|
assert(S.todos[0].id === 'a', 'older event must not roll back S.todos');
|
||||||
|
assert(persistCalls === 1 && refreshCalls === 1, 'older event must not persist or refresh');
|
||||||
|
|
||||||
|
handler({data:'not json'}, S, INFLIGHT, activeSid, persistInflightState, scheduleTodosRefresh);
|
||||||
|
assert(S.todos[0].id === 'a', 'malformed event must be swallowed');
|
||||||
|
'''
|
||||||
|
script_path = tmp_path / "todo_listener_test.js"
|
||||||
|
script_path.write_text(script, encoding="utf-8")
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", str(script_path), str(REPO_ROOT / "static" / "messages.js")],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr or result.stdout
|
||||||
@@ -4,15 +4,17 @@ from pathlib import Path
|
|||||||
REPO_ROOT = Path(__file__).parent.parent
|
REPO_ROOT = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_messages_loaded_copies_session_todo_state_sidecar():
|
def test_ensure_messages_loaded_hydrates_session_todo_state_sidecar():
|
||||||
src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "Object.prototype.hasOwnProperty.call(data.session,'todo_state')" in src
|
assert "if(data.session.todo_state !== undefined)" in src
|
||||||
assert "S.session.todo_state=data.session.todo_state" in src
|
assert "S.session.todo_state = data.session.todo_state" in src
|
||||||
assert "else delete S.session.todo_state" in src
|
assert "delete S.session.todo_state" in src
|
||||||
|
assert "_hydrateTodosFromSession(S.session)" in src
|
||||||
|
assert "scheduleTodosRefresh()" in src
|
||||||
|
|
||||||
|
|
||||||
def test_load_todos_prefers_session_todo_state_before_legacy_scan():
|
def test_load_todos_renders_single_source_of_truth_before_legacy_scan():
|
||||||
src = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
src = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||||
start = src.find("function loadTodos()")
|
start = src.find("function loadTodos()")
|
||||||
end = src.find("function _legacyTodosFromMessages()")
|
end = src.find("function _legacyTodosFromMessages()")
|
||||||
@@ -21,11 +23,10 @@ def test_load_todos_prefers_session_todo_state_before_legacy_scan():
|
|||||||
assert end != -1
|
assert end != -1
|
||||||
load_todos = src[start:end]
|
load_todos = src[start:end]
|
||||||
|
|
||||||
assert "const sessionTodoState = S.session && S.session.todo_state;" in load_todos
|
assert "if (S.todoStateMeta)" in load_todos
|
||||||
assert "sessionTodoState && Array.isArray(sessionTodoState.todos)" in load_todos
|
assert "todos = Array.isArray(S.todos) ? S.todos : [];" in load_todos
|
||||||
assert "todos = sessionTodoState.todos;" in load_todos
|
|
||||||
assert "todos = _legacyTodosFromMessages();" in load_todos
|
assert "todos = _legacyTodosFromMessages();" in load_todos
|
||||||
assert load_todos.find("todos = sessionTodoState.todos;") < load_todos.find("todos = _legacyTodosFromMessages();")
|
assert load_todos.find("todos = Array.isArray(S.todos) ? S.todos : [];") < load_todos.find("todos = _legacyTodosFromMessages();")
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_todos_fallback_still_uses_raw_session_messages():
|
def test_legacy_todos_fallback_still_uses_raw_session_messages():
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from api import helpers
|
||||||
|
from api.todo_state import EVENT_NAME, emit_todo_state
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(todos):
|
||||||
|
return json.dumps({"todos": todos, "summary": {"total": len(todos)}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_todo_state_emits_full_snapshot_with_session_tags():
|
||||||
|
events = []
|
||||||
|
|
||||||
|
emitted = emit_todo_state(
|
||||||
|
lambda event, data: events.append((event, data)),
|
||||||
|
name="todo",
|
||||||
|
function_result=_payload([{"id": "1", "content": "ship", "status": "pending"}]),
|
||||||
|
session_id="session-1",
|
||||||
|
stream_id="stream-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert emitted is True
|
||||||
|
assert len(events) == 1
|
||||||
|
event, data = events[0]
|
||||||
|
assert event == EVENT_NAME
|
||||||
|
assert data["session_id"] == "session-1"
|
||||||
|
assert data["stream_id"] == "stream-1"
|
||||||
|
assert data["source"] == "tool"
|
||||||
|
assert data["todos"][0]["content"] == "ship"
|
||||||
|
assert data["summary"]["total"] == 1
|
||||||
|
assert data["version"] == 1
|
||||||
|
assert isinstance(data["ts"], float)
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_todo_state_ignores_non_todo_or_bad_payload():
|
||||||
|
events = []
|
||||||
|
|
||||||
|
assert emit_todo_state(lambda event, data: events.append((event, data)), name="web_search", function_result=_payload([]), session_id="s", stream_id="st") is False
|
||||||
|
assert emit_todo_state(lambda event, data: events.append((event, data)), name="todo", function_result="not json", session_id="s", stream_id="st") is False
|
||||||
|
assert emit_todo_state(lambda event, data: events.append((event, data)), name="todo", function_result='{"todos":"not-list"}', session_id="s", stream_id="st") is False
|
||||||
|
assert events == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_todo_state_redacts_before_sse(monkeypatch):
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def fake_redact(value, *, _enabled=None):
|
||||||
|
redacted = dict(value)
|
||||||
|
redacted["todos"] = [{"id": "1", "content": "[REDACTED]", "status": "pending"}]
|
||||||
|
return redacted
|
||||||
|
|
||||||
|
monkeypatch.setattr(helpers, "_redact_value", fake_redact)
|
||||||
|
|
||||||
|
emitted = emit_todo_state(
|
||||||
|
lambda event, data: events.append((event, data)),
|
||||||
|
name="todo",
|
||||||
|
function_result=_payload([{"id": "1", "content": "secret token", "status": "pending"}]),
|
||||||
|
session_id="s",
|
||||||
|
stream_id="st",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert emitted is True
|
||||||
|
assert events[0][1]["todos"][0]["content"] == "[REDACTED]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_todo_state_threads_redaction_enabled_flag_once(monkeypatch):
|
||||||
|
events = []
|
||||||
|
settings_calls = []
|
||||||
|
redaction_enabled_args = []
|
||||||
|
|
||||||
|
def fake_load_settings():
|
||||||
|
settings_calls.append("load_settings")
|
||||||
|
return {"api_redact_enabled": False}
|
||||||
|
|
||||||
|
def fake_redact(value, *, _enabled=None):
|
||||||
|
redaction_enabled_args.append(_enabled)
|
||||||
|
return dict(value)
|
||||||
|
|
||||||
|
monkeypatch.setattr("api.config.load_settings", fake_load_settings)
|
||||||
|
monkeypatch.setattr(helpers, "_redact_value", fake_redact)
|
||||||
|
|
||||||
|
emitted = emit_todo_state(
|
||||||
|
lambda event, data: events.append((event, data)),
|
||||||
|
name="todo",
|
||||||
|
function_result=_payload([{"id": "1", "content": "ordinary task", "status": "pending"}]),
|
||||||
|
session_id="s",
|
||||||
|
stream_id="st",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert emitted is True
|
||||||
|
assert len(events) == 1
|
||||||
|
assert settings_calls == ["load_settings"]
|
||||||
|
assert redaction_enabled_args == [False]
|
||||||
Reference in new issue
Block a user