Merge branch 'master' into feat/gateway-run-reattach-after-restart

This commit is contained in:
carlotestor authored and GitHub committed 2026-09-24 15:51:42 +01:00
commit 05aae134cf
86 files changed
+14694 -479

No files matched your search

+77 -2
View File
@@ -278,6 +278,26 @@ visibility stage decides whether recovered background rows are shown. In
are merged; cross-profile scoping, visibility, deduplication, and final route
limits remain downstream responsibilities.
#### Compression lineage and session-list invalidation
`api.agent_sessions._is_continuation_session()` is the shared classifier for
sidebar projection, lineage metadata/reporting, and `state.db` transcript
stitching. It uses the direct parent link, no conflicting non-empty source,
`compression` or `cli_close` parent end reason, and the existing two-second
`started_at` overlap allowance. A `source="tool"` child is always a separate
conversation, even when its parent is also a tool session or has no source.
A direct `_branched_from`, `_delegate_from`, or `_reset_from` marker in the
child's `model_config` also makes a boundary; inherited ancestor markers do
not. Malformed or unverifiable marker evidence fails closed as a boundary.
The overlap allowance is the existing master policy, not a new window set by
this change.
The gateway watcher's cheap database fingerprint includes `model_config`, so
a marker-only update causes a fresh projection. Its published-payload hash
covers every emitted session field, not just ID, activity time and message
count; a changed projected title or lineage field can therefore emit
`sessions_changed` even without message-row churn.
### 4.3 SSE Streaming Engine
This is the most architecturally interesting part. Two endpoints cooperate:
@@ -475,6 +495,52 @@ pointer-removed cases to `tests/test_agent_compat.py`. The resolver is
compatibility-only: delete it, and import directly from the new homes, once the WebUI
stops supporting Agents that predate the split.
### 4.10 MCP Runtime Profile Boundary
Hermes Agent owns the in-process MCP ledger. It keys a connection by
`(profile_home_key, name)` and registers its tools in that profile's registry overlay
only when the calling task serves a *routed* profile: the context-local Hermes-home
override differs from the process home. Otherwise the connection uses the bare server
name and the global registry slot, which belong to the process profile.
- `api.profiles._set_hermes_home()` is the single writer of the process-profile home:
startup (`init_profile_state()`) and `switch_profile(process_wide=True)` both go
through it, so `get_process_profile_home()` and the Agent pin
(`hermes_constants.pin_process_hermes_home()`, when the Agent provides it) are updated
in the same step as `HERMES_HOME`. Streaming turns still mirror their profile into
`os.environ['HERMES_HOME']` for legacy readers; without the pin, that mirror makes a
turn's own profile look like the process profile, so same-named servers of different
profiles share one bare-name connection.
- `/api/mcp/servers`, `/api/mcp/tools`, `/api/notes/sources` and `/reload-mcp` run
their Agent calls inside `api.mcp_runtime.mcp_runtime_scope()`, which binds the
request profile's home and secret scope (root profile included) without touching
`os.environ`. Status, tool count and inventory are read from one scope; the inventory
lists only tools in that profile's own registry slot for servers it configures.
- A connection *serves* a profile when that profile owns it (`_server_scope_keys`, else
the scope in the key) or adopted it (`_server_tool_scopes`, an identical shared
connection). `api.mcp_runtime.ledger_key_serves_view()` is the one predicate for
status, inventory and the reload summary. The Agent's launch-profile view (scope
`None`) is process-wide, so `filter_runtime_status_to_view()` downgrades
`get_mcp_status()` rows of servers the root profile does not serve to `configured`
rather than showing a routed profile's connection, tool count or connect error.
- The scope is trusted only when the Agent's routing decision matches the profile WebUI
resolved. When it cannot be confirmed (for example a same-profile turn's mirror on an
Agent without the pin), status and inventory withhold runtime data
(`runtime_scope: "unavailable"`, shown as a notice in the MCP panel) and
`/reload-mcp` refuses instead of resetting another owner's connection.
- `/reload-mcp` calls `shutdown_mcp_servers(scope=..., names=...)` with the profile's
own scope and the names of its live connections; `scope=None` without `names` is the
process-wide wildcard and is used only with Agents that predate profile-scoped MCP
(`runtime_scope: "legacy_process"`). A scoped shutdown only clears connect backoff for
the live keys it tears down, so WebUI also drops the profile's own cooldown/error
entries (`clear_profile_connect_cooldowns()`) before rediscovery: a server that failed
to spawn is retried by the reload, as the wildcard did, without touching another
owner's backoff.
Status and inventory stay passive: they never start or probe an MCP server. Ledger key
helpers resolve to Hermes Agent's `tools.mcp_tool_scope` when present so the key shape
has one owner; the local fallbacks only cover Agents that predate that module.
---
## 5. Frontend Architecture: Current State
@@ -534,7 +600,9 @@ to the active conversation rather than a global app setting.
Session management:
newSession() POST /api/session/new, update S.session, save to localStorage
loadSession(sid) GET /api/session?session_id=X, check INFLIGHT first, update S
loadSession(sid) GET /api/session?session_id=X (initial load uses the
bounded tail `msg_limit=30`; jump-to-start and outline
jump pass `msg_limit=all`), check INFLIGHT first, update S
deleteSession(sid) POST /api/session/delete, handle active/inactive cases correctly
renderSessionList() GET /api/sessions, rebuild #sessionList DOM
@@ -1402,7 +1470,14 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3).
/ Returns full HTML app (index page)
/index.html Same as /
/health {"status":"ok","sessions":N}
/api/session ?session_id=X -> full session + messages. 400 if no ID.
/api/session ?session_id=X -> session + messages. 400 if no ID.
Bare (no msg_limit) keeps the historical full-transcript
contract. Recovery paths request a bounded tail
(`msg_limit=30`) and restore `_messages_truncated` /
`_messages_offset` before persisting anchor-scene
metadata. Outline jump and jump-to-start opt in to the
full transcript via the explicit `msg_limit=all`
escape hatch. See #7310 / #7625 / #7628.
/api/sessions List of all session compact() dicts, sorted by updated_at
/api/list ?session_id=X&path=. -> directory listing for session workspace
/api/file ?session_id=X&path=rel -> file content (text, 200KB limit)
+122
View File
@@ -5,6 +5,9 @@
### Added
- **The settings file can live outside the state directory.** `HERMES_WEBUI_SETTINGS_FILE` points one
instance at its own `settings.json`, while sessions, workspaces and projects stay in the state
directory. It is read once at startup, so restart after changing it. (#6433 by @futureworld678-create)
- **Full-session resolve concurrency is configurable.** `HERMES_WEBUI_MAX_SESSION_RESOLVE` sets how
many full-transcript session resolves may run at once (default 2, a positive integer up to 64;
zero, negative, non-numeric or out-of-range values fall back to 2). It is process-wide, so a
@@ -17,6 +20,14 @@
### Performance
- **Reconnect, settle, cancel and undo no longer re-download the whole transcript.** Six recovery
paths (offline/bfcache refresh, stream-end settle, cancel sync, `/compress` preflight, `/retry` and
`/undo`) sent a bare `GET /api/session` that re-walked, re-redacted and re-serialized every row. The
author measured 4–15 s and 28 MB on a 5,003-row session, against 7 ms and 80 KB for the bounded tail.
They now request the 30-row tail. The two views that address rows by absolute index (outline jump,
jump-to-start) opt into the full transcript with the new `?msg_limit=all`, and session-level
`tool_calls` are windowed whenever the returned messages were actually truncated. A bare request
keeps its full-transcript contract. (#7310, #7625, #7628 by @happy5318)
- **Opening a session while its task is still running is much faster.** Rebuilding the live
snapshot from the run journal parsed the journal twice, walked every metering row and grew the
reasoning text with repeated string concatenation, which is quadratic on long runs. The journal
@@ -25,9 +36,120 @@
rebuild going from 4.34 s to 0.49 s with a byte-identical snapshot. The interim-echo check now matches a
compact-equivalent suffix without a fixed window, so an echo stretched by interior whitespace is
no longer shown twice. (#7310, #7569 by @happy5318)
- **Long transcripts with virtualization on stop re-measuring rows in a loop.** When the rendered
window switched back and forth between two positions, each switch reset the measurement retry
budget, so opt-in transcript virtualization could keep re-measuring rows instead of settling. The
budget now resets only when the window reaches a position it hasn't just visited. (#6654, #6717 by
@webtecnica)
### Fixed
- **Picking a model from a named custom provider sends the right model name.** Choosing a model
that belongs to a non-default custom provider (for example `@custom:my-server:model-x`) sent
the whole picker id, prefix included, to the provider, which rejected the request. The
`@custom:<slug>:` prefix is now stripped before sending and the provider is routed from the
slug. Endpoint-style slugs such as `custom:localhost:11434` keep their host and port.
(#6895 by @webtecnica, fixes #6884)
- **A gateway reset or tool conversation no longer disappears into the parent session.** When a
session ended by compression, the sidebar also treated a gateway reset child (stamped
`_reset_from`) as its continuation, so that separate conversation vanished from the list and its
transcript was stitched into the parent's. Reset and tool children now stay separate, matching
Hermes Agent's own continuation rule; a real compression continuation still joins the chain. A
lineage marker that can't be read is treated as a boundary, which keeps the row visible rather
than merging it. A change to a session's lineage markers alone now also refreshes the sidebar.
(#6565 by @ruizanthony)
- **A background tab stops polling a session that's gone, and still recovers after a profile
switch in another tab.** A hidden tab polling a deleted session used to loop on 404s every six
seconds. It now stops after three consecutive 404s but keeps the session as its resume target,
so if the 404 came from switching profile in another tab and you switch back, the tab reattaches
when you return to it. A queued poll response can no longer stop a replacement poll for the same
session. (#7301, @laitekin; completes the #7299 fix)
- **An image turn's provider context stays out of your message bubble, and Edit/Undo no longer
brings a removed image turn back.** When an image turn was mirrored into the Agent's state.db,
its rich provider payload could appear in the user bubble, and after Edit or Undo a removed
image-turn row could come back in the full, paginated and model-context reads. The bubble now
shows what you typed, the payload stays in model context only, and removed rows stay removed,
including a same-timestamp duplicate row and a reply that exists only in state.db after an
edited checkpoint. (#7754, @starship-s)
- **MCP status, tool inventory and `/reload-mcp` follow the profile you're using.** With several
profiles in one WebUI, a chat turn mirrored its profile into the process environment, so the Agent
saw every profile as the launch profile. The MCP panel could then show another profile's servers,
and `/reload-mcp` could restart them. Status, tool listing and reload now resolve through the
request's profile, and a reload in one profile leaves another profile's live MCP connections and
in-flight tool calls alone. This needs the Agent's `pin_process_hermes_home` (hermes-agent #120103).
On an older Agent the panel says live status is unavailable while a turn runs, and `/reload-mcp`
refuses, rather than guessing. (#7720 by @tancou, fixes #7721)
- **On a phone, Enter in the composer inserts a newline.** Some iPhones report a fine pointer to the
browser, so the phone-keyboard check fell through and plain Enter sent the message mid-sentence.
Phones (iPhone, iPod, and Android phones) now always get a newline on Enter; Ctrl/Cmd+Enter and
the Send button still send, and the Send-key setting still wins. Tablets and touch laptops keep
the existing check, so an iPad with a Magic Keyboard or an Android tablet with a Bluetooth
keyboard still sends on Enter (#3076). The saved Send-key preference is also read before the
first keypress, so the first Enter after a slow page load no longer sends. (#6746 by @happy5318)
- **Sessions archived in the CLI stay archived in the WebUI.** A cron, webhook, Kanban or CLI
session archived from the CLI came back as active in the sidebar whenever it had no WebUI sidecar,
because the projection treated a missing sidecar as "not archived". A missing sidecar now means no
opinion, so the state.db `archived` flag applies; archiving or unarchiving in the WebUI still wins.
In all-profiles mode, a CLI archive in another profile now refreshes the cached session list.
(#7548, #7798 by @webtecnica)
- **Approval and clarify prompts send a browser notification whenever you aren't looking at them.**
A card that surfaced through the normal prompt path never produced a notification, and the
visibility gate muted cards in a tab that was visible but unfocused. Now every approval or clarify
surfacing notifies unless it belongs to the session open in the pane and the tab is both visible
and focused. Notifications from other sessions still arrive while you work. When permission is
still undecided, the browser is asked at most once per page load, so a pending prompt's 1.5 s
re-surfacing no longer repeats the permission request or the "notifications denied" toast. (#7493 by
@CharlesMcquade)
- **Delegated subagent sessions show inside their parent's project.** With a project selected in the
sidebar, subagent sessions disappeared because `state.db` never gives them a `project_id`. Only
subagent rows now inherit their parent's project, resolved once per lineage, so forks keep their
own "No project" assignment and deep lineages stay fast. (#7765 by @carlotestor)
- **Saving a cron job with the model picker at "Default" keeps its provider-only pin.** Editing a job
pinned to a provider with no model (the usual shape for a self-hosted OpenAI-compatible router)
sent `provider: null` and wiped the pin. The editor now preserves a provider-only pin when the
model picker is left at Default. (#7779 by @cushingw)
- **A consumed mid-turn `/steer` no longer leaves its out-of-band wrapper in the settled chat (#7600).**
A `/steer` reaches the agent as an `[OUT-OF-BAND USER MESSAGE …]` block appended to the turn's
last tool result. After the turn settled, that wrapper stayed visible in the chat transcript. The
settled-transcript writeback now scrubs the consumed wrapper from the rows it is built from.
(#7610 by @webtecnica)
- **Saving a very large session no longer reads and parses the whole file just to count messages.**
The #1558 backup safeguard in `Session.save()` needs the on-disk message count; it obtained it by
loading the entire sidecar. On a real 203 MB / 266,940-message session that made every save
expensive. The count is now taken without a full parse, and the shrink-backup behaviour is
unchanged. (#7578 by @rodrigogs)
- **A cancelled or recovered turn no longer duplicates an answer that was already saved (#6366).**
When a completed assistant turn had been persisted and a later cancel/recovery path ran for the
same pending turn, recovery could append a duplicate user turn plus a `_partial` clone of the
journal. Recovery now stops once the transcript has already advanced past the pending turn.
(#7682 by @happy5318)
- **The live stream reports which model actually served the turn.** A new additive `runtime_model`
SSE event carries the model (and provider, when known) that the Agent reported while producing
output, separately from the model that was requested. It is journaled for replay, is exposed as
`runtime_journal_snapshot.runtime_model`, never falls back to the configured selection when
unknown, and never changes the model requested for the next turn. Existing clients ignore it.
(#7767 by @ruizanthony)
- **Reconnecting to a new turn no longer resumes from the previous turn's replay cursor.** A new
turn copies the full transcript into the in-flight state, so the previous turn's assistant reply
could seed the replay floor and the reattached stream skipped the current reply's early events.
Replay now seeds only from the current live assistant row, and falls back to a full replay that
rebuilds the assistant body when a cursor outlives its live state. (#7651 by @happy5318)
- **Context-length lookup keeps the configured base URL for ownerless and underscore-named
providers.** The #7535 ownership guard also dropped the global `model.base_url` for a `model:`
section with a `base_url` but no `provider`, and for provider IDs written with an underscore
(`opencode_go` vs `opencode-go`), so those sessions could resolve the wrong context window. Both
now keep the URL, while a different declared owner still does not leak its endpoint.
(#7743 by @webtecnica)
- **A session deleted during a restart no longer produces a spurious recovery warning.** When
WebUI startup recovery re-attached background processes, a session that had vanished between
enumeration and rebind raised a `KeyError` that was logged as a warning. It now follows the
existing skip path, confined to the session lookup, so the vanished owner is skipped, live owners
still rebind, and registry errors still warn. (#7753, #7774 by @happy5318)
- **Waiting on the Agent's session lease is shown as a warning instead of looking stuck.** When
another Hermes process (gateway, CLI or cron) holds the session's turn lease, the Agent's
"another Hermes process is using this session" notices now reach the chat as a warning status
+4 -1
View File
@@ -220,7 +220,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup
- Session actions via `⋯` dropdown per session — pin, move to project, archive, duplicate, delete
- Pin/star sessions to the top of the sidebar (gold indicator)
- Archive sessions (hide without deleting, toggle to show)
- Session projects -- named groups with colors for organizing sessions
- Session projects -- named groups with colors for organizing sessions; delegated subagent sessions have no project of their own and follow their nearest ancestor's project in the project filter and the Unassigned chip; forks and other child sessions keep their own project, so a fork moved to "No project" stays Unassigned
- Session tags -- add #tag to titles for colored chips and click-to-filter
- Grouped by Today / Yesterday / Earlier in the sidebar (collapsible date groups)
- Download as Markdown transcript, full JSON export, or import from JSON
@@ -285,12 +285,14 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup
### Settings and configuration
- **Hermes Control Center** (sidebar launcher button) -- Conversation tab (export/import/clear), Preferences tab (model, send key, theme, language, all toggles), System tab (version, password)
- Send key: Enter (default) or Ctrl/Cmd+Enter
- Send-key on touch devices: plain Enter inserts a newline on phones (iPhone/iPod, Android phones) and on tablets / iPadOS / touch-capable Macs that only expose a coarse pointer (no attached hardware keyboard), matching the software keyboard's return key. Devices that report a fine pointer (for example, a tablet with a hardware keyboard) keep the configured physical-keyboard send-key behavior. The configured shortcut and the Send button remain available for sending.
- Show/hide CLI sessions toggle (enabled by default)
- Token usage display toggle (off by default, also via `/usage` command)
- Control Center always opens on the Conversation tab; resets on close
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
- Cron completion alerts -- toast notifications and unread badges scoped to the active profile on the Tasks tab and session sidebar
- Background agent error alerts -- banner when a non-active session encounters an error
- Approval and clarification browser alerts notify once per pending owner when its session is not actively viewed. Local approval dismissal hides attention without resolving the prompt; resolution, replacement, and cancellation retire that owner's alert state. Denied or failed notification delivery can retry while the prompt remains pending.
### Slash commands
- Type `/` in the composer for autocomplete dropdown
@@ -361,6 +363,7 @@ Full list of environment variables:
| `HERMES_WEBUI_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for all IPv4, `::` for all IPv6, `::1` for IPv6 loopback) |
| `HERMES_WEBUI_PORT` | `8787` | Port |
| `HERMES_WEBUI_STATE_DIR` | `$HERMES_HOME/webui` (Windows default `%LOCALAPPDATA%\hermes\webui`, POSIX default `~/.hermes/webui`) | Where sessions and state are stored. **Note (upgrade):** the default now follows `HERMES_HOME` — if you previously relocated `HERMES_HOME` to a non-default base **without** setting `HERMES_WEBUI_STATE_DIR`, your WebUI state now resolves to `$HERMES_HOME/webui` instead of the old platform-default `~/.hermes/webui`. To keep using the old location, set `HERMES_WEBUI_STATE_DIR` to it (or move the directory). Installs with `HERMES_HOME` unset or at the default base are unaffected. |
| `HERMES_WEBUI_SETTINGS_FILE` | `<state dir>/settings.json` | Optional path for this instance's WebUI settings file (sessions, workspaces and projects stay in the state directory). Read once at startup, so restart after changing it |
| `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace |
| `HERMES_WEBUI_DEFAULT_MODEL` | *(provider default)* | Optional model override; leave unset to use the active Hermes provider default |
| `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication |
+68 -29
View File
@@ -366,28 +366,59 @@ def is_cli_session_row_visible(row: dict) -> bool:
return _count_user_turns(row) >= CLI_MIN_UNTITLED_USER_MESSAGE_COUNT
def _branch_markers(row: dict | None) -> tuple[str | None, str | None]:
"""Return ``(_branched_from, _delegate_from)`` from a row's ``model_config``.
# Every model_config marker Hermes Agent binds to ``parent_session_id`` in its
# non-continuation child predicate (``_NON_CONTINUATION_CHILD_FILTER_SQL``):
# ``_delegate_from`` (delegate_task), ``_branched_from`` (/branch) and
# ``_reset_from`` (gateway reset children, stamped at creation or durably by
# ``reopen_session()`` in ``gateway/session_recovery.py``).
_MODEL_CONFIG_LINEAGE_KEYS = ('_delegate_from', '_branched_from', '_reset_from')
Hermes Agent stamps explicit branches and delegate/subagent runs in the
``model_config`` JSON column (``_branched_from`` / ``_delegate_from``);
``session_source='fork'`` alone only covers WebUI-created forks. Missing,
empty, or unparsable ``model_config`` degrades to ``(None, None)``.
def _branch_markers(row: dict | None) -> tuple[str, dict[str, str]]:
"""Return ``(state, markers)`` for a row's ``model_config`` lineage identity.
Hermes Agent stamps explicit branches, delegate/subagent runs and gateway
reset children in the ``model_config`` JSON column (see
``_MODEL_CONFIG_LINEAGE_KEYS``); ``session_source='fork'`` alone only
covers WebUI-created forks.
``state`` is one of:
- ``'none'``: no ``model_config``, or an object carrying no lineage marker;
- ``'markers'``: every non-null marker is a non-empty string, returned by key;
- ``'unknown'``: identity evidence exists but cannot be trusted (payload is
not JSON, not a JSON object, too deeply nested to decode, or a marker is
not a non-empty string). Callers must fail closed on this state and treat
the row as a lineage boundary, never as a continuation.
"""
if not row:
return None, None
return 'none', {}
raw = row.get('model_config')
if isinstance(raw, dict):
config = raw
elif isinstance(raw, str) and raw.strip():
if raw is None:
return 'none', {}
if isinstance(raw, (str, bytes, bytearray)):
if not raw.strip():
return 'none', {}
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
parsed = None
config = parsed if isinstance(parsed, dict) else {}
config = json.loads(raw)
except (TypeError, ValueError, RecursionError):
# RecursionError: a valid but pathologically deep payload exceeds
# the decoder's recursion limit; it is untrusted identity evidence
# like any other undecodable payload, never an escaping crash.
return 'unknown', {}
else:
config = {}
return config.get('_branched_from'), config.get('_delegate_from')
config = raw
if not isinstance(config, dict):
return 'unknown', {}
markers: dict[str, str] = {}
for key in _MODEL_CONFIG_LINEAGE_KEYS:
value = config.get(key)
if value is None:
continue
if not isinstance(value, str) or not value.strip():
return 'unknown', {}
markers[key] = value.strip()
return ('markers', markers) if markers else ('none', {})
def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
@@ -405,30 +436,36 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
conversation; otherwise the tip inherits the root's title/source metadata and
can disappear under messaging/sidebar policies.
Explicit branches/delegates never continue: a child whose ``model_config``
``_branched_from`` / ``_delegate_from`` points at this parent is a fork of
the conversation and must stay visible. Beyond the bounded tolerance window
there is no reliable continuation signal — titles are user-controlled and
non-unique, so they are never used to widen the window (#7021 re-gate).
Explicit branches/delegates/resets never continue: a child whose
``model_config`` ``_branched_from`` / ``_delegate_from`` / ``_reset_from``
points at this parent is a separate conversation and must stay visible.
Unparsable or non-string marker evidence fails closed as a boundary.
Beyond the bounded tolerance window there is no reliable continuation
signal — titles are user-controlled and non-unique, so they are never used
to widen the window (#7021 re-gate).
"""
if not parent or not child:
return False
if str(child.get('session_source') or '').strip().lower() == 'fork':
return False
# Real Agent branches/delegates are marked in model_config (not
# Real Agent branches/delegates/resets are marked in model_config (not
# session_source, which only WebUI-created forks carry). The marker must
# reference THIS parent: compression continuations inherit the rotated
# agent's model_config verbatim, so presence alone (a delegate's
# continuation still carries the delegate's own ``_delegate_from``) would
# misclassify real continuations.
child_branched_from, child_delegate_from = _branch_markers(child)
parent_id = parent.get('id')
if parent_id and (
child_branched_from == parent_id or child_delegate_from == parent_id
):
# misclassify real continuations. Untrusted identity evidence fails closed.
marker_state, child_markers = _branch_markers(child)
if marker_state == 'unknown':
return False
parent_id = str(parent.get('id') or '').strip()
if parent_id and parent_id in child_markers.values():
return False
parent_source = str(parent.get('source') or '').strip().lower()
child_source = str(child.get('source') or '').strip().lower()
# Agent lineage excludes tool children even when the parent has the same
# source (or no source); a timestamp overlap cannot override that boundary.
if child_source == 'tool':
return False
if parent_source and child_source and parent_source != child_source:
return False
if parent.get('end_reason') not in {'compression', 'cli_close'}:
@@ -576,7 +613,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
# expects from "Show agent sessions" sorted by activity.
for key in (
'id', 'model', 'message_count', 'actual_message_count', 'actual_user_message_count',
'ended_at', 'end_reason', 'last_activity',
'ended_at', 'end_reason', 'last_activity', 'archived',
):
if key in tip:
merged[key] = tip[key]
@@ -683,6 +720,7 @@ def read_importable_agent_session_rows(
origin_chat_id_expr = _optional_col('origin_chat_id', session_cols)
origin_user_id_expr = _optional_col('origin_user_id', session_cols)
platform_expr = _optional_col('platform', session_cols)
archived_expr = _optional_col('archived', session_cols)
# Older/minimal state.db schemas can have NO ``messages`` table at all,
# or a ``messages`` table without a ``session_id`` / ``timestamp`` column.
# The projection SQL below joins ``messages`` and aggregates
@@ -793,6 +831,7 @@ def read_importable_agent_session_rows(
{parent_expr},
{ended_expr},
{end_reason_expr},
{archived_expr},
{actual_count_expr} AS actual_message_count,
{user_message_count_expr} AS actual_user_message_count,
{last_activity_expr} AS last_activity
+16 -1
View File
@@ -1883,7 +1883,22 @@ def recover_processes_for_webui(process_registry=None, get_session_fn=None) -> i
try:
proc_session = process_registry.get(process_id)
session_key = str(getattr(proc_session, "session_key", "") or "")
if not session_key or get_session_fn(session_key, metadata_only=True) is None:
if not session_key:
continue
# The session resolver (``api.models._resolve_session_once``)
# raises ``KeyError(sid)`` for a missing session — that is its
# documented contract for the "owner gone" outcome, not a
# fault. Treat it identically to a ``None`` return: skip the
# process without logging, and let a neighbouring live
# process still rebind. The narrow ``except KeyError`` lives
# INSIDE the outer try so an unrelated ``KeyError`` from
# ``process_registry.get()`` (or any routing-index work) still
# surfaces in the existing warning path.
try:
resolved = get_session_fn(session_key, metadata_only=True)
except KeyError:
continue
if resolved is None:
continue
except Exception:
logger.warning(
+80 -11
View File
@@ -257,11 +257,28 @@ def _run_codex_runtime_command(arg_string: str) -> str:
def _run_reload_mcp_command() -> str:
"""Execute the MCP reconnect path and return a short user-facing summary."""
"""Execute the MCP reconnect path and return a short user-facing summary.
Scoped to the request profile: only that profile's connections are shut
down and rediscovered from that profile's config, so other profiles'
same-named servers keep running (agents with profile-scoped MCP; older
agents keep their process-wide reload).
"""
with _RELOAD_MCP_LOCK:
try:
import importlib
from tools.mcp_tool import _servers, _lock
# Resolved by full name (like the ``from`` import above) so a stubbed
# ``tools.mcp_tool`` without a ``tools`` package still works.
_core = importlib.import_module("tools.mcp_tool")
from api.agent_compat import agent_attr
from api.mcp_runtime import (
accepts_keywords,
clear_profile_connect_cooldowns,
ledger_key_serves_view,
mcp_key_name,
mcp_runtime_scope,
)
shutdown_mcp_servers = agent_attr(
"tools.mcp_tool", "shutdown_mcp_servers", "tools.mcp_tool_lifecycle"
@@ -269,22 +286,74 @@ def _run_reload_mcp_command() -> str:
discover_mcp_tools = agent_attr(
"tools.mcp_tool", "discover_mcp_tools", "tools.mcp_tool_discovery"
)
try:
register_mcp_servers = agent_attr(
"tools.mcp_tool", "register_mcp_servers", "tools.mcp_tool_discovery"
)
except Exception:
register_mcp_servers = None # older agent: no overlay reconciliation
except Exception as exc:
logger.warning("Failed to import MCP runtime for /reload-mcp", exc_info=True)
raise RuntimeError("MCP runtime unavailable") from exc
try:
with _lock:
old_servers = set(_servers.keys())
with mcp_runtime_scope("/reload-mcp") as view:
if not view.trusted:
# Resetting under an unconfirmed scope could stop another profile's
# connection or rebuild this one under the wrong owner.
raise RuntimeError(
"MCP reload unavailable: this profile's MCP runtime scope could not be "
"confirmed (a chat turn on this profile may be running); retry when it finishes"
)
try:
# Profile-scoped teardown needs both the scoped ledger and a
# shutdown that takes scope/names; otherwise keep the legacy reload.
legacy = view.legacy or not accepts_keywords(shutdown_mcp_servers, "scope", "names")
shutdown_mcp_servers()
new_tools = discover_mcp_tools()
def live_names() -> set:
# Same criteria as the status page: the connection serves this
# profile (owned or adopted) and is live. A parked task (failed
# spawn retained for retry) has no session and is not "connected".
return {
mcp_key_name(key) for key, server in _servers.items()
if (legacy or ledger_key_serves_view(_core, key, view))
and getattr(server, "session", True) is not None
}
with _lock:
connected_servers = set(_servers.keys())
except Exception as exc:
logger.warning("Failed to reload MCP servers", exc_info=True)
raise RuntimeError("Failed to reload MCP servers") from exc
with _lock:
old_servers = live_names()
# Every ledger entry serving this profile, parked ones included:
# a parked task left in place would make discovery skip the server.
owned_names = {
mcp_key_name(key) for key in _servers
if ledger_key_serves_view(_core, key, view)
}
if legacy:
shutdown_mcp_servers()
else:
# ``names`` is required: ``scope=None`` alone is the process-wide
# wildcard. Only names with a ledger key are passed; shutdown
# ignores anything else, and a key of another owner is never selected.
shutdown_mcp_servers(scope=view.registry_scope, names=owned_names)
# The wildcard also dropped connect backoff; keep that for this
# profile's own failed servers so the reload retries them now.
clear_profile_connect_cooldowns(_core, view)
# A connection this profile ADOPTED (another profile's identical
# route) is not torn down above, and ``discover_mcp_tools()``
# returns before its reconcile step when the profile's config
# has no server left, so the adopted tools would stay callable.
# ``register_mcp_servers({})`` judges every server serving this
# scope against the profile's config on disk and detaches only
# this profile's overlay; the owner's connection keeps running.
if register_mcp_servers is not None and view.registry_scope is not None:
register_mcp_servers({})
new_tools = discover_mcp_tools()
with _lock:
connected_servers = live_names()
except Exception as exc:
logger.warning("Failed to reload MCP servers", exc_info=True)
raise RuntimeError("Failed to reload MCP servers") from exc
added = connected_servers - old_servers
removed = old_servers - connected_servers
+10 -1
View File
@@ -98,10 +98,19 @@ STATE_DIR = (
.resolve()
)
def _resolve_settings_file(state_dir: Path) -> Path:
"""Resolve an optional per-instance settings file without splitting session state."""
configured = os.getenv("HERMES_WEBUI_SETTINGS_FILE", "").strip()
if configured:
return Path(configured).expanduser().resolve()
return state_dir / "settings.json"
SESSION_DIR = STATE_DIR / "sessions"
WORKSPACES_FILE = STATE_DIR / "workspaces.json"
SESSION_INDEX_FILE = SESSION_DIR / "_index.json"
SETTINGS_FILE = STATE_DIR / "settings.json"
SETTINGS_FILE = _resolve_settings_file(STATE_DIR)
LAST_WORKSPACE_FILE = STATE_DIR / "last_workspace.txt"
PROJECTS_FILE = STATE_DIR / "projects.json"
+32 -1
View File
@@ -38,6 +38,7 @@ from api.config import (
from api.helpers import _redact_text, redact_session_data
from api.models import clear_process_wakeup_pause, get_session, merge_session_messages_append_only
from api.run_journal import RunJournalWriter, bound_run_journal_snapshot_args
from api.turn_journal import append_turn_journal_event_for_stream
logger = logging.getLogger(__name__)
@@ -1515,7 +1516,11 @@ def _run_gateway_chat_streaming(
# role/content ordering instead of turn order.
assistant_ts = now + 0.000001
pending_source = getattr(s, "pending_user_source", None) or "webui"
from api.streaming import _active_turn_authority, _materialize_active_turn_user
from api.streaming import (
_active_turn_authority,
_active_turn_token_matches,
_materialize_active_turn_user,
)
active_turn_identity = _active_turn_authority(s, stream_id, msg_text)
user_msg = _materialize_active_turn_user(
@@ -1576,6 +1581,9 @@ def _run_gateway_chat_streaming(
s.context_messages,
str(msg_text or ""),
source=pending_source,
verification_nudge_provenance={
"active_turn_identity": active_turn_identity,
},
)
except Exception:
logger.debug("Failed to merge gateway display transcript", exc_info=True)
@@ -1588,6 +1596,14 @@ def _run_gateway_chat_streaming(
if latest_text == msg_norm:
display = display[:-1]
s.messages = display + [user_msg, assistant_msg]
if active_turn_identity.get("token"):
current_display_rows = [
message
for message in s.messages
if _active_turn_token_matches(message, active_turn_identity)
]
if len(current_display_rows) == 1:
current_display_rows[0]["timestamp"] = user_msg["timestamp"]
s.active_stream_id = None
s.gateway_run = None
s.pending_user_message = None
@@ -1623,6 +1639,21 @@ def _run_gateway_chat_streaming(
if cancel_event.is_set():
_restore_cancelled_success_writeback()
return
# #6366 re-gate: record the durable same-stream completion
# event in the crash-safe turn journal. The run journal's
# terminal state is only reached on its own ``stream_end``
# write path, so a Gateway run whose terminal write is lost
# would otherwise leave no completion evidence at all and
# stale-cancel recovery would re-append a duplicate
# recovered row after the valid final answer.
try:
append_turn_journal_event_for_stream(
session_id,
stream_id,
{"event": "completed", "created_at": time.time()},
)
except Exception:
logger.debug("Failed to append completed turn journal event", exc_info=True)
success_writeback_committed = True
try:
from api.goals import evaluate_goal_after_turn, has_active_goal
+26 -10
View File
@@ -9,6 +9,7 @@ This enables real-time session list updates in the sidebar without
requiring any changes to hermes-agent.
"""
import hashlib
import json
import logging
import os
import queue
@@ -27,12 +28,27 @@ logger = logging.getLogger(__name__)
# ── State hash tracking ─────────────────────────────────────────────────────
def _snapshot_hash(sessions: list) -> str:
"""Create a lightweight hash of session IDs and timestamps for change detection."""
key = '|'.join(
f"{s['session_id']}:{s.get('updated_at', 0)}:{s.get('message_count', 0)}"
for s in sorted(sessions, key=lambda x: x['session_id'])
)
return hashlib.md5(key.encode(), usedforsecurity=False).hexdigest()
"""Hash the complete published session payload for change detection.
Every emitted field participates, not only the id / activity timestamp /
message count triple. Projection authority (compression collapse,
``model_config`` lineage markers, title promotion) can change a row's
``title``, ``created_at`` or source metadata while that triple stays fixed;
hashing only the triple let the projection rerun, keep ``_last_sessions``
stale and emit no ``sessions_changed`` event. Entries are canonicalised
(sorted keys, stable separators) and ordered by ``session_id`` so the hash
is deterministic and independent of sidebar ordering; cost is bounded by
the projection's own row limit.
"""
digest = hashlib.md5(usedforsecurity=False)
for session in sorted(sessions, key=lambda x: str(x.get('session_id') or '')):
digest.update(
json.dumps(
session, sort_keys=True, separators=(',', ':'), default=str
).encode('utf-8', 'replace')
)
digest.update(b'\x1e')
return digest.hexdigest()
# Sources excluded from the WebUI sidebar projection. Must match the default
@@ -70,10 +86,10 @@ def _cheap_change_fingerprint(db_path: Path) -> str | None:
# are always present (``source`` is required for the projection to run at
# all); the rest are optional on older agent schemas and filtered below.
_PROJECTION_SESSION_COLS = (
'id', 'source', 'session_source', 'title', 'model', 'message_count',
'started_at', 'ended_at', 'end_reason', 'parent_session_id', 'archived',
'user_id', 'chat_id', 'chat_type', 'thread_id', 'session_key',
'origin_chat_id', 'origin_user_id', 'platform',
'id', 'source', 'session_source', 'model_config', 'title', 'model',
'message_count', 'started_at', 'ended_at', 'end_reason',
'parent_session_id', 'archived', 'user_id', 'chat_id', 'chat_type',
'thread_id', 'session_key', 'origin_chat_id', 'origin_user_id', 'platform',
)
try:
with closing(open_state_db_readonly(db_path)) as conn:
+2
View File
@@ -24,6 +24,8 @@ _PUBLIC_MESSAGE_INTERNAL_FIELDS = frozenset({
"_active_turn_token",
"_active_turn_user",
"_fork_child_turn",
"_webui_trusted_agent_input_text",
"_webui_unmatched_native_image_mirror",
})
+325
View File
@@ -0,0 +1,325 @@
"""Profile-bound view of Hermes Agent's in-process MCP runtime.
The MCP status page, the tool inventory and ``/reload-mcp`` read or reset
Hermes Agent's MCP ledger from the HTTP request thread. Hermes Agent scopes
that ledger per profile (connection key ``(profile_home_key, name)``) only
when the calling task serves a *routed* profile, i.e. its context-local
Hermes-home override differs from the process home. These helpers bind the
request profile's home (without touching ``os.environ``) and check that the
agent's routing decision matches the profile WebUI resolved, so a read never
shows another profile's connection and a reload never resets one.
Ledger helpers below take ``core`` (``tools.mcp_tool``) explicitly and read
its maps with defaults, so an agent that lacks a map degrades to "unscoped"
rather than raising inside a status read.
"""
from __future__ import annotations
import importlib
import inspect
import logging
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Generator, Iterable, Optional
logger = logging.getLogger(__name__)
# Scope label reported by the MCP endpoints.
SCOPE_PROFILE = "profile" # runtime view bound to the request profile
SCOPE_LEGACY = "legacy_process" # agent predates profile-scoped MCP; process-wide view
SCOPE_UNAVAILABLE = "unavailable" # profile scope could not be confirmed; runtime hidden
@dataclass(frozen=True)
class McpRuntimeView:
"""How the current context sees Hermes Agent's MCP runtime.
``trusted``: runtime reads/writes in this context belong to ``profile_home``.
``legacy``: the agent has no profile-scoped MCP ledger (process-wide by name).
``registry_scope``: the tool-registry slot owning this profile's MCP tools
(the profile overlay key when routed, ``None`` for the process profile).
"""
profile_home: Path
trusted: bool
legacy: bool
registry_scope: Optional[str] = None
@property
def scope_label(self) -> str:
if not self.trusted:
return SCOPE_UNAVAILABLE
return SCOPE_LEGACY if self.legacy else SCOPE_PROFILE
def _routing_view(profile_home: Path, override_bound: bool) -> McpRuntimeView:
try:
from agent.secret_scope import serves_routed_profile
from hermes_constants import hermes_home_key
from tools.registry import registry
except ImportError:
return McpRuntimeView(profile_home, trusted=True, legacy=True)
from api.profiles import get_process_profile_home
try:
expected_routed = hermes_home_key(profile_home) != hermes_home_key(get_process_profile_home())
# Without the override a routed profile cannot be served; with it, the agent must
# agree. It disagrees when a streaming turn has mirrored this profile's home into
# os.environ['HERMES_HOME'] on an agent without a pinned process home.
if expected_routed and not override_bound:
return McpRuntimeView(profile_home, trusted=False, legacy=False)
if bool(serves_routed_profile()) != expected_routed:
return McpRuntimeView(profile_home, trusted=False, legacy=False)
scope = registry.current_scope_key() if expected_routed else None
except Exception:
logger.debug("Failed to resolve MCP runtime scope for %s", profile_home, exc_info=True)
return McpRuntimeView(profile_home, trusted=False, legacy=False)
return McpRuntimeView(profile_home, trusted=True, legacy=False, registry_scope=scope)
@contextmanager
def mcp_runtime_scope(purpose: str) -> Generator[McpRuntimeView, None, None]:
"""Bind the active request profile for Hermes Agent MCP runtime calls.
Installs the context-local Hermes-home override and secret scope for the
request profile (including the root profile) and restores them on exit,
including on exceptions. Never mutates ``os.environ``, starts, or probes
MCP servers.
"""
from api.profiles import get_active_hermes_home, profile_env_for_active_request_readonly
profile_home = Path(get_active_hermes_home())
with profile_env_for_active_request_readonly(
purpose, logger_override=logger, include_root=True
) as override_bound:
yield _routing_view(profile_home, bool(override_bound))
def accepts_keywords(fn: Callable, *names: str) -> bool:
"""True when *fn*'s signature exposes every keyword in *names*.
Introspection failures (builtins, C callables, exotic wrappers) count as
"not supported" so callers fall back to the bare call instead of dropping
the whole read.
"""
try:
params = inspect.signature(fn).parameters
except (TypeError, ValueError):
return False
return all(name in params for name in names)
# ── Ledger keys ──────────────────────────────────────────────────────────────
# Hermes Agent's own helpers (``tools.mcp_tool_scope``) are preferred so the key
# shape has one owner; the lambdas only cover agents that predate that module.
def _key_helper(name: str, fallback: Callable) -> Callable:
try:
# Full-name import: works with a stubbed module and no ``tools`` package.
mcp_tool_scope = importlib.import_module("tools.mcp_tool_scope")
except Exception:
return fallback
helper = getattr(mcp_tool_scope, name, None)
return helper if callable(helper) else fallback
def mcp_key_name(key: Any) -> str:
"""Server name of an MCP connection-ledger key (bare name or ``(scope, name)``)."""
return str(_key_helper("_key_name", lambda k: k[1] if isinstance(k, tuple) else k)(key))
def mcp_key_scope(key: Any) -> Optional[str]:
"""Owning registry scope encoded in a ledger key (``None`` for a bare key)."""
return _key_helper("_key_scope", lambda k: k[0] if isinstance(k, tuple) else None)(key)
def ledger_key_owner(core: Any, key: Any) -> Optional[str]:
"""Scope owning the connection under *key*: recorded at adoption, else the key's own."""
owners = getattr(core, "_server_scope_keys", None)
if isinstance(owners, dict) and key in owners:
return owners[key]
return mcp_key_scope(key)
def ledger_key_serves_view(core: Any, key: Any, view: McpRuntimeView) -> bool:
"""True when the connection under *key* serves the profile in *view*.
Owned by the view's registry scope, or adopted into it (a routed profile
sharing an identical connection; ``_server_tool_scopes``). Hermes Agent's
own launch-profile view (scope ``None``) is process-wide, so this predicate
is what narrows the root profile to its unscoped connections. Legacy agents
have one process-wide ledger, so every key counts.
"""
if view.legacy:
return True
if ledger_key_owner(core, key) == view.registry_scope:
return True
if view.registry_scope is None:
return False
adopters = getattr(core, "_server_tool_scopes", None)
if not isinstance(adopters, dict):
return False
return view.registry_scope in (adopters.get(key) or ())
@dataclass(frozen=True)
class ServedRuntime:
"""Runtime state of one server name as seen from a profile's ledger keys."""
live_tools: Optional[int] = None # tool count of a live (session-bearing) connection
connecting: bool = False
error: Optional[str] = None
lazy_tools: Optional[int] = None
@property
def live(self) -> bool:
return self.live_tools is not None
def _served_keys(core: Any, ledger: str, view: McpRuntimeView) -> list:
keys = getattr(core, ledger, None)
if not isinstance(keys, (dict, set, frozenset, list, tuple)):
return []
return [key for key in list(keys) if ledger_key_serves_view(core, key, view)]
def view_served_runtime(core: Any, view: McpRuntimeView) -> dict[str, ServedRuntime]:
"""Per-name runtime state of the ledger entries serving *view*.
Caller holds ``core._lock`` or tolerates a racy read (status surfaces).
"""
live: dict[str, int] = {}
connecting: set[str] = set()
errors: dict[str, str] = {}
lazy: dict[str, int] = {}
servers = getattr(core, "_servers", None)
if isinstance(servers, dict):
for key in _served_keys(core, "_servers", view):
server = servers.get(key)
if server is None or getattr(server, "session", None) is None:
continue # parked / mid-connect task: retained, not connected
names = getattr(server, "_registered_tool_names", None)
if not isinstance(names, (list, tuple, set)):
names = getattr(server, "_tools", None) or ()
live[mcp_key_name(key)] = len(names)
for key in _served_keys(core, "_server_connecting", view):
connecting.add(mcp_key_name(key))
error_map = getattr(core, "_server_connect_errors", None)
if isinstance(error_map, dict):
for key in _served_keys(core, "_server_connect_errors", view):
errors[mcp_key_name(key)] = str(error_map.get(key, ""))
lazy_tools = getattr(core, "_lazy_server_tool_names", None)
for key in _served_keys(core, "_lazy_server_configs", view):
count = lazy_tools.get(key) if isinstance(lazy_tools, dict) else None
lazy[mcp_key_name(key)] = len(count) if isinstance(count, (list, tuple, set)) else 0
out: dict[str, ServedRuntime] = {}
for name in set(live) | connecting | set(errors) | set(lazy):
out[name] = ServedRuntime(
live_tools=live.get(name),
connecting=name in connecting,
error=errors.get(name),
lazy_tools=lazy.get(name),
)
return out
_RUNTIME_ROW_FIELDS = ("connected", "tools", "status", "error", "sampling", "tool_schemas")
def filter_runtime_status_to_view(statuses: Iterable[Any], view: McpRuntimeView) -> list:
"""Rebuild the runtime fields of ``get_mcp_status()`` rows from the keys serving *view*.
Under a routed scope the agent already filters by owner/adopter; under the
launch profile (scope ``None``) it reports every profile's connection and
indexes them by bare name, so the root profile would show "Active, N tools"
for a routed profile's server it never connected (or its own parked server
as connected because another profile's same-named one is live). Rows keep
their config-derived fields (name, transport, disabled) and get
``connected`` / ``tools`` / ``status`` / ``error`` from this profile's own
ledger entries only.
"""
rows = [row for row in statuses if isinstance(row, dict)]
if view.legacy or not rows:
return rows
try:
core = importlib.import_module("tools.mcp_tool")
except Exception:
return rows
lock = getattr(core, "_lock", None)
try:
if lock is not None:
with lock:
served = view_served_runtime(core, view)
else:
served = view_served_runtime(core, view)
except Exception:
logger.debug("Failed to read MCP ledger ownership for %s", view.profile_home, exc_info=True)
return []
out = []
for row in rows:
rebuilt = {k: v for k, v in row.items() if k not in _RUNTIME_ROW_FIELDS}
state = served.get(str(row.get("name")))
disabled = bool(row.get("disabled")) or row.get("status") == "disabled"
rebuilt["connected"] = bool(state and state.live)
rebuilt["tools"] = state.live_tools if state and state.live else (
state.lazy_tools if state and state.lazy_tools is not None and not disabled else 0
)
if state and state.live:
rebuilt["status"] = "connected"
elif disabled:
rebuilt["status"] = "disabled"
elif state and state.connecting:
rebuilt["status"] = "connecting"
elif state and state.error is not None:
rebuilt["status"] = "failed"
rebuilt["error"] = state.error
elif state and state.lazy_tools is not None:
rebuilt["status"] = "lazy"
else:
rebuilt["status"] = "configured"
out.append(rebuilt)
return out
_COOLDOWN_LEDGERS = ("_server_connect_retry_after", "_server_connect_failures", "_server_connect_errors")
def clear_profile_connect_cooldowns(core: Any, view: McpRuntimeView) -> None:
"""Forget connect backoff/error state of the servers *view*'s profile owns.
A server that failed to spawn never reaches ``_servers``, so a scoped
``shutdown_mcp_servers(scope=..., names=...)`` (which clears cooldowns for
the live keys it tears down) leaves its backoff in place and the following
``discover_mcp_tools()`` skips it. The process-wide wildcard cleared every
cooldown; this keeps ``/reload-mcp`` a retry for the profile's own servers
without touching another owner's backoff. Owned keys only, never adopted ones.
"""
lock = getattr(core, "_lock", None)
if lock is None:
return
with lock:
for ledger in _COOLDOWN_LEDGERS:
entries = getattr(core, ledger, None)
if not isinstance(entries, dict):
continue
for key in [k for k in entries if ledger_key_owner(core, k) == view.registry_scope]:
entries.pop(key, None)
def registry_tool_owned_by_view(registry, tool_name: str, view: McpRuntimeView) -> bool:
"""True when *tool_name* is registered in this profile's own registry slot.
The merged registry view overlays a profile's tools on the global slot, so a
routed profile would otherwise also list the process profile's MCP tools.
"""
if view.legacy:
return True
snapshot = getattr(registry, "snapshot_registration", None)
if not callable(snapshot):
return True
try:
return snapshot(tool_name, scope=view.registry_scope) is not None
except Exception:
return False
+1017 -106
View File
File diff suppressed because it is too large. Load diff
+61 -7
View File
@@ -56,6 +56,11 @@ _loaded_profile_env_keys: set[str] = set()
# process-global _active_profile.
_tls = threading.local()
# Home of the profile this process serves as its own (set by init_profile_state).
# Streaming turns mirror their profile into os.environ['HERMES_HOME'], so the live
# env var is not a stable identity; see _pin_process_profile_home().
_PROCESS_PROFILE_HOME: Optional[str] = None
_SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool")
_SKILL_HOME_MODULE_PATCH_LOCK = threading.RLock()
@@ -1360,6 +1365,8 @@ def profile_env_for_background_worker(
def profile_env_for_active_request_readonly(
purpose: str = "provider/model read",
logger_override: Optional[logging.Logger] = None,
*,
include_root: bool = False,
):
"""Apply the active per-request profile's env to thread-local state only (#3957).
@@ -1376,11 +1383,18 @@ def profile_env_for_active_request_readonly(
process-global ``os.environ``.
No-ops for the default/root profile, which is the common single-profile
deployment case.
deployment case, unless ``include_root`` is set: callers whose Hermes Agent
reads must not follow a streaming turn's mirrored ``HERMES_HOME`` (MCP
runtime status/reload) bind the root profile's home explicitly too.
Yields True when the context-local Hermes-home override is installed for
the resolved profile, else False (no-op, older agent, or resolution error).
"""
profile = (get_active_profile_name() or "").strip()
if not profile or _is_root_profile(profile):
yield
if include_root and not profile:
profile = "default"
if not profile or (_is_root_profile(profile) and not include_root):
yield False
return
try:
from api.config import _clear_thread_env, _set_thread_env, _thread_ctx
@@ -1396,7 +1410,7 @@ def profile_env_for_active_request_readonly(
purpose,
exc_info=True,
)
yield
yield False
return
try:
from hermes_constants import (
@@ -1440,7 +1454,7 @@ def profile_env_for_active_request_readonly(
purpose,
exc_info=True,
)
yield
yield home_override_installed
finally:
if _has_scope and _secret_scope_mod is not None:
try:
@@ -1529,8 +1543,14 @@ def profile_scope_for_detached_worker(
def _set_hermes_home(home: Path):
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
"""Set HERMES_HOME env var and monkey-patch cached module-level paths.
Every process-wide home change (startup, ``switch_profile(process_wide=True)``)
goes through here, so the process-profile pin used for MCP routing decisions
is updated in the same step and cannot drift from ``HERMES_HOME``.
"""
os.environ['HERMES_HOME'] = str(home)
_pin_process_profile_home(home)
patch_skill_home_modules(home)
@@ -1610,11 +1630,45 @@ def init_profile_state() -> None:
else:
_active_profile = _read_active_profile_file()
home = get_active_hermes_home()
_set_hermes_home(home)
_set_hermes_home(home) # also pins the process-profile home (MCP routing anchor)
install_cron_scheduler_profile_isolation()
_reload_dotenv(home)
def _pin_process_profile_home(home: Path) -> None:
"""Record the profile home this process serves as its own, for WebUI and Hermes Agent.
Called from ``_set_hermes_home()`` so startup and process-wide profile switches
keep one owner for the value. Hermes Agent keys MCP connections and registry
overlays by profile only when a task serves a *routed* profile: the context-local
Hermes-home override differs from the process home. Streaming turns mirror their
profile into ``os.environ['HERMES_HOME']`` for legacy readers, which makes every
turn's own profile look like the process profile, so same-named MCP servers of
different profiles share one bare-name connection. Agents exposing
``hermes_constants.pin_process_hermes_home`` take a stable anchor instead; older
agents keep following the env var, and ``get_process_profile_home()`` still gives
WebUI the anchor to detect that skew.
"""
global _PROCESS_PROFILE_HOME
_PROCESS_PROFILE_HOME = str(home)
try:
import hermes_constants
pin = getattr(hermes_constants, 'pin_process_hermes_home', None)
if callable(pin):
pin(str(home))
except Exception:
logger.debug("Hermes Agent process-home pin unavailable", exc_info=True)
def get_process_profile_home() -> Path:
"""Return the profile home this WebUI process serves as its own (stable per process)."""
if _PROCESS_PROFILE_HOME:
return Path(_PROCESS_PROFILE_HOME)
if _INITIAL_HERMES_HOME:
return Path(_INITIAL_HERMES_HOME).expanduser()
return _DEFAULT_HERMES_HOME
def switch_profile(name: str, *, process_wide: bool = True) -> dict:
"""Switch the active profile.
+17
View File
@@ -606,6 +606,22 @@ def _session_list_cache_source_stamp(key: tuple) -> tuple[tuple[int, int], tuple
session_index_path = _session_list_cache_session_dir() / "_index.json"
except Exception:
session_index_path = None
all_profiles_state_stamp = None
if _cache_all_profiles:
try:
from api.models import _all_profiles_cli_contexts
contexts, _ = _all_profiles_cli_contexts()
all_profiles_state_stamp = tuple(
(
_session_list_cache_path_stamp(ctx_db),
_session_list_cache_path_stamp(ctx_db.with_name(f"{ctx_db.name}-wal") if ctx_db else None),
_session_list_cache_state_db_fingerprint(ctx_db),
)
for _home, ctx_db, _prof in contexts
)
except Exception:
all_profiles_state_stamp = None
return (
_session_list_cache_path_stamp(state_db_path),
_session_list_cache_path_stamp(state_db_wal_path),
@@ -617,6 +633,7 @@ def _session_list_cache_source_stamp(key: tuple) -> tuple[tuple[int, int], tuple
# frame size), so without this a freshly-committed CLI/gateway session
# could be served stale for the cache TTL. Mirrors the models-layer fix.
_session_list_cache_state_db_fingerprint(state_db_path),
all_profiles_state_stamp,
swv,
)
+254 -59
View File
@@ -4067,6 +4067,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
"tool_calls": tool_calls,
"last_assistant_text": assistant_text,
"last_reasoning_text": reasoning_text,
"runtime_model": runtime_model_from_events(session_id, stream_id, events),
"activity_burst_anchors": activity_burst_anchors,
"current_activity_burst_id": current_activity_burst_id,
"current_live_segment_seq": current_live_segment_seq,
@@ -7183,7 +7184,35 @@ def _context_length_lookup_inputs_for_model(
if not effective_provider:
effective_provider = _canonical_context_provider(model_cfg.get("provider"))
if not effective_base_url:
effective_base_url = str(model_cfg.get("base_url") or "").strip()
# #7535: the global model.base_url may only fill an empty slot when
# the session provider IS the configured model.provider owner
# (mirror the ownership predicate used for model_cfg's API key in
# _context_length_config_api_key_for_provider). A built-in registry
# provider (empty base_url by design) must keep the slot empty so
# the registry endpoint resolves instead of another provider's URL.
#
# Two shapes cannot own the slot and therefore cannot conflict, so
# they keep master's backfill: a config that declares no provider
# at all (the profile-setup path writes model.base_url without one)
# and the two spellings of the same built-in id (opencode_go ==
# opencode-go), folded through api.config._canonicalise_provider_id
# so distinct custom:* slugs stay distinct.
_model_cfg_provider = _canonical_context_provider(model_cfg.get("provider"))
_owner_provider = _model_cfg_provider
_session_provider = effective_provider
try:
from api.config import _canonicalise_provider_id as _canon_provider_id
_owner_provider = _canon_provider_id(_owner_provider) or _owner_provider
_session_provider = _canon_provider_id(_session_provider) or _session_provider
except Exception:
pass
if (
not effective_provider
or not _model_cfg_provider
or _providers_match_for_context(_owner_provider, _session_provider)
):
effective_base_url = str(model_cfg.get("base_url") or "").strip()
custom_providers = cfg.get("custom_providers") if isinstance(cfg, dict) else None
if not isinstance(custom_providers, list):
@@ -9103,9 +9132,11 @@ def _parse_msg_limit(raw):
"""Parse and clamp the ``?msg_limit=`` query value.
Returns a positive int clamped to ``[1, _MAX_MSG_LIMIT]``, or ``None`` when
the value is absent/empty/malformed (the bare no-``msg_limit`` path, which
intentionally returns the full transcript for callers that need it).
Extracted from the handler so the clamp expression has direct test coverage.
the value is absent/empty/malformed. ``?msg_limit=all`` also returns
``None`` — an explicit escape hatch for the full-transcript paths the
frontend genuinely needs; the handler distinguishes it from a bare request
via :func:`_resolve_effective_msg_limit`. Extracted from the handler so
the clamp expression has direct test coverage.
"""
if not raw:
return None
@@ -9116,6 +9147,26 @@ def _parse_msg_limit(raw):
return max(1, min(value, _MAX_MSG_LIMIT))
def _resolve_effective_msg_limit(raw_limit):
"""Resolve the effective ``msg_limit`` for ``GET /api/session``.
Returns ``(effective_limit, explicit_all)``.
- numeric ``?msg_limit=N`` → clamped int (existing pagination).
- ``?msg_limit=all`` → ``(None, True)``: explicit full-transcript escape
hatch. Frontend paths that address rows by absolute transcript index
(outline jump, jump-to-start) genuinely need everything; they pass
``all`` instead of relying on the bare no-limit shape.
- any other bare shape (no limit) → ``(None, False)``: the historical
full-transcript contract is preserved (contract tests pin tool-row
preservation and the runtime-journal snapshot on this shape), so the
bounded-window fix is enforced at the frontend call sites instead.
"""
explicit_all = str(raw_limit or "").strip().lower() == "all"
limit = _parse_msg_limit(raw_limit)
return limit, explicit_all
# If a sidecar JSON file exceeds this threshold, the display-path tail
# optimization fires regardless of message count. Sessions with few messages
# but large tool outputs (multi-MB JSON) should not force a full-scan merge.
@@ -9298,6 +9349,13 @@ def _limited_webui_messages_for_display_with_sidecar(
state_db_messages = list(state_db_messages or [])
if not state_db_messages:
return sidecar_messages
state_db_messages = _suppress_native_image_display_mirrors(
session,
state_db_messages,
)
if not state_db_messages:
return sidecar_messages
# NOTE: do not short-circuit to the sidecar when state.db has no strictly
# newer rows. A state.db row whose timestamp is at-or-before the sidecar's
# newest (recovery / edited-in-place / missing-timestamp cases) is still
@@ -9358,6 +9416,11 @@ def _limited_webui_messages_for_display_with_sidecar(
truncation_boundary=getattr(session, "truncation_boundary", None),
incoming_provenance="state_db",
)
merged = _project_native_image_payload_conflicts_for_display(
sidecar_messages,
state_db_messages,
merged,
)
if cache_key is not None:
_state_key = cache_key[4]
_streaming_key = (
@@ -10053,7 +10116,35 @@ def _merged_session_messages_for_display(session, cli_messages=None) -> list:
def _merged_webui_lineage_messages_for_display(session, messages=None) -> list:
_LINEAGE_PARENT_SESSION_UNSET = object()
def _webui_lineage_parent_session_for_display(session):
"""Load the immediate parent only for display-eligible continuations."""
parent_id = str(getattr(session, "parent_session_id", "") or "").strip()
if not parent_id:
return None
if (
str(getattr(session, "compression_recovery_source_session_id", "") or "").strip()
and str(getattr(session, "compression_recovery_action", "") or "").strip()
):
return None
source = str(getattr(session, "session_source", "") or "").strip().lower()
relationship = str(getattr(session, "relationship_type", "") or "").strip().lower()
if source == "fork" or relationship == "child_session":
return None
try:
return get_session(parent_id, metadata_only=False)
except Exception:
return None
def _merged_webui_lineage_messages_for_display(
session,
messages=None,
*,
parent_session=_LINEAGE_PARENT_SESSION_UNSET,
) -> list:
"""Include immediate parent-only rows when a WebUI continuation sidecar is partial.
Compression/continuation sessions should render as one conversation. Most
@@ -10064,23 +10155,9 @@ def _merged_webui_lineage_messages_for_display(session, messages=None) -> list:
subset of their parent.
"""
primary_messages = list(messages if messages is not None else (getattr(session, "messages", []) or []))
parent_id = str(getattr(session, "parent_session_id", "") or "").strip()
if not parent_id:
return primary_messages
if (
str(getattr(session, "compression_recovery_source_session_id", "") or "").strip()
and str(getattr(session, "compression_recovery_action", "") or "").strip()
):
return primary_messages
source = str(getattr(session, "session_source", "") or "").strip().lower()
relationship = str(getattr(session, "relationship_type", "") or "").strip().lower()
if source == "fork" or relationship == "child_session":
return primary_messages
try:
parent = get_session(parent_id, metadata_only=False)
except Exception:
return primary_messages
parent_messages = list(getattr(parent, "messages", []) or [])
if parent_session is _LINEAGE_PARENT_SESSION_UNSET:
parent_session = _webui_lineage_parent_session_for_display(session)
parent_messages = list(getattr(parent_session, "messages", []) or [])
if not parent_messages:
return primary_messages
if _messages_start_with_visible_prefix(primary_messages, parent_messages):
@@ -10560,7 +10637,6 @@ from api.models import (
new_session,
all_sessions,
title_from,
_write_session_index,
SESSION_INDEX_FILE,
_active_state_db_path,
load_projects,
@@ -10574,6 +10650,8 @@ from api.models import (
get_state_db_session_message_keys_before_timestamp,
get_state_db_session_summary,
merge_session_messages_append_only,
_project_native_image_payload_conflicts_for_display,
_suppress_native_image_display_mirrors,
_reconcile_api_content_sidecars,
_enrich_sidebar_lineage_metadata,
_active_stream_ids,
@@ -10839,6 +10917,7 @@ from api.streaming import (
)
from api.gateway_chat import _run_gateway_chat_streaming, webui_gateway_chat_enabled
from api.run_journal import (
runtime_model_from_events,
_parse_run_journal_event_id as _shared_parse_run_journal_event_id,
_summary_from_events,
bound_run_journal_snapshot_args,
@@ -13151,8 +13230,10 @@ def _handle_session_get(handler, parsed) -> bool:
# payload; the existing _messages_truncated signal covers the clamped
# case (the client sees there are more rows than returned). Parsing +
# clamping live in _parse_msg_limit so the expression has direct test
# coverage; None means the bare no-msg_limit path (full transcript).
msg_limit = _parse_msg_limit(query.get("msg_limit", [None])[0])
# coverage. The frontend recovery paths request a bounded tail
# explicitly (msg_limit=30), and the two absolute-index paths opt in to
# the full transcript via msg_limit=all (#7310/#7625).
_raw_msg_limit = query.get("msg_limit", [None])[0]
# ?msg_before=N — 0-based index into the full message array.
# Returns messages before this index (for scroll-to-top lazy loading).
# Combined with msg_limit for paging.
@@ -13161,6 +13242,9 @@ def _handle_session_get(handler, parsed) -> bool:
msg_before = int(_msg_before) if _msg_before else None
except (ValueError, TypeError):
msg_before = None
msg_limit, _msg_limit_explicit_all = _resolve_effective_msg_limit(
_raw_msg_limit,
)
# ?expand_renderable=1 is retained for compatibility with older
# frontends. msg_limit now counts visible transcript rows by default, so
# the flag no longer changes the server-side pagination semantics.
@@ -13314,13 +13398,33 @@ def _handle_session_get(handler, parsed) -> bool:
msg_before=msg_before,
)
else:
state_db_messages = _suppress_native_image_display_mirrors(
s,
state_db_messages,
)
sidecar_messages = _webui_sidecar_lineage_messages_for_display(s)
lineage_parent = _webui_lineage_parent_session_for_display(s)
projection_sidecar_messages = _merged_webui_lineage_messages_for_display(
s,
sidecar_messages,
parent_session=lineage_parent,
)
_all_msgs = merge_session_messages_append_only(
_webui_sidecar_lineage_messages_for_display(s),
sidecar_messages,
state_db_messages,
truncation_watermark=getattr(s, "truncation_watermark", None),
truncation_boundary=getattr(s, "truncation_boundary", None),
)
_all_msgs = _merged_webui_lineage_messages_for_display(s, _all_msgs)
_all_msgs = _merged_webui_lineage_messages_for_display(
s,
_all_msgs,
parent_session=lineage_parent,
)
_all_msgs = _project_native_image_payload_conflicts_for_display(
projection_sidecar_messages,
state_db_messages,
_all_msgs,
)
else:
if is_messaging_session and cli_messages:
_all_msgs = _merged_session_messages_for_display(s, cli_messages)
@@ -13370,9 +13474,13 @@ def _handle_session_get(handler, parsed) -> bool:
_messages_offset = 0
# Index of the first returned message in the full message array.
# Frontend uses this as cursor for scroll-to-top paging.
# Session-level tool_calls windowing keys off whether the returned
# message array was actually truncated (msg_before paging, any
# effective msg_limit) rather than whether a limit parameter was
# present — the full-transcript shape returns everything, so the
# length comparison alone decides (#7310/#7625).
_windowed_messages = (
load_messages
and msg_limit is not None
and (msg_before is not None or len(_truncated_msgs) < len(_all_msgs))
)
# Resolve effective context_length with model-metadata fallback so
@@ -13468,7 +13576,7 @@ def _handle_session_get(handler, parsed) -> bool:
"tool_calls": _session_tool_calls,
"active_stream_id": getattr(s, "active_stream_id", None),
"pending_user_message": getattr(s, "pending_user_message", None),
"pending_attachments": getattr(s, "pending_attachments", []) if load_messages else [],
"pending_attachments": getattr(s, "pending_attachments", []) if (load_messages or getattr(s, "pending_user_message", None)) else [],
"pending_started_at": getattr(s, "pending_started_at", None),
"pending_user_source": getattr(s, "pending_user_source", None),
"context_length": _persisted_cl,
@@ -23106,6 +23214,7 @@ def _prepare_chat_start_session_for_stream(
s.pending_attachments = attachments
s.pending_started_at = started_at if started_at is not None else time.time()
s.pending_user_source = effective_source
s._webui_pending_user_timestamp_identity = None
if retained_user is not None:
from api.process_event_utils import build_active_turn_token
@@ -25157,10 +25266,12 @@ def _handle_chat_sync(handler, body):
_active_turn_boundary,
_assign_stable_message_ids,
_dedupe_replayed_context_messages,
_find_active_turn_checkpoint_index,
_merge_display_messages_after_agent_result,
_resolve_active_turn_authority,
_restore_display_reasoning_metadata,
_restore_reasoning_metadata_before_boundary,
_settle_current_turn_boundary,
_sanitize_messages_for_agent,
_compact_session_image_parts_for_persistence,
_context_messages_for_new_turn,
@@ -25225,6 +25336,33 @@ def _handle_chat_sync(handler, body):
result=result,
agent=agent,
)
if (
isinstance(_active_turn_identity, dict)
and _active_turn_identity.get("agent_turn_boundary_resolved") is True
and not _active_turn_identity.get("token")
):
_active_image_index = _find_active_turn_checkpoint_index(
_result_messages,
_previous_context_messages,
_active_turn_identity,
msg,
)
_active_image_content = (
_result_messages[_active_image_index].get("content")
if _active_image_index is not None
else None
)
if isinstance(_active_image_content, list) and any(
isinstance(part, dict)
and part.get("type") in {"image", "image_url", "input_image"}
for part in _active_image_content
):
from api.process_event_utils import build_active_turn_token
_active_turn_identity["token"] = build_active_turn_token(
f"sync:{s.session_id}:{_active_turn_identity['turn_id']}",
time.time(),
)
_turn_boundary = _active_turn_boundary(
_result_messages, _previous_context_messages, _active_turn_identity, msg,
)
@@ -25243,6 +25381,14 @@ def _handle_chat_sync(handler, body):
_next_context_messages,
msg,
)
if _active_turn_identity.get("token"):
_next_context_messages = _settle_current_turn_boundary(
_previous_context_messages,
_next_context_messages,
_active_turn_identity,
msg,
getattr(s, "pending_user_source", None) or "webui",
)
s.context_messages = _next_context_messages
s.messages = _merge_display_messages_after_agent_result(
_previous_messages,
@@ -25252,6 +25398,9 @@ def _handle_chat_sync(handler, body):
),
msg,
source=getattr(s, "pending_user_source", None) or "webui",
verification_nudge_provenance={
"active_turn_identity": _active_turn_identity,
},
)
_compact_session_image_parts_for_persistence(s)
# Only auto-generate title when still default; preserves user renames
@@ -29285,17 +29434,34 @@ def _parse_mcp_enabled(value) -> bool:
return True
def _mcp_runtime_status_by_name() -> dict[str, dict]:
def _mcp_runtime_status_by_name(servers=None, view=None) -> dict[str, dict]:
"""Return already-known MCP runtime status without starting servers.
``tools.mcp_tool.get_mcp_status()`` only reads the existing MCP registry and
configuration; it does not probe or spawn MCP subprocesses. If Hermes Agent
is unavailable, fall back to an empty map so the API remains safe.
Call it inside ``mcp_runtime_scope()`` and pass its ``view``: the agent
filters a routed profile's connections itself, but its launch-profile view
is process-wide, so rows are narrowed to the connections serving ``view``
(``api.mcp_runtime.filter_runtime_status_to_view``). ``servers`` (the
profile's ``mcp_servers`` WebUI displays) is passed as ``configured`` when
supported so both read the same config.
"""
try:
from api.agent_compat import agent_attr
from api.mcp_runtime import accepts_keywords, filter_runtime_status_to_view
get_mcp_status = agent_attr("tools.mcp_tool", "get_mcp_status", "tools.mcp_tool_discovery")
statuses = get_mcp_status()
if isinstance(servers, dict) and accepts_keywords(get_mcp_status, "configured"):
# Invalid entries are summarized as invalid_config by WebUI; keep them
# out of the agent call so one bad entry cannot blank every status.
statuses = get_mcp_status(configured={
str(name): scfg for name, scfg in servers.items() if isinstance(scfg, dict)
})
else:
statuses = get_mcp_status()
if view is not None and isinstance(statuses, list):
statuses = filter_runtime_status_to_view(statuses, view)
except Exception:
return {}
if not isinstance(statuses, list):
@@ -29478,10 +29644,18 @@ def _mcp_tools_from_runtime_status(runtime_by_name, server_summaries):
return tools
def _mcp_tools_from_registry(server_summaries):
"""Read already-registered MCP tool schemas without probing MCP servers."""
def _mcp_tools_from_registry(server_summaries, view=None):
"""Read already-registered MCP tool schemas without probing MCP servers.
With a profile ``view`` (see ``api.mcp_runtime``), only tools registered in
that profile's own registry slot are listed. The slot is the isolation check;
the raw ``mcp_servers`` config is not an allowlist: the agent merges portable
plugin servers into the running config at runtime, and their tools are
registered in the same slot without a ``config.yaml`` entry.
"""
try:
from tools.registry import registry
from api.mcp_runtime import registry_tool_owned_by_view
except Exception:
return []
tools = []
@@ -29497,6 +29671,10 @@ def _mcp_tools_from_registry(server_summaries):
if not isinstance(toolset, str) or not toolset.startswith("mcp-"):
continue
server_name = toolset[len("mcp-"):]
if view is not None and not view.legacy and not registry_tool_owned_by_view(
registry, tool_name, view
):
continue
schema = registry.get_schema(tool_name) or {}
server_summary = server_summaries.get(server_name, {
"name": server_name,
@@ -29508,22 +29686,45 @@ def _mcp_tools_from_registry(server_summaries):
return tools
def _mcp_profile_runtime_inventory(servers, purpose, *, include_tools=True):
"""Build server summaries and tools from ONE runtime view of the request profile.
Status, tool count and inventory are all read inside the same
``mcp_runtime_scope()`` so they describe the same profile's connections.
When that profile scope cannot be confirmed, runtime data is withheld rather
than showing another profile's connection. Passive: never starts or probes
MCP servers.
"""
from api.mcp_runtime import mcp_runtime_scope
runtime = {}
tools = []
source = "none"
with mcp_runtime_scope(purpose) as view:
if view.trusted:
runtime = _mcp_runtime_status_by_name(servers, view)
server_summaries = {
str(name): _server_summary(str(name), scfg, runtime.get(str(name)))
for name, scfg in servers.items()
}
if include_tools and view.trusted:
tools = _mcp_tools_from_runtime_status(runtime, server_summaries)
source = "mcp_runtime_status"
if not tools:
tools = _mcp_tools_from_registry(server_summaries, view)
source = "tool_registry" if tools else "none"
return server_summaries, tools, source, view.scope_label
def _handle_mcp_tools_list(handler):
"""List known MCP tools from already-available runtime inventory only."""
cfg = get_config_for_profile_home(get_active_hermes_home())
servers = cfg.get("mcp_servers", {})
if not isinstance(servers, dict):
servers = {}
runtime = _mcp_runtime_status_by_name()
server_summaries = {
str(name): _server_summary(str(name), scfg, runtime.get(str(name)))
for name, scfg in servers.items()
}
tools = _mcp_tools_from_runtime_status(runtime, server_summaries)
source = "mcp_runtime_status"
if not tools:
tools = _mcp_tools_from_registry(server_summaries)
source = "tool_registry" if tools else "none"
server_summaries, tools, source, runtime_scope = _mcp_profile_runtime_inventory(
servers, "/api/mcp/tools"
)
tools.sort(key=lambda row: (row.get("server", ""), row.get("name", "")))
unavailable_servers = [
summary["name"] for summary in server_summaries.values()
@@ -29534,6 +29735,7 @@ def _handle_mcp_tools_list(handler):
"total": len(tools),
"source": source,
"inventory_scope": "already_known_runtime_only",
"runtime_scope": runtime_scope,
"unavailable_servers": unavailable_servers,
})
@@ -29723,21 +29925,15 @@ def _handle_notes_sources_list(handler):
servers = cfg.get("mcp_servers", {})
if not isinstance(servers, dict):
servers = {}
runtime = _mcp_runtime_status_by_name()
server_summaries = {
str(name): _server_summary(str(name), scfg, runtime.get(str(name)))
for name, scfg in servers.items()
}
tools = _mcp_tools_from_runtime_status(runtime, server_summaries)
source = "mcp_runtime_status"
if not tools:
tools = _mcp_tools_from_registry(server_summaries)
source = "tool_registry" if tools else "none"
server_summaries, tools, source, runtime_scope = _mcp_profile_runtime_inventory(
servers, "/api/notes/sources"
)
return j(handler, {
"enabled": True,
"sources": _notes_sources_from_mcp_inventory(server_summaries, tools),
"source": source,
"inventory_scope": "already_known_runtime_only",
"runtime_scope": runtime_scope,
"attach_supported": False,
"automatic_recall_unchanged": True,
"recent_ai_notes": _joplin_recent_ai_notes(limit=6),
@@ -30028,15 +30224,14 @@ def _handle_mcp_servers_list(handler):
servers = cfg.get("mcp_servers", {})
if not isinstance(servers, dict):
servers = {}
runtime = _mcp_runtime_status_by_name()
result = [
_server_summary(name, scfg, runtime.get(str(name)))
for name, scfg in servers.items()
]
server_summaries, _tools, _source, runtime_scope = _mcp_profile_runtime_inventory(
servers, "/api/mcp/servers", include_tools=False
)
return j(handler, {
"servers": result,
"servers": list(server_summaries.values()),
"toggle_supported": True,
"reload_required": True,
"runtime_scope": runtime_scope,
})
+35 -2
View File
@@ -10,6 +10,7 @@ import os
import re
import threading
import time
from copy import deepcopy
from collections import OrderedDict
from pathlib import Path
from typing import Any, Iterable
@@ -130,7 +131,7 @@ def _get_cached_summary(path: Path) -> dict | None:
_SUMMARY_CACHE.pop(key, None)
return None
_SUMMARY_CACHE.move_to_end(key)
return dict(summary)
return deepcopy(summary)
def _cache_summary(
@@ -147,7 +148,7 @@ def _cache_summary(
return
key = str(path)
with _SUMMARY_CACHE_LOCK:
_SUMMARY_CACHE[key] = (signature, dict(summary))
_SUMMARY_CACHE[key] = (signature, deepcopy(summary))
_SUMMARY_CACHE.move_to_end(key)
while len(_SUMMARY_CACHE) > _SUMMARY_CACHE_MAX_ENTRIES:
_SUMMARY_CACHE.popitem(last=False)
@@ -534,6 +535,37 @@ def select_authoritative_terminal_event(events: Iterable[dict]) -> dict | None:
)
def runtime_model_from_events(session_id: str, stream_id: str, events: Iterable[dict]) -> dict | None:
"""Project only observed serving identity for this journal owner.
A fallback warning or malformed later observation invalidates old evidence;
the configured selection and free-text status never supply serving identity.
"""
observed = None
for event in events:
if not isinstance(event, dict) or event.get("session_id") != session_id or event.get("run_id") != stream_id:
continue
payload = event.get("payload")
if not isinstance(payload, dict):
if event.get("event") == "runtime_model":
observed = None
continue
if event.get("event") == "warning" and payload.get("type") == "fallback":
observed = None
elif event.get("event") == "runtime_model":
observed = None
if (payload.get("session_id") != session_id or payload.get("stream_id") != stream_id
or not isinstance(payload.get("model"), str) or not payload["model"].strip()
or not isinstance(payload.get("fallback_active"), bool)
or payload.get("phase") not in ("observed_output", "route_observed")):
continue
observed = {key: payload[key] for key in (
"session_id", "stream_id", "model", "fallback_active", "phase")}
if isinstance(payload.get("provider"), str) and payload["provider"].strip():
observed["provider"] = payload["provider"]
return observed
def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -> dict:
ordered = [event for event in events if isinstance(event, dict)]
last = ordered[-1] if ordered else None
@@ -549,6 +581,7 @@ def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -
"terminal": bool(terminal),
"terminal_state": status,
"last_event": (last or {}).get("event"),
"runtime_model": runtime_model_from_events(session_id, run_id, ordered),
}
+8 -1
View File
@@ -601,7 +601,14 @@ def _state_db_row_to_sidecar(row: dict) -> dict:
'session_id': row.get('id'),
'title': row.get('title') or 'Recovered WebUI Session',
'workspace': workspace_value if isinstance(workspace_value, str) else '',
'message_count': row.get('message_count') if isinstance(row.get('message_count'), int) else len(messages),
# state.db is canonical for the recovered message rows. Its denormalized
# sessions.message_count is absent from older schemas (read above as 0)
# and can be stale after recovery, so never copy it into a sidecar whose
# `messages` array we just materialized. Session.save() uses this field
# to decide whether an overwrite shrinks the transcript; it must match
# the payload exactly or the #1558 backup / empty-snapshot guards can be
# bypassed.
'message_count': len(messages if isinstance(messages, list) else []),
'worktree_path': row.get('worktree_path') or None,
'worktree_branch': row.get('worktree_branch') or None,
'worktree_repo_root': row.get('worktree_repo_root') or None,
+508 -40
View File
@@ -75,7 +75,11 @@ from api.turn_journal import append_turn_journal_event_for_stream
from api.usage import prompt_cache_hit_percent
from api.models import (
StateDBSessionMessagesSnapshot,
_WEBUI_TRUSTED_AGENT_INPUT_FIELD,
_is_empty_partial_activity_message,
_message_exact_timestamp_details,
_message_private_identity_compatible,
_validated_webui_pending_user_timestamp_identity,
_evict_sessions_over_cap,
clear_process_wakeup_pause,
get_state_db_session_messages,
@@ -891,6 +895,9 @@ def _is_fallback_lifecycle_message(kind: str, message: str) -> bool:
or 'falling back' in m
or 'fallback activated' in m
or 'trying fallback' in m
or 'model fallback:' in m
or 'switched to fallback' in m
or 'primary model restored:' in m
)
)
@@ -2102,6 +2109,58 @@ def _mark_active_turn_checkpoint_in_history(messages, identity, msg_text, *, all
return messages, True
def _active_turn_user_text_matches(message, expected_text):
"""Validate the Agent's declared user row against the submitted prompt."""
if not isinstance(message, dict) or message.get('role') != 'user' or not isinstance(expected_text, str):
return False
content = message.get('content')
if isinstance(content, list):
leading_text = []
for part in content:
if not isinstance(part, dict):
return False
part_type = str(part.get('type') or '').lower()
if part_type in {'image', 'image_url', 'input_image'}:
break
if part_type not in {'', 'text', 'input_text', 'output_text'}:
return False
text = part.get('text', part.get('input_text', part.get('output_text', '')))
if not isinstance(text, str):
return False
leading_text.append(text)
if not content:
return False
actual_text = '\n'.join(leading_text)
elif isinstance(content, str):
actual_text = content
else:
return False
if (
isinstance(content, str)
and " ".join(actual_text.split()) == " ".join(expected_text.split())
):
return True
return _submitted_user_text_matches(actual_text, expected_text)
def _submitted_user_text_matches(actual_text, expected_text):
"""Match exact prompt text with at most its generated workspace prefix."""
if actual_text == expected_text:
return True
if not isinstance(actual_text, str) or not isinstance(expected_text, str):
return False
if not actual_text.endswith(expected_text):
return False
prefix = actual_text[:-len(expected_text)] if expected_text else actual_text
return bool(
prefix
and (
_WORKSPACE_PREFIX_RE.fullmatch(prefix)
or _LEGACY_WORKSPACE_PREFIX_RE.fullmatch(prefix)
)
)
def _owner_projection_current_turn_row(messages, identity):
messages = list(messages or [])
if not isinstance(identity, dict):
@@ -2138,16 +2197,19 @@ def _find_active_turn_checkpoint_index(result_messages, previous_context, identi
return idx
if not _active_turn_boundary_is_valid(identity):
return None
expected_text = identity.get('text') if identity.get('text') is not None else msg_text
idx = identity['current_turn_user_idx']
if idx < 0 or idx >= len(result_messages):
return None
message = result_messages[idx]
expected_text = identity.get('text') if identity.get('text') is not None else msg_text
if _active_turn_user_text_matches(message, expected_text):
return idx
trusted_agent_input = identity.get('trusted_agent_input_text')
if (
isinstance(message, dict)
and message.get('role') == 'user'
and _normalize_user_text(_message_text(message.get('content')))
== _normalize_user_text(expected_text)
identity.get('text') == msg_text
and isinstance(trusted_agent_input, str)
and trusted_agent_input != expected_text
and _active_turn_user_text_matches(message, trusted_agent_input)
):
return idx
return None
@@ -2236,17 +2298,19 @@ def _settle_current_turn_boundary(previous_context, result_messages, identity, m
)
if _checkpoint_idx is not None:
existing_checkpoint = result_messages[_checkpoint_idx]
if isinstance(identity.get('checkpoint'), dict):
retained_checkpoint = _materialize_active_turn_user(identity, msg_text, source)
if (
retained_checkpoint.get('id') is None
and isinstance(existing_checkpoint, dict)
and existing_checkpoint.get('id') is not None
):
retained_checkpoint['id'] = existing_checkpoint['id']
result_messages[_checkpoint_idx] = retained_checkpoint
else:
_mark_active_turn_checkpoint(existing_checkpoint, identity)
_mark_active_turn_checkpoint(existing_checkpoint, identity)
checkpoint = identity.get('checkpoint')
if isinstance(checkpoint, dict):
for key in ('id', 'timestamp'):
if existing_checkpoint.get(key) is None and checkpoint.get(key) is not None:
existing_checkpoint[key] = copy.deepcopy(checkpoint[key])
if checkpoint.get('attachments'):
existing_checkpoint['attachments'] = copy.deepcopy(checkpoint['attachments'])
stamp_message_source(
existing_checkpoint,
identity.get('source') or source or 'webui',
active_turn_token=identity.get('token'),
)
return result_messages
previous_context = list(previous_context or [])
if _messages_have_prefix(result_messages, previous_context):
@@ -2316,6 +2380,14 @@ def _prepare_marker_clean_writeback(
cleaned, has_verification_nudge = _clean_synthetic_control_messages_with_provenance(
result_messages
)
# Same internal-control class, second home: a consumed mid-turn /steer is
# appended to the turn's last tool result wrapped in
# [OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]. Strip it
# here, on the rows both writebacks are built from, so neither
# session.messages (rendered verbatim) nor session.context_messages keeps
# the raw wrapper. Stripping the incoming rows too keeps them identity-equal
# to the marker-free rows persisted by earlier turns. (#7600)
cleaned = _strip_oob_markers_from_messages(cleaned)
provenance = {
'verification_nudge_seen': has_verification_nudge,
'active_turn_identity': copy.deepcopy(active_turn_identity),
@@ -2428,6 +2500,50 @@ def _settle_result_messages(
source=source,
verification_nudge_provenance=verification_nudge_provenance,
)
trusted_agent_input = (
active_turn_identity.get('trusted_agent_input_text')
if isinstance(active_turn_identity, dict)
else None
)
if (
isinstance(trusted_agent_input, str)
and active_turn_identity.get('text') == msg_text
and active_turn_identity.get('token')
):
current_display_rows = [
message for message in session.messages
if _active_turn_token_matches(message, active_turn_identity)
]
current_context_rows = [
message for message in session.context_messages
if _active_turn_token_matches(message, active_turn_identity)
]
if (
len(current_display_rows) == 1
and current_display_rows[0].get('content') == msg_text
and len(current_context_rows) == 1
):
context_user = current_context_rows[0]
context_content = context_user.get('content')
if (
isinstance(context_content, list)
and any(
isinstance(part, dict)
and str(part.get('type') or '').lower()
in {'image', 'image_url', 'input_image'}
for part in context_content
)
and _active_turn_user_text_matches(
context_user,
trusted_agent_input,
)
):
context_user[_WEBUI_TRUSTED_AGENT_INPUT_FIELD] = trusted_agent_input
# The merge carries earlier display rows across turns verbatim, so a row
# settled before this guard existed would keep its raw wrapper forever.
# Scrub the persisted display copy too — after the merge, so identity
# matching above still saw the rows unchanged. (#7600)
session.messages = _strip_oob_markers_from_messages(session.messages)
_annotate_media_snapshots_for_settled_messages(session.messages)
_compact_session_image_parts_for_persistence(session)
_advance_truncation_watermark_after_commit(session) # #3831
@@ -2455,6 +2571,61 @@ def _current_turn_already_has_visible_assistant_answer(messages, *, active_turn_
return False
def _trusted_native_image_context_display_mirror_tokens(previous_display, previous_context):
"""Find unique token-paired rich image rows already represented in display."""
display_by_token = {}
context_by_token = {}
for messages, rows_by_token in (
(previous_display, display_by_token),
(previous_context, context_by_token),
):
for message in messages or []:
if not isinstance(message, dict) or message.get('role') != 'user':
continue
token = message.get('_active_turn_token')
if not isinstance(token, str) or not token:
continue
rows_by_token.setdefault(token, []).append(message)
mirrored_tokens = set()
for token, context_rows in context_by_token.items():
display_rows = display_by_token.get(token, [])
if len(context_rows) != 1 or len(display_rows) != 1:
continue
context, display = context_rows[0], display_rows[0]
content = context.get('content')
if (
not isinstance(content, list)
or not any(
isinstance(part, dict)
and str(part.get('type') or '').lower() in {'image', 'image_url', 'input_image'}
for part in content
)
or not isinstance(
context.get(_WEBUI_TRUSTED_AGENT_INPUT_FIELD),
str,
)
or not _active_turn_user_text_matches(
context,
context.get(_WEBUI_TRUSTED_AGENT_INPUT_FIELD),
)
or not _message_private_identity_compatible(display, context)
):
continue
display_ts, display_ts_valid = _message_exact_timestamp_details(display)
context_ts, context_ts_valid = _message_exact_timestamp_details(context)
if (
not display_ts_valid
or not context_ts_valid
or display_ts is None
or context_ts is None
or display_ts != context_ts
):
continue
mirrored_tokens.add(token)
return mirrored_tokens
def _agent_result_tool_limit_reached(result) -> bool:
"""Return True when current-turn metadata says the tool iteration cap fired."""
if not isinstance(result, dict):
@@ -5590,6 +5761,94 @@ def _strip_oob_blocks(content):
return content
_OOB_ANY_OPEN_RE = re.compile(
r'\[OUT-OF-BAND\s+USER\s+MESSAGE(?:\s*(?:—|-)\s*.*?)?\]',
re.IGNORECASE,
)
_OOB_ANY_CLOSE_RE = re.compile(
r'\[/OUT-OF-BAND\s+USER\s+MESSAGE\]',
re.IGNORECASE,
)
def _unwrap_single_oob_frame(content: str) -> str | None:
"""Unwrap exactly one fully-validated [OUT-OF-BAND USER MESSAGE] frame.
Returns the extracted inner user text if and only if ``content`` consists of
exactly one valid opening tag and one valid closing tag wrapping the user
content. If markers are multiple, nested, incomplete, or ambiguous, returns
None so caller preserves the row byte-for-byte.
"""
if not isinstance(content, str):
return None
stripped = content.strip()
if not stripped:
return None
open_matches = list(_OOB_ANY_OPEN_RE.finditer(stripped))
close_matches = list(_OOB_ANY_CLOSE_RE.finditer(stripped))
# Must have exactly one opening marker and one closing marker
if len(open_matches) != 1 or len(close_matches) != 1:
return None
open_m = open_matches[0]
close_m = close_matches[0]
# Opening marker must be at the very start of stripped content
if open_m.start() != 0:
return None
# Closing marker must be at the very end of stripped content
if close_m.end() != len(stripped):
return None
# Opening marker must end before closing marker starts
if open_m.end() > close_m.start():
return None
inner = stripped[open_m.end():close_m.start()]
# Strip surrounding whitespace/newlines from the extracted user text
return inner.strip('\r\n').strip()
def _unwrap_steer_row_oob_marker(message: dict) -> None:
"""Extract inner steer text from a typed steer row in place (#7600).
The Hermes Agent emits mid-turn steers as standalone typed user rows
(`role == 'user'`, `display_kind == 'steer'`). The control wrapper
`[OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]` is
extracted to preserve only the user-authored instruction.
Mutates caller-row in place to maintain object identity. If the marker
frame is malformed, nested, multiple, or legacy, preserves the row
byte-for-byte.
"""
if not isinstance(message, dict):
return
if message.get('role') != 'user' or message.get('display_kind') != 'steer':
return
content = message.get('content')
if isinstance(content, str):
unwrapped = _unwrap_single_oob_frame(content)
if unwrapped is not None:
message['content'] = unwrapped
elif isinstance(content, list):
if len(content) == 1 and isinstance(content[0], dict):
part = content[0]
if part.get('type') == 'text' and isinstance(part.get('text'), str):
unwrapped = _unwrap_single_oob_frame(part['text'])
if unwrapped is not None:
part['text'] = unwrapped
def _strip_oob_markers_from_messages(messages):
"""Unwrap OOB steer markers from typed steer rows in place (#7600)."""
for message in messages or []:
_unwrap_steer_row_oob_marker(message)
return messages
def _content_has_reasoning_only_parts(content) -> bool:
if not isinstance(content, list) or not content:
return False
@@ -7426,6 +7685,46 @@ def _merge_display_messages_after_agent_result(
result_messages = _drop_synthetic_control_messages(result_messages)
if not result_messages:
return previous_display
active_turn_row_index = _find_active_turn_checkpoint_index(
result_messages, previous_context, _active_turn_identity, msg_text,
)
active_turn_row = (
result_messages[active_turn_row_index]
if active_turn_row_index is not None
else None
)
active_turn_display_text = None
active_content = active_turn_row.get('content') if isinstance(active_turn_row, dict) else None
if (
isinstance(active_turn_row, dict)
and active_turn_row.get('role') == 'user'
and isinstance(active_content, list)
and any(
isinstance(part, dict)
and isinstance(part.get('type'), str)
and part.get('type') in {'image', 'image_url', 'input_image'}
for part in active_content
)
):
active_turn_display_text = (
_active_turn_identity.get('text')
if _active_turn_identity.get('text') is not None
else msg_text
)
if not isinstance(active_turn_display_text, str):
active_turn_display_text = None
else:
for display_message in previous_display:
if _active_turn_token_matches(display_message, _active_turn_identity):
display_message['content'] = active_turn_display_text
display_message.pop('api_content', None)
for key in ('id', 'timestamp', '_row_id'):
if display_message.get(key) is None and active_turn_row.get(key) is not None:
display_message[key] = copy.deepcopy(active_turn_row[key])
if not display_message.get('attachments') and active_turn_row.get('attachments'):
display_message['attachments'] = copy.deepcopy(
active_turn_row['attachments']
)
previous_user_tail = _stale_user_tail_candidate(_last_user_row(previous_context))
# ── Backfill normal turns from previous_context that are missing from
@@ -7440,16 +7739,34 @@ def _merge_display_messages_after_agent_result(
# at/after a cursor. Any context messages between the cursor and that
# match are context-only gaps that get spliced in before the display msg.
if previous_display and previous_context:
_displayed_native_image_context_tokens = (
_trusted_native_image_context_display_mirror_tokens(
previous_display,
previous_context,
)
)
_display_id_set = {_message_identity(m) for m in previous_display}
_context_id_set = {
_message_identity(m)
for m in previous_context
if not (
isinstance(m, dict)
and m.get('_active_turn_token') in _displayed_native_image_context_tokens
)
if not _is_context_compression_marker(m)
and not _is_compressed_context_tool_result_summary_message(m)
}
_has_context_only_turns = bool(_context_id_set - _display_id_set)
if _has_context_only_turns:
context_keys = [_message_identity(m) for m in previous_context]
context_keys = [
None
if (
isinstance(m, dict)
and m.get('_active_turn_token') in _displayed_native_image_context_tokens
)
else _message_identity(m)
for m in previous_context
]
# Precompute display keys once; avoids repeated json.dumps calls inside
# the inner any() loop (was O(D²·C) — see perf fix below).
_display_keys = [_message_identity(m) for m in previous_display]
@@ -7649,10 +7966,55 @@ def _merge_display_messages_after_agent_result(
or _is_compressed_context_tool_result_summary_message(msg)
):
continue
is_active_image_row = (
active_turn_display_text is not None
and isinstance(msg, dict)
and msg.get('role') == 'user'
and (
msg is active_turn_row
or _active_turn_token_matches(msg, _active_turn_identity)
)
)
if is_active_image_row:
display_row = _materialize_active_turn_user(
_active_turn_identity,
active_turn_display_text,
source,
)
display_row['content'] = active_turn_display_text
display_row.pop('api_content', None)
for key in ('id', 'timestamp', '_row_id'):
if display_row.get(key) is None and msg.get(key) is not None:
display_row[key] = copy.deepcopy(msg[key])
if not display_row.get('attachments') and msg.get('attachments'):
display_row['attachments'] = copy.deepcopy(msg['attachments'])
_mark_active_turn_checkpoint(display_row, _active_turn_identity)
existing_idx = next(
(
idx for idx, existing in enumerate(merged)
if _active_turn_token_matches(existing, _active_turn_identity)
),
None,
)
if existing_idx is not None:
existing = merged[existing_idx]
existing['content'] = active_turn_display_text
existing.pop('api_content', None)
for key in ('id', 'timestamp', '_row_id'):
if existing.get(key) is None and msg.get(key) is not None:
existing[key] = copy.deepcopy(msg[key])
if not existing.get('attachments') and msg.get('attachments'):
existing['attachments'] = copy.deepcopy(msg['attachments'])
if not existing.get('attachments') and display_row.get('attachments'):
existing['attachments'] = copy.deepcopy(display_row['attachments'])
_mark_active_turn_checkpoint(existing, _active_turn_identity)
continue
msg = display_row
key = _message_identity(msg)
is_current_user_turn = _looks_like_current_user_turn(msg, msg_text)
if (
((key is not None and key == current_user_key) or is_current_user_turn)
not is_active_image_row
and ((key is not None and key == current_user_key) or is_current_user_turn)
and merged
and (
_message_identity(merged[-1]) == current_user_key
@@ -7692,12 +8054,14 @@ def _merge_display_messages_after_agent_result(
continue
display_msg = msg
if (
((key is not None and key == current_user_key) or is_current_user_turn)
((key is not None and key == current_user_key) or is_current_user_turn or is_active_image_row)
and isinstance(msg, dict)
and msg.get('role') == 'user'
):
display_msg = copy.deepcopy(msg)
display_msg['content'] = msg_text
display_msg['content'] = (
active_turn_display_text if is_active_image_row else msg_text
)
stamp_message_source(display_msg, source)
merged.append(copy.deepcopy(display_msg))
if key is not None:
@@ -8903,6 +9267,39 @@ def _add_supported_run_conversation_kwarg(callable_obj, kwargs, name, value):
return False
def _register_pending_user_timestamp_identity(
callable_obj, session, persist_user_timestamp,
):
"""Persist the pending-turn proof while the caller owns the session lock."""
if session is None:
return
try:
timestamp_parameter = inspect.signature(callable_obj).parameters.get(
"persist_user_timestamp"
)
except (TypeError, ValueError):
timestamp_parameter = None
has_timestamp_contract = (
timestamp_parameter is not None
and timestamp_parameter.kind is not inspect.Parameter.POSITIONAL_ONLY
)
identity = (
_validated_webui_pending_user_timestamp_identity(
session,
(getattr(session, "active_stream_id", None), persist_user_timestamp),
)
if has_timestamp_contract
else None
)
previous = getattr(session, "_webui_pending_user_timestamp_identity", None)
session._webui_pending_user_timestamp_identity = identity
session_id = getattr(session, "session_id", None)
save = getattr(session, "save", None)
if callable(save) and session_id and previous != identity:
# Persist before run_conversation can write this turn to state.db.
save(touch_updated_at=False, skip_index=True)
def _build_run_conversation_kwargs(
callable_obj,
*,
@@ -8914,7 +9311,7 @@ def _build_run_conversation_kwargs(
persist_user_message,
persist_user_timestamp,
):
"""Build one rolling-compatible Agent invocation contract.
"""Build one rolling-compatible Agent invocation contract without mutation.
``persist_user_timestamp`` is signature-gated (#6935): an older
hermes-agent whose ``run_conversation()`` predates the kwarg must not
@@ -9760,6 +10157,37 @@ def _run_agent_streaming(
except Exception:
logger.debug("Failed to put event to queue")
_last_runtime_model_identity = None
_runtime_model_session_id = session_id
def _observe_runtime_model():
"""Publish this turn's Agent identity at output, never at an attempted route."""
nonlocal _last_runtime_model_identity
raw_model = getattr(agent, 'model', None)
if not isinstance(raw_model, str) or not raw_model.strip():
return
raw_provider = getattr(agent, 'provider', None)
provider = str(raw_provider).strip().lstrip('@').lower() if isinstance(raw_provider, str) else ''
# The Agent may carry a provider-qualified routing hint in its model.
from api.config import _parse_provider_qualified_model_id
parsed = _parse_provider_qualified_model_id(raw_model.strip())
model_id = (parsed[0] if parsed else raw_model).strip()
if not model_id:
return
fallback_active = getattr(agent, '_provider_fallback_active', None) is True
identity = (provider, model_id.lower(), fallback_active)
if identity == _last_runtime_model_identity:
return
payload = {
'session_id': _runtime_model_session_id, 'stream_id': stream_id,
'model': model_id, 'fallback_active': fallback_active,
'phase': 'observed_output',
}
if provider:
payload['provider'] = provider
put('runtime_model', payload)
_last_runtime_model_identity = identity
# #5940: capture a terminal (non-retryable) provider error the Agent emits via
# its lifecycle status_callback. The Agent aborts a non-retryable API error
# (e.g. HTTP 400 "invalid model / no credentials") with
@@ -9780,6 +10208,7 @@ def _run_agent_streaming(
turn-completion classifier can report the real cause instead of the
generic no_response fallback. All other lifecycle messages are dropped.
"""
nonlocal _last_runtime_model_identity
_message = str(message or '').strip()
_kind = str(kind or '').strip().lower()
if not _message:
@@ -9809,6 +10238,7 @@ def _run_agent_streaming(
return
_is_fallback_notice = _is_fallback_lifecycle_message(_kind, _message)
if _is_fallback_notice:
_last_runtime_model_identity = None
put('warning', {'type': 'fallback', 'message': _message})
# xsession wakeup misroute root fix (Option 1): pre-init so the outer
@@ -10134,14 +10564,11 @@ def _run_agent_streaming(
# loaded the default profile's `mcp_servers`, even when the session
# was stamped with a non-default profile. See issue #1968.
#
# NOTE: `_servers` in `tools/mcp_tool.py` is a process-global registry
# keyed by server name. This means once profile A registers a server
# named e.g. `postgres`, profile B's discovery sees it as already
# connected and skips it — even if B's config points at a different
# binary. Fully fixing multi-profile concurrent use requires keying
# `_servers` by `(profile_home, name)` upstream in hermes-agent; that
# lives outside this WebUI repo. This change fixes the headline bug
# for users who run a single non-default profile per WebUI process.
# NOTE: hermes-agent keys MCP connections by `(profile_home, name)` only
# for a routed profile (override != process home). The HERMES_HOME
# mirror above would make this turn's own profile look like the process
# profile, falling back to bare, cross-profile names, unless the agent
# honours the pin from `api.profiles._pin_process_profile_home()`.
try:
from api.agent_compat import agent_attr
discover_mcp_tools = agent_attr("tools.mcp_tool", "discover_mcp_tools", "tools.mcp_tool_discovery")
@@ -10356,6 +10783,8 @@ def _run_agent_streaming(
nonlocal _token_sent
if text is None:
return # end-of-stream sentinel
if text:
_observe_runtime_model()
# #4729: visible output is starting — flush any buffered reasoning tail
# first so the live Thinking stream is complete before/at the transition.
_flush_reasoning_buffer()
@@ -10399,6 +10828,8 @@ def _run_agent_streaming(
# partial window is not lost when the reasoning phase ends.
_flush_reasoning_buffer()
return
if text:
_observe_runtime_model()
_tool_boundary_advanced = False
reasoning_delta = str(text)
# Some runtimes mirror user-visible progress text through the
@@ -11494,6 +11925,11 @@ def _run_agent_streaming(
_agent_msg_text = "\n\n".join([*_process_notifications, msg_text]).strip()
user_message = _build_native_multimodal_message(workspace_ctx, _agent_msg_text, attachments, workspace, cfg=_cfg, active_provider=(resolved_provider or ""), active_model=(resolved_model or ""), requested_provider=(_session_requested_provider or ""), profile=(getattr(s, "profile", None) or Path(_profile_home)))
_persistent_state_before = _persistent_state_snapshot(_profile_home)
with _agent_lock:
_persist_user_timestamp = getattr(s, 'pending_started_at', None)
_register_pending_user_timestamp_identity(
agent.run_conversation, s, _persist_user_timestamp
)
_run_conversation_kwargs = _build_run_conversation_kwargs(
agent.run_conversation,
user_message=user_message,
@@ -11509,7 +11945,7 @@ def _run_agent_streaming(
conversation_history_revision=_conversation_history_revision,
task_id=session_id,
persist_user_message=msg_text,
persist_user_timestamp=getattr(s, 'pending_started_at', None),
persist_user_timestamp=_persist_user_timestamp,
)
# Only pass moa_config when a /moa override is actually active, so a
# normal send never trips a TypeError on an older hermes-agent whose
@@ -11551,6 +11987,11 @@ def _run_agent_streaming(
profile=(getattr(s, "profile", None) or Path(_profile_home)),
)
_run_conversation_kwargs["user_message"] = user_message
# This exact WebUI-composed input is identity evidence for the
# Agent-indexed current row. Keep identity['text'] as the clean
# submitted prompt for display. Assignment follows notification
# rejection/rebuild so rejected text cannot claim a returned row.
_active_turn_identity['trusted_agent_input_text'] = _agent_msg_text
_result_partial_pre_call_context = list(_previous_context_messages)
if not _agent_can_invoke(agent):
with _agent_lock:
@@ -11601,6 +12042,11 @@ def _run_agent_streaming(
if isinstance(_m, dict) and _m.get('role') == 'assistant':
_answer = str(_m.get('content', ''))
break
if (_answer.strip() and not result.get('error')
and not _agent_result_terminal_failure(result)
and not getattr(agent, '_last_error', None)
and not _captured_terminal_error[0]):
_observe_runtime_model()
# /btw is intentionally non-persistent, but its terminal SSE
# payload is still public output. Project the ephemeral
# session before enqueueing it so raw Agent ``api_content`` or
@@ -12068,6 +12514,15 @@ def _run_agent_streaming(
_heal_context_messages,
_heal_conversation_history_revision,
) = _refresh_context_and_revision_from_state_db()
_heal_persist_user_timestamp = getattr(
s, 'pending_started_at', None
)
# The returned-error path already owns _agent_lock.
_register_pending_user_timestamp_identity(
agent.run_conversation,
s,
_heal_persist_user_timestamp,
)
_heal_kwargs = _build_run_conversation_kwargs(
agent.run_conversation,
user_message=user_message,
@@ -12085,7 +12540,7 @@ def _run_agent_streaming(
),
task_id=session_id,
persist_user_message=msg_text,
persist_user_timestamp=getattr(s, 'pending_started_at', None),
persist_user_timestamp=_heal_persist_user_timestamp,
)
if moa_config is not None:
_heal_kwargs["moa_config"] = moa_config
@@ -12303,6 +12758,8 @@ def _run_agent_streaming(
# the catch-all label, hint, and provider details.
return # apperror already closes the stream on the client side
_observe_runtime_model()
# ── Handle context compression side effects ──
# Also detect compression via the result dict or compressor state
if not _compressed:
@@ -12445,13 +12902,9 @@ def _run_agent_streaming(
if attachments:
display_attachments = [_attachment_name(a) for a in attachments if _attachment_name(a)]
for m in reversed(s.messages):
if m.get('role') == 'user':
content = str(m.get('content', ''))
# Match if content is part of the sent message or vice-versa
base_text = msg_text.split('\n\n[Attached files:')[0].strip() if '\n\n[Attached files:' in msg_text else msg_text
if base_text[:60] in content or content[:60] in msg_text:
m['attachments'] = display_attachments
break
if _active_turn_token_matches(m, _active_turn_identity):
m['attachments'] = display_attachments
break
# Persist reasoning trace in the session so it survives reload.
# Must run BEFORE s.save() — otherwise the mutation lives only in
# memory until the next turn's save, and the last-turn thinking card
@@ -12509,7 +12962,9 @@ def _run_agent_streaming(
# mutates agent.model when a fallback fires, so the pre-run
# resolved_model would mis-attribute exactly the turns where
# attribution matters most.
_used_model = getattr(agent, 'model', None) or resolved_model or model
# The configured selection is not proof that it served this turn.
_observed_model = getattr(agent, 'model', None)
_used_model = _observed_model.strip() if isinstance(_observed_model, str) else None
if _gateway_routing:
s.gateway_routing = _gateway_routing
_history = list(getattr(s, 'gateway_routing_history', None) or [])
@@ -13395,6 +13850,9 @@ def _run_agent_streaming(
if 'credential_pool' in _agent_params:
_heal_kwargs['credential_pool'] = _runtime_bundle['credential_pool']
_heal_agent = _AIAgent(**_heal_kwargs)
# Delta callbacks close over `agent`; the replacement, not
# the failed original, owns any successful retry output.
agent = _heal_agent
_agent_sig = _compute_agent_cache_signature(
resolved_model,
resolved_api_key,
@@ -13423,6 +13881,15 @@ def _run_agent_streaming(
_heal_context_messages,
_heal_conversation_history_revision,
) = _refresh_context_and_revision_from_state_db()
with _agent_lock:
_heal_persist_user_timestamp = getattr(
s, 'pending_started_at', None
)
_register_pending_user_timestamp_identity(
_heal_agent.run_conversation,
s,
_heal_persist_user_timestamp,
)
_heal_kwargs2 = _build_run_conversation_kwargs(
_heal_agent.run_conversation,
user_message=user_message,
@@ -13440,7 +13907,7 @@ def _run_agent_streaming(
),
task_id=session_id,
persist_user_message=msg_text,
persist_user_timestamp=getattr(s, 'pending_started_at', None),
persist_user_timestamp=_heal_persist_user_timestamp,
)
if moa_config is not None:
_heal_kwargs2["moa_config"] = moa_config
@@ -13522,6 +13989,7 @@ def _run_agent_streaming(
s, tool_calls=s.tool_calls
)
)
_observe_runtime_model()
if _done_session_payload is not None:
put('done', {
'session': _done_session_payload,
+62 -1
View File
@@ -59,6 +59,41 @@ proposes. It is routed in `api/routes.py` and implemented by
is a global invalidation signal, not a per-session lifecycle stream. The proposed
`GET /api/sessions/{session_id}/events` is per-session and path-distinct.
### Hidden-tab observation and recovery (implemented client behavior)
The browser closes its persistent per-session SSE while hidden and uses
`_startHiddenActiveStreamPoll()` in `static/messages.js` to poll
`GET /api/session/status?session_id=...` immediately and then every six seconds,
subject to browser timer throttling. An active stream can be attached through
the existing replay path; successful attachment stops the poll.
HTTP `404` is ambiguous: older profile-visibility guards and the legacy
unknown-profile path can return it for a live session. Current master returns
`409 session_profile_mismatch` for a known foreign profile; that response
remains retryable when another tab changes the browser-wide profile cookie.
A single `404` therefore keeps polling and retains the hidden-resume owner.
After three consecutive `404` responses, the poll pauses to bound repeated
missing-session requests, but retains that owner so returning to the visible
tab can reopen SSE. Any other response or network error resets this budget;
a newly started poll also starts with a fresh budget.
HTTP `410` is terminal: it stops the interval and clears the matching
hidden-resume session ID. Returning to the visible tab then does not reopen
SSE through that owner. Responses and queued ticks belong to one poll timer,
not merely a session ID: they cannot stop or attach a replacement poll, even
when the replacement observes the same session. The budget counts responses
received by the current poll. Cleanup affects browser observation state only;
it does not delete a session or cancel an agent run.
Successful idle responses with no `active_stream_id`, network failures, and
other non-success HTTP responses (including `401`, `403`, `429`, and `5xx`)
remain retryable. Visibility return normally restores per-session SSE when a
resume owner still exists, including after repeated `404` responses. Only a
terminal `410` removes that automatic recovery path. A profile restored before
the three-miss limit can recover through the next hidden poll; after the limit,
recovery waits for visibility return or explicit session loading. Behavior coverage lives in
`tests/test_hidden_tab_server_initiated_turn.py`.
### Heartbeat
`_SSE_HEARTBEAT_INTERVAL_SECONDS = 5` (defined in `api/routes.py`) is the current
@@ -162,7 +197,7 @@ gate rather than inventing values without source support.
## Authoritative emitted events (`/api/chat/stream`)
These are the **real wire `event:` names** emitted by `api/streaming.py` today
(23 names). Clients and docs must use this table — not the semantic draft above —
(24 names). Clients and docs must use this table — not the semantic draft above —
when integrating with the live chat SSE relay.
| Wire name | Role |
@@ -179,6 +214,7 @@ when integrating with the live chat SSE relay.
| `title` | Session title update (often after `done`) |
| `title_status` | Title generation status / skip reason |
| `warning` | Non-fatal provider/fallback warning |
| `runtime_model` | Local Agent serving identity observed at output or successful completion |
| `apperror` | Terminal application error (no trailing `stream_end`) |
| `cancel` | Run cancelled |
| `done` | Turn finalized (session payload); title/`stream_end` may follow |
@@ -199,6 +235,31 @@ The semantic taxonomy table remains a draft for the proposed per-session
endpoint vocabulary and must be confirmed during maintainer review before that
endpoint claims parity.
### Observed local runtime model
The local worker emits `runtime_model` with
`{session_id, stream_id, model, provider?, fallback_active, phase}`. The IDs
refer to the original run-journal owner even if compression rotates the Agent's
session. `phase="observed_output"` means the Agent's own model was read at a
nonempty token/reasoning callback or after successful completion (including a
non-streaming reply or credential self-heal). It does not promise that a turn
which emitted partial output will finish successfully. Missing Agent model
sends no observation; the configured selection is not used as serving proof.
`fallback_active` is true only when the Agent explicitly reports its fallback
flag. Consecutive identical observations are deduplicated within the turn.
Fallback lifecycle warnings invalidate older serving evidence and reset the
deduplication; status text alone never establishes the replacement model. A
fresh observation after the warning reestablishes it, including when a buffered
success notice arrives after output. Durable replay retains the event IDs.
The journal summary and HTTP `runtime_journal_snapshot.runtime_model` project
the latest valid observation from the same session/stream event window, or null
if a warning or malformed latest observation invalidated it. No previous run's
footer or selected route fills an unknown current run. This is a local-worker
producer; gateway attribution and frontend presentation are separate concerns
(see #6272 and #7181). `route_observed` remains reserved for route-only data,
not successful output.
## Cursor and resume semantics
`Last-Event-ID` is the standard SSE reconnect header. Clients send the last
@@ -215,6 +215,14 @@ settle.
Steer must not exist only as a toast or transient DOM state. It must survive
refresh, session switch, replay, and settled render.
**The transport wrapper is not the Steer row.** A Steer is emitted as a standalone
typed user row (`role: user`, `display_kind: "steer"`). The surrounding
`[OUT-OF-BAND USER MESSAGE …] … [/OUT-OF-BAND USER MESSAGE]` block is transport-level
control data: settlement unwraps the single validated frame in place, extracting
and preserving the user's authored steer instruction while dropping the wrapper.
Legacy tool rows and rows where markers are multiple, nested, incomplete, or
contain ambiguous delimiters are preserved byte-for-byte.
### Relationship to system control events
Steer is similar to system-delivered control events such as tool-iteration-limit
@@ -171,7 +171,41 @@ and 5; it does not mark every run-state boundary implemented.
do not conflict, and the pairing is unambiguous. Keep the rich sidecar row;
if any requirement is missing or contradictory, preserve both rows rather
than deduplicating. Literal scalar `[screenshot]` text alone is not identity
evidence.
evidence. A WebUI-submitted native-image turn has a separate display owner:
keep its exact submitted text and attachment in the visible session row,
while the Agent's expanded multipart row remains in `context_messages` for
model replay. While the turn is active, the WebUI may hide an Agent user row
from display only after its worker confirms that the active stream's exact
`pending_started_at` value was passed to the Agent as
`persist_user_timestamp`; persist that private proof with the session and
validate it against the pending stream, source, and timestamp after reload.
Never include the proof in public session payloads. This applies to
native-image and scalar text-attachment rows, and never changes model
context. If multiple user rows share that timestamp, omit the whole
ambiguous display bucket until the turn settles; keep all rows in model
context. Do not identify the row by its text. If a stream dies before
settlement, state.db self-heal must save the submitted prompt and attachments
as a visible sidecar row before clearing pending metadata only when there is
genuine state.db output beyond that submitted turn. Otherwise, leave pending
state intact for journaled partial-output and interruption-marker recovery.
The Agent row remains available in model context. A partial continuation
must use one consistent parent snapshot when projecting a conflicting
provider payload onto its sidecar-owned display row.
Match settled native-image scalar projections only with trusted turn and
durable-row identity, never the marker alone. A durable row ID proves row
identity, not provider-payload freshness: when the sidecar and state.db have
conflicting nonempty `api_content`, preserve both versions for model-context
replay without mutating either. For visible display, a marked mirror may
share the existing sidecar bubble only when its valid durable row ID, exact
timestamp, and exact visible user content match; keep the sidecar-owned row
and its display metadata. Distinct row IDs, ambiguous or invalid identities,
and different visible user text remain separate only while eligible under
the existing edit/undo truncation watermark and checkpoint-order rules;
removed rows must not reappear in display or model replay. Fill a missing
payload from the other copy; repeated reconciliation must remain bounded
and idempotent.
Agent state.db alone cannot restore the original attachment if the WebUI
sidecar is lost.
Visible interim assistant progress must remain visible timeline content; a
compact Activity disclosure may summarize adjacent tool/debug detail, but it
must not be the only place where the user can see emitted progress text.
+21
View File
@@ -309,6 +309,27 @@ Interpret the two together:
---
## MCP panel shows another profile's servers, or "Live status for this profile is unavailable"
**Symptom.** With several profiles, the MCP settings panel of profile A shows a server as *Active* with a tool count while the tool inventory is empty (or lists profile B's tools); `/reload-mcp` on one profile stops the other profile's servers; or the MCP panel and the external Notes drawer show the notice *"Live status for this profile is unavailable right now"* and `/reload-mcp` answers *"MCP runtime scope could not be confirmed"*.
**Why.** Hermes Agent keeps one in-process MCP ledger per WebUI process and keys a connection by profile only when it can tell the request serves a profile other than the process's own. The WebUI binds every MCP status read and `/reload-mcp` to the request profile (`ARCHITECTURE.md` §4.10). While a chat turn is streaming, the WebUI mirrors that turn's profile into `HERMES_HOME`; Agents that predate `hermes_constants.pin_process_hermes_home` cannot distinguish that mirror from the process profile, so the WebUI withholds runtime data and refuses the reload instead of showing or resetting another profile's connection.
**Diagnostic commands.**
```bash
# runtime_scope: "profile" (bound), "legacy_process" (Agent without profile-scoped MCP),
# "unavailable" (scope could not be confirmed right now)
curl -s -b "hermes_profile=<profile>" http://127.0.0.1:8787/api/mcp/servers | python3 -m json.tool | grep -E '"(name|status|tool_count|runtime_scope)"'
python3 -c "import hermes_constants; print(hasattr(hermes_constants, 'pin_process_hermes_home'))"
```
**Fix.** `unavailable` while a turn is running is expected: refresh once the turn finishes. If it persists with no turn running, the Agent predates the process-home pin; upgrade Hermes Agent. `legacy_process` means the Agent has no profile-scoped MCP ledger at all; its `/reload-mcp` stays process-wide by design. After changing a profile's `mcp_servers`, run `/reload-mcp` **from that profile**: it only resets that profile's own connections and retries its failed servers.
**When to file a bug.** File a WebUI bug if `runtime_scope` is `"profile"` and a server is still reported *Active* with tools you cannot see in the inventory, or if `/reload-mcp` from one profile changes another profile's `tool_count`.
---
## Other troubleshooting
This document grows over time. If a recurring failure mode isn't covered here yet, add it via PR. The format for each entry: **Symptom → Why → Diagnostic commands → Fix → When to file a bug**.
+26 -3
View File
@@ -59,6 +59,7 @@ async function cancelStream(reason){
else setStatus('');
// /api/chat/cancel only exposes `cancelled:bool`, so we cannot
// distinguish reasons — keep the toast generic and short.
if(typeof _clearPendingPromptsForSession==='function') _clearPendingPromptsForSession(sid);
if(typeof showToast==='function') showToast('Stream is no longer active',2000);
}
return respOk;
@@ -91,6 +92,9 @@ async function cancelSessionStream(session){
if(typeof setComposerStatus==='function') setComposerStatus('');
else setStatus('');
}
if(typeof _clearPendingPromptsForSession==='function') _clearPendingPromptsForSession(sid);
if(typeof stopApprovalPollingForSession==='function') stopApprovalPollingForSession(sid);
if(typeof stopClarifyPollingForSession==='function') stopClarifyPollingForSession(sid);
if(typeof _approvalSessionId!=='undefined' && _approvalSessionId===sid){
stopApprovalPolling();
hideApprovalCard(true);
@@ -2388,9 +2392,29 @@ window._isImeEnter=_isImeEnter;
function _hasFinePointerCoexisting(){
try{ return matchMedia('(any-pointer:fine)').matches; }catch(_){ return false; }
}
// Detect phone software keyboards without undoing #3076's hardware-input
// guard for tablets. Some iOS Safari versions report (any-pointer:fine) on a
// plain iPhone, so phone UAs bypass that unreliable signal. Tablets and
// touch-capable desktop UAs still require a coarse pointer with no fine pointer.
function _isTouchOnlyDevice(){
const ua=navigator.userAgent||'';
if(/iPhone|iPod/i.test(ua)) return true;
if(/Android.*Mobile/i.test(ua)) return true;
try{
return matchMedia('(pointer:coarse)').matches&&!_hasFinePointerCoexisting();
}catch(_){}
return false;
}
function _isNumpadEnter(e){
return e.key==='Enter'&&(e.code==='NumpadEnter'||e.location===KeyboardEvent.DOM_KEY_LOCATION_NUMPAD);
}
// Initialise _sendKey synchronously from the localStorage cache so the keydown
// handler below has the correct value before the async /api/settings call
// (line ~3231) resolves. Without this, on slow mobile networks the race window
// leaves _sendKey=undefined, _mobileDefault evaluates false, and plain Enter
// falls through to the `else { send() }` branch — sending the message instead
// of inserting a newline (issue: mobile Enter sends on fresh page load).
try{ window._sendKey=localStorage.getItem('hermes-pref-send_key')||'enter'; }catch(_){ window._sendKey='enter'; }
$('msg').addEventListener('keydown',e=>{
// Autocomplete navigation when dropdown is open
const dd=$('cmdDropdown');
@@ -2424,9 +2448,8 @@ $('msg').addEventListener('keydown',e=>{
if(e.key==='Enter'){
if(_isImeEnter(e)){return;}
const isNumpadEnter=_isNumpadEnter(e);
const _mobileDefault=matchMedia('(pointer:coarse)').matches
&&!_hasFinePointerCoexisting()
&&window._sendKey==='enter';
const _mobileDefault=_isTouchOnlyDevice()
&&(window._sendKey==='enter'||typeof window._sendKey==='undefined');
if(window._sendKey==='shift+enter'){
if(e.shiftKey){e.preventDefault();send();}
} else if(window._sendKey==='ctrl+enter'||_mobileDefault){
+20 -6
View File
@@ -930,6 +930,12 @@ async function _applyManualCompressionResult(data, focusTopic, visibleCount, com
clearLiveToolCards();
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
// Restore paging signals from the (possibly full) transcript response.
// A successful /compress returns the full transcript, so the bounded
// preflight's _messagesTruncated/_oldestIdx must be reset before render
// (#7628). Same restore-before-render ordering as the settle/cancel paths.
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!data.session._messages_truncated;
if(typeof _oldestIdx!=='undefined') _oldestIdx=data.session._messages_offset||0;
syncTopbar();
renderMessages();
await renderSessionList();
@@ -1028,14 +1034,18 @@ async function _runManualCompression(focusTopic){
// Preflight: verify the viewed session still exists before compressing.
// This avoids a confusing "not found" toast when the UI is stale.
try{
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
// Bounded tail: a bare preflight used to pull and re-redact the whole
// transcript before every manual compression (#7310/#7625). Session
// existence + the current tail is all this preflight needs.
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);
if(!live||!live.session||live.session.session_id!==sid){
throw new Error('session no longer available');
}
S.session=live.session;
S.messages=live.session.messages||[];
S.toolCalls=live.session.tool_calls||[];
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=false;
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!(live.session._messages_truncated);
if(typeof _oldestIdx!=='undefined') _oldestIdx=live.session._messages_offset||0;
}catch(preflightErr){
if(typeof clearCompressionUi==='function') clearCompressionUi();
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
@@ -1823,11 +1833,13 @@ async function cmdRetry(){
const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})});
if(r&&r.error){showToast(r.error);return;}
if(!S.session||S.session.session_id!==activeSid)return;
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
// Bounded tail: a bare reload used to pull and re-redact the whole
// transcript on every /retry recovery (#7310/#7625).
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)+'&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1');
// #5924 SILENT-race guard: a session switch during the GET await must not let
// this recovery apply session A's intent to whatever session is now visible.
if(!S.session||S.session.session_id!==activeSid)return;
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();}
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!(data.session._messages_truncated);if(typeof _oldestIdx!=='undefined')_oldestIdx=data.session._messages_offset||0;renderMessages();}
$('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize();
// Re-arm the single-shot explicit-pick marker from the captured non-default
// pick — but only if it's still safe at fire time (session unchanged, current
@@ -1845,8 +1857,10 @@ async function cmdUndo(){
const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})});
if(r&&r.error){showToast(r.error);return;}
if(!S.session||S.session.session_id!==activeSid)return;
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();}
// Bounded tail: a bare reload used to pull and re-redact the whole
// transcript on every /undo recovery (#7310/#7625).
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)+'&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1');
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!(data.session._messages_truncated);if(typeof _oldestIdx!=='undefined')_oldestIdx=data.session._messages_offset||0;renderMessages();}
showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
}catch(e){showToast(t('undo_failed')+e.message);}
}
+14
View File
@@ -78,6 +78,7 @@ const LOCALES = {
mcp_servers_title: 'MCP Servers',
mcp_servers_desc: 'View MCP servers configured in config.yaml.',
mcp_no_servers: 'No MCP servers configured.',
mcp_runtime_scope_unavailable: 'Live status for this profile is unavailable right now (a chat turn may be running). Refresh when it finishes.',
mcp_add_server: '+ Add Server',
mcp_field_name: 'Server Name',
mcp_transport_label: 'Transport Type',
@@ -1945,6 +1946,7 @@ const LOCALES = {
mcp_servers_title: 'Server MCP',
mcp_servers_desc: 'Visualizza i server MCP configurati in config.yaml.',
mcp_no_servers: 'Nessun server MCP configurato.',
mcp_runtime_scope_unavailable: 'Lo stato in tempo reale di questo profilo non è al momento disponibile (potrebbe essere in corso un turno di chat). Aggiorna quando sarà terminato.',
mcp_add_server: '+ Aggiungi Server',
mcp_field_name: 'Nome Server',
mcp_transport_label: 'Tipo Trasporto',
@@ -3750,6 +3752,7 @@ const LOCALES = {
mcp_servers_title: 'MCPサーバー',
mcp_servers_desc: 'config.yaml で設定された MCP サーバーを管理します。',
mcp_no_servers: 'MCPサーバーは設定されていません。',
mcp_runtime_scope_unavailable: 'このプロファイルのライブステータスは現在利用できません(チャットのターンが実行中の可能性があります)。完了後に更新してください。',
mcp_add_server: '+ サーバーを追加',
mcp_field_name: 'サーバー名',
mcp_transport_label: 'トランスポート種別',
@@ -5557,6 +5560,7 @@ const LOCALES = {
mcp_servers_title: 'Серверы MCP',
mcp_servers_desc: 'Просмотр серверов MCP, настроенных в config.yaml.',
mcp_no_servers: 'Серверы MCP не настроены.',
mcp_runtime_scope_unavailable: 'Актуальный статус этого профиля сейчас недоступен (возможно, выполняется ход чата). Обновите, когда он завершится.',
mcp_add_server: '+ Добавить сервер',
mcp_field_name: 'Имя сервера',
mcp_transport_label: 'Тип транспорта',
@@ -7329,6 +7333,7 @@ const LOCALES = {
mcp_servers_title: 'MCP 服务器',
mcp_servers_desc: '管理 config.yaml 中配置的 MCP 服务器。',
mcp_no_servers: '未配置 MCP 服务器。',
mcp_runtime_scope_unavailable: 'El estado en vivo de este perfil no está disponible ahora mismo (puede haber un turno de chat en curso). Actualiza cuando termine.',
mcp_add_server: '+ 添加服务器',
mcp_field_name: '服务器名称',
mcp_transport_label: '传输类型',
@@ -9080,6 +9085,7 @@ const LOCALES = {
mcp_servers_title: 'MCP-Server',
mcp_servers_desc: 'MCP-Server in config.yaml verwalten.',
mcp_no_servers: 'Keine MCP-Server konfiguriert.',
mcp_runtime_scope_unavailable: 'Der Live-Status für dieses Profil ist derzeit nicht verfügbar (möglicherweise läuft gerade ein Chat-Zug). Aktualisieren Sie, wenn er abgeschlossen ist.',
mcp_add_server: '+ Server hinzufügen',
mcp_field_name: 'Servername',
mcp_transport_label: 'Transporttyp',
@@ -10825,6 +10831,7 @@ const LOCALES = {
mcp_servers_title: 'MCP 服务器',
mcp_servers_desc: '管理 config.yaml 中配置的 MCP 服务器。',
mcp_no_servers: '未配置 MCP 服务器。',
mcp_runtime_scope_unavailable: '当前无法获取此配置的实时状态(可能有对话回合正在进行)。请在其完成后刷新。',
mcp_add_server: '+ 添加服务器',
mcp_field_name: '服务器名称',
mcp_transport_label: '传输类型',
@@ -12580,6 +12587,7 @@ const LOCALES = {
mcp_servers_title: 'MCP 伺服器',
mcp_servers_desc: '檢視 config.yaml 中設定的 MCP 伺服器。',
mcp_no_servers: '未設定 MCP 伺服器。',
mcp_runtime_scope_unavailable: '目前無法取得此設定檔的即時狀態(可能有對話回合正在進行)。請在其完成後重新整理。',
mcp_add_server: '+ 新增伺服器',
mcp_field_name: '伺服器名稱',
mcp_transport_label: '傳輸類型',
@@ -15996,6 +16004,7 @@ const LOCALES = {
mcp_servers_title: 'MCP Servers',
mcp_servers_desc: 'Manage Model Context Protocol servers configured in config.yaml.',
mcp_no_servers: 'No MCP servers configured.',
mcp_runtime_scope_unavailable: '이 프로필의 실시간 상태를 현재 확인할 수 없습니다(채팅 턴이 진행 중일 수 있음). 완료된 후 새로 고치세요.',
mcp_add_server: '+ Add Server',
mcp_field_name: 'Server Name',
mcp_transport_label: 'Transport Type',
@@ -17801,6 +17810,7 @@ const LOCALES = {
mcp_servers_title: 'Serveurs MCP',
mcp_servers_desc: 'Afficher les serveurs MCP configurés dans config.yaml.',
mcp_no_servers: 'Aucun serveur MCP configuré.',
mcp_runtime_scope_unavailable: 'Le statut en direct de ce profil est indisponible pour le moment (un tour de conversation est peut-être en cours). Actualisez quand il sera terminé.',
mcp_add_server: '+ Ajouter un serveur',
mcp_field_name: 'Nom du serveur',
mcp_transport_label: 'Type de transport',
@@ -20909,6 +20919,7 @@ const LOCALES = {
mcp_load_failed: 'Načtení serverů MCP se nezdařilo.',
mcp_name_required: 'Je vyžadováno jméno serveru.',
mcp_no_servers: 'Nejsou nakonfigurovány žádné servery MCP.',
mcp_runtime_scope_unavailable: 'Živý stav tohoto profilu není momentálně k dispozici (možná probíhá tah chatu). Obnovte, až skončí.',
mcp_restart_hint: 'Změny serveru jsou zatím jen pro čtení. Upravte config.yaml a restartujte Hermes, aby se změny projevily.',
mcp_save: 'Uložit',
mcp_save_failed: 'Uložení serveru MCP se nezdařilo.',
@@ -21350,6 +21361,7 @@ const LOCALES = {
mcp_servers_title: 'MCP Sunucuları',
mcp_servers_desc: 'config.yaml dosyasında yapılandırılmış MCP sunucularını görüntüleyin.',
mcp_no_servers: 'Hiçbir MCP sunucusu yapılandırılmadı.',
mcp_runtime_scope_unavailable: 'Bu profilin canlı durumu şu anda kullanılamıyor (bir sohbet turu çalışıyor olabilir). Bittiğinde yenileyin.',
mcp_add_server: '+ Sunucu Ekle',
mcp_field_name: 'Sunucu Adı',
mcp_transport_label: 'Taşıma Türü',
@@ -23148,6 +23160,7 @@ const LOCALES = {
mcp_servers_title: 'Serwery MCP',
mcp_servers_desc: 'Wyświetl serwery MCP skonfigurowane w config.yaml.',
mcp_no_servers: 'Brak skonfigurowanych serwerów MCP.',
mcp_runtime_scope_unavailable: 'Stan na żywo tego profilu jest obecnie niedostępny (może trwać tura czatu). Odśwież, gdy się zakończy.',
mcp_add_server: '+ Dodaj serwer',
mcp_field_name: 'Nazwa serwera',
mcp_transport_label: 'Typ transportu',
@@ -24941,6 +24954,7 @@ const LOCALES = {
mcp_servers_title: 'Máy chủ MCP',
mcp_servers_desc: 'Xem các máy chủ MCP được cấu hình trong config.yaml.',
mcp_no_servers: 'Chưa có máy chủ MCP nào được cấu hình.',
mcp_runtime_scope_unavailable: 'Trạng thái trực tiếp của hồ sơ này hiện không khả dụng (có thể một lượt trò chuyện đang chạy). Hãy làm mới khi lượt đó kết thúc.',
mcp_add_server: '+ Thêm máy chủ',
mcp_field_name: 'Tên máy chủ',
mcp_transport_label: 'Loại kết nối',
+1 -1
View File
@@ -592,7 +592,7 @@
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
<span class="voice-mode-label" id="voiceModeLabel"></span>
</div>
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
<textarea id="msg" rows="1" enterkeyhint="enter" placeholder="Message Hermes…"></textarea>
<div id="composerWorkspaceContext" class="sr-only"></div>
<div class="composer-footer">
<div class="composer-left">
+287 -47
View File
@@ -2760,11 +2760,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let _currentActivityBurstId=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentActivityBurstId)||0)||0;
let _currentLiveSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0;
let _assistantSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0;
let _lastRunJournalSeq=reconnecting
? Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalSeq)||0)
// #7640: the replay floor is only as trustworthy as the recovery state behind
// it. A cache that kept the cursor but lost the live assistant projection must
// not raise `after_seq`: the server would then replay only the tail (often just
// `stream_end`), and the missing journal range never gets a chance to rebuild
// the body — the settled footer paints over a blank message until a reload.
// Fall back to the zero floor whenever the state cannot be validated.
const _replayCursorInflight=reconnecting?INFLIGHT[activeSid]:null;
let _lastRunJournalSeq=(typeof _runJournalReplayFloorForInflight==='function')
? _runJournalReplayFloorForInflight(_replayCursorInflight)
: 0;
let _lastRunJournalEventId=reconnecting
? String((INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId)||'')
let _lastRunJournalEventId=(typeof _runJournalReplayEventIdForInflight==='function')
? _runJournalReplayEventIdForInflight(_replayCursorInflight)
: '';
const _STREAM_FADE_MS=620;
const _STREAM_FADE_MAX_MS=900;
@@ -6099,7 +6106,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_applyToAnchor('approval',d,e);
showApprovalForSession(activeSid, d, d.pending_count || 1);
playAttentionSound(_attentionSoundKey(activeSid,'approval',1));
sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid});
// Browser notification is owned by showApprovalCard()/_notifyPromptCard()
// so every surfacing path (SSE, fallback poll, post-respond refresh,
// reload) dedupes through the same per-id gate.
});
source.addEventListener('clarify',e=>{
@@ -6107,7 +6116,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_applyToAnchor('clarify',d,e);
showClarifyForSession(activeSid, d);
playAttentionSound(_attentionSoundKey(activeSid,'clarify',1));
sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid});
// Browser notification is owned by showClarifyCard()/_notifyPromptCard().
});
source.addEventListener('state_saved',e=>{
@@ -6759,6 +6768,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.session=d.session;
const _nextMsgs3018=(d.session.messages||[]).filter(m=>m&&m.role);
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(d.session);
// Same persist-before-offset trap as settle/cancel: refresh paging
// before attaching the projected scene (#7628).
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!d.session._messages_truncated;
if(typeof _oldestIdx!=='undefined') _oldestIdx=d.session._messages_offset||0;
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
if(S.session&&S.session.session_id){
@@ -7000,6 +7013,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.session=sessionPayload;
const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(sessionPayload);
// A bounded cancel-recovery reload returns a tail window: keep the
// Load-earlier paging gate honest, and refresh _oldestIdx BEFORE
// persisting the projected scene (#7310/#7625/#7628).
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!sessionPayload._messages_truncated;
if(typeof _oldestIdx!=='undefined') _oldestIdx=sessionPayload._messages_offset||0;
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
@@ -7021,8 +7039,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(_applyCancelSessionPayload(_cancelSessionPayload)) return;
// Fetch latest session from server to get accurate message list (includes cancel status)
// This ensures messages stay in sync with server, fixing race condition where local
// "*Task cancelled.*" message gets lost when done event overwrites S.messages
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
// "*Task cancelled.*" message gets lost when done event overwrites S.messages.
// Bounded tail: a bare reload used to pull and re-redact the whole transcript
// on every cancel recovery (#7310/#7625).
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);
if(data&&data.session) _applyCancelSessionPayload(data.session);
}catch(_){
// Fallback to local cancel message if API fails
@@ -7114,7 +7134,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
return returnStatus?'stale':false;
}
try{
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`,options&&options.requestOptions||{});
// Bounded tail: a bare reload used to pull and re-redact the whole
// transcript on every stream-end settle/reconnect recovery (#7310/#7625).
// The Load-earlier paging gate is restored from the response below.
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`,options&&options.requestOptions||{});
if(isCurrent&&!isCurrent()) return returnStatus?'stale':false;
// Opus #2852 race-fix: if a late `done` event ran the finalize path while
// we were awaiting the network roundtrip, bail out — done already settled.
@@ -7149,6 +7172,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role);
const _currentMessages=Array.isArray(S.messages)?S.messages:[];
const _currentVisibleMessages=_filterRecoveryControlMessages(_currentMessages || []);
// Restore paging from the bounded tail BEFORE attaching/persisting the
// projected scene: _persistSettledAnchorScene reads _oldestIdx to
// compute the absolute transcript index (#7628).
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!session._messages_truncated;
if(typeof _oldestIdx!=='undefined') _oldestIdx=session._messages_offset||0;
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(session);
if(S.session&&S.session.session_id){
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
}
const _stagedMessages=_carryForwardEphemeralTurnFields(_currentMessages, _nextMsgs3018);
const _currentVisibleEndsWithTerminalMarker=(
_currentVisibleMessages.length>0 &&
@@ -7164,18 +7197,40 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
return !!stagedKey && stagedKey===currentKey;
})
);
const _preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&_stagedMatchesCurrentPrefix;
// Bounded settle returns a suffix of the durable transcript, not a
// prefix of the currently visible window. On a long session the
// prefix check fails and would drop the terminal recovery marker
// (#7628). Treat a suffix match the same as a prefix match.
const _durableVisibleCount=_currentVisibleEndsWithTerminalMarker
? _currentVisibleMessages.length-1
: _currentVisibleMessages.length;
const _stagedSuffixStart=_durableVisibleCount-_stagedMessages.length;
const _stagedMatchesCurrentSuffix=(
_stagedMessages.length>0 &&
_stagedSuffixStart>=0 &&
_stagedMessages.length<_currentVisibleMessages.length &&
_currentVisibleEndsWithTerminalMarker &&
_stagedMessages.every((message, idx)=>{
const stagedKey=_messageIdentityKey(message);
const currentKey=_messageIdentityKey(_currentVisibleMessages[_stagedSuffixStart+idx]);
return !!stagedKey && stagedKey===currentKey;
})
);
// The server's truncation signal decides the strategy, not an `||`:
// a bounded settle returns a SUFFIX of the durable transcript, so
// suffix matching is the only correct strategy when truncated. Prefix
// preservation is reserved for untruncated snapshots. When repeated
// identical turns make BOTH comparisons succeed, preferring the prefix
// splices at the wrong offset and silently drops/duplicates rows
// (#7628). Never prefer prefix when truncation is active.
const _truncatedRecovery=(typeof _messagesTruncated!=='undefined'&&!!_messagesTruncated)||(typeof _oldestIdx!=='undefined'&&!!(_oldestIdx>0));
const _preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&(_truncatedRecovery?_stagedMatchesCurrentSuffix:_stagedMatchesCurrentPrefix);
const _resolvedMessages=_preserveCurrentTranscript
? [..._stagedMessages,..._currentVisibleMessages.slice(_stagedMessages.length)]
? [..._stagedMessages,..._currentVisibleMessages.slice(_truncatedRecovery?_stagedSuffixStart+_stagedMessages.length:_stagedMessages.length)]
: _stagedMessages;
S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);
_attachProjectedAnchorSceneToLastAssistant(S.messages);
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
if(S.session&&S.session.session_id){
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
}
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(session);
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error');
const hasMessageToolMetadata=S.messages.some(m=>{
@@ -7574,19 +7629,25 @@ function hideApprovalCard(force=false) {
let _approvalSessionId = null;
let _approvalCurrentId = null; // approval_id of the card currently shown
let _approvalPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map();
let _approvalResponding = null;
let _approvalClearedOwner = null;
let _approvalDisplayedOwner = null;
const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
// Dismissed approvals are namespaced by session so that two sessions carrying
// the SAME approval_id (e.g. a gateway/run source that reuses externally
// supplied IDs across sessions) can't have a dismissal in one session hide the
// other's still-pending approval. Stored value is "<sid>\u0000<approval_id>".
// Dismissal uses the same injective full-owner tuple as notifications.
// Legacy session/ID-only tombstones cannot safely identify a gateway run.
function _approvalDismissKey(sid, approvalId) {
if (!approvalId) return '';
return String(sid || '') + '\u0000' + String(approvalId);
const pending = typeof approvalId === 'object' ? approvalId : {approval_id: approvalId};
return _promptNotifyKey('approval', sid, pending);
}
function _legacyApprovalDismissKey(sid, approvalId) {
const pending = typeof approvalId === 'object' ? approvalId : {approval_id: approvalId};
const id = pending && pending.approval_id;
return sid && id ? String(sid) + '\0' + String(id) : '';
}
function _getDismissedApprovals() {
@@ -7597,7 +7658,23 @@ function _getDismissedApprovals() {
function _isApprovalDismissed(sid, approvalId) {
const key = _approvalDismissKey(sid, approvalId);
if (!key) return false;
return _getDismissedApprovals().includes(key);
const dismissed = _getDismissedApprovals();
if (dismissed.includes(key)) return true;
const pending = typeof approvalId === "object" ? approvalId : {approval_id: approvalId};
const legacyId = pending && pending.approval_id;
const legacyKey = sid && legacyId ? String(sid) + "\0" + String(legacyId) : "";
if (!legacyKey || !dismissed.includes(legacyKey)) return false;
// Bind an old session/ID tombstone to the currently observed full owner.
// This keeps an unresolved dismissal quiet through upgrade without letting
// the ambiguous legacy key suppress every future gateway run forever.
const migrated = dismissed.filter(item => item !== legacyKey && item !== key);
const hasFullOwner = !!(pending && pending.run_id && pending._gateway_mirror_token);
// A legacy tombstone cannot identify a gateway run. Consume it rather than
// transferring its authority to an unrelated later owner that reused the ID.
if (!hasFullOwner) migrated.push(key);
try { localStorage.setItem(_DISMISSED_APPROVALS_KEY, JSON.stringify(migrated.slice(-100))); }
catch (_) {}
return !hasFullOwner;
}
function _markApprovalDismissed(sid, approvalId) {
@@ -7628,15 +7705,38 @@ function _approvalPromptBelongsToActiveSession(sid) {
function activeSessionHasPendingPromptAttention() {
const sid = _promptActiveSessionId();
return !!(sid && (
_approvalPendingBySession.has(sid) ||
(_approvalPendingBySession.has(sid) &&
!_isApprovalDismissed(sid, _approvalPendingBySession.get(sid).pending)) ||
_clarifyPendingBySession.has(sid)
));
}
function _approvalPromptGeneration(sid) {
return Number(_approvalPromptGenerationBySession.get(sid) || 0);
}
function _bumpApprovalPromptGeneration(sid) {
if (sid) _approvalPromptGenerationBySession.set(sid, _approvalPromptGeneration(sid) + 1);
}
function _rememberApprovalPending(pending, pendingCount) {
if (!pending) return null;
const sid = pending._session_id || _promptActiveSessionId();
if (!sid) return null;
const prev = _approvalPendingBySession.get(sid);
// A replacement pending entry DISPLACES the previous prompt for this
// session: retire the displaced prompt notification-dedupe key so the same
// externally supplied ID can legitimately notify again later. A re-render
// of the SAME prompt serializes to the same owner key - skip retirement
// there so repeated poll ticks stay deduped.
if (prev && prev.pending
&& _promptNotifyKey("approval", sid, prev.pending) !== _promptNotifyKey("approval", sid, pending)) {
_retirePromptNotifyKey("approval", sid, prev.pending);
_unmarkApprovalDismissed(sid, prev.pending);
_bumpApprovalPromptGeneration(sid);
} else if (!prev) {
_bumpApprovalPromptGeneration(sid);
}
const nextPending = {...pending, _session_id: sid};
_approvalPendingBySession.set(sid, {pending: nextPending, pendingCount: pendingCount || 1});
return sid;
@@ -7644,7 +7744,11 @@ function _rememberApprovalPending(pending, pendingCount) {
function _clearApprovalPendingForSession(sid) {
if (sid) {
const entry = _approvalPendingBySession.get(sid);
_approvalPendingBySession.delete(sid);
_bumpApprovalPromptGeneration(sid);
if (entry && entry.pending) _unmarkApprovalDismissed(sid, entry.pending);
if (entry && entry.pending) _retirePromptNotifyKey('approval', sid, entry.pending);
if (typeof syncTopbar === 'function') syncTopbar();
}
}
@@ -7787,8 +7891,9 @@ function showApprovalForSession(sid, pending, pendingCount) {
function showApprovalCard(pending, pendingCount) {
const sid = _rememberApprovalPending(pending, pendingCount);
if (pending && pending.approval_id && _isApprovalDismissed(sid, pending)) return;
if(typeof _notifyPromptCard==='function') _notifyPromptCard('approval', sid, pending);
if (!_approvalPromptBelongsToActiveSession(sid)) return;
if (pending && pending.approval_id && _isApprovalDismissed(sid, pending.approval_id)) return;
_approvalClearedOwner = null;
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
@@ -7847,9 +7952,12 @@ function showApprovalCard(pending, pendingCount) {
function dismissApprovalCard() {
const sid = _approvalSessionId;
if (_approvalCurrentId) _markApprovalDismissed(sid, _approvalCurrentId);
const entry = _approvalPendingBySession.get(sid);
if (entry && entry.pending) _markApprovalDismissed(sid, entry.pending);
// Keep the unresolved owner for authoritative resolution/replacement.
// Local dismissal hides attention, not the server-owned prompt.
if (typeof syncTopbar === 'function') syncTopbar();
hideApprovalCard(true);
if (sid) _clearApprovalPendingForSession(sid);
}
function _syncApprovalCollapseButton(card) {
@@ -8041,7 +8149,9 @@ function _startApprovalFallbackPoll(sid) {
if (_approvalFallbackPollInFlight) return;
_approvalFallbackPollInFlight = true;
try {
const generation = _approvalPromptGeneration(sid);
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
if (_approvalPollingSessionMissingOrMismatched(sid) || _approvalPromptGeneration(sid) !== generation) return;
if (data.pending) { showApprovalForSession(sid, data.pending, data.pending_count||1); }
else if (!_approvalPollingSessionMissingOrMismatched(sid)) {
const _resolvedEntry = _approvalPendingBySession.get(sid);
@@ -8205,32 +8315,37 @@ function _startHiddenActiveStreamPoll(sid) {
if (!sid) return;
_stopHiddenActiveStreamPoll();
_sessionStreamHiddenPollSid = sid;
let notFoundCount = 0;
let pollTimer = null;
const ownsPoll = () => _sessionStreamHiddenPollSid === sid &&
_sessionStreamHiddenPollTimer === pollTimer;
const tick = () => {
// A queued tick/response must not mutate a replacement, even for the same sid.
if (!ownsPoll()) return;
// Stop conditions: tab became visible (real SSE takes over), session
// switched, or we're already rendering a stream.
if (typeof document !== 'undefined' && !document.hidden) { _stopHiddenActiveStreamPoll(); return; }
if (_sessionStreamHiddenPollSid !== sid) { _stopHiddenActiveStreamPoll(); return; }
if (S.activeStreamId) return; // already rendering; wait it out
try {
fetch(_apiUrl('api/session/status?session_id=' + encodeURIComponent(sid)), {credentials: 'same-origin'})
.then(r => {
// #7299: 404 Not Found / 410 Gone are TERMINAL for this
// session-owned poll. The session has been deleted or no
// longer exists in the active state directory, so further
// polls are guaranteed to fail. Stop the poll immediately
// to avoid the infinite 404 loop on stale background tabs
// (one tab could fire ~10 such requests per minute; multiple
// tabs multiply the noise). Transient failures (5xx, rate
// limit, network error) keep polling — only the missing
// session itself is terminal.
if ((r.status === 404 || r.status === 410) && _sessionStreamHiddenPollSid === sid) {
if (!ownsPoll()) return null;
if (r && r.status === 404) {
// Profile visibility also returns 404. Bound repeated misses, but
// retain the resume owner so a profile flip back can self-heal.
if (++notFoundCount >= 3) _stopHiddenActiveStreamPoll();
return null;
}
notFoundCount = 0;
if (r && r.status === 410) {
if (_sessionStreamHiddenSid === sid) _sessionStreamHiddenSid = null;
_stopHiddenActiveStreamPoll();
return null;
}
return r.ok ? r.json() : null;
return r && r.ok ? r.json() : null;
})
.then(d => {
if (!d || _sessionStreamHiddenPollSid !== sid) return;
if (!d || !ownsPoll()) return;
const streamId = d.active_stream_id;
if (streamId && S.activeStreamId !== String(streamId)) {
// Server-initiated turn in flight while hidden → attach as replay.
@@ -8263,10 +8378,11 @@ function _startHiddenActiveStreamPoll(sid) {
}
}
})
.catch(() => {});
} catch (_) {}
.catch(() => { notFoundCount = 0; });
} catch (_) { notFoundCount = 0; }
};
_sessionStreamHiddenPollTimer = setInterval(tick, 6000);
pollTimer = setInterval(tick, 6000);
_sessionStreamHiddenPollTimer = pollTimer;
// Fire one immediately so a turn already running when we go hidden is caught
// without waiting a full interval.
tick();
@@ -8611,16 +8727,36 @@ let _clarifyMissingEndpointWarned = false;
let _clarifyCountdownTimer = null;
let _clarifyExpiresAt = 0;
let _clarifyPendingBySession = new Map();
const _clarifyPromptGenerationBySession = new Map();
const CLARIFY_MIN_VISIBLE_MS = 30000;
function _clarifyPromptBelongsToActiveSession(sid) {
return !!(sid && _promptActiveSessionId() === sid);
}
function _clarifyPromptGeneration(sid) {
return Number(_clarifyPromptGenerationBySession.get(sid) || 0);
}
function _bumpClarifyPromptGeneration(sid) {
if (sid) _clarifyPromptGenerationBySession.set(sid, _clarifyPromptGeneration(sid) + 1);
}
function _rememberClarifyPending(pending) {
if (!pending) return null;
const sid = pending._session_id || _promptActiveSessionId();
if (!sid) return null;
const prev = _clarifyPendingBySession.get(sid);
// Mirror of the approval displacement retirement above: a NEW clarification
// replaces the session pending entry, so the displaced prompt dedupe key
// retires (same-owner re-renders keep their key).
if (prev && prev.pending
&& _promptNotifyKey("clarify", sid, prev.pending) !== _promptNotifyKey("clarify", sid, pending)) {
_retirePromptNotifyKey("clarify", sid, prev.pending);
_bumpClarifyPromptGeneration(sid);
} else if (!prev) {
_bumpClarifyPromptGeneration(sid);
}
const nextPending = {...pending, _session_id: sid};
_clarifyPendingBySession.set(sid, {pending: nextPending});
return sid;
@@ -8628,11 +8764,19 @@ function _rememberClarifyPending(pending) {
function _clearClarifyPendingForSession(sid) {
if (sid) {
const entry = _clarifyPendingBySession.get(sid);
_clarifyPendingBySession.delete(sid);
_bumpClarifyPromptGeneration(sid);
if (entry && entry.pending) _retirePromptNotifyKey('clarify', sid, entry.pending);
if (typeof syncTopbar === 'function') syncTopbar();
}
}
function _clearPendingPromptsForSession(sid) {
_clearApprovalPendingForSession(sid);
_clearClarifyPendingForSession(sid);
}
function _hideClarifyCardIfOwner(sid, force=false, reason="dismissed") {
if (!sid || _clarifySessionId === sid) hideClarifyCard(force, reason);
}
@@ -8920,6 +9064,7 @@ function _clarifySetControlsDisabled(disabled, loading=false) {
function showClarifyCard(pending) {
const sid = _rememberClarifyPending(pending);
if(typeof _notifyPromptCard==='function') _notifyPromptCard('clarify', sid, pending);
if (!_clarifyPromptBelongsToActiveSession(sid)) return;
const question = pending.question || pending.description || '';
const choices = Array.isArray(pending.choices_offered)
@@ -9179,7 +9324,9 @@ function _startClarifyFallbackPoll(sid) {
if (_clarifyFallbackPollInFlight) return;
_clarifyFallbackPollInFlight = true;
try {
const generation = _clarifyPromptGeneration(sid);
const data = await api("/api/clarify/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
if (!S.session || S.session.session_id !== sid || _clarifyPromptGeneration(sid) !== generation) return;
if (data.pending) { showClarifyForSession(sid, data.pending); }
else { _clearClarifyPendingForSession(sid); _hideClarifyCardIfOwner(sid, false, 'expired'); }
} catch(e) {
@@ -9398,6 +9545,81 @@ function requestNotificationPermission(){
return p;
});
}
const _promptNotifySeen = new Map();
// Prompt-card notifications: an approval or clarify card BLOCKS the run until
// it is answered, so every surfacing path must notify — the live SSE event,
// the 1.5s fallback poll, the post-respond "next approval" refresh, and a
// page reload while a prompt is still pending. The chokepoint is the card
// renderer itself (showApprovalCard / showClarifyCard); this helper dedupes
// per logical OWNER - the typed tuple [kind, sid, prompt id, run owner id,
// mirror token] serialized with JSON.stringify so delimiter-bearing producer
// strings cannot collide (the gateway accepts approval_id as an unrestricted
// string) - so repeated poll ticks, re-renders, and session switches ping
// exactly once per owner. The run-owner fields mirror the approval-owner
// identity used elsewhere in this file (_approvalOwnerForPending): the same
// externally supplied approval_id may legitimately be pending in two
// sessions, or in two gateway runs within one session, and each owner must
// notify on its own. Seen entries are retired by PROMPT LIFECYCLE, not by
// wall-clock age: _clearApprovalPendingForSession() and
// _clearClarifyPendingForSession() (the resolution/dismissal/terminal
// chokepoints) call _retirePromptNotifyKey(), so a prompt left pending for
// hours never re-notifies, while a resolved prompt whose ID is later reused
// legitimately notifies again. Entries are also NOT recorded when
// notifications are disabled, so enabling mid-prompt re-notifies that owner.
function _promptNotifyKey(kind, sid, pending){
const p = pending || {};
// Older clarify producers can omit clarify_id. Their stable prompt payload is
// still a usable lifecycle identity and is retired with the pending entry.
const id = p.approval_id || p.clarify_id || (kind === 'clarify'
? JSON.stringify([p.question || p.description || '', p.choices_offered || p.choices || [], p.requested_at || ''])
: '');
const runId = String(p.run_id || '').trim();
const mirrorToken = String(p._gateway_mirror_token || '').trim();
return JSON.stringify([kind, String(sid || ''), String(id),
runId && mirrorToken ? runId : '',
runId && mirrorToken ? mirrorToken : '']);
}
function _retirePromptNotifyKey(kind, sid, pending){
if (!pending) return;
const key = _promptNotifyKey(kind, sid, pending);
if (!key) return;
_promptNotifySeen.delete(key);
}
function _notifyPromptCard(kind, sid, pending){
const p = pending || {};
const id = p.approval_id || p.clarify_id || (kind === 'clarify' ? p.question || p.description || '' : '');
if (!id) return;
const key = _promptNotifyKey(kind, sid, p);
if (_promptNotifySeen.has(key)) return;
// Suppress ONLY when the user is effectively looking at this prompt right
// now: it belongs to the session open in the pane AND the tab is visible
// AND focused. Every other combination pings:
// 1. prompt for a non-active session (even with the tab focused)
// 2. active session, but a different tab is selected
// 3. active session, tab active, but the Chrome window is not focused
// Suppression is NOT recorded, so the same prompt still pings exactly once
// the moment the user is no longer looking at it.
if (typeof _isSessionActivelyViewed === 'function' && _isSessionActivelyViewed(sid)) return;
if (typeof sendBrowserNotification !== 'function') return;
if (typeof window !== 'undefined' && !window._notificationsEnabled) return;
const attempt = {};
_promptNotifySeen.set(key, attempt);
// forceHidden: this caller already made the visibility decision (the
// actively-viewed gate above). sendBrowserNotification's own live gate
// ("notify only when document.hidden") would otherwise veto the
// unfocused-but-visible case, which this feature explicitly notifies for.
const rollback = () => {
if (_promptNotifySeen.get(key) === attempt) _promptNotifySeen.delete(key);
};
try {
const delivery = kind === 'clarify'
? sendBrowserNotification('Clarification needed', p.question || p.description || 'Tool clarification needed', { sid, forceHidden: true })
: sendBrowserNotification('Approval required', p.description || p.command || 'Tool approval needed', { sid, forceHidden: true });
Promise.resolve(delivery).then(accepted => {
if (accepted !== true) rollback();
}, rollback);
} catch (_) { rollback(); }
}
function sendBrowserNotification(title,body,options={}){
const force=!!(options&&options.force);
// #4416: `forceHidden` means the caller already determined the tab was hidden
@@ -9410,14 +9632,32 @@ function sendBrowserNotification(title,body,options={}){
if(!force&&!window._notificationsEnabled) return;
if(!force&&!forceHidden&&!_isBackgroundedForBrowserNotification()) return;
if(!('Notification' in window)) return;
if(Notification.permission==='granted'){
_showPwaNotification(title,body,options).catch(()=>{try{new Notification(title||assistantDisplayName(),_notificationOptions(body,options));}catch(_err){}});
}else if(Notification.permission==='denied'){
const deliver = async () => {
try {
await _showPwaNotification(title,body,options);
return true;
} catch (_) {
try {
new Notification(title||assistantDisplayName(),_notificationOptions(body,options));
return true;
} catch (_err) { return false; }
}
};
if(Notification.permission==='granted') return deliver();
if(Notification.permission==='denied'){
// Explicit "Send test" (force) deserves feedback instead of a silent no-op.
if(force&&typeof showToast==='function') showToast(t('notifications_denied'),3500,'error');
}else{
requestNotificationPermission().then(p=>{if(p==='granted') _showPwaNotification(title,body,options).catch(()=>{try{new Notification(title||assistantDisplayName(),_notificationOptions(body,options));}catch(_err){}});});
return false;
}
// Permission still 'default': prompt-card owners retry on every 1.5s poll
// tick while pending, so an automatic (non-gesture) request is made at most
// once per page — otherwise each tick re-prompts and re-toasts "denied".
// The explicit "Send test" (force) path always asks.
if(!force){
if(sendBrowserNotification._autoPermissionRequested) return false;
sendBrowserNotification._autoPermissionRequested=true;
}
return requestNotificationPermission().then(p => p==='granted' ? deliver() : false).catch(() => false);
}
// ── /btw ephemeral stream ────────────────────────────────────────────────────
+6 -7
View File
@@ -104,16 +104,15 @@ function _jumpToMessage(rawIdx) {
}
// Row is outside the render window — reload the full session and retry.
// Use the bare messages=1 path (no msg_limit) so the server returns the
// COMPLETE transcript: the target row is addressed by absolute index
// (msg-user-<rawIdx>), so a bounded tail window would miss early rows.
// (A previous version sent msg_limit=9999 as a "give me everything" hack,
// but the server now clamps msg_limit, so the bare path is the correct way
// to request the full transcript here.)
// Request the COMPLETE transcript via the explicit msg_limit=all escape
// hatch: the target row is addressed by absolute index (msg-user-<rawIdx>),
// so a bounded tail window would miss early rows. The bare no-limit shape
// is not used here intentionally — full-transcript intent must be explicit
// so recovery paths stay bounded (#7310/#7625).
if (typeof api !== 'function') return;
if (S.busy || S.activeStreamId) return;
api('/api/session?session_id=' + encodeURIComponent(sid) +
'&messages=1&resolve_model=0')
'&messages=1&resolve_model=0&msg_limit=all')
.then(function(data) {
if (!data || !data.session) return;
if (!S.session || S.session.session_id !== sid) return; // session switched
+36 -4
View File
@@ -25,6 +25,7 @@ let _currentCronDetail = null; // full cron job object
let _currentCronDetailKey = '';
let _cronMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit'
let _cronPreFormDetail = null; // snapshot of prior selection when entering a form
let _cronModelPickerTouched = false; // true once the user changes the model picker in the current form
let _showAllCronProfiles = false;
let _cronOtherProfileCount = 0;
let _currentWorkspaceDetail = null; // { path, name, is_default }
@@ -1550,6 +1551,7 @@ function duplicateCurrentCron(){
const job = _currentCronDetail;
if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
_cronPreFormDetail = { ...job };
_cronModelPickerTouched = false;
_editingCronId = null;
_cronMode = 'create';
_cronIsDuplicate = true;
@@ -1606,6 +1608,7 @@ let _cronDeliveryOptionsCache=null;
function openCronCreate(){
if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
_cronPreFormDetail = _currentCronDetail ? { ..._currentCronDetail } : null;
_cronModelPickerTouched = false;
_editingCronId = null;
_cronMode = 'create';
_cronIsDuplicate = false;
@@ -1623,6 +1626,7 @@ function openCronCreate(){
function openCronEdit(job){
if (!job) return;
_cronPreFormDetail = { ...job };
_cronModelPickerTouched = false;
_editingCronId = job.id;
_cronMode = 'edit';
_cronSelectedSkills = Array.isArray(job.skills) ? [...job.skills] : [];
@@ -1855,6 +1859,9 @@ async function _populateCronFormModelSelect(selectedModel, selectedProvider, dis
sel.appendChild(opt);
}
sel.dataset.loaded = '1';
// Track deliberate picker changes so an untouched "Default" can keep a
// hidden provider-only pin on save (see _cronProviderForClear).
sel.addEventListener('change', () => { _cronModelPickerTouched = true; });
} catch (e) {
console.warn('Failed to load cron model picker:', e.message);
// Load failed: dataset.loaded stays unset so saveCronForm omits model/provider
@@ -1940,6 +1947,18 @@ function _cronModelBareName(model, provider) {
return _modelBareNameForProvider(model, provider);
}
function _cronProviderForClear(prevDetail, pickerTouched) {
// Provider to submit when the picker shows "Default" (no model) on save.
// An explicit return to Default honors "Default = no overrides" (#4030) and
// clears the provider. An untouched picker must not: provider-only jobs
// (a pinned provider with no model) render as "Default" in the combined
// picker, so saving any other field would silently erase the pin the user
// never touched.
const prev = prevDetail || {};
if (pickerTouched) return null;
return (prev.model == null) ? prev.provider : null;
}
async function saveCronForm(){
const nameEl=$('cronFormName');
const schEl=$('cronFormSchedule');
@@ -1977,8 +1996,11 @@ async function saveCronForm(){
updates.model = _cronModelBareName(modelState.model, modelState.model_provider) || null;
updates.provider = modelState.model_provider || null;
} else if (modelLoaded) {
// "Default" selected (no model). An untouched picker preserves a
// hidden provider-only pin; a deliberate return to Default clears
// it (#4030 "Default = no overrides").
updates.model = null;
updates.provider = null;
updates.provider = _cronProviderForClear(_cronPreFormDetail, _cronModelPickerTouched);
}
// else: select not yet populated — omit model/provider to preserve saved value
}
@@ -5393,8 +5415,13 @@ function _renderExternalNotesSources() {
const recall = data.automatic_recall_unchanged !== false
? `<div class="memory-detail-mtime">${esc(t('external_notes_auto_recall_hint'))}</div>`
: '';
// Same withheld-runtime state as the MCP panel: sources still list, but their
// live status/tools are hidden until the profile's runtime scope is confirmed.
const scopeNotice = data.runtime_scope === 'unavailable'
? `<div class="memory-detail-mtime">${esc(t('mcp_runtime_scope_unavailable'))}</div>`
: '';
if (!sources.length) {
body.innerHTML = `<div class="main-view-content">${recall}<div class="memory-empty">${esc(t('external_notes_empty'))}</div></div>`;
body.innerHTML = `<div class="main-view-content">${recall}${scopeNotice}<div class="memory-empty">${esc(t('external_notes_empty'))}</div></div>`;
} else {
const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0];
_notesSelectedSource = (selected && selected.name) || 'joplin';
@@ -5441,7 +5468,7 @@ function _renderExternalNotesSources() {
${searchError}
${resultHtml}
</section>`;
body.innerHTML = `<div class="main-view-content">${recall}${recentAiHtml}${searchUi}${previewHtml}${cards}</div>`;
body.innerHTML = `<div class="main-view-content">${recall}${scopeNotice}${recentAiHtml}${searchUi}${previewHtml}${cards}</div>`;
}
body.style.display = '';
if (empty) empty.style.display = 'none';
@@ -13212,7 +13239,12 @@ function loadMcpServers(){
list.innerHTML=`<div class="mcp-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('mcp_no_servers'))}</div>`;
return;
}
list.innerHTML=r.servers.map(s=>{
// Live status is withheld while the profile's runtime scope cannot be confirmed
// (e.g. a chat turn on this profile is running); say so instead of "not connected".
const scopeNotice=r.runtime_scope==='unavailable'
?`<div class="mcp-runtime-notice" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('mcp_runtime_scope_unavailable'))}</div>`
:'';
list.innerHTML=scopeNotice+r.servers.map(s=>{
const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+(s.transport||'unknown'));
const transportClass=s.transport==='http'?'mcp-http':s.transport==='stdio'?'mcp-stdio':'mcp-unknown';
const transportBadge=`<span class="mcp-transport-badge ${transportClass}">${esc(transportLabel)}</span>`;
+134 -10
View File
@@ -1035,6 +1035,74 @@ function _inflightHasVisibleLiveState(inflight) {
return false;
}
// #7640: a run-journal replay cursor is only safe when the recovery object can
// still repaint the live assistant turn. `_inflightHasVisibleLiveState()` above
// deliberately counts a plain user row (the optimistic row has to survive a
// mid-turn reload), but such an object carries no assistant projection at all:
// seeding `after_seq` from it tells the server to skip every journal event that
// would rebuild the body, so the settled footer lands over a blank message until
// a manual reload replays the journal from the zero floor. Only recoverable live
// output may raise the replay floor.
function _inflightCanSeedJournalReplay(inflight){
if(!inflight||typeof inflight!=='object') return false;
if(String(inflight.lastAssistantText||'').trim()) return true;
if(String(inflight.lastReasoningText||'').trim()) return true;
if(String(inflight.liveTurnHtml||'').trim()) return true;
if(Array.isArray(inflight.toolCalls)&&inflight.toolCalls.length) return true;
if(Array.isArray(inflight.activityBurstAnchors)&&inflight.activityBurstAnchors.length) return true;
const anchorScene=inflight.anchorActivityScene;
if(anchorScene&&Array.isArray(anchorScene.activity_rows)&&anchorScene.activity_rows.length) return true;
if(Array.isArray(inflight.messages)){
// The transcript copy carries every earlier turn (#7651): in an established
// conversation the last assistant row is the PREVIOUS turn's reply, not the
// one the cursor belongs to, so it must never authorize a nonzero floor.
// Message evidence counts only from a current `_live` assistant standing
// after the latest user boundary; anything historical fails closed to zero.
const list=inflight.messages;
let latestUserIdx=-1;
for(let i=list.length-1;i>=0;i--){
const row=list[i];
if(row&&row.role==='user'){latestUserIdx=i;break;}
}
return list.some((msg,idx)=>{
if(!msg||msg.role!=='assistant') return false;
if(!msg._live) return false;
if(idx<=latestUserIdx) return false;
const content=msg.content;
if(typeof content==='string') return Boolean(content.trim());
if(Array.isArray(content)) return content.length>0;
return Boolean(content);
});
}
return false;
}
function _runJournalReplayFloorForInflight(inflight){
if(!_inflightCanSeedJournalReplay(inflight)) return 0;
return Math.max(0,Number((inflight&&inflight.lastRunJournalSeq)||0)||0);
}
function _runJournalReplayEventIdForInflight(inflight){
if(!_runJournalReplayFloorForInflight(inflight)) return '';
return String((inflight&&inflight.lastRunJournalEventId)||'');
}
// #7640: re-validate the selected recovery object immediately before reattach
// instead of trusting its existence. A cursor that outlived its assistant
// projection is dropped here so it cannot reach the wire as a replay floor; the
// replay then restarts from zero, which is the path a manual reload already
// proves out. Normalising the object (not just the floor) also keeps a later
// persist from resurrecting the stale cursor on the next reattach.
function _normalizeInflightReplayCursorForReattach(inflight){
if(!inflight||typeof inflight!=='object') return inflight;
const seq=Math.max(0,Number(inflight.lastRunJournalSeq||0)||0);
if(seq>0&&!_inflightCanSeedJournalReplay(inflight)){
inflight.lastRunJournalSeq=0;
inflight.lastRunJournalEventId='';
}
return inflight;
}
function _serverLiveSnapshotToolId(tc){
return String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim();
}
@@ -2208,6 +2276,11 @@ async function loadSession(sid){
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
INFLIGHT[sid].reattach=false;
if (!_isCurrentLoad()) return;
// #7640: validate the selected recovery object at the moment of reattach
// rather than trusting its existence. A cache that kept `lastRunJournalSeq`
// but lost the live assistant projection would otherwise seed the replay
// floor and skip the journal range that rebuilds the message body.
_normalizeInflightReplayCursorForReattach(INFLIGHT[sid]);
didReconnect=true;
attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
}
@@ -2337,6 +2410,8 @@ async function loadSession(sid){
if(activeStreamId){
S.busy=true;
S.activeStreamId=activeStreamId;
// #7640: same reattach validation as the `reattach` branch above.
if(INFLIGHT[sid]) _normalizeInflightReplayCursorForReattach(INFLIGHT[sid]);
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
updateSendBtn();
@@ -3932,7 +4007,7 @@ async function _ensureAllMessagesLoaded() {
_loadingOlder = true;
try {
const sid = S.session.session_id;
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0`, {timeoutMs:120000});
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=all`, {timeoutMs:120000});
// Guard: api() may have redirected (401) and returned undefined.
if (!data || !data.session) return;
// Session may have been switched while we awaited. Bail rather than
@@ -7596,7 +7671,50 @@ function _sidebarRowHasVisibleMessages(s, activeSidForSidebar){
(S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0);
}
function _isDelegatedSubagentRow(s){
if(!_isChildSession(s)) return false;
const role=[s.raw_source,s.source_tag,s.source].map(v=>String(v||'').trim().toLowerCase()).find(Boolean)||'';
return role==='subagent';
}
// Delegated subagents without a project_id take their nearest ancestor's project;
// every other row keeps its own. Memoized per render, so each lineage resolves once.
function _sidebarProjectResolver(rowsById){
const memo=new Map();
return function projectIdFor(s){
if(!s) return null;
const path=[];
const onPath=new Set();
let cur=s, result=null;
while(cur){
const sid=cur.session_id;
if(memo.has(sid)){ result=memo.get(sid); break; }
if(cur.project_id||!_isDelegatedSubagentRow(cur)){ result=cur.project_id||null; if(sid) path.push(sid); break; }
if(onPath.has(sid)) break;
onPath.add(sid); path.push(sid);
cur=rowsById?rowsById.get(cur.parent_session_id):null;
}
for(const sid of path) memo.set(sid,result);
return result;
};
}
function _sidebarRowsById(rows){
const byId=new Map();
for(const list of rows){
if(!Array.isArray(list)) continue;
for(const s of list) if(s&&s.session_id&&!byId.has(s.session_id)) byId.set(s.session_id,s);
}
return byId;
}
// True when any row resolves to no project, using the filter's own ancestor map.
function _sidebarHasUnprojectedRows(rows, projectIdFor){
return rows.some(s=>!projectIdFor(s));
}
function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
const projectIdFor=_sidebarProjectResolver(_sidebarRowsById([allMatched, typeof _sidebarReferenceSessions!=='undefined'?_sidebarReferenceSessions:null]));
let cliSessionCount=0;
const webuiProfileFiltered=[];
const cliProfileFiltered=[];
@@ -7615,10 +7733,11 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
const referenceRaw=isCli ? cliReferenceRaw : webuiReferenceRaw;
const sessionsRaw=isCli ? cliSessionsRaw : webuiSessionsRaw;
profileFiltered.push(s);
const projectId=projectIdFor(s);
if(_activeProject===NO_PROJECT_FILTER){
if(s.project_id) continue;
if(projectId) continue;
} else if(_activeProject){
if(s.project_id!==_activeProject) continue;
if(projectId!==_activeProject) continue;
}
referenceRaw.push(s);
if(s.archived){
@@ -7642,6 +7761,7 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
cliReferenceRaw,
webuiSessionsRaw,
cliSessionsRaw,
projectIdFor,
};
}
@@ -7653,15 +7773,18 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
// suppression context — silently hiding a visible child/fork whose archived
// ancestor lives outside the current view. Scope the references to the same
// project + source bucket as the render they feed before using them.
function _scopedSidebarReferenceRows(isCli){
// projectIdFor: the render's shared resolver; standalone callers get one over references + _allSessions.
function _scopedSidebarReferenceRows(isCli, projectIdFor){
if(typeof _sidebarReferenceSessions==='undefined'||!Array.isArray(_sidebarReferenceSessions)||!_sidebarReferenceSessions.length) return [];
const resolve=projectIdFor||_sidebarProjectResolver(_sidebarRowsById([_sidebarReferenceSessions, typeof _allSessions!=='undefined'?_allSessions:null]));
return _sidebarReferenceSessions.filter(s=>{
if(!s) return false;
// Source scope: only references in the same webui/cli bucket as this render.
if(_isCliSession(s)!==!!isCli) return false;
// Project scope: mirror _partitionSidebarSessionRows exactly.
if(_activeProject===NO_PROJECT_FILTER){ if(s.project_id) return false; }
else if(_activeProject){ if(s.project_id!==_activeProject) return false; }
const projectId=resolve(s);
if(_activeProject===NO_PROJECT_FILTER){ if(projectId) return false; }
else if(_activeProject){ if(projectId!==_activeProject) return false; }
return true;
});
}
@@ -7767,19 +7890,20 @@ function renderSessionListFromCache(){
cliReferenceRaw,
webuiSessionsRaw,
cliSessionsRaw,
projectIdFor,
}=_partitionSidebarSessionRows(allMatched, activeSidForSidebar);
const referenceRaw=_sessionSourceFilter==='cli'?cliReferenceRaw:webuiReferenceRaw;
const isCliView=_sessionSourceFilter==='cli';
const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView)]);
const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView, projectIdFor)]);
// Server-provided source bucket counts are authoritative for the current
// payload. When present, skip the expensive cross-bucket render/count pass;
// null is a deliberate "not computed" sentinel consumed only by
// _sessionSourceTabCount's fallback path below.
const renderedWebuiSessionCount=_serverWebuiSessionCount===null
? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length
? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false, projectIdFor)]).length
: null;
const renderedCliSessionCount=_serverCliSessionCount===null
? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length
? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true, projectIdFor)]).length
: null;
const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);
const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount);
@@ -7841,7 +7965,7 @@ function renderSessionListFromCache(){
}
// Project filter bar — show when there are real projects OR there are
// unassigned sessions (so the Unassigned chip has something to filter to).
const hasUnprojected=profileFiltered.some(s=>!s.project_id);
const hasUnprojected=_sidebarHasUnprojectedRows(profileFiltered, projectIdFor);
if(_allProjects.length>0||hasUnprojected){
const bar=document.createElement('div');
bar.className='project-bar';
+207 -20
View File
@@ -556,7 +556,24 @@ function _messageVirtualDefaultHeightForRole(role){
role&&Object.prototype.hasOwnProperty.call(MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS,role)?role:'default'
];
}
const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=2;
// Cycle-aware measurement burst tracking (#6654/#6717): instead of a flat
// render cap, the burst remembers every window cycle key it has already seen.
// An UNSEEN key means the window is still converging forward (A->B->C->settled
// — content reflow, late fonts/images, dynamic height) and may proceed; a key
// that REPEATS means the browser is oscillating (A->B->A->B) and the burst
// terminates. Keys repeat only when geometry flaps, so legitimate convergence
// is never capped while oscillation is always bounded.
// Absolute per-burst ceiling (#6717 re-gate): the seen-key rule alone only
// ends the burst on a REPEATED key, so a browser emitting a monotonically
// changing geometry (A->B->C->D->... never repeating) would still loop without
// limit — the #6654 CPU-runaway class under a different trigger — and the
// seen-key collection would grow without bound (memory). A distinct-key
// convergence realistically settles in a handful of frames, so this
// conservative cap (the historical MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS
// was 2) ends the burst regardless of key novelty AND bounds the seen-key
// memory to the cap. Reset through _resetMessageVirtualMeasurementBurst().
const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=12;
let _messageVirtualMeasurementSeenKeys=[];
let _messageRenderWindowSid=null;
let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
let _messageVirtualHeightCache=[];
@@ -567,7 +584,19 @@ let _messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('defau
let _messageVirtualScrollRaf=0;
let _messageVirtualWindowKey='';
let _messageVirtualMeasurementCycleKey='';
let _messageVirtualMeasurementRetryCount=0;
let _messageVirtualMeasurementBurstActive=false;
// Provenance of the QUEUED virtualized render: 'internal' while the pending
// rAF was requested by the internal measurement chain, 'external' for any
// other trigger (user scroll / scroll-settle / message append / session
// load). The origin lives on the QUEUED render itself, NOT in a global
// consumable flag: when an internal and an external request coalesce into one
// rAF, _scheduleMessageVirtualizedRender lets EXTERNAL win, so a render that
// fires after any external trigger is never mis-attributed as internal
// (#6717 re-gate). renderMessages() resets the burst on every UNMARKED
// (externally initiated) render trigger, so an overlapping external render
// always starts a fresh cycle even when an internal measurement callback is
// still pending.
let _messageVirtualRenderQueuedOrigin=null;
let _messageVirtualScrollActive=false;
let _messageVirtualScrollSettleTimer=0;
let _messageVirtualDeferredMeasurement=null;
@@ -614,7 +643,7 @@ function _clearMessageVirtualHeightCache(){
_messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('default');
_messageVirtualWindowKey='';
_messageVirtualMeasurementCycleKey='';
_messageVirtualMeasurementRetryCount=0;
_resetMessageVirtualMeasurementBurst();
_messageVirtualScrollActive=false;
clearTimeout(_messageVirtualScrollSettleTimer);
_messageVirtualScrollSettleTimer=0;
@@ -742,6 +771,13 @@ function _messageVirtualMeasurementCycleKeyFor(windowMetrics){
windowMetrics.tailStart||0,
].join(':');
}
function _resetMessageVirtualMeasurementBurst(){
_messageVirtualMeasurementSeenKeys=[];
_messageVirtualMeasurementBurstActive=false;
// Strip any pending internal provenance: an external reset must never let
// an internal marker survive onto a render that fires later (#6717 re-gate).
_messageVirtualRenderQueuedOrigin=null;
}
function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){
if(_messageVirtualScrollActive){
_messageVirtualDeferredMeasurement=windowMetrics;
@@ -750,15 +786,55 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){
const cycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics);
if(_messageVirtualMeasurementCycleKey!==cycleKey){
_messageVirtualMeasurementCycleKey=cycleKey;
_messageVirtualMeasurementRetryCount=0;
}
if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;
_messageVirtualMeasurementRetryCount++;
requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true); });
// Cycle-aware burst tracking (#6717 re-gate): the burst follows ONLY the
// internally measurement-scheduled chain (requestAnimationFrame ->
// _scheduleMessageVirtualizedRender -> renderMessages -> re-measure -> here).
// Within one burst we remember every cycle key already seen. An UNSEEN key
// (genuine forward convergence A->B->C->settled — content reflow, late
// fonts/images, dynamic height) may proceed, so convergence is never capped
// at a flat render count; a key that REPEATS (WebKit's A->B->A->B window
// oscillation) ends the burst, so the rAF/measure loop can never run forever
// (#6654). The burst starts fresh on every EXTERNALLY initiated render
// (session load, message append, real content change — see renderMessages)
// and on measurement settlement — never on a cycle-key change alone.
if(!_messageVirtualMeasurementBurstActive){
_messageVirtualMeasurementSeenKeys=[];
_messageVirtualMeasurementBurstActive=true;
}
// Absolute per-burst cap (#6717 re-gate): even an ALL-DISTINCT monotonic
// key sequence (A->B->C->D->... never repeating) must terminate — a repeated
// key is not the only way the burst can end. This also bounds the seen-key
// collection to the cap (memory). Distinct-key convergence settles in a
// handful of frames, so the cap is far above any legitimate multi-pass
// reflow while still closing the #6654 CPU-runaway class.
if(_messageVirtualMeasurementSeenKeys.length>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS){
_resetMessageVirtualMeasurementBurst();
return;
}
const lastKey = _messageVirtualMeasurementSeenKeys.length ? _messageVirtualMeasurementSeenKeys[_messageVirtualMeasurementSeenKeys.length - 1] : null;
if(cycleKey !== lastKey && _messageVirtualMeasurementSeenKeys.includes(cycleKey)){
// Non-consecutive repeated key (A->B->A): the window is oscillating, not
// converging. End the burst (no further internal re-render is scheduled),
// so the next externally initiated cycle starts fresh instead of being
// starved of retries. A consecutive same-key pass (A->A) proceeds because
// heights changed while the window bounds did not (e.g. row shrink across
// two passes), still bounded by the absolute per-burst cap (#6717 re-gate).
_resetMessageVirtualMeasurementBurst();
return;
}
_messageVirtualMeasurementSeenKeys.push(cycleKey);
// The internal-measurement origin travels WITH the scheduled render request
// (threaded through BOTH rAF layers into renderMessages), so coalescing can
// never consume a stale global marker onto an unrelated render. When an
// external request coalesces into the queued rAF, EXTERNAL wins and the
// render that fires degrades to an unmarked (burst-resetting) render
// (#6717 re-gate).
requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true,{origin:'internal'}); });
}
function _markMessageVirtualMeasurementsSettled(windowMetrics){
_messageVirtualMeasurementCycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics);
_messageVirtualMeasurementRetryCount=0;
_resetMessageVirtualMeasurementBurst();
}
function _messageVirtualHeightEntryMatches(previousEntry, nextEntry){
return !!(
@@ -1474,7 +1550,7 @@ function _rememberRenderedUserRowIntrinsicHeights(){
}
}
}
function _scheduleMessageVirtualizedRender(force){
function _scheduleMessageVirtualizedRender(force, request){
const container=$('messages');
const inner=$('msgInner');
if(!container||!inner) return;
@@ -1486,9 +1562,28 @@ function _scheduleMessageVirtualizedRender(force){
_messageVirtualWindowKey=nextKey;
return;
}
if(_messageVirtualScrollRaf) return;
// The request carries its own origin: the internal measurement chain passes
// {origin:'internal'} (see _scheduleMessageVirtualMeasurementRefresh);
// every other caller is external by default.
const requestOrigin=(request&&request.origin==='internal')?'internal':'external';
if(_messageVirtualScrollRaf){
// Coalescing into an already-queued rAF: the queued render is shared, so
// merge the provenance. EXTERNAL always wins — the render that actually
// fires must reset the burst, and an internal marker must never survive
// onto an external render (#6717 re-gate). An internal request joining a
// queued render never downgrades an already-external one.
if(requestOrigin==='external') _messageVirtualRenderQueuedOrigin='external';
return;
}
_messageVirtualRenderQueuedOrigin=requestOrigin;
_messageVirtualScrollRaf=requestAnimationFrame(()=>{
_messageVirtualScrollRaf=0;
// The provenance belongs to THIS queued render: it was set when the render
// was scheduled (and possibly flipped to 'external' by a coalesced
// external request). Consume it here — no global consumable flag that an
// unrelated render could steal (#6717 re-gate).
const internalMeasurement=_messageVirtualRenderQueuedOrigin==='internal';
_messageVirtualRenderQueuedOrigin=null;
const liveVisWithIdx=_getVisibleMessagesWithIdx();
const liveWindow=_currentMessageVirtualWindow(liveVisWithIdx,_messageVirtualKeepTailCount());
const liveKey=_messageVirtualWindowKeyFor(liveWindow);
@@ -1496,14 +1591,14 @@ function _scheduleMessageVirtualizedRender(force){
if(_scrollbarDragActive){
_programmaticScroll=true;
_programmaticScrollSetAt=performance.now();
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); });
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true, _internalMeasurement: internalMeasurement }); });
_deferClearProgrammaticScroll();
_messageVirtualWindowKey=liveKey;
return;
}
_msgNodeRecycleEnabled=true;
try{
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); });
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true, _internalMeasurement: internalMeasurement }); });
}
finally{ _msgNodeRecycleEnabled=false; }
});
@@ -3011,12 +3106,54 @@ function _getOptionProviderId(opt){
return group.dataset.provider;
}
const value=String(opt.value||'');
if(value.startsWith('@') && value.includes(':')) return value.slice(1,value.lastIndexOf(':'));
if(value.startsWith('@') && value.includes(':')){
// Non-greedy parse for @custom:<slug>:<model> — provider is the slug only.
// Preserves endpoint-style host:port custom slugs (e.g. custom:localhost:11434)
// while keeping colon-bearing model ids (e.g. @custom:backup:model-a:free -> custom:backup).
if(value.startsWith('@custom:')){
const afterCustom=value.substring('@custom:'.length);
const parts=afterCustom.split(':');
if(parts.length>=3 && /^\d+$/.test(parts[1])){
const port=parseInt(parts[1], 10);
const host=parts[0];
const hl=host.toLowerCase();
if(port>=1 && port<=65535 && (hl==='localhost' || host.includes('.'))){
return 'custom:'+host+':'+parts[1];
}
}
const firstColon=afterCustom.indexOf(':');
if(firstColon>=0) return 'custom:'+afterCustom.substring(0,firstColon);
return 'custom:'+afterCustom;
}
// Other @provider:model — provider is up to first colon
return value.slice(1,value.indexOf(':'));
}
return '';
}
function _providerFromModelValue(modelId){
const value=String(modelId||'').trim();
if(value.startsWith('@')&&value.includes(':')) return value.slice(1,value.lastIndexOf(':'));
if(value.startsWith('@')&&value.includes(':')){
// Non-greedy parse for @custom:<slug>:<model> — provider is the slug only.
// Preserves endpoint-style host:port custom slugs (e.g. custom:localhost:11434)
// while keeping colon-bearing model ids (e.g. @custom:backup:model-a:free -> custom:backup).
if(value.startsWith('@custom:')){
const afterCustom=value.substring('@custom:'.length);
const parts=afterCustom.split(':');
if(parts.length>=3 && /^\d+$/.test(parts[1])){
const port=parseInt(parts[1], 10);
const host=parts[0];
const hl=host.toLowerCase();
if(port>=1 && port<=65535 && (hl==='localhost' || host.includes('.'))){
return 'custom:'+host+':'+parts[1];
}
}
const firstColon=afterCustom.indexOf(':');
if(firstColon>=0) return 'custom:'+afterCustom.substring(0,firstColon);
return 'custom:'+afterCustom;
}
// Other @provider:model — provider is up to first colon
return value.slice(1,value.indexOf(':'));
}
return '';
}
function _modelPickerOptionIdentity(modelId, providerId){
@@ -3027,9 +3164,19 @@ function _modelPickerOptionIdentity(modelId, providerId){
if(exactPrefix && value.toLowerCase().startsWith(exactPrefix.toLowerCase())){
value=value.substring(exactPrefix.length);
}else if(value.startsWith('@custom:')){
const namedProvider=value.substring('@custom:'.length);
const splitAt=namedProvider.indexOf(':');
value=splitAt>=0 ? namedProvider.substring(splitAt+1) : namedProvider;
const afterCustom=value.substring('@custom:'.length);
const parts=afterCustom.split(':');
let splitAt=-1;
if(parts.length>=3 && /^\d+$/.test(parts[1])){
const port=parseInt(parts[1], 10);
const host=parts[0];
const hl=host.toLowerCase();
if(port>=1 && port<=65535 && (hl==='localhost' || host.includes('.'))){
splitAt=parts[0].length + 1 + parts[1].length;
}
}
if(splitAt<0) splitAt=afterCustom.indexOf(':');
value=splitAt>=0 ? afterCustom.substring(splitAt+1) : afterCustom;
}else{
value=value.substring(value.indexOf(':')+1);
}
@@ -3088,7 +3235,25 @@ function _modelStateForSelect(sel, modelId){
// id (e.g. model-a:free) synthesized as @custom:backup:model-a:free would
// otherwise mis-parse to provider "custom:backup:model-a" (#6221 re-gate).
const routedProvider=selected?String(_getOptionProviderId(selected)||'').trim():'';
return {model:routedModel||value,model_provider:routedProvider||explicitProvider};
// Normally-rendered catalog options only carry the qualified
// @custom:<slug>:<model> value — data-model is set solely by the fallback
// injection path (_ensureModelOptionInDropdown). When it is missing, strip
// the @custom:<slug>: prefix instead of sending the raw dropdown value as
// the model id (#6884). The prefix must come from the option metadata's
// authoritative provider (routedProvider), NOT from explicitProvider: the
// latter re-parses the value at its LAST colon, so a colon-bearing model
// id like @custom:backup:model-a:free would otherwise strip to just
// "free" (re-gate on the #6221 family). Only custom providers are
// stripped: a non-custom qualified id like @safe:gpt-4o-mini is a real
// provider namespace and must be preserved (#1771).
const effectiveProvider=routedProvider||explicitProvider;
const effectiveProviderLc=effectiveProvider.toLowerCase();
const isCustomProvider=effectiveProviderLc==='custom'||effectiveProviderLc.startsWith('custom:');
const explicitPrefix=`@${effectiveProvider}:`;
const strippedModel=isCustomProvider&&value.toLowerCase().startsWith(explicitPrefix.toLowerCase())
?value.slice(explicitPrefix.length)
:value;
return {model:routedModel||strippedModel||value,model_provider:effectiveProvider};
}
// Resolve the provider from the option whose VALUE matches the requested
// model — never blindly from sel.selectedOptions[0] (#5567). During a profile
@@ -4465,7 +4630,23 @@ function renderModelDropdown(){
const _provider=String((m&&m.providerId)||(m&&m.badge&&m.badge.provider)||((typeof _providerFromModelValue==='function')?_providerFromModelValue(m&&m.value):'')||'').trim();
return (_provider&&_provider!=='default')?_provider:null;
};
const _isSelectedModelRow=(m)=>String((m&&m.value)||'')===String((_selectedModelState&&_selectedModelState.model)||(sel&&sel.value)||'')&&String(_modelProviderForSelectedBadge(m)||'')===String((_selectedModelState&&_selectedModelState.model_provider)||'');
const _isSelectedModelRow=(m)=>{
const _rowModel=String((m&&m.value)||'');
const _rowProvider=String(_modelProviderForSelectedBadge(m)||'');
const _stateModel=String((_selectedModelState&&_selectedModelState.model)||(sel&&sel.value)||'');
const _stateProvider=String((_selectedModelState&&_selectedModelState.model_provider)||'');
// Normalize both sides to the same model/provider identity. Catalog rows
// carry the qualified @custom:<slug>:<model> value while the outgoing
// state model is bare (#6884) — a raw string comparison would leave no
// row marked active/"Selected" after a restore. _modelPickerOptionIdentity
// is the same identity used for picker dedup, so the row that survives is
// exactly the one the send path resolves.
const _norm=(model,provider)=>typeof _modelPickerOptionIdentity==='function'
?_modelPickerOptionIdentity(model,provider)
:String(model||'');
return _norm(_rowModel,_rowProvider)===_norm(_stateModel,_stateProvider)
&&_rowProvider===_stateProvider;
};
const _selectedModelBadge=(m)=>_isSelectedModelRow(m)
?`<span class="model-opt-badge model-opt-badge--selected">${esc(t('model_badge_selected')||'Selected')}</span>`
:'';
@@ -10353,7 +10534,11 @@ async function refreshSession() {
dismissReconnect();
if (!S.session) return;
try {
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
// Bounded tail (msg_limit=30) — a bare reload used to pull and re-redact
// the ENTIRE transcript on every offline/bfcache recovery (#7310/#7625).
// truncation signal + _oldestIdx are read below, so the Load-earlier
// paging gate still works after the windowed refresh.
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);
S.session = data.session;
if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);
S.messages = data.session.messages || [];
@@ -17126,6 +17311,8 @@ function _insertSegmentBlock(seg, html){
}
function renderMessages(options){
_lastMessageRenderAt=performance.now();
// typeof guard: node harnesses extract renderMessages() without its helpers (#6717).
if(!(options&&options._internalMeasurement) && typeof _resetMessageVirtualMeasurementBurst==='function'){ _resetMessageVirtualMeasurementBurst(); }
const preserveScroll=!!(options&&options.preserveScroll);
const virtualFallback=!!(options&&options._virtualFallback);
// Capture the pre-wipe scroll position when preserving OR when the reader has
+6 -3
View File
@@ -93,6 +93,9 @@ eval(extractFunc('_collapseSessionLineageForSidebar'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
eval(extractFunc('_sessionAttentionState'));
eval(extractFunc('_sidebarRowHasVisibleMessages'));
eval(extractFunc('_isDelegatedSubagentRow'));
eval(extractFunc('_sidebarProjectResolver'));
eval(extractFunc('_sidebarRowsById'));
eval(extractFunc('_partitionSidebarSessionRows'));
eval(extractFunc('_scopedSidebarReferenceRows'));
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
@@ -216,9 +219,9 @@ console.log(JSON.stringify({
}));
"""
out = json.loads(_run_node(source))
# Child survives the visibility/project scope into sessionsRaw (parent does not)...
assert out["sessionsRaw"] == ["subagent_child"]
# ...but is NOT rendered as a top-level orphan.
# The child inherits its parent's project, so the "no project" filter drops both...
assert out["sessionsRaw"] == []
# ...and it is NOT rendered as a top-level orphan.
assert out["topLevel"] == []
assert out["orphans"] == []
+363
View File
@@ -0,0 +1,363 @@
"""Behavioral tests for #7628 review round: /compress paging restore + suffix
strategy for repeated identical turns.
Round-3 review (nesquena-hermes 2026-09-19) found two data-integrity blockers:
1. [CORE] A successful /compress returns the FULL transcript, but
`_applyManualCompressionResult` did not restore `_messagesTruncated` /
`_oldestIdx` — the bounded preflight's values (e.g. truncated=true,
oldestIdx=70) survived, producing a false "70 older messages" affordance
and shifting every absolute transcript index by 70.
2. [SILENT] `_restoreSettledSession` preservation used
`(_stagedMatchesCurrentPrefix || _stagedMatchesCurrentSuffix)`, so when
identical repeated turns made BOTH comparisons succeed the prefix branch
spliced at the wrong offset — silently dropping and duplicating rows.
The server truncation signal now decides: truncated => suffix-exclusive.
Node-backed tests extract the real functions from static/ and drive them
against a stub DOM (same harness pattern as test_issue5224).
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_ROOT = Path(__file__).resolve().parents[1]
_COMMANDS_JS = _ROOT / "static" / "commands.js"
_MESSAGES_JS = _ROOT / "static" / "messages.js"
_SESSIONS_JS = _ROOT / "static" / "sessions.js"
_NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(_NODE is None, reason="node is required to execute message runtime tests")
_DRIVER = r"""
const fs = require('fs');
const commandsSrc = fs.readFileSync(process.argv[2], 'utf8');
const messagesSrc = fs.readFileSync(process.argv[3], 'utf8');
const sessionsSrc = fs.readFileSync(process.argv[4], 'utf8');
const scenario = JSON.parse(process.argv[5] || '{}');
function extractFunction(source, name) {
const markers = [`async function ${name}(`, `function ${name}(${name}`, `function ${name}(`, `${name}=async(`, `${name}=async (`];
let start = -1;
for (const marker of markers) {
start = source.indexOf(marker);
if (start >= 0) break;
}
if (start < 0) throw new Error(`missing ${name}`);
let i = source.indexOf('{', start);
if (i < 0) throw new Error(`missing body for ${name}`);
let depth = 1; i++;
while (i < source.length) {
const ch = source[i];
if (ch === '{') depth++;
if (ch === '}') { depth--; if (depth === 0) return source.slice(start, i + 1); }
i++;
}
throw new Error(`unterminated body for ${name}`);
}
function install(name, src) {
const body = extractFunction(src, name);
// Drop the `async function name(` or `function name(` prefix, keep body.
const eq = body.indexOf('{');
const inner = body.slice(eq);
globalThis[name] = new Function(`return (${name === 'jumpToSessionStart' ? 'async function' : 'function'} ${inner})`)();
}
// ---- helpers from messages.js (mirror test_issue5224 set) ----
const MESSAGE_HELPERS = [
"_isMarkerOnlyAssistantMessage", "_streamRecoveryControlMessageText",
"_streamRecoveryControlMessage", "_filterRecoveryControlMessages",
"_replaceMarkerOnlyAssistantWithStreamError", "_messageIdentityKey",
"_isHistoricalAnchorActivityScene", "_carryForwardEphemeralTurnFields",
"_isTerminalStreamErrorMarkerMessage", "_restoreSettledSession",
"_mergeSettledToolCallsWithLiveMetadata",
];
for (const h of MESSAGE_HELPERS) {
const body = extractFunction(messagesSrc, h);
const factory = new Function(`${body}; return ${h};`);
globalThis[h] = factory();
}
// ---- compress result applier from commands.js ----
const COMPRESS_BODY = extractFunction(commandsSrc, '_applyManualCompressionResult');
// Rebuild as a normal function (it contains `await` – keep async).
globalThis._applyManualCompressionResult = new Function(
'return (' + COMPRESS_BODY + ')'
)();
function runtimeStubs() {
const calls = [];
globalThis.S = { session: { session_id: 's1' }, messages: [], toolCalls: [] };
globalThis.activeSid = 's1';
globalThis.streamId = 'stream-1';
globalThis.assistantText = false;
globalThis._messagesTruncated = !!scenario.prestateTruncated;
globalThis._oldestIdx = scenario.prestateOldestIdx || 0;
globalThis._messagesGeneration = 1;
globalThis._loadingOlder = false;
globalThis._loadingSessionId = null;
globalThis._messageRenderWindowSize = 50;
globalThis._msgLimitMax = scenario.msgLimitMax || 200;
globalThis._INITIAL_MSG_LIMIT = 50;
globalThis._EPHEMERAL_TURN_FIELDS = ['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene'];
globalThis._isActiveSession = () => scenario.isActiveSession !== false;
globalThis._isSessionCurrentPane = () => scenario.isSessionCurrentPane !== false;
globalThis._isSessionActivelyViewed = () => !!scenario.isSessionActivelyViewed;
globalThis._closeSource = () => calls.push('closeSource');
globalThis._clearStreamEndRecovery = () => calls.push('clearStreamEndRecovery');
globalThis._clearOwnerInflightState = () => calls.push('clearOwnerInflight');
globalThis.clearLiveToolCards = () => calls.push('clearLiveToolCards');
globalThis.removeThinking = () => calls.push('removeThinking');
globalThis._flushReasoningToAnchor = () => calls.push('flushReasoning');
globalThis._applyToAnchor = () => calls.push('applyToAnchor');
globalThis._hydrateTodosFromSession = () => calls.push('hydrateTodos');
globalThis._scheduleAnchorRegistryCleanup = () => calls.push('scheduleAnchorRegistryCleanup');
globalThis._smdEndParser = () => calls.push('smdEndParser');
globalThis._markSessionCompletionUnread = () => calls.push('markCompletionUnread');
globalThis._markSessionViewed = () => calls.push('markSessionViewed');
globalThis.localStorage = { setItem: () => calls.push('setLocalStorageItem'), getItem: () => null, removeItem: () => calls.push('removeLocalStorageItem') };
globalThis._setActiveSessionUrl = () => calls.push('setActiveSessionUrl');
globalThis.showToast = () => calls.push('showToast');
globalThis._clearApprovalForOwner = () => calls.push('clearApprovalForOwner');
globalThis._clearClarifyForOwner = () => calls.push('clearClarifyForOwner');
globalThis._streamFadeCleanupReduceMotionListener = () => calls.push('streamFadeCleanup');
globalThis._cancelThrottledSnapshotTimer = () => calls.push('cancelThrottledSnapshot');
globalThis._clearAnchorProseIncrementalNode = () => calls.push('clearAnchorProse');
globalThis._cancelAnimationFramePendingStreamRender = () => calls.push('cancelRaf');
globalThis.finalizeThinkingCard = () => calls.push('finalizeThinkingCard');
globalThis.syncTopbar = () => calls.push('syncTopbar');
globalThis.renderMessages = () => calls.push('renderMessages');
globalThis.renderSessionList = () => calls.push('renderSessionList');
globalThis.updateQueueBadge = () => calls.push('updateQueueBadge');
globalThis._setActivePaneIdleIfOwner = () => calls.push('setActivePaneIdle');
globalThis.setBusy = () => calls.push('setBusy');
globalThis.setComposerStatus = () => calls.push('setComposerStatus');
globalThis.setCompressionUi = () => calls.push('setCompressionUi');
globalThis.setStatus = () => calls.push('setStatus');
globalThis.msgContent = (m) => (m && m.content && typeof m.content === 'string' ? m.content : '');
globalThis._isContextCompactionMessage = () => false;
globalThis._setCompressionSessionLock = () => calls.push('setCompressionSessionLock');
globalThis._messageRenderableMessageCount = () => scenario.messageRenderableCount || 50;
globalThis._currentMessageRenderWindowSize = () => scenario.currentWindowSize || 12;
globalThis._restoreMessageRenderWindowAfterSettledRender = () => calls.push('restoreRenderWindow');
globalThis.projectSessionArtifactsForOwner = () => calls.push('projectArtifacts');
globalThis._streamFinalized = false;
globalThis._persistTimer = null;
globalThis._attachProjectedAnchorSceneToLastAssistant = () => calls.push('attachProjected');
globalThis._isMessagePaneNearBottom = () => true;
globalThis._isMessageReaderUnpinned = () => false;
globalThis._queueDrainSid = null;
// reducer used by _restoreSettledSession (mirrors live code)
return calls;
}
(async () => {
const calls = runtimeStubs();
if (scenario.action === 'compress_restores_paging') {
// Pre-state: bounded preflight left truncated=true, oldestIdx=70.
globalThis._messagesTruncated = true;
globalThis._oldestIdx = 70;
const fullRows = [];
for (let i = 0; i < 100; i++) {
fullRows.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `row-${i}`, _ts: `ts-${i}` });
}
const payload = {
session: {
session_id: 's1',
message_count: 100,
messages: fullRows,
tool_calls: [],
_messages_truncated: false,
_messages_offset: 0,
},
};
await globalThis._applyManualCompressionResult(payload, '', 100, '/compress');
console.log(JSON.stringify({
action: 'compress_restores_paging',
messagesTruncated: !!globalThis._messagesTruncated,
oldestIdx: globalThis._oldestIdx,
messageCountAfter: Array.isArray(S.messages) ? S.messages.length : -1,
calls,
}));
return;
}
if (scenario.action === 'settle_repeated_turns') {
// Bounded settle against a transcript containing repeated identical turns
// (same role/content but distinct _ids). Round-2 bug: BOTH prefix and
// suffix matched, prefix won, and rows were dropped+duplicated.
const state = scenario.state || {};
globalThis.S.session = { session_id: 's1', message_count: state.messageCount };
globalThis.S.messages = state.messages || [];
globalThis.streamId = 'stream-7628';
globalThis.S.activeStreamId = 'stream-7628';
globalThis.api = async () => scenario.apiPayload || { session: null };
const status = await globalThis._restoreSettledSession({}, { status: true, preserveVisibleOnShorterTerminalSnapshot: true });
const msgs = Array.isArray(globalThis.S.messages) ? globalThis.S.messages : [];
console.log(JSON.stringify({
action: 'settle_repeated_turns',
status,
messages: msgs.map((m) => ({ role: m.role, content: m.content, _ts: m._ts, _id: m._id || null })),
oldestIdx: globalThis._oldestIdx,
messagesTruncated: !!globalThis._messagesTruncated,
terminalMarkerCount: msgs.filter(globalThis._isTerminalStreamErrorMarkerMessage).length,
calls,
}));
return;
}
if (scenario.action === 'load_older_paging') {
// After a bounded recovery (offset=70), "Load earlier" must request a page
// keyed off _oldestIdx (no gap and no overlap), and merge must close the
// window: final oldestIdx === requested offset.
globalThis._messagesTruncated = true;
globalThis._oldestIdx = scenario.oldestIdx || 70;
const existing = [];
for (let i = 70; i < 100; i++) existing.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `row-${i}`, _ts: `ts-${i}` });
globalThis.S.messages = existing;
globalThis.S.session = { session_id: 's1', message_count: 100 };
const requested = [];
globalThis.api = async (url, opts) => {
requested.push(String(url));
// Return one older page of size 50 (rows 20..69).
const older = [];
for (let i = 20; i < 70; i++) older.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `row-${i}`, _ts: `ts-${i}` });
return {
session: { session_id: 's1', message_count: 100, messages: older, _messages_truncated: true, _messages_offset: 20 },
};
};
// Extract the live loader from sessions.js.
const loaderBody = extractFunction(sessionsSrc, '_loadOlderMessages');
const loaderFactory = new Function(`${loaderBody}; return _loadOlderMessages;`);
const loader = loaderFactory();
await loader.call(globalThis);
console.log(JSON.stringify({
action: 'load_older_paging',
requested,
messagesTruncated: !!globalThis._messagesTruncated,
oldestIdx: globalThis._oldestIdx,
messagesLen: Array.isArray(S.messages) ? S.messages.length : -1,
loadingOlder: !!globalThis._loadingOlder,
}));
return;
}
throw new Error(`unknown action: ${scenario.action}`);
})().catch((err) => {
console.error(err && err.stack ? err.stack : String(err));
process.exit(1);
});
"""
@pytest.fixture(scope="module")
def driver_path(tmp_path_factory):
driver = tmp_path_factory.mktemp("7628_driver") / "driver.js"
driver.write_text(_DRIVER, encoding="utf-8")
return str(driver)
def _run_scenario(driver_path: str, scenario: dict) -> dict:
command = [_NODE, driver_path, str(_COMMANDS_JS), str(_MESSAGES_JS), str(_SESSIONS_JS), json.dumps(scenario)]
result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", check=False, timeout=30)
if result.returncode != 0:
raise RuntimeError(f"node driver failed: {result.stderr}")
return json.loads(result.stdout.strip())
def test_compress_success_restores_paging_state(driver_path):
"""A successful /compress returning the full transcript must reset the
bounded preflight's paging signals before render (#7628 blocker 1)."""
outcome = _run_scenario(driver_path, {"action": "compress_restores_paging"})
assert outcome["messagesTruncated"] is False, "compress success must clear _messagesTruncated"
assert outcome["oldestIdx"] == 0, f"compress success must reset _oldestIdx to 0, got {outcome['oldestIdx']}"
assert outcome["messageCountAfter"] == 100
def test_settle_bounded_preserves_no_drop_no_dup(driver_path):
"""A bounded settle against repeated identical turns must not drop or
duplicate rows (#7628 blocker 2, exact reviewer reproduction).
All user rows share one identity key (same role/content/_ts), all assistant
rows share one too — _messageIdentityKey collides across every pair. A
bounded tail [u3,a3,u2,a2] therefore matches BOTH the visible prefix
(u1,a1,u2,a2…) and the visible suffix (…u2,a2) at key level. The round-2
`||` code preferred the prefix branch: splice offset = staged.length
dropped u1/a1 and re-emitted u3/a3 (duplicate). The server truncation
signal must force suffix-exclusive splicing.
"""
_u = {"role": "user", "content": "What's the status?", "_ts": "t-u"}
_a = {"role": "assistant", "content": "All good.", "_ts": "t-a"}
# Visible before settle: u1,a1,u2,a2,u3,a3 + Connection-interrupted marker.
state_messages = [
{**_u, "_id": 1}, {**_a, "_id": 2},
{**_u, "_id": 3}, {**_a, "_id": 4},
{**_u, "_id": 5}, {**_a, "_id": 6},
{"role": "assistant", "content": "**Connection interrupted:** The browser lost the live SSE connection before the response finished.", "_ts": "t-err", "_id": 7},
]
outcome = _run_scenario(driver_path, {
"action": "settle_repeated_turns",
"isSessionCurrentPane": True,
"state": {
"messageCount": 12,
"messages": state_messages,
},
"apiPayload": {
"session": {
"session_id": "s1",
"active_stream_id": None,
"pending_user_message": None,
"message_count": 12,
"messages": [
# Reviewer's tail: [u3,a3,u2,a2] — identity keys collide
# with u1/a1/u2/a2/u3/a3 above, so prefix AND suffix both
# compare equal.
{**_u, "_id": 8}, {**_a, "_id": 9},
{**_u, "_id": 10}, {**_a, "_id": 11},
],
"_messages_truncated": True,
"_messages_offset": 4,
},
},
})
assert outcome["status"] == "restored", outcome
ids = [m["_id"] for m in outcome["messages"]]
# No dropped id, no dup id (the prefix-wins bug duplicated u3/a3 and
# dropped u1/a1 — every retained row must be unique).
assert len(ids) == len(set(ids)), f"duplicate rows survived settle: {ids}"
# Staged (authoritative) rows must all be present, plus the marker once.
assert set(ids) == {8, 9, 10, 11, 7}, f"unexpected transcript after fix: {ids}"
# Identity (role|_ts|content) counts: staged rows legitimately repeat
# (two identical user pairs in the tail), but the bug added a THIRD copy by
# splicing the visible tail back in. user<=2, assistant<=2, marker exactly 1.
keys = [(m["role"], m["_ts"], m["content"]) for m in outcome["messages"]]
kc = {}
for k in keys:
kc[k] = kc.get(k, 0) + 1
user_key = ("user", "t-u", "What's the status?")
asst_key = ("assistant", "t-a", "All good.")
assert kc.get(user_key, 0) == 2, f"user rows must appear exactly twice (staged), got {kc.get(user_key, 0)}: {keys}"
assert kc.get(asst_key, 0) == 2, f"assistant rows must appear exactly twice (staged), got {kc.get(asst_key, 0)}: {keys}"
assert outcome["terminalMarkerCount"] == 1, outcome
assert outcome["oldestIdx"] == 4, f"offset must follow server, got {outcome['oldestIdx']}"
assert outcome["messagesTruncated"] is True
def test_bounded_recovery_prepares_gap_free_load_earlier(driver_path):
"""After a bounded recovery (oldestIdx=70), Load Earlier must page from the
authoritative offset so the fetched range has no gap and no overlap
(#7628)."""
outcome = _run_scenario(driver_path, {"action": "load_older_paging", "oldestIdx": 70, "msgLimitMax": 80})
assert outcome["requested"], "loader must have issued an api call"
url = outcome["requested"][0]
assert "msg_before=70" in url, f"load-earlier must page from authoritative offset: {url}"
assert "msg_limit=" in url, url
@@ -0,0 +1,343 @@
"""Behavioural regressions for the #7628 review round-3 findings.
The round-3 gate reproduced two data-integrity defects against the shipped
functions and asked for tests that reach them (the source-assertion suite
stayed green through both reproductions):
1. **A successful `/compress` kept the bounded preflight's paging state.**
The success path assigned the full-transcript response without restoring
``_messagesTruncated`` / ``_oldestIdx``, so after a compression that
returned 100 full rows the client still showed "70 older messages" and
every absolute transcript index was off by 70 — which is what edit /
fork / regenerate address rows by.
2. **Prefix match won over suffix match when both succeeded.** A bounded
settle returns a *suffix* of the durable transcript, but the old
``prefix || suffix`` expression preferred the prefix branch, so a
transcript with repeated identical turns spliced at the wrong offset:
``u1/a1`` silently dropped, ``u3/a3`` duplicated.
These tests execute the real decision code rather than grepping it: the
splice strategy block is extracted from ``static/messages.js`` verbatim and
driven through a Node VM with the paging signals as the only variable, and
the compress success path is asserted at source level for the restore
ordering (that block issues a live ``/api/session`` fetch, so driving it
would need a network).
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.js_source_extract import extract_function
ROOT = Path(__file__).resolve().parents[1]
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
COMMANDS_JS = (ROOT / "static" / "commands.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
# ── extraction helpers ───────────────────────────────────────────────────────
_SPLICE_DEPS = [
"_messageIdentityKey",
"_isTerminalStreamErrorMarkerMessage",
"_streamRecoveryControlMessage",
"_filterRecoveryControlMessages",
"_carryForwardEphemeralTurnFields",
]
def _splice_block() -> str:
"""The bounded-settle splice decision, verbatim from messages.js.
Spans the terminal-marker detection and the prefix/suffix comparisons
through the ``S.messages`` assignment — the region the round-3 gate
patched. Everything before it (paging restore, session assignment) is set
up as harness state instead.
"""
start_marker = "const _currentVisibleEndsWithTerminalMarker=("
start = MESSAGES_JS.find(start_marker)
assert start >= 0, "terminal-marker detection not found in static/messages.js"
end_marker = "S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);"
end = MESSAGES_JS.find(end_marker, start)
assert end >= 0, "S.messages assignment after the splice not found"
return MESSAGES_JS[start : end + len(end_marker)]
def _dependency_functions() -> list[str]:
"""The splice block's transitive pure dependencies, in definition order.
The extraction is closure-driven: start from the helpers the block names,
then keep pulling the helpers those helpers reference until the set is
closed. That keeps the harness honest when the production code grows a
new dependency — a missing one fails the node run instead of silently
stubbing behaviour away.
"""
import re
needed: list[str] = list(_SPLICE_DEPS)
resolved: list[str] = []
seen: set[str] = set()
while needed:
name = needed.pop(0)
if name in seen:
continue
seen.add(name)
try:
body = extract_function(MESSAGES_JS, name)
except AssertionError:
continue
resolved.append(name)
for ref in re.findall(r"\b(_[a-zA-Z][a-zA-Z0-9_]*)\s*\(", body):
if ref not in seen and ref != name:
needed.append(ref)
# Definition order matters only for hoisted `function` declarations, which
# JS hoists anyway; keep the resolved order for readability.
return [extract_function(MESSAGES_JS, name) for name in resolved]
def _compress_success_block() -> str:
"""The `/compress` success path that assigns the full-transcript response.
Anchored on the bounded preflight fetch's continuation: the block runs
after a successful compression response and must restore the paging
signals before ``renderMessages()``.
"""
anchor = "const summary=data&&data.summary;"
start = COMMANDS_JS.rfind(anchor)
assert start >= 0, "compress success block not found in static/commands.js"
assign = COMMANDS_JS.rfind("S.messages=", 0, start)
assert assign >= 0, "S.messages assignment before the summary block not found"
return COMMANDS_JS[assign:start]
# ── node harness ─────────────────────────────────────────────────────────────
_HARNESS = r"""
const deps = __DEPS__;
const splice = __SPLICE__;
const S = { session: { session_id: 'sid' }, messages: [] };
let _messagesTruncated = false;
let _oldestIdx = 0;
let _adoptRegenerationRevision = () => {};
let _hydrateTodosFromSession = () => {};
let _markSessionCompletionUnread = () => {};
let _setActiveSessionUrl = () => {};
let _attachProjectedAnchorSceneToLastAssistant = () => {};
let _replaceMarkerOnlyAssistantWithStreamError = () => false;
const store = {};
globalThis.localStorage = {
getItem: (k) => (Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
};
// Closure-scope constant that lives just above the extracted helper in
// messages.js (extract_function only captures the function body).
const _EPHEMERAL_TURN_FIELDS = ['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene'];
for (const fn of deps) eval(fn);
// Scenario: repeated identical turns in the visible window, a terminal
// marker at the end, and a bounded durable tail whose ids ALSO appear
// earlier — so both the prefix and the suffix comparison succeed.
function makeTurn(n) {
return [
{ role: 'user', id: `u-${n}`, content: `prompt ${n}` },
{ role: 'assistant', id: `a-${n}`, content: `answer ${n}` },
];
}
S.messages = [];
for (const n of [1, 2, 3]) S.messages.push(...makeTurn(n));
// The real terminal marker shape: an assistant row whose content starts with
// the Connection-interrupted prefix (what _isTerminalStreamErrorMarkerMessage
// actually matches).
S.messages.push({
role: 'assistant',
id: 'marker',
content: '**Connection interrupted:** The browser lost the live SSE connection before the response finished.',
});
const durableTail = [
{ role: 'user', id: 'u-3', content: 'prompt 3' },
{ role: 'assistant', id: 'a-3', content: 'answer 3' },
];
const session = {
session_id: 'sid',
messages: durableTail,
_messages_truncated: __TRUNCATED__,
_messages_offset: 4,
};
_messagesTruncated = __TRUNCATED__;
_oldestIdx = __OFFSET__;
// The state the splice block reads, mirroring the lines just above it.
const _currentMessages = S.messages.slice();
const _currentVisibleMessages = _filterRecoveryControlMessages(_currentMessages);
const _stagedMessages = _carryForwardEphemeralTurnFields(_currentMessages, session.messages);
const preserveVisibleOnShorterTerminalSnapshot = true;
S.session = session;
eval(splice);
const out = {
truncated: _messagesTruncated,
oldestIdx: _oldestIdx,
rows: (S.messages || []).map(m => `${m.role}:${m.id}`),
};
console.log(JSON.stringify(out));
"""
def _run_node(script: str) -> dict:
result = subprocess.run(
[NODE, "-e", script],
cwd=str(ROOT),
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, (
f"node subprocess failed:\n--- stdout ---\n{result.stdout}\n"
f"--- stderr ---\n{result.stderr}"
)
return json.loads(result.stdout.strip().splitlines()[-1])
def _drive(*, truncated: bool, offset: int) -> dict:
deps = "[" + ",".join(json.dumps(f) for f in _dependency_functions()) + "]"
splice = json.dumps(_splice_block())
script = (
_HARNESS.replace("__DEPS__", deps)
.replace("__SPLICE__", splice)
.replace("__TRUNCATED__", "true" if truncated else "false")
.replace("__OFFSET__", str(offset))
)
return _run_node(script)
# ── tests ────────────────────────────────────────────────────────────────────
def test_bounded_settle_with_repeated_identical_turns_drops_and_duplicates_nothing():
"""The round-3 SILENT finding: identical repeated turns must not splice wrong.
The visible window holds repeated identical turns, so the durable tail's
ids appear at BOTH the head and the tail of the window and both the
prefix and the suffix comparison succeed. The pre-fix
``prefix || suffix`` expression preferred the prefix branch and spliced at
the head offset — dropping ``u2/a2`` and ``u3/a3`` from the window even
though the server returned them as a truncated suffix (``_oldestIdx=4``).
The truncation signal must select the suffix branch instead.
The two branches produce observably different row lists on this shape,
which is what makes this a regression test rather than a tautology: with
the prefix branch restored the splice keeps the wrong rows.
"""
out = _drive(truncated=True, offset=4)
rows = out["rows"]
# The durable tail rows are present exactly once each — the pre-fix
# prefix branch duplicated them.
assert rows.count("user:u-3") == 1, rows
assert rows.count("assistant:a-3") == 1, rows
# No row appears twice anywhere.
assert len(rows) == len(set(rows)), f"duplicate rows in {rows}"
# The suffix splice keeps the durable tail plus the rows that follow it
# in the visible window (the terminal marker). The prefix branch would
# splice at the head offset and re-emit u2/a2/u3/a3.
assert rows == ["user:u-3", "assistant:a-3", "assistant:marker"], (
"the suffix splice must keep durable-tail + the rows after it; got "
f"{rows} (extra rows mean the prefix branch won)"
)
# Paging follows the response so absolute indices line up.
assert out["truncated"] is True
assert out["oldestIdx"] == 4
def test_bounded_settle_keeps_the_response_paging_signals():
"""Paging flags follow the server's truncation signal, not the preflight."""
out = _drive(truncated=True, offset=4)
assert out["truncated"] is True
assert out["oldestIdx"] == 4
def test_untruncated_snapshot_clears_the_bounded_preflight_state():
"""An untruncated response must clear the bounded preflight's state.
This is the `/compress` success-path shape from the CORE finding: the
preflight bounded the tail, the response carries the full transcript, and
the client must end at ``truncated=false`` / ``offset=0`` so absolute
transcript indices line up again.
"""
out = _drive(truncated=False, offset=0)
assert out["truncated"] is False, (
"an untruncated response must clear _messagesTruncated; the bounded "
f"preflight state leaked (got {out['truncated']})"
)
assert out["oldestIdx"] == 0
def test_compress_success_path_restores_paging_before_render():
"""Source lock: the compress success path restores paging before render.
The round-3 CORE reproduction was a successful `/compress` whose response
carried 100 full rows while the client stayed at
``_messagesTruncated=true, _oldestIdx=70``. The restore must appear
between the response assignment and ``renderMessages()`` — the same
ordering the settle and cancel paths already use.
"""
block = _compress_success_block()
assign_idx = block.find("S.messages=")
restore_idx = block.find("_messagesTruncated=")
offset_idx = block.find("_oldestIdx=")
render_idx = block.find("renderMessages()")
assert assign_idx >= 0, "compress success path must assign S.messages"
assert restore_idx > assign_idx, (
"_messagesTruncated must be restored from the response AFTER the "
"assignment — the preflight's bounded value otherwise survives"
)
assert offset_idx > assign_idx, "_oldestIdx must be restored from _messages_offset"
assert render_idx > restore_idx and render_idx > offset_idx, (
"paging must be restored BEFORE renderMessages() so the rendered rows "
"and any absolute index computation see the restored values"
)
# The restore must read the response's own signals, not hardcode false.
assert "_messages_truncated" in block, (
"the restore must read data.session._messages_truncated rather than "
"hardcoding _messagesTruncated=false"
)
assert "_messages_offset" in block, (
"the restore must read data.session._messages_offset"
)
def test_truncation_signal_selects_suffix_strategy_exclusively():
"""Source lock: the strategy is a truncation-gated ternary, never an `||`.
The pre-fix expression was ``_stagedMatchesCurrentPrefix ||
_stagedMatchesCurrentSuffix``: when repeated identical turns made both
comparisons succeed the prefix branch won and spliced at the wrong
offset. The gate is now ``_truncatedRecovery ? suffix : prefix``.
"""
block = _splice_block()
assert "_truncatedRecovery" in block, (
"the splice strategy must consult the truncation signal"
)
assert "_truncatedRecovery?_stagedMatchesCurrentSuffix" in block.replace(" ", ""), (
"a truncated recovery must select the SUFFIX comparison exclusively"
)
# The old defect: a bare `||` between the two comparisons.
assert "||_stagedMatchesCurrentSuffix" not in block.replace(" ", ""), (
"prefix and suffix comparisons must not be OR-ed; preferring the "
"prefix when both match splices repeated turns at the wrong offset"
)
@@ -0,0 +1,65 @@
"""#7754: a marked native-image state row that replays its sidecar row must consume that replay slot.
Before the fix, the row-id fast path for a marked native-image mirror row `continue`d without
advancing the replay index. The edited checkpoint then never read as consumed, and a rerun reply
that exists only in state.db was truncated. Master (no marker branch) keeps that reply.
"""
import api.models as models
def _merge(*, marked):
field = models._WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD
image_row = {
"role": "user",
"content": "look",
"timestamp": 860.0,
"_state_db_row_id": 42,
"api_content": "PAYLOAD-A",
}
sidecar = [
{"role": "user", "content": "u1", "timestamp": 100.0},
{"role": "assistant", "content": "a1", "timestamp": 101.0},
dict(image_row),
{"role": "assistant", "content": "a_img", "timestamp": 861.0},
{"role": "user", "content": "uE", "timestamp": 900.0},
]
state_image_row = dict(image_row)
if marked:
state_image_row[field] = True
state = [
{"role": "user", "content": "u1", "timestamp": 100.0},
{"role": "assistant", "content": "a1", "timestamp": 101.0},
state_image_row,
{"role": "assistant", "content": "a_img", "timestamp": 861.0},
{"role": "user", "content": "u_old", "timestamp": 870.0},
{"role": "assistant", "content": "a_old", "timestamp": 871.0},
{"role": "user", "content": "uE", "timestamp": 900.0},
{"role": "assistant", "content": "aE", "timestamp": 901.0},
]
return models.merge_session_messages_append_only(
sidecar,
state,
truncation_watermark=900.0,
truncation_boundary=861.0,
incoming_provenance="state_db",
)
def _contents(rows):
return [row.get("content") for row in rows]
def test_marked_image_row_keeps_state_only_reply_after_edited_checkpoint():
merged = _merge(marked=True)
assert _contents(merged) == ["u1", "a1", "look", "a_img", "uE", "aE"]
def test_marked_and_unmarked_image_rows_merge_identically():
assert _contents(_merge(marked=True)) == _contents(_merge(marked=False))
def test_removed_rows_between_boundary_and_watermark_stay_out():
contents = _contents(_merge(marked=True))
assert "u_old" not in contents
assert "a_old" not in contents
+1
View File
@@ -679,6 +679,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
function _scrollAfterMessageRender() {{}}
function _maybeRecoverVirtualizedBlankViewport() {{ return false; }}
function _updateMessageVirtualMeasurements() {{}}
function _resetMessageVirtualMeasurementBurst() {{}}
function postProcessRenderedMessages() {{}}
function _postProcessWithAnchorSuppression() {{}}
function _formatGatewayModelLabel() {{ return ''; }}
@@ -1,5 +1,6 @@
"""Regression coverage for notify_on_complete across a WebUI restart."""
import logging
import threading
import time
from types import SimpleNamespace
@@ -159,3 +160,102 @@ def test_recovery_is_fail_soft_without_agent(monkeypatch):
assert bp.recover_processes_for_webui() == 0
assert bp._PROCESS_RECOVERY_DONE is False
def test_recovery_skips_process_whose_session_is_gone(monkeypatch, caplog):
"""A checkpointed process whose owner has been pruned (the resolver's
``KeyError(sid)`` contract) must be silently skipped, while a neighbouring
live process in the same recovery sweep still rebinds. Regression for
issue #7753: previously every vanished owner logged a full WARNING
traceback, indistinguishable from a real fault.
"""
calls = {"registered": []}
class FakeRegistry:
def recover_from_checkpoint(self):
return 2 # two checkpointed processes survive restart
def list_sessions(self):
return [
{"session_id": "proc_vanished", "detached": True},
{"session_id": "proc_live", "detached": True},
]
def get(self, process_id):
if process_id == "proc_vanished":
return _FakeProcessSession("vanished-session")
if process_id == "proc_live":
return _FakeProcessSession("live-session")
raise AssertionError(process_id)
fake_registry = FakeRegistry()
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
monkeypatch.setattr(
bp,
"register_process_session",
lambda key, sid: calls["registered"].append((key, sid)),
)
def get_session(sid, metadata_only=False):
# Mirrors api.models._resolve_session_once's contract: missing
# sessions raise KeyError, NOT None.
if sid == "vanished-session":
raise KeyError(sid)
return SimpleNamespace(id=sid)
with caplog.at_level(logging.WARNING, logger="api.background_process"):
result = bp.recover_processes_for_webui(fake_registry, get_session)
assert result == 2
# The live process is rebound; the vanished one is silently skipped.
assert calls["registered"] == [("live-session", "live-session")]
# No WARNING emitted for the vanished-owner case.
warning_records = [
r for r in caplog.records if r.levelno >= logging.WARNING
]
assert warning_records == [], (
"vanished-session KeyError must not produce a WARNING, got: "
f"{[r.getMessage() for r in warning_records]}"
)
assert "Could not resolve recovered WebUI process" not in caplog.text
def test_recovery_still_warns_on_unexpected_resolve_failure(monkeypatch, caplog):
"""Non-KeyError failures from the resolver (e.g. ``OSError`` on an
unreadable sidecar) must keep entering the existing WARNING path. This
pins that the fix for #7753 is narrow and does not over-broaden the
silent-skip contract.
"""
class FakeRegistry:
def recover_from_checkpoint(self):
return 1
def list_sessions(self):
return [{"session_id": "proc_unexpected", "detached": True}]
def get(self, process_id):
assert process_id == "proc_unexpected"
return _FakeProcessSession("webui-session")
fake_registry = FakeRegistry()
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
def get_session(sid, metadata_only=False):
raise OSError("sidecar unreadable")
with caplog.at_level(logging.WARNING, logger="api.background_process"):
result = bp.recover_processes_for_webui(fake_registry, get_session)
assert result == 1
warning_records = [
r for r in caplog.records if r.levelno >= logging.WARNING
]
assert any(
"Could not resolve recovered WebUI process 'proc_unexpected'" in r.getMessage()
for r in warning_records
), (
"OSError from the resolver must still emit a WARNING, got: "
f"{[r.getMessage() for r in warning_records]}"
)
+7 -6
View File
@@ -41,12 +41,13 @@ class TestRootWorkspaceUnblocked:
assert "'/etc'" in src or 'Path("/etc")' in src or "Path('/etc')" in src
assert "'/proc'" in src or 'Path("/proc")' in src or "Path('/proc')" in src
def test_split_guard_present(self):
src = read("api/streaming.py")
assert "'\\n\\n[Attached files:' in msg_text" in src, (
"base_text split must guard against missing '[Attached files:' "
"to avoid empty-string on plain messages"
)
def test_attached_files_suffix_is_optional(self):
from api.models import _strip_attached_files_marker
assert _strip_attached_files_marker("Plain message") == "Plain message"
assert _strip_attached_files_marker(
"Question\n\n[Attached files: photo.png]"
) == "Question"
# ── Group B: custom_providers visibility ─────────────────────────────────────
+3 -1
View File
@@ -219,7 +219,9 @@ class TestCancelledTurnPersistenceGuards:
assert "const _applyCancelSessionPayload=(sessionPayload)=>" in block
assert "const _cancelSessionPayload=_cancelData&&typeof _cancelData.session==='object'?_cancelData.session:null;" in block
assert "if(_applyCancelSessionPayload(_cancelSessionPayload)) return;" in block
assert "const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);" in block
# #7310/#7625: the HTTP fallback is a bounded tail now — the full
# transcript was being re-walked/re-redacted on every cancel recovery.
assert "const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);" in block
assert block.index("if(_applyCancelSessionPayload(_cancelSessionPayload)) return;") < block.index("const data=await api("), (
"Cancel handler must apply the terminal SSE session payload before falling back "
"to /api/session so captured _partial reasoning/tool rows are visible immediately."
+8 -1
View File
@@ -1068,6 +1068,7 @@ def test_auth_self_heal_refreshes_revision_after_first_agent_persists_user(
messages=prior_messages,
context_messages=prior_messages,
)
_session.pending_user_source = "webui"
revisions = []
class AuthThenRecoverAgent:
@@ -1080,8 +1081,14 @@ def test_auth_self_heal_refreshes_revision_after_first_agent_persists_user(
self._last_error = None
self.stream_delta_callback = _kwargs.get("stream_delta_callback")
def run_conversation(self, **kwargs):
def run_conversation(self, persist_user_timestamp=None, **kwargs):
type(self).runs += 1
assert persist_user_timestamp == 10.0
persisted = Session.load(sid)
assert persisted._webui_pending_user_timestamp_identity == (
stream_id,
persist_user_timestamp,
)
revisions.append(kwargs["conversation_history_revision"])
history = list(kwargs.get("conversation_history") or [])
if type(self).runs == 1:
@@ -5,6 +5,7 @@ import pathlib
import urllib.error
import urllib.request
from api import config as webui_config
from tests._pytest_port import BASE
ROOT = pathlib.Path(__file__).resolve().parent.parent
@@ -42,6 +43,16 @@ def make_session(created, title):
return sid
def test_settings_file_path_can_be_scoped_per_instance(tmp_path, monkeypatch):
default_state_dir = tmp_path / "default-state"
monkeypatch.delenv("HERMES_WEBUI_SETTINGS_FILE", raising=False)
assert webui_config._resolve_settings_file(default_state_dir) == default_state_dir / "settings.json"
staging_settings = tmp_path / "staging" / "settings.json"
monkeypatch.setenv("HERMES_WEBUI_SETTINGS_FILE", str(staging_settings))
assert webui_config._resolve_settings_file(default_state_dir) == staging_settings.resolve()
def test_pin_limit_setting_is_exposed_and_wired_through_ui():
assert '"pinned_sessions_limit": 3' in CONFIG_PY
assert '"pinned_sessions_limit": (1, 99)' in CONFIG_PY
@@ -160,6 +160,412 @@ def test_colon_bearing_missing_fallback_keeps_authoritative_provider():
}
# Non-default model of a named custom_providers[] entry. The catalog render
# paths build the option with the qualified "@custom:<slug>:<model>" value and
# NEVER set data-model (that attribute is only set by the fallback injection
# path _ensureModelOptionInDropdown). Regression for #6884: the returned model
# id must be the stripped bare name ("sol"), not the raw dropdown value.
_NON_DEFAULT_CUSTOM_DRIVER = r"""
const fs = require('fs');
const uiSrc = fs.readFileSync(process.argv[1], 'utf8');
function extractFunction(source, name) {
const marker = 'function ' + name + '(';
const start = source.indexOf(marker);
if (start < 0) throw new Error('not found: ' + name);
const brace = source.indexOf('{', source.indexOf(')', start));
let depth = 0;
for (let i = brace; i < source.length; i++) {
if (source[i] === '{') depth += 1;
else if (source[i] === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, i + 1);
}
}
throw new Error('unterminated: ' + name);
}
eval([
'_getOptionProviderId',
'_providerFromModelValue',
'_modelStateForSelect',
].map(name => extractFunction(uiSrc, name)).join('\n'));
globalThis.document = {
createElement(tag) {
return {
tagName: String(tag).toUpperCase(),
value: '',
textContent: '',
dataset: {},
parentElement: null,
};
},
};
globalThis.getModelLabel = value => String(value || '');
globalThis.window = { _configuredModelBadges: {} };
const group = {tagName: 'OPTGROUP', dataset: {provider: 'custom:hetmer.net'}};
const luna = {
value: 'luna',
textContent: 'luna',
dataset: {},
parentElement: group,
};
const sol = {
value: '@custom:hetmer.net:sol',
textContent: 'sol',
dataset: {}, // no data-model — normal catalog render path
parentElement: group,
};
// Colon-bearing model id on the SAME normal catalog render path (no
// data-model). Regression for the #6884 re-gate: the prefix to strip must
// come from the option metadata's authoritative provider (custom:hetmer.net),
// NOT from the last-colon value reparse (which would yield the malformed
// "custom:hetmer.net:model-a" prefix and truncate the model to "free").
const modelAFree = {
value: '@custom:hetmer.net:model-a:free',
textContent: 'model-a:free',
dataset: {}, // no data-model — normal catalog render path
parentElement: group,
};
const localhostGroup = {tagName: 'OPTGROUP', dataset: {provider: 'custom:localhost:11434'}};
const llama = {
value: '@custom:localhost:11434:llama3.2',
textContent: 'llama3.2',
dataset: {},
parentElement: localhostGroup,
};
const select = {
id: 'modelSelect',
options: [luna, sol, modelAFree, llama],
querySelectorAll() { return []; },
get selectedOptions() { return [sol]; },
get value() { return sol.value; },
set value(value) {},
};
process.stdout.write(JSON.stringify({
nonDefault: _modelStateForSelect(select, '@custom:hetmer.net:sol'),
defaultUnprefixed: _modelStateForSelect(select, 'luna'),
colonBearingModel: _modelStateForSelect(select, '@custom:hetmer.net:model-a:free'),
localhostEndpoint: _modelStateForSelect(select, '@custom:localhost:11434:llama3.2'),
missingOptionCustomInput: _modelStateForSelect(select, '@custom:localhost:11434:mistral-custom'),
}));
"""
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_non_default_named_custom_provider_model_strips_qualified_prefix():
assert NODE is not None
result = subprocess.run(
[NODE, "-e", _NON_DEFAULT_CUSTOM_DRIVER, str(UI_JS)],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
# #6884: the normally-rendered option has no data-model, so the qualified
# "@custom:hetmer.net:sol" value must be stripped to the bare model id.
assert payload["nonDefault"] == {
"model": "sol",
"model_provider": "custom:hetmer.net",
}
# Sanity: the unprefixed default option still returns its value as-is.
assert payload["defaultUnprefixed"] == {
"model": "luna",
"model_provider": "custom:hetmer.net",
}
# Colon-bearing model id on the same normal catalog render path: the strip
# prefix comes from the option metadata provider (custom:hetmer.net), so
# the full "model-a:free" survives — not just the "free" suffix (re-gate).
assert payload["colonBearingModel"] == {
"model": "model-a:free",
"model_provider": "custom:hetmer.net",
}
# Endpoint-style custom provider (e.g. host:port derived from base_url authority):
# preserves full custom:localhost:11434 for both catalog and missing-option paths.
assert payload["localhostEndpoint"] == {
"model": "llama3.2",
"model_provider": "custom:localhost:11434",
}
assert payload["missingOptionCustomInput"] == {
"model": "mistral-custom",
"model_provider": "custom:localhost:11434",
}
# Round-trip: send -> persist -> restore for a colon-bearing named
# custom-provider model. The send path emits the bare id ("model-a:free");
# on restore, _findModelInDropdown reverse-matches the bare id back to the
# qualified catalog option ("@custom:hetmer.net:model-a:free") and the
# dropdown re-renders. _isSelectedModelRow must normalize both sides to the
# same model/provider identity so exactly ONE row renders as active /
# "Selected" — with the model id intact, not truncated.
_ROUND_TRIP_DRIVER = r"""
const fs = require('fs');
const ui = fs.readFileSync(process.argv[2], 'utf8');
function extractFunc(name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\(');
const start = ui.search(re);
if (start < 0) throw new Error(name + ' not found');
let openParen = ui.indexOf('(', start);
let i = openParen + 1;
let parenDepth = 1;
while (parenDepth > 0 && i < ui.length) {
if (ui[i] === '(') parenDepth++;
else if (ui[i] === ')') parenDepth--;
i++;
}
i = ui.indexOf('{', i);
let depth = 1;
i++;
while (depth > 0 && i < ui.length) {
if (ui[i] === '{') depth++;
else if (ui[i] === '}') depth--;
i++;
}
return ui.slice(start, i);
}
function makeClassList(initial) {
const set = new Set(initial || []);
return {
_set: set,
add(cls) { set.add(cls); },
remove(cls) { set.delete(cls); },
contains(cls) { return set.has(cls); },
toggle(cls, force) {
if (force === true) { set.add(cls); return true; }
if (force === false) { set.delete(cls); return false; }
if (set.has(cls)) { set.delete(cls); return false; }
set.add(cls);
return true;
},
};
}
function defineClassName(node) {
Object.defineProperty(node, 'className', {
get() { return [...node.classList._set].join(' '); },
set(v) { node.classList = makeClassList(String(v || '').split(/\s+/).filter(Boolean)); },
});
}
function makeNode(tag) {
const node = {
tagName: String(tag || '').toUpperCase(),
children: [],
dataset: {},
style: {},
parentElement: null,
textContent: '',
value: '',
tabIndex: 0,
onclick: null,
_listeners: {},
_innerHTML: '',
appendChild(child) {
child.parentElement = this;
this.children.push(child);
if (this.tagName === 'OPTGROUP' && this._ownerSelect && child.tagName === 'OPTION') {
this._ownerSelect.options.push(child);
}
return child;
},
addEventListener(type, handler) { this._listeners[type] = handler; },
querySelector(selector) { return this._qs ? this._qs[selector] || null : null; },
setAttribute(name, value) { this[name] = value; },
focus() { this._focused = true; },
};
node.classList = makeClassList();
defineClassName(node);
Object.defineProperty(node, 'innerHTML', {
get() { return this._innerHTML; },
set(v) {
this._innerHTML = String(v || '');
this.children = [];
this._qs = {};
if (this.tagName === 'DIV' && this._innerHTML.includes('model-search-input')) {
const input = makeNode('input');
input.className = 'model-search-input';
const clear = makeNode('button');
clear.className = 'model-search-clear';
this._qs['.model-search-input'] = input;
this._qs['.model-search-clear'] = clear;
} else if (this.tagName === 'DIV' && this._innerHTML.includes('model-custom-input')) {
const input = makeNode('input');
input.className = 'model-custom-input';
const btn = makeNode('button');
btn.className = 'model-custom-btn';
this._qs['.model-custom-input'] = input;
this._qs['.model-custom-btn'] = btn;
}
},
});
return node;
}
function makeOption(value, label, parent) {
const opt = makeNode('option');
opt.value = value;
opt.textContent = label || value;
opt.parentElement = parent || null;
return opt;
}
function makeSelect(groups, selectedValue) {
const sel = { id: 'modelSelect', children: [], options: [], _value: selectedValue || '' };
Object.defineProperty(sel, 'value', {get(){return sel._value;}, set(v){sel._value=String(v||'');}});
Object.defineProperty(sel, 'selectedOptions', {get(){const o=sel.options.find(x=>x.value===sel._value);return o?[o]:[];}});
sel.appendChild=function(option){option.parentElement=null;sel.options.push(option);};
sel.querySelectorAll=function(){return [];};
for (const group of groups || []) {
const og = makeNode('optgroup');
og.label = group.provider || '';
og.dataset.provider = group.provider_id || '';
og._ownerSelect = sel;
for (const model of group.models || []) og.appendChild(makeOption(model.id, model.label || model.id, og));
sel.children.push(og);
sel.options.push(...og.children);
}
return sel;
}
function snapshot(dd) {
const out = [];
const walk = (node) => {
for (const child of (node.children || [])) {
out.push({
className: child.className,
textContent: child.textContent,
html: child._innerHTML || '',
});
if (child.children && child.children.length) walk(child);
}
};
walk(dd);
return out;
}
const payload = JSON.parse(process.argv[3]);
const dropdown = makeNode('div');
dropdown.classList.add('open');
const modelSelect = makeSelect(payload.groups, payload.selectedValue || payload.groups[0].models[0].id);
function $(id) {
if (id === 'composerModelDropdown') return dropdown;
if (id === 'modelSelect') return modelSelect;
return null;
}
const window = { _configuredModelBadges: payload.configuredBadges || {} };
const document = { createElement(tag) { return makeNode(tag); } };
function esc(v) { return String(v || ''); }
function t(key, ...args) {
if (key === 'model_show_all_models') return `Show all ${args[0]} models`;
return key;
}
function li() { return 'x'; }
function getModelLabel(v) { return String(v || ''); }
function _providerFromModelValue(v) {
const value = String(v || '');
if (value.startsWith('@') && value.includes(':')) return value.slice(1, value.lastIndexOf(':'));
return '';
}
function _normalizeConfiguredModelKey(v) { return String(v || '').toLowerCase(); }
function _getConfiguredModelBadge(value, badgeMap) { return badgeMap[value] || null; }
function closeModelDropdown() {}
function syncModelChip() {}
function _refreshOpenModelDropdown() {}
function _deduplicateModelPickerOptions() { return 0; }
async function selectModelFromDropdown(value, provider) {
_ensureModelOptionInDropdown(value, modelSelect, provider);
window.__picked=_modelStateForSelect(modelSelect,modelSelect.value);
}
for (const name of [
'_modelPickerOptionIdentity',
'_readModelOverflowData',
'_appendOverflowOptionsToGroup',
'_isEquivalentConfiguredModelEntry',
'_getOptionProviderId',
'_modelStateForSelect',
'_findModelInDropdown',
'_applyModelToDropdown',
'_ensureModelOptionInDropdown',
'renderModelDropdown',
]) {
eval(extractFunc(name));
}
// Send path: the picked state carries the bare, intact model id.
const sent = _modelStateForSelect(modelSelect, '@custom:hetmer.net:model-a:free');
// Restore path: _findModelInDropdown reverse-matches the bare id to the
// qualified catalog option, the picker restores sel.value to it and
// re-renders the dropdown.
const restoreValue = _findModelInDropdown(sent.model, modelSelect, sent.model_provider);
modelSelect.value = restoreValue;
renderModelDropdown();
const rows = snapshot(dropdown).filter(n => String(n.className || '').includes('model-opt'));
const activeRows = rows.filter(n => String(n.className || '').includes(' active'));
process.stdout.write(JSON.stringify({
sent,
restoreValue,
activeCount: activeRows.length,
activeRow: activeRows.length ? {
className: activeRows[0].className,
text: activeRows[0].textContent,
html: activeRows[0].html,
} : null,
}));
"""
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_colon_bearing_custom_provider_round_trip_restores_selected_row(tmp_path):
driver = tmp_path / "round_trip_driver.js"
driver.write_text(_ROUND_TRIP_DRIVER, encoding="utf-8")
payload = {
"groups": [
{
"provider": "hetmer.net",
"provider_id": "custom:hetmer.net",
"models": [
{"id": "luna", "label": "luna"},
{"id": "@custom:hetmer.net:model-a:free", "label": "model-a:free"},
],
}
],
"configuredBadges": {},
"selectedValue": "luna",
}
assert NODE is not None
result = subprocess.run(
[NODE, str(driver), str(UI_JS), json.dumps(payload)],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
actual = json.loads(result.stdout)
# Send path: bare model id, intact (NOT truncated to "free").
assert actual["sent"] == {
"model": "model-a:free",
"model_provider": "custom:hetmer.net",
}
# Restore path: the bare id maps back to the qualified catalog option.
assert actual["restoreValue"] == "@custom:hetmer.net:model-a:free"
# Exactly one row renders as active/"Selected", with the intact model id.
assert actual["activeCount"] == 1, actual
assert "model-opt-badge--selected" in actual["activeRow"]["html"]
assert "model-a:free" in actual["activeRow"]["html"]
assert ">free<" not in actual["activeRow"]["html"].replace("model-a:free", "")
_RENDERED_CLICK_DRIVER = r"""
const fs = require('fs');
+88
View File
@@ -8,6 +8,8 @@ import sys
import types
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
@@ -170,3 +172,89 @@ def test_cron_model_picker_marks_loaded_only_after_successful_population():
catch_block = body.split("} catch (e)", 1)[1]
assert "sel.dataset.loaded = '1'" in try_block
assert "sel.dataset.loaded = '1'" not in catch_block
def test_cron_update_preserves_provider_only_pin_when_model_cleared():
"""Clearing the picker to "Default" must not wipe a provider-only pin.
#4030 intentionally made "clearing the picker = no overrides", which is
correct for model+provider pins but silently destroyed provider-only
pins (jobs that pin a provider with no model, e.g. self-hosted LLM
endpoints). The combined picker cannot represent that state, so
saveCronForm must preserve prev.provider when the job carried no model
AND the user never changed the picker; a deliberate return to Default
must clear it.
"""
save_body = _function_body("saveCronForm")
helper_body = _function_body("_cronProviderForClear")
# The clear branch delegates to the helper and records picker changes.
assert "_cronProviderForClear(_cronPreFormDetail, _cronModelPickerTouched)" in save_body
assert "preserveProvider" not in save_body
# The helper distinguishes an explicit clear (pickerTouched) from an
# untouched provider-only pin.
assert "if (pickerTouched) return null" in helper_body
assert "prev.model == null" in helper_body
# The form entry points reset the touched flag.
for fn in ("openCronCreate", "openCronEdit", "duplicateCurrentCron"):
assert "_cronModelPickerTouched = false" in _function_body(fn)
# The picker records deliberate changes.
pop_body = _function_body("_populateCronFormModelSelect")
assert "_cronModelPickerTouched = true" in pop_body
def test_cron_provider_for_clear_behavior():
"""Exercise _cronProviderForClear through node for both pin shapes."""
import shutil
import subprocess
node = shutil.which("node")
if node is None:
pytest.skip("node not on PATH")
# Locate the full declaration (signature + body) of the helper.
src = PANELS_JS
start = src.find("function _cronProviderForClear(")
assert start != -1, "helper declaration not found"
brace = src.find("{", start)
depth = 0
end = None
for i in range(brace, len(src)):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
if depth == 0:
end = i + 1
break
decl = src[start:end]
driver = f"""
{decl}
const cases = [
['provider_only_untouched', {{model: null, provider: 'custom:llama-swap'}}, false],
['provider_only_touched', {{model: null, provider: 'custom:llama-swap'}}, true],
['model_pin_touched', {{model: 'gpt-5', provider: 'openai'}}, true],
['no_override_untouched', {{model: null, provider: null}}, false],
['no_prev', null, false],
];
const out = {{}};
for (const [name, detail, touched] of cases) out[name] = _cronProviderForClear(detail, touched) ?? null;
process.stdout.write(JSON.stringify(out));
"""
proc = subprocess.run([node, "-e", driver], capture_output=True, text=True, timeout=30)
assert proc.returncode == 0, f"node driver failed: {proc.stderr}"
r = json.loads(proc.stdout)
# Untouched picker on a provider-only job: the pin survives.
assert r["provider_only_untouched"] == "custom:llama-swap"
# Deliberate return to Default: "Default = no overrides" wins.
assert r["provider_only_touched"] is None
# Model-pinned job, explicit clear: provider removed too.
assert r["model_pin_touched"] is None
# No overrides stored, untouched picker: nothing to preserve.
assert r["no_override_untouched"] is None
# No pre-form snapshot (create form): no provider.
assert r["no_prev"] is None
+174
View File
@@ -18,6 +18,8 @@ import time
import urllib.error
import urllib.request
import pytest
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
from tests._pytest_port import BASE
@@ -500,6 +502,178 @@ def test_compression_chain_collapses_to_latest_tip_in_sidebar():
post('/api/settings', {'show_cli_sessions': False})
@pytest.mark.parametrize('reset_marker_origin', ('creation', 'reopen'))
def test_reopen_then_recompress_reset_child_stays_independent_across_readers(
tmp_path, monkeypatch, reset_marker_origin
):
"""Real Agent lifecycle: reset child, ``reopen_session()``, later compression.
Driven through the installed ``SessionDB`` so the rows carry exactly the
production shape. ``creation`` mirrors the gateway creating the reset child
with ``_reset_from`` in its INSERT; ``reopen`` mirrors a marker-less legacy
reset child that ``reopen_session()`` durably stamps. Either way the parent
ends up ``end_reason='compression'`` with a live reset child, and every
WebUI reader must agree: the reset conversation stays visible and its
transcript is never prefixed with the parent's.
The WebUI readers are the contract under test. The Agent's own
``find_live_compression_child`` / ``get_compression_tip`` verdict is only
cross-checked when the installed Agent already binds ``_reset_from`` in
its non-continuation child predicate; an older Agent whose predicate
predates that marker still has to drive the same fixture without turning
this WebUI test red.
"""
hermes_state = pytest.importorskip('hermes_state')
# SessionDB lazily imports sibling Agent modules (e.g. hermes_logging);
# keep the Agent checkout importable even after the suite's sys.path
# isolation guard restored an earlier snapshot.
monkeypatch.syspath_prepend(str(pathlib.Path(hermes_state.__file__).resolve().parent))
import api.models as models
from api.agent_sessions import (
read_importable_agent_session_rows,
read_session_lineage_metadata,
read_session_lineage_report,
)
from api.gateway_watcher import GatewayWatcher
from api.models import get_state_db_session_messages
parent_id = 'recompressed_reset_parent'
child_id = 'reset_child_conversation'
routing = {'session_key': 'telegram:reset-peer', 'user_id': 'peer', 'chat_id': 'chat-1'}
db_path = tmp_path / 'state.db'
db = hermes_state.SessionDB(db_path=db_path)
try:
db.create_session(parent_id, source='telegram', model='test-model', **routing)
db.append_messages_batch(
parent_id,
[
{'role': 'user', 'content': 'before reset', 'timestamp': 100.0},
{'role': 'assistant', 'content': 'answer before reset', 'timestamp': 101.0},
],
)
assert db.promote_to_session_reset(parent_id) is True
child_model_config = (
{'_reset_from': parent_id} if reset_marker_origin == 'creation' else None
)
db.create_session(
child_id,
source='telegram',
model='test-model',
parent_session_id=parent_id,
model_config=child_model_config,
**routing,
)
db.append_messages_batch(
child_id,
[
{'role': 'user', 'content': 'after reset', 'timestamp': 200.0},
{'role': 'assistant', 'content': 'answer after reset', 'timestamp': 201.0},
],
)
if reset_marker_origin == 'reopen':
assert db.get_session(child_id)['model_config'] is None
# The user resumes the old conversation: reopen_session() durably
# stamps _reset_from on the legacy reset child before clearing the
# parent's reset boundary. Should an installed Agent predate that
# stamping, apply the same schema the upstream Agent writes
# (``model_config._reset_from = parent_session_id``) so the rows still
# carry the production shape the WebUI readers are contracted against.
db.reopen_session(parent_id)
expected_child_config = {'_reset_from': parent_id}
if json.loads(db.get_session(child_id)['model_config'] or '{}') != expected_child_config:
with sqlite3.connect(str(db_path)) as conn:
conn.execute(
"UPDATE sessions SET model_config = ? WHERE id = ?",
(json.dumps(expected_child_config), child_id),
)
assert json.loads(db.get_session(child_id)['model_config']) == expected_child_config
reopened = db.get_session(parent_id)
assert reopened['ended_at'] is None and reopened['end_reason'] is None
db.append_messages_batch(
parent_id,
[{'role': 'user', 'content': 'resumed parent', 'timestamp': 300.0}],
)
# ...and that resumed parent is later closed by compression.
db.end_session(parent_id, 'compression')
assert db.get_session(parent_id)['end_reason'] == 'compression'
# Agent cross-check, only when the installed Agent's child predicate
# already binds _reset_from: the reset child is not a compression
# continuation there either. Older Agents are not this test's subject.
agent_filter_sql = str(
getattr(hermes_state.SessionDB, '_NON_CONTINUATION_CHILD_FILTER_SQL', '')
)
if '_reset_from' in agent_filter_sql:
assert db.find_live_compression_child(parent_id) is None
assert db.get_compression_tip(parent_id) == parent_id
finally:
db.close()
with sqlite3.connect(str(db_path)) as conn:
conn.execute(
"UPDATE sessions SET title = ? WHERE id = ?",
('Reset parent conversation', parent_id),
)
conn.execute(
"UPDATE sessions SET title = ? WHERE id = ?",
('Reset child conversation', child_id),
)
# (a) Sidebar projection: both rows visible; the child keeps its own
# title and is a plain child_session, never a hidden continuation.
projected = read_importable_agent_session_rows(db_path, limit=None, exclude_sources=None)
projected_by_id = {row['id']: row for row in projected}
assert set(projected_by_id) == {parent_id, child_id}
child_row = projected_by_id[child_id]
assert child_row['title'] == 'Reset child conversation'
assert child_row['relationship_type'] == 'child_session'
assert child_row['parent_session_id'] == parent_id
assert '_lineage_root_id' not in child_row
parent_row = projected_by_id[parent_id]
assert parent_row['title'] == 'Reset parent conversation'
assert '_lineage_tip_id' not in parent_row
assert '_compression_segment_count' not in parent_row
# (b) Lineage metadata and report.
metadata = read_session_lineage_metadata(db_path, {parent_id, child_id})
assert metadata[child_id]['relationship_type'] == 'child_session'
assert '_lineage_root_id' not in metadata[child_id]
assert '_lineage_tip_id' not in metadata.get(parent_id, {})
child_report = read_session_lineage_report(db_path, child_id)
assert child_report['lineage_key'] == child_id
assert child_report['tip_session_id'] == child_id
assert child_report['total_segments'] == 1
parent_report = read_session_lineage_report(db_path, parent_id)
assert parent_report['tip_session_id'] == parent_id
assert parent_report['total_segments'] == 1
assert [child['session_id'] for child in parent_report['children']] == [child_id]
assert parent_report['children'][0]['role'] == 'child_session'
# (c) Watcher publication: the SSE payload carries both conversations
# with their own titles.
watcher = GatewayWatcher(state_db_path=db_path)
subscriber = watcher.subscribe()
assert watcher._poll_once(now=1.0) is True
event = subscriber.get_nowait()
published = {row['session_id']: row for row in event['sessions']}
assert set(published) == {parent_id, child_id}
assert published[child_id]['title'] == 'Reset child conversation'
assert published[parent_id]['title'] == 'Reset parent conversation'
# (d) Transcript stitching never prepends the parent's transcript.
monkeypatch.setattr(models, '_active_state_db_path', lambda: db_path)
stitched = get_state_db_session_messages(child_id, stitch_continuations=True)
assert [message['content'] for message in stitched] == [
'after reset',
'answer after reset',
]
parent_messages = get_state_db_session_messages(parent_id, stitch_continuations=True)
assert [message['content'] for message in parent_messages] == [
'before reset',
'answer before reset',
'resumed parent',
]
def test_compression_lineage_prefers_freshest_descendant_over_newer_direct_sibling():
"""A later-started stale sibling must not hide a deeper active branch."""
conn = _ensure_state_db()
+325 -6
View File
@@ -21,8 +21,14 @@ tests pinning the contract:
tab that transitions to hidden via the ``visibilitychange`` hook.
"""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
SESSION_OPS = (REPO_ROOT / "api" / "session_ops.py").read_text(encoding="utf-8")
@@ -89,9 +95,7 @@ def test_hidden_poll_hits_session_status_and_attaches_as_replay():
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
assert start != -1
# #7299 added a 404/410 terminal-stop branch to the response handler.
# The window must cover the original fetch chain + the new branch.
body = MESSAGES_JS[start:start + 8000]
body = MESSAGES_JS[start:MESSAGES_JS.index("function _stopHiddenActiveStreamPoll()", start)]
assert "api/session/status?session_id=" in body
assert "d.active_stream_id" in body
# attaches as replay (recovered=true) — turn is already mid-flight
@@ -174,9 +178,7 @@ def test_poll_stops_only_when_attach_succeeds():
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
assert start != -1, "_startHiddenActiveStreamPoll signature not found"
# Window 2400: the multi-pane follow-up adds an explanatory comment block
# before the attach call, pushing it past a narrower slice.
body = MESSAGES_JS[start:start + 8000]
body = MESSAGES_JS[start:MESSAGES_JS.index("function _stopHiddenActiveStreamPoll()", start)]
assert "const attached = _attachServerInitiatedStream(sid, streamId, true)" in body
# Stop the poll only on a true attach (the false branch keeps polling within
# the bounded-retry budget rather than stopping).
@@ -185,3 +187,320 @@ def test_poll_stops_only_when_attach_succeeds():
# The bounded-retry give-up: a never-current pane stops after the budget.
assert "_sessionStreamHiddenPollFalseCount" in body
assert "_SESSION_STREAM_HIDDEN_POLL_MAX_FALSE" in body
# ── Missing-session responses preserve recoverable profile ownership ──────
NODE = shutil.which("node")
@pytest.fixture(scope="module")
def hidden_poll_results():
"""Execute the real poll and visibility handler with deterministic responses."""
if NODE is None:
pytest.skip("node not available")
start = MESSAGES_JS.index("function _startHiddenActiveStreamPoll(sid)")
poll_end = MESSAGES_JS.index("function _chatStreamActiveForSession(sid)", start)
stream_end = MESSAGES_JS.index("function stopSessionStream()", poll_end)
functions = {
"poll": MESSAGES_JS[start:poll_end],
"session": MESSAGES_JS[poll_end:stream_end],
}
driver = textwrap.dedent(
r"""
const functions = JSON.parse(process.argv[1]);
let _sessionStreamHiddenPollTimer = null;
let _sessionStreamHiddenPollSid = null;
let _sessionStreamHiddenPollFalseStreamId = null;
let _sessionStreamHiddenPollFalseCount = 0;
let _sessionStreamHiddenSid = null;
let _sessionStreamSessionId = null;
let _sessionEventSource = null;
let _sessionStreamReconnectTimer = null;
const _SESSION_STREAM_HIDDEN_POLL_MAX_FALSE = 20;
let visibilityChange = null;
const document = {
hidden: true,
addEventListener: (type, listener) => {
if (type === 'visibilitychange') visibilityChange = listener;
},
};
const S = {
activeStreamId: null,
messages: [],
session: {session_id: 'session-a', message_count: 0},
};
const _apiUrl = value => value;
let attachCalls = 0;
const _attachServerInitiatedStream = () => { attachCalls += 1; return true; };
let intervalFn = null;
let intervalSeq = 0;
let fetchCalls = 0;
let eventSourceCalls = 0;
class EventSource {
constructor() {
eventSourceCalls += 1;
this.readyState = 1;
}
addEventListener() {}
close() { this.readyState = 2; }
}
globalThis.setInterval = fn => {
intervalFn = fn;
return ++intervalSeq;
};
globalThis.clearInterval = () => { intervalFn = null; };
eval(functions.poll);
function stopSessionStream() {
if (_sessionEventSource) _sessionEventSource.close();
_sessionEventSource = null;
_sessionStreamSessionId = null;
_stopHiddenActiveStreamPoll();
}
eval(functions.session);
const flush = () => new Promise(resolve => setImmediate(resolve));
const response = status => ({
ok: status >= 200 && status < 300,
status,
json: async () => ({active_stream_id: null}),
});
async function settle() {
await flush();
await flush();
}
function reset() {
stopSessionStream();
_sessionStreamHiddenSid = null;
document.hidden = true;
document._hermesSessionStreamVisibilityHook = false;
visibilityChange = null;
intervalFn = null;
fetchCalls = 0;
eventSourceCalls = 0;
attachCalls = 0;
}
async function runStatus(status, reject = false) {
reset();
_sessionStreamHiddenSid = 'session-a';
globalThis.fetch = () => {
fetchCalls += 1;
return reject ? Promise.reject(new Error('offline')) : Promise.resolve(response(status));
};
_startHiddenActiveStreamPoll('session-a');
await settle();
const nextTick = intervalFn;
if (nextTick) {
nextTick();
await settle();
}
return {
fetchCalls,
running: intervalFn !== null,
pollSid: _sessionStreamHiddenPollSid,
hiddenSid: _sessionStreamHiddenSid,
};
}
async function runStaleResponse(status, sameSession = false) {
reset();
let resolveA;
globalThis.fetch = url => {
fetchCalls += 1;
if (fetchCalls === 1) {
return new Promise(resolve => { resolveA = resolve; });
}
return Promise.resolve(response(200));
};
_sessionStreamHiddenSid = 'session-a';
_startHiddenActiveStreamPoll('session-a');
const oldTick = intervalFn;
const replacementSid = sameSession ? 'session-a' : 'session-b';
_sessionStreamHiddenSid = replacementSid;
_startHiddenActiveStreamPoll(replacementSid);
await settle();
resolveA(response(status));
await settle();
oldTick();
await settle();
return {
running: intervalFn !== null,
pollSid: _sessionStreamHiddenPollSid,
hiddenSid: _sessionStreamHiddenSid,
};
}
async function runVisibilityRecovery(status) {
reset();
globalThis.fetch = () => {
fetchCalls += 1;
return Promise.resolve(response(status));
};
startSessionStream('session-a');
await settle();
if (!visibilityChange) throw new Error('visibilitychange listener was not installed');
document.hidden = false;
visibilityChange();
await settle();
return {
eventSourceCalls,
running: intervalFn !== null,
pollSid: _sessionStreamHiddenPollSid,
hiddenSid: _sessionStreamHiddenSid,
};
}
async function runLateJson() {
reset();
let resolveBody;
globalThis.fetch = () => {
fetchCalls++;
if (fetchCalls === 1) return Promise.resolve({ok: true, status: 200,
json: () => new Promise(resolve => { resolveBody = resolve; })});
return Promise.resolve(response(200));
};
_sessionStreamHiddenSid = 'session-a';
_startHiddenActiveStreamPoll('session-a');
await settle();
_startHiddenActiveStreamPoll('session-a');
resolveBody({active_stream_id: 'old-stream'});
await settle();
return {attachCalls, running: intervalFn !== null};
}
async function runSequence(statuses) {
reset();
const states = [];
globalThis.fetch = () => {
const status = statuses[fetchCalls++];
if (status === -1) return Promise.reject(new Error('offline'));
if (status === 'active') return Promise.resolve({ok: true, status: 200,
json: async () => ({active_stream_id: 'live-a'})});
return Promise.resolve(response(status));
};
startSessionStream('session-a');
for (let i = 0; i < statuses.length; i++) {
if (i && intervalFn) intervalFn();
await settle();
states.push({running: intervalFn !== null, hiddenSid: _sessionStreamHiddenSid});
}
document.hidden = false;
visibilityChange();
await settle();
return {states, fetchCalls, eventSourceCalls, attachCalls};
}
(async () => {
const result = {
missing404: await runStatus(404),
missing410: await runStatus(410),
server500: await runStatus(500),
profile409: await runStatus(409),
auth401: await runStatus(401),
forbidden403: await runStatus(403),
rate429: await runStatus(429),
offline: await runStatus(0, true),
idle200: await runStatus(200),
stale404: await runStaleResponse(404),
stale410: await runStaleResponse(410),
sameSession410: await runStaleResponse(410, true),
visible404: await runVisibilityRecovery(404),
visible410: await runVisibilityRecovery(410),
repeated404: await runSequence([404, 404, 404, 404]),
recoveredProfile: await runSequence([404, 'active']),
recoveredKnownProfile: await runSequence([409, 'active']),
reset200: await runSequence([404, 404, 200, 404, 404, 404]),
reset500: await runSequence([404, 404, 500, 404, 404, 404]),
resetOffline: await runSequence([404, 404, -1, 404, 404, 404]),
reset409: await runSequence([404, 404, 409, 404, 404, 404]),
lateJson: await runLateJson(),
};
process.stdout.write(JSON.stringify(result));
})().catch(error => {
console.error(error);
process.exit(1);
});
"""
)
proc = subprocess.run(
[NODE, "-e", driver, json.dumps(functions)],
capture_output=True,
text=True,
timeout=10,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_hidden_poll_transient_404_preserves_visibility_recovery(hidden_poll_results):
result = hidden_poll_results
assert result["visible404"]["eventSourceCalls"] == 1
assert result["missing404"] == {
"fetchCalls": 2, "running": True, "pollSid": "session-a", "hiddenSid": "session-a",
}
assert result["recoveredProfile"]["attachCalls"] == 1
assert result["recoveredKnownProfile"]["attachCalls"] == 1
def test_hidden_poll_repeated_404_is_bounded_but_can_resume(hidden_poll_results):
result = hidden_poll_results["repeated404"]
assert [s["running"] for s in result["states"]] == [True, True, False, False]
assert all(s["hiddenSid"] == "session-a" for s in result["states"])
assert result["fetchCalls"] == 3
assert result["eventSourceCalls"] == 1
@pytest.mark.parametrize("key", ["reset200", "reset500", "resetOffline", "reset409"])
def test_hidden_poll_404_budget_requires_consecutive_responses(hidden_poll_results, key):
result = hidden_poll_results[key]
assert [s["running"] for s in result["states"]] == [True] * 5 + [False]
assert result["eventSourceCalls"] == 1
def test_hidden_poll_410_stops_and_clears_resume_owner(hidden_poll_results):
result = hidden_poll_results
assert result["visible410"] == {
"eventSourceCalls": 0,
"running": False,
"pollSid": None,
"hiddenSid": None,
}
assert result["missing410"] == {
"fetchCalls": 1,
"running": False,
"pollSid": None,
"hiddenSid": None,
}
def test_hidden_poll_transient_failures_and_idle_remain_retryable(hidden_poll_results):
result = hidden_poll_results
for key in ("server500", "offline", "idle200", "profile409", "auth401", "forbidden403", "rate429"):
assert result[key]["fetchCalls"] == 2
assert result[key]["running"] is True
assert result[key]["pollSid"] == "session-a"
assert result[key]["hiddenSid"] == "session-a"
@pytest.mark.parametrize("key,sid", [
("stale404", "session-b"), ("stale410", "session-b"), ("sameSession410", "session-a"),
])
def test_hidden_poll_stale_response_and_tick_cannot_stop_replacement(hidden_poll_results, key, sid):
assert hidden_poll_results[key] == {
"running": True,
"pollSid": sid,
"hiddenSid": sid,
}
def test_hidden_poll_late_json_cannot_attach_into_replacement(hidden_poll_results):
assert hidden_poll_results["lateJson"] == {"attachCalls": 0, "running": True}
+303 -1
View File
@@ -1060,6 +1060,305 @@ def test_load_session_discards_cursor_only_inflight_before_reattach():
assert 0 <= guard_pos < inflight_branch_pos
def test_user_only_inflight_must_not_seed_nonzero_replay_floor():
"""#7640: a plain user row must not authorize a nonzero replay floor.
After a mid-stream SSE drop in transparent mode the client re-attaches with
`replay=1&after_seq=<cursor>`. When the cached INFLIGHT entry kept the
optimistic user row plus `lastRunJournalSeq` but lost the live assistant
projection, that cursor told the server to skip every journal event that
rebuilds the message body: only the tail (often just `stream_end`) came back
and the settled footer painted over a blank message until a manual reload.
"""
assert NODE, "node not on PATH"
script = "\n".join(
[
"const assert=require('assert');",
_function_decl(SESSIONS_JS, "_inflightCanSeedJournalReplay"),
_function_decl(SESSIONS_JS, "_runJournalReplayFloorForInflight"),
_function_decl(SESSIONS_JS, "_runJournalReplayEventIdForInflight"),
"""
const userOnly={
messages:[{role:'user', content:'a very long ttft prompt'}],
lastRunJournalSeq:206,
lastRunJournalEventId:'stream-e75e:206',
};
assert.strictEqual(_inflightCanSeedJournalReplay(userOnly), false);
assert.strictEqual(_runJournalReplayFloorForInflight(userOnly), 0);
assert.strictEqual(_runJournalReplayEventIdForInflight(userOnly), '');
// The floor helper reports, it does not mutate the recovery object.
assert.strictEqual(userOnly.lastRunJournalSeq, 206);
// A user row with a populated but assistant-less transcript is still user-only.
assert.strictEqual(
_runJournalReplayFloorForInflight({
messages:[{role:'user', content:'hi'}, {role:'assistant', content:''}],
lastRunJournalSeq:206,
}),
0
);
assert.strictEqual(
_runJournalReplayFloorForInflight({
messages:[{role:'user', content:'hi'}, {role:'assistant', content:' '}],
lastRunJournalSeq:206,
}),
0
);
// Positive inverse: any recoverable live output keeps the cursor.
const recovering=[
{lastAssistantText:'partial answer'},
{lastReasoningText:'thinking...'},
{liveTurnHtml:'<div>projected turn</div>'},
{toolCalls:[{name:'read_file'}]},
{activityBurstAnchors:[{seq:12}]},
{anchorActivityScene:{activity_rows:[{kind:'tool'}]}},
{messages:[{role:'user', content:'p'}, {role:'assistant', content:'answer', _live:true}]},
];
for (const extra of recovering) {
const state={messages:[{role:'user', content:'p'}], ...extra};
state.lastRunJournalSeq=206;
state.lastRunJournalEventId='stream-e75e:206';
assert.strictEqual(
_runJournalReplayFloorForInflight(state),
206,
'recoverable live output must keep the cursor: '+JSON.stringify(extra)
);
assert.strictEqual(_runJournalReplayEventIdForInflight(state), 'stream-e75e:206');
}
// #7651: an ESTABLISHED conversation copies the whole transcript, so the last
// assistant row is the previous turn's reply. Historical assistant content must
// never authorize the stale cursor that belongs to the still-running turn.
const established={
messages:[
{role:'user', content:'first question'},
{role:'assistant', content:'the previous turn answer'},
{role:'user', content:'current question'},
],
lastRunJournalSeq:206,
lastRunJournalEventId:'stream-e75e:206',
};
assert.strictEqual(
_inflightCanSeedJournalReplay(established),
false,
'a historical assistant row must not seed the replay floor'
);
assert.strictEqual(_runJournalReplayFloorForInflight(established), 0);
assert.strictEqual(_runJournalReplayEventIdForInflight(established), '');
// Even after the latest user boundary, evidence must be LIVE output: a
// settled/non-live assistant row at the current position still yields zero.
assert.strictEqual(
_runJournalReplayFloorForInflight({
messages:[
{role:'user', content:'current question'},
{role:'assistant', content:'settled reply without _live'},
],
lastRunJournalSeq:206,
}),
0
);
// Degenerate inputs must stay on the zero floor.
assert.strictEqual(_runJournalReplayFloorForInflight(null), 0);
assert.strictEqual(_runJournalReplayFloorForInflight({}), 0);
assert.strictEqual(_runJournalReplayFloorForInflight({lastRunJournalSeq:206, messages:[]}), 0);
assert.strictEqual(
_runJournalReplayFloorForInflight({lastAssistantText:'partial', lastRunJournalSeq:-4}),
0
);
assert.strictEqual(_runJournalReplayEventIdForInflight({}), '');
""",
]
)
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
def test_reattach_discards_replay_cursor_without_live_assistant_state():
"""#7640: validate the recovery object immediately before reattach.
Trusting the object's existence (rather than its contents) is what let a
cursor-only cache reach the wire as a replay floor. Reattach now drops the
cursor so the journal replays from zero, which is the path a manual reload
already proves out, while a cache that still holds live assistant output
keeps its cursor and avoids re-rendering the whole journal.
"""
assert NODE, "node not on PATH"
script = "\n".join(
[
"const assert=require('assert');",
_function_decl(SESSIONS_JS, "_inflightCanSeedJournalReplay"),
_function_decl(SESSIONS_JS, "_runJournalReplayFloorForInflight"),
_function_decl(SESSIONS_JS, "_normalizeInflightReplayCursorForReattach"),
"""
const cursorOnly={
streamId:'stream-e75e',
reattach:true,
messages:[{role:'user', content:'a very long ttft prompt'}],
lastRunJournalSeq:206,
lastRunJournalEventId:'stream-e75e:206',
};
assert.strictEqual(
_normalizeInflightReplayCursorForReattach(cursorOnly),
cursorOnly,
'must return the same object so the caller keeps mutating the live cache'
);
assert.strictEqual(cursorOnly.lastRunJournalSeq, 0);
assert.strictEqual(cursorOnly.lastRunJournalEventId, '');
assert.strictEqual(_runJournalReplayFloorForInflight(cursorOnly), 0);
assert.strictEqual(
cursorOnly.messages.length,
1,
'the optimistic user row must survive so pending dedupe still works'
);
const recovering={
streamId:'stream-e75e',
reattach:true,
messages:[{role:'user', content:'p'}],
lastAssistantText:'partial answer',
lastRunJournalSeq:206,
lastRunJournalEventId:'stream-e75e:206',
};
_normalizeInflightReplayCursorForReattach(recovering);
assert.strictEqual(recovering.lastRunJournalSeq, 206);
assert.strictEqual(recovering.lastRunJournalEventId, 'stream-e75e:206');
// #7651: a historical assistant row must not protect the cursor either, or an
// established conversation settles with a blank body over a painted footer.
const established={
streamId:'stream-e75e',
reattach:true,
messages:[
{role:'user', content:'first question'},
{role:'assistant', content:'the previous turn answer'},
{role:'user', content:'current question'},
],
lastRunJournalSeq:206,
lastRunJournalEventId:'stream-e75e:206',
};
_normalizeInflightReplayCursorForReattach(established);
assert.strictEqual(established.lastRunJournalSeq, 0);
assert.strictEqual(established.lastRunJournalEventId, '');
assert.strictEqual(_runJournalReplayFloorForInflight(established), 0);
assert.strictEqual(
established.messages.length,
3,
'the transcript must survive; only the cursor halves are dropped'
);
const noCursor={streamId:'stream-e75e', messages:[{role:'user', content:'p'}]};
_normalizeInflightReplayCursorForReattach(noCursor);
assert.strictEqual(noCursor.lastRunJournalSeq, undefined);
assert.strictEqual(_runJournalReplayFloorForInflight(noCursor), 0);
assert.strictEqual(_normalizeInflightReplayCursorForReattach(null), null);
assert.strictEqual(_normalizeInflightReplayCursorForReattach(undefined), undefined);
""",
]
)
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
def test_reattach_replay_params_from_a_cursor_only_cache_replay_from_zero():
"""#7640: end-to-end wire contract for a cursor-only reattach.
Derives the replay params from the real floor helpers (node) and feeds them
to the real server cursor parser. Tying both halves together matters because
`api.routes._parse_run_journal_after_seq()` reads `after_event_id` BEFORE
`after_seq`: a fix that zeroed only the seq would still resume at the stale
event id and skip every journal event that rebuilds the message body.
"""
assert NODE, "node not on PATH"
script = "\n".join(
[
"const assert=require('assert');",
_function_decl(SESSIONS_JS, "_inflightCanSeedJournalReplay"),
_function_decl(SESSIONS_JS, "_runJournalReplayFloorForInflight"),
_function_decl(SESSIONS_JS, "_runJournalReplayEventIdForInflight"),
"""
const runId='stream-7640';
const userOnly={
messages:[{role:'user', content:'a very long ttft prompt'}],
lastRunJournalSeq:206,
lastRunJournalEventId:runId+':205',
};
console.log(JSON.stringify({
after_seq:String(_runJournalReplayFloorForInflight(userOnly)),
after_event_id:String(_runJournalReplayEventIdForInflight(userOnly)||''),
}));
""",
]
)
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
params = json.loads(result.stdout.strip().splitlines()[-1])
# What the fixed client puts on the wire for a cache with no live projection.
assert params == {"after_seq": "0", "after_event_id": ""}
from api.routes import _parse_run_journal_after_seq
run_id = "stream-7640"
assert (
_parse_run_journal_after_seq(
{"after_seq": [params["after_seq"]], "after_event_id": [params["after_event_id"]]},
run_id,
)
== 0
)
# Precedence proof: dropping the seq alone would still resume at the stale id.
assert (
_parse_run_journal_after_seq(
{"after_seq": ["0"], "after_event_id": [f"{run_id}:205"]}, run_id
)
== 205
)
# ...and the pre-fix pair, for the record.
assert (
_parse_run_journal_after_seq(
{"after_seq": ["206"], "after_event_id": [f"{run_id}:205"]}, run_id
)
== 205
)
def test_attach_and_reattach_use_the_validated_replay_floor():
"""#7640 wiring guard: the validated floor is what reaches the wire.
The behavioral coverage for the floor helpers lives in
`test_user_only_inflight_must_not_seed_nonzero_replay_floor` and
`test_reattach_discards_replay_cursor_without_live_assistant_state`; this
test pins that `attachLiveStream()` and both `loadSession()` reattach paths
actually go through them instead of reading the raw cursor field.
"""
body = _function_body(MESSAGES_JS, "attachLiveStream")
init_pos = body.index("const _replayCursorInflight=")
init_block = body[init_pos : body.index(";", body.index("let _lastRunJournalEventId=", init_pos))]
assert "INFLIGHT[activeSid]" in init_block, "must still read the live cache"
assert "_runJournalReplayFloorForInflight(_replayCursorInflight)" in init_block
assert "_runJournalReplayEventIdForInflight(_replayCursorInflight)" in init_block
assert "INFLIGHT[activeSid].lastRunJournalSeq" not in init_block, (
"attachLiveStream must not read the raw cursor field anymore"
)
assert "INFLIGHT[activeSid].lastRunJournalEventId" not in init_block
load_body = re.sub(r"\s+", "", _function_body(SESSIONS_JS, "loadSession"))
assert load_body.count("_normalizeInflightReplayCursorForReattach(INFLIGHT[sid])") == 2, (
"both reattach paths must re-validate the recovery object"
)
first = load_body.index("_normalizeInflightReplayCursorForReattach(INFLIGHT[sid])")
first_attach = load_body.index("attachLiveStream(sid,activeStreamId", first)
assert first < first_attach, "validation must run before the stream is attached"
last = load_body.rindex("_normalizeInflightReplayCursorForReattach(INFLIGHT[sid])")
last_attach = load_body.rindex("attachLiveStream(sid,activeStreamId", 0, last)
assert last_attach < last
def test_live_recovery_prefers_newer_durable_run_journal_snapshot():
"""Recovery chooses by stream identity and durable journal progress."""
assert NODE, "node not on PATH"
@@ -1174,7 +1473,10 @@ assert.strictEqual(inflight.lastRunJournalEventId, 'run-a:7');
assert "lastRunJournalEventId:String(stored.lastRunJournalEventId||'')" in load_body
assert "lastRunJournalEventId:state.lastRunJournalEventId||''" in compact_body
assert "inflight.lastRunJournalEventId||''" in attach_body
assert "INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId" in attach_body
# #7640: the cursor's event id is gated by the same validation as the seq —
# reading the raw field here let a cursor outlive its assistant projection.
assert "_runJournalReplayEventIdForInflight(_replayCursorInflight)" in attach_body
assert "INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId" not in attach_body
assert "inflight.lastRunJournalEventId=raw" in attach_body
assert "INFLIGHT[activeSid].streamId=streamId" in attach_body
assert "INFLIGHT[activeSid].lastRunJournalEventId=''" in attach_body
@@ -631,3 +631,165 @@ def test_sse_done_path_broadened_to_any_model_window_mismatch():
assert "_model_matches_configured_default as _mmcd_cc" not in _STREAMING_SRC, (
"the old default-only save matcher (_mmcd_cc) must be removed (#4618)"
)
# --- #7535: provider-ownership guard on the global model.base_url backfill ---
# Same family as #3256: a global value scoped to its owner must not leak onto
# sessions owned by a different provider.
def test_builtin_provider_lookup_does_not_inherit_other_providers_base_url():
"""#7535: a session on a built-in registry provider (whose base_url is empty
by design because the endpoint lives in PROVIDER_REGISTRY, not config.yaml)
must NOT inherit the global ``model.base_url`` owned by a different (custom)
provider. The unguarded backfill handed the custom endpoint to the
context-length resolver, which probed the wrong ``/models`` catalog and fell
back to the generic 128K catalog entry for a 1M-context model, while the
on-disk session and SSE stream both carried 1,000,000."""
from api.routes import _context_length_lookup_inputs_for_model
ark_base = "https://ark.cn-beijing.volces.com/api/coding/v3"
cfg = {
"model": {
"provider": "custom:ark",
"base_url": ark_base,
"default": "ark-code-latest",
"context_length": 1000000,
},
"custom_providers": [
{
"name": "ark",
"base_url": ark_base,
"api_key": "ark-key",
"model": "ark-code-latest",
}
],
}
lookup = _context_length_lookup_inputs_for_model(
"@opencode-go:deepseek-v4.1-flash",
"opencode-go",
cfg=cfg,
)
assert lookup.provider == "opencode-go"
assert lookup.base_url == "", (
"the global model.base_url belongs to custom:ark; the opencode-go "
"registry slot must stay empty so PROVIDER_REGISTRY's endpoint "
"resolves the context window (#7535)"
)
def test_same_provider_session_keeps_global_base_url_backfill():
"""#7535 control (compat): when the session provider IS the configured
owner of ``model.base_url``, the backfill behavior is preserved."""
from api.routes import _context_length_lookup_inputs_for_model
ark_base = "https://ark.cn-beijing.volces.com/api/coding/v3"
cfg = {
"model": {
"provider": "custom:ark",
"base_url": ark_base,
"default": "ark-code-latest",
},
}
lookup = _context_length_lookup_inputs_for_model(
"ark-code-latest",
"custom:ark",
cfg=cfg,
)
assert lookup.provider == "custom:ark"
assert lookup.base_url == ark_base, (
"same-owner sessions must keep the model.base_url backfill (#7535 compat)"
)
def test_base_url_only_config_without_provider_keeps_endpoint():
"""Regression (PR #7743 gate): a ``model`` section that declares
``base_url`` but NO ``provider`` has no owner that could conflict, so any
session must still receive the URL — exactly as master did. The profile
setup path writes this shape (api/profiles.py), and dropping the URL made
the lookup skip the endpoint and silently fall back to 256,000."""
from api.routes import _context_length_lookup_inputs_for_model
only_base_url = "http://127.0.0.1:9/v1"
cfg = {
"model": {
"base_url": only_base_url,
"default": "some-model",
},
}
for session_provider in ("custom", "openrouter"):
lookup = _context_length_lookup_inputs_for_model(
"some-model",
session_provider,
cfg=cfg,
)
assert lookup.provider == session_provider
assert lookup.base_url == only_base_url, (
f"a config with no declared provider owner cannot conflict with the "
f"{session_provider!r} session, so the base_url must survive the "
f"ownership guard"
)
def test_underscore_provider_owner_matches_hyphen_session():
"""Regression (PR #7743 gate): ``opencode_go`` and ``opencode-go`` are two
spellings of the SAME built-in provider, so the owner session must still
receive ``model.base_url``. Comparing the ids through the repo's
canonicaliser folds the underscore without collapsing distinct
``custom:*`` slugs."""
from api.routes import _context_length_lookup_inputs_for_model
owner_base = "http://127.0.0.1:9/v1"
cfg = {
"model": {
"provider": "opencode_go",
"base_url": owner_base,
"default": "some-model",
},
}
lookup = _context_length_lookup_inputs_for_model(
"some-model",
"opencode-go",
cfg=cfg,
)
assert lookup.provider == "opencode-go"
assert lookup.base_url == owner_base, (
"opencode_go and opencode-go are the same built-in provider, so the "
"owner session must keep the base_url backfill"
)
def test_underscore_provider_still_blocks_distinct_custom_owner():
"""Counterpart to test_underscore_provider_owner_matches_hyphen_session:
folding underscores must not open the #7535 guard. ``custom:my_box`` and a
session on the unrelated built-in ``opencode-go`` are still different
providers, so the custom owner's URL stays out of the registry slot."""
from api.routes import _context_length_lookup_inputs_for_model
cfg = {
"model": {
"provider": "custom:my_box",
"base_url": "http://127.0.0.1:9/v1",
"default": "some-model",
},
}
lookup = _context_length_lookup_inputs_for_model(
"some-model",
"opencode-go",
cfg=cfg,
)
assert lookup.provider == "opencode-go"
assert lookup.base_url == "", (
"underscore folding must not let a distinct custom provider's "
"base_url leak onto another provider's session (#7535 guard)"
)
+160
View File
@@ -159,6 +159,7 @@ def _make_db(tmp_path: Path):
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
session_source TEXT,
model_config TEXT,
model TEXT,
started_at REAL NOT NULL,
ended_at REAL,
@@ -355,6 +356,165 @@ def test_periodic_projection_recovers_role_only_sidebar_visibility_change(tmp_pa
assert subscriber.empty()
def test_marker_only_update_invalidates_projection_on_next_poll(tmp_path):
"""model_config is projection authority, so its mutation cannot wait for parity."""
gw = importlib.import_module("api.gateway_watcher")
db, conn = _make_db(tmp_path)
_add_session(conn, "compression-parent", "telegram", mc=1, started=100.0)
_add_session(conn, "compression-child", "telegram", mc=1, started=199.0)
conn.execute(
"UPDATE sessions SET ended_at = 200.0, end_reason = 'compression' "
"WHERE id = 'compression-parent'"
)
conn.execute(
"UPDATE sessions SET parent_session_id = 'compression-parent' "
"WHERE id = 'compression-child'"
)
conn.commit()
watcher = gw.GatewayWatcher(state_db_path=db)
subscriber = watcher.subscribe()
assert watcher._poll_once(now=1.0) is True
initial = subscriber.get_nowait()
assert [row["session_id"] for row in initial["sessions"]] == [
"compression-child"
]
# No timestamp, count, source, or message row changes. This one field turns
# the previously-collapsed child into a direct branch and must be visible on
# the ordinary five-second poll rather than the 60-second parity fallback.
conn.execute(
"UPDATE sessions SET model_config = ? WHERE id = 'compression-child'",
('{"_branched_from":"compression-parent"}',),
)
conn.commit()
assert watcher._poll_once(now=2.0) is True
event = subscriber.get_nowait()
assert {row["session_id"] for row in event["sessions"]} == {
"compression-parent",
"compression-child",
}
assert subscriber.empty()
def test_snapshot_hash_covers_every_published_field():
"""Any emitted field change must change the hash; ordering must not."""
gw = importlib.import_module("api.gateway_watcher")
base = [
{
"session_id": "b",
"title": "Chat B",
"model": "m",
"message_count": 2,
"created_at": 100.0,
"updated_at": 150.0,
"source": "telegram",
"raw_source": "telegram",
"session_source": "messaging",
"source_label": "Telegram",
},
{
"session_id": "a",
"title": "Chat A",
"model": "m",
"message_count": 1,
"created_at": 50.0,
"updated_at": 60.0,
"source": "cli",
"raw_source": "cli",
"session_source": "cli",
"source_label": "CLI",
},
]
reference = gw._snapshot_hash(base)
assert gw._snapshot_hash(list(reversed(base))) == reference, "order-independent"
assert gw._snapshot_hash([dict(row) for row in base]) == reference, "deterministic"
assert gw._snapshot_hash([]) != reference
# Mutating any single published field, including the ones the old
# id/updated_at/message_count triple ignored, changes the hash.
for field in base[0]:
mutated = [dict(row) for row in base]
mutated[0][field] = f"changed-{field}"
assert gw._snapshot_hash(mutated) != reference, field
def test_marker_only_projection_change_emits_sse_event_without_row_churn(tmp_path):
"""ID / message count / activity stay fixed; title + segments change → event.
Gate reproduction: a lineage-marker mutation reruns the projection (the
cheap fingerprint covers ``model_config``) and changes the published
title/segment projection of a row, but the session id set, every
``message_count`` and every ``updated_at`` in the payload are unchanged.
The old snapshot hash saw no difference, so ``_last_sessions`` kept the
stale projection and no subscriber event was queued.
"""
gw = importlib.import_module("api.gateway_watcher")
db, conn = _make_db(tmp_path)
# A compression chain whose root segment holds no messages (its
# transcript moved to the continuation): the projection publishes ONE
# row under the tip's id carrying the root's title and a segment count
# of 2. A message-less root is hidden when it stands alone.
_add_session(conn, "root-a", "telegram", mc=0, started=100.0, title="Alpha")
_add_session(conn, "tip-b", "telegram", mc=1, started=150.0, title="Beta")
conn.execute(
"UPDATE sessions SET ended_at = 150.0, end_reason = 'compression' WHERE id = 'root-a'"
)
conn.execute("UPDATE sessions SET parent_session_id = 'root-a' WHERE id = 'tip-b'")
conn.commit()
watcher = gw.GatewayWatcher(state_db_path=db)
subscriber = watcher.subscribe()
assert watcher._poll_once(now=1.0) is True
initial = subscriber.get_nowait()
initial_rows = {row["session_id"]: row for row in initial["sessions"]}
assert set(initial_rows) == {"tip-b"}
assert initial_rows["tip-b"]["title"] == "Alpha"
assert initial_rows["tip-b"]["message_count"] == 1
assert initial_rows["tip-b"]["updated_at"] == 150.0
assert watcher._last_sessions == initial["sessions"]
# Marker-only flip: stamp _reset_from on the tip. No timestamp, count,
# source or message row changes anywhere. The tip is now a reset child
# (its own conversation, own title, one segment) and the message-less
# root drops out — the published id set, message_count and updated_at
# are byte-identical to the previous snapshot.
conn.execute(
"UPDATE sessions SET model_config = ? WHERE id = 'tip-b'",
('{"_reset_from":"root-a"}',),
)
conn.commit()
assert gw._cheap_change_fingerprint(db) != watcher._last_cheap_fp
assert watcher._poll_once(now=2.0) is True
event = subscriber.get_nowait()
rows = {row["session_id"]: row for row in event["sessions"]}
assert set(rows) == {"tip-b"}
assert rows["tip-b"]["title"] == "Beta"
assert rows["tip-b"]["message_count"] == initial_rows["tip-b"]["message_count"]
assert rows["tip-b"]["updated_at"] == initial_rows["tip-b"]["updated_at"]
assert watcher._last_sessions == event["sessions"]
assert subscriber.empty()
# A title-only projection change on an otherwise identical row is also
# delivered on the ordinary poll.
conn.execute("UPDATE sessions SET title = 'Beta renamed' WHERE id = 'tip-b'")
conn.commit()
assert watcher._poll_once(now=3.0) is True
event = subscriber.get_nowait()
rows = {row["session_id"]: row for row in event["sessions"]}
assert set(rows) == {"tip-b"}
assert rows["tip-b"]["title"] == "Beta renamed"
assert rows["tip-b"]["updated_at"] == initial_rows["tip-b"]["updated_at"]
assert watcher._last_sessions == event["sessions"]
assert subscriber.empty()
# Unchanged payload: the parity projection reruns but emits nothing.
assert watcher._poll_once(now=3.0 + watcher.PROJECTION_PARITY_INTERVAL) is True
assert subscriber.empty()
def test_initial_missing_db_does_not_publish_an_empty_snapshot(tmp_path):
gw = importlib.import_module("api.gateway_watcher")
watcher = gw.GatewayWatcher(state_db_path=tmp_path / "missing.db")
@@ -79,6 +79,8 @@ function extractFunc(name){
// ── module-scope state the extracted functions close over ──
let _clarifyPendingBySession = new Map();
let _approvalPendingBySession = new Map();
let _clarifyPromptGenerationBySession = new Map();
let _approvalPromptGenerationBySession = new Map();
let _clarifySessionId = null;
let _approvalSessionId = null;
let S = { session: null };
@@ -109,6 +111,10 @@ function hideApprovalCard(force){
// ── real functions under test ──
eval(extractFunc('_promptActiveSessionId'));
eval(extractFunc('_clarifyPromptGeneration'));
eval(extractFunc('_bumpClarifyPromptGeneration'));
eval(extractFunc('_approvalPromptGeneration'));
eval(extractFunc('_bumpApprovalPromptGeneration'));
eval(extractFunc('_clarifyPromptBelongsToActiveSession'));
eval(extractFunc('_rememberClarifyPending'));
eval(extractFunc('_clearClarifyPendingForSession'));
@@ -99,7 +99,7 @@ def test_js_recycle_flag_lifecycle():
"""_scheduleMessageVirtualizedRender sets _msgNodeRecycleEnabled=true
before the compensate call and clears it in finally."""
fn_match = re.search(
r'function _scheduleMessageVirtualizedRender\(force\)\{(.+?)^(?=function )',
r'function _scheduleMessageVirtualizedRender\(force(?:, request)?\)\{(.+?)^(?=function )',
JS, re.DOTALL | re.MULTILINE
)
assert fn_match, "_scheduleMessageVirtualizedRender not found"
@@ -1101,7 +1101,7 @@ class TestScrollbarDragRenderDuringDrag:
"""_scheduleMessageVirtualizedRender must have a _scrollbarDragActive
branch that runs a full render with scroll compensation."""
fn_match = re.search(
r'function _scheduleMessageVirtualizedRender\(force\)\{(.+?)^(?=function )',
r'function _scheduleMessageVirtualizedRender\(force(?:, request)?\)\{(.+?)^(?=function )',
JS, re.DOTALL | re.MULTILINE
)
assert fn_match, "_scheduleMessageVirtualizedRender not found"
@@ -53,45 +53,12 @@ def test_dismissed_set_capped_at_100():
# Guard in showApprovalCard
# ---------------------------------------------------------------------------
def test_guard_in_show_approval_card():
compact = _compact(MESSAGES_JS)
# Guard must appear inside showApprovalCard, after _rememberApprovalPending
func_start = compact.find("functionshowApprovalCard(")
assert func_start != -1
# Locate the guard after the function start (dismissals are namespaced by
# session, so the guard passes sid + approval_id).
guard = "_isApprovalDismissed(sid,pending.approval_id)"
guard_idx = compact.find(guard, func_start)
assert guard_idx != -1, "guard _isApprovalDismissed must appear in showApprovalCard"
# _rememberApprovalPending must appear before the guard
remember = "_rememberApprovalPending("
remember_idx = compact.find(remember, func_start)
assert remember_idx != -1
assert remember_idx < guard_idx, "guard must come after _rememberApprovalPending"
def test_guard_returns_early():
# The guard must be a return statement
compact = _compact(MESSAGES_JS)
assert "if(pending&&pending.approval_id&&_isApprovalDismissed(sid,pending.approval_id))return;" in compact
# ---------------------------------------------------------------------------
# dismissApprovalCard function
# ---------------------------------------------------------------------------
def test_dismiss_approval_card_defined():
assert "function dismissApprovalCard(" in MESSAGES_JS
def test_dismiss_approval_card_marks_dismissed():
compact = _compact(MESSAGES_JS)
func_start = compact.find("functiondismissApprovalCard(")
assert func_start != -1
body_end = compact.find("}", func_start)
body = compact[func_start:body_end + 1]
assert "_markApprovalDismissed(sid,_approvalCurrentId)" in body
def test_dismiss_approval_card_hides_card():
compact = _compact(MESSAGES_JS)
@@ -102,19 +69,6 @@ def test_dismiss_approval_card_hides_card():
assert "hideApprovalCard(true)" in body
def test_dismiss_approval_card_clears_pending_attention():
# dismissApprovalCard must call _clearApprovalPendingForSession so the tab
# indicator stops blinking after dismiss. Without this, _rememberApprovalPending
# reinsertes the pending entry on every poll tick and the indicator stays lit.
compact = _compact(MESSAGES_JS)
func_start = compact.find("functiondismissApprovalCard(")
assert func_start != -1
body_end = compact.find("}", func_start)
body = compact[func_start:body_end + 1]
assert "_clearApprovalPendingForSession(sid)" in body, (
"dismissApprovalCard must call _clearApprovalPendingForSession(sid)"
)
def test_dismiss_approval_card_captures_sid_before_hide():
# The session ID must be captured before hideApprovalCard(true) nulls out
@@ -286,7 +240,7 @@ def test_same_approval_id_in_two_sessions_does_not_collide():
pytest.skip("node not available")
helpers = "\n".join(
_extract_fn(MESSAGES_JS, n)
for n in ("_approvalDismissKey", "_getDismissedApprovals",
for n in ("_promptNotifyKey", "_approvalDismissKey", "_getDismissedApprovals",
"_isApprovalDismissed", "_markApprovalDismissed", "_unmarkApprovalDismissed")
)
script = (
@@ -67,6 +67,10 @@ def _run_failure_case(api_js: str) -> dict:
_extract_fn(MESSAGES_JS, "_getDismissedApprovals"),
_extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
_extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"),
_extract_fn(MESSAGES_JS, "_promptNotifyKey"),
_extract_fn(MESSAGES_JS, "_retirePromptNotifyKey"),
_extract_fn(MESSAGES_JS, "_approvalPromptGeneration"),
_extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration"),
_extract_fn(MESSAGES_JS, "_promptActiveSessionId"),
_extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
@@ -103,6 +107,7 @@ let renderCalls = 0;
let _approvalSessionId = 'sess-1';
let _approvalCurrentId = 'appr-1';
let _approvalPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map();
let _loadSessionGeneration = 1;
let _approvalResponding = null;
let _approvalClearedOwner = null;
@@ -111,6 +116,7 @@ let _approvalSignature = '';
let _approvalVisibleSince = 0;
let _approvalHideTimer = null;
const _clarifyPendingBySession = new Map();
const _promptNotifySeen = new Map();
const buttons = new Map();
function makeButton(id) {{
return {{
@@ -258,6 +264,10 @@ def test_poll_rerender_keeps_inflight_buttons_disabled_and_blocks_duplicates():
_extract_fn(MESSAGES_JS, "_getDismissedApprovals"),
_extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
_extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"),
_extract_fn(MESSAGES_JS, "_promptNotifyKey"),
_extract_fn(MESSAGES_JS, "_retirePromptNotifyKey"),
_extract_fn(MESSAGES_JS, "_approvalPromptGeneration"),
_extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration"),
_extract_fn(MESSAGES_JS, "_promptActiveSessionId"),
_extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
@@ -289,6 +299,7 @@ const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
let _approvalSessionId = 'sess-1';
let _approvalCurrentId = 'appr-1';
let _approvalPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map();
let _loadSessionGeneration = 1;
let _approvalResponding = null;
let _approvalClearedOwner = null;
@@ -297,6 +308,7 @@ let _approvalSignature = '';
let _approvalVisibleSince = 0;
let _approvalHideTimer = null;
const _clarifyPendingBySession = new Map();
const _promptNotifySeen = new Map();
let resolveApi;
let apiCalls = 0;
const buttons = new Map();
+3 -1
View File
@@ -199,7 +199,9 @@ def test_missing_sidecar_returns_default_without_caching_growth(tmp_path):
session_dir.mkdir()
with mock.patch("api.models.SESSION_DIR", session_dir):
meta = models._state_projection_sidecar_metadata("cron_nope_999")
assert meta == {"title": None, "archived": False}
# No sidecar means no opinion on archived (None), so the projection falls back
# to the state.db row's archived flag (#7548) instead of forcing it to False.
assert meta == {"title": None, "archived": None}
# No file → nothing cached (so the cache can't be poisoned by absent files).
assert len(models._SIDECAR_METADATA_CACHE) == 0
@@ -1,5 +1,6 @@
"""Regression coverage for issue #500 transcript virtualization."""
import json
import re
import shutil
import subprocess
import tempfile
@@ -1345,7 +1346,9 @@ let _messageVirtualHeightCacheSrc = {};
let _messageVirtualEstimatedRowHeight = 200;
let _messageVirtualWindowKey = 'old-key';
let _messageVirtualMeasurementCycleKey = 'old-cycle';
let _messageVirtualMeasurementRetryCount = 3;
let _messageVirtualMeasurementSeenKeys = ['stale-key'];
let _messageVirtualMeasurementBurstActive = true;
let _messageVirtualRenderQueuedOrigin = 'internal';
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
// #4367 introduced per-role default heights; the combined _clearMessageVirtualHeightCache
// resets the estimate via _messageVirtualDefaultHeightForRole('default'), so the harness
@@ -1353,6 +1356,7 @@ const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = {user:120, assistant:160, tool_call:400, default:140};
function clearTimeout(id){ timerCleared = (id === 99); }
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
eval(extractFunc('_resetMessageVirtualMeasurementBurst'));
eval(extractFunc('_clearMessageVirtualHeightCache'));
_clearMessageVirtualHeightCache();
console.log(JSON.stringify({
@@ -1360,6 +1364,9 @@ console.log(JSON.stringify({
settleTimer: _messageVirtualScrollSettleTimer,
deferred: _messageVirtualDeferredMeasurement,
timerCleared: timerCleared,
seenKeys: _messageVirtualMeasurementSeenKeys,
burstActive: _messageVirtualMeasurementBurstActive,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
}));
"""
metrics = json.loads(_run_node(source))
@@ -1375,3 +1382,425 @@ console.log(JSON.stringify({
assert metrics["timerCleared"] is True, (
"_clearMessageVirtualHeightCache must call clearTimeout on the pending settle timer"
)
assert metrics["seenKeys"] == [], (
"_clearMessageVirtualHeightCache must clear the measurement-burst seen-key "
f"memory: seenKeys {metrics['seenKeys']}"
)
assert metrics["burstActive"] is False, (
"_clearMessageVirtualHeightCache must end any active measurement burst: "
f"burstActive {metrics['burstActive']}"
)
assert metrics["queuedOrigin"] is None, (
"_clearMessageVirtualHeightCache must clear any queued internal-measurement "
f"provenance: queuedOrigin {metrics['queuedOrigin']}"
)
def _measurement_burst_harness(scenario_js: str) -> str:
"""Shared queued-rAF harness for the measurement-burst chain (#6654/#6717).
Drives the REAL production chain: the cycle-aware scheduler queues a first
rAF, _scheduleMessageVirtualizedRender queues a second rAF (the
internal-measurement provenance marker is threaded through BOTH layers),
and the renderMessages stub applies the production burst-reset contract for
every unmarked (external) render, then simulates the measure pass from the
caller-provided plan (measurePlan). Unlike the earlier synchronous-rAF
harnesses, external renders can land BETWEEN queued rAF callbacks — exactly
where the overlap/convergence regressions lived.
"""
js = UI_JS_PATH.read_text(encoding="utf-8")
cap_match = re.search(
r"MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS\s*=\s*(\d+)", js
)
assert cap_match, (
"MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS must be defined in ui.js "
"(absolute per-burst cap, #6717 re-gate)"
)
cap = int(cap_match.group(1))
return _extract_func_script(js) + (
"// Absolute per-burst ceiling, read from production ui.js (#6717 re-gate).\n"
f"const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = {cap};\n"
) + """
// ── queued-rAF harness ────────────────────────────────────────────────────────
let renderLog = []; // every renderMessages call: {internal, preserveScroll}
let rafQueue = [];
function requestAnimationFrame(cb){ rafQueue.push(cb); return rafQueue.length; }
function flushRaf(){ const q = rafQueue; rafQueue = []; for (const cb of q) cb(); }
let _messageVirtualScrollRaf = 0;
let _messageVirtualWindowKey = '';
let _messageVirtualMeasurementCycleKey = '';
let _messageVirtualMeasurementSeenKeys = [];
let _messageVirtualMeasurementBurstActive = false;
let _messageVirtualRenderQueuedOrigin = null;
let _messageVirtualScrollActive = false;
let _messageVirtualDeferredMeasurement = null;
let _scrollbarDragActive = false;
let _msgNodeRecycleEnabled = false;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
function _deferClearProgrammaticScroll(){}
function $(id){ return {}; }
function _getVisibleMessagesWithIdx(){ return []; }
function _messageVirtualKeepTailCount(){ return 0; }
function _currentMessageVirtualWindow(){
return { virtualized:true, start:0, end:10, topPad:0, bottomPad:0, tailStart:10, total:20 };
}
function _messageVirtualMeasurementCycleKeyFor(w){ return (w && w.key) || ''; }
function _compensateScrollForMeasurementDelta(fn){ return fn(); }
// Simulated measure pass: after each render, the plan yields the next measured
// window cycle key; undefined means the measurements settled (unchanged).
let measurePlan = [];
let measureIdx = 0;
function renderMessages(options){
const internal = !!(options && options._internalMeasurement);
renderLog.push({ internal: internal, preserveScroll: !!(options && options.preserveScroll) });
// Contract of production renderMessages(): every UNMARKED (external) render
// trigger starts a fresh measurement burst, overriding any pending internal
// callback.
if(!internal) _resetMessageVirtualMeasurementBurst();
const next = measurePlan[measureIdx++];
if(next === undefined){
_markMessageVirtualMeasurementsSettled({ key: 'settled' });
}else{
_scheduleMessageVirtualMeasurementRefresh({ key: next });
}
}
// Production functions under test.
eval(extractFunc('_resetMessageVirtualMeasurementBurst'));
eval(extractFunc('_messageVirtualWindowKeyFor'));
eval(extractFunc('_scheduleMessageVirtualMeasurementRefresh'));
eval(extractFunc('_scheduleMessageVirtualizedRender'));
eval(extractFunc('_markMessageVirtualMeasurementsSettled'));
""" + scenario_js
def test_oscillating_cycle_keys_terminate_burst_with_queued_raf():
"""#6654/#6717 re-gate (c): a repeated oscillating window key (A->B->A->B)
must terminate the measurement burst even though every key was unseen on
first sight — the burst tracks SEEN keys, so geometry that flaps between
two states can never renew the chain and keep the rAF/measure loop alive
(~30% WebContent CPU, issue #6654). The queued-rAF harness drives the
oscillation through the real two-rAF-layer chain."""
source = _measurement_burst_harness("""
measurePlan = ['B','A','B','A','B','A'];
renderMessages({}); // external trigger: measures B -> burst starts
flushRaf(); flushRaf(); // layer-1 then layer-2 rAF -> internal render (A)
flushRaf(); flushRaf(); // next cycle -> internal render (B repeats) -> burst ends
flushRaf(); flushRaf(); // nothing left queued
console.log(JSON.stringify({
renders: renderLog.length,
internal: renderLog.filter(r => r.internal).length,
burstActive: _messageVirtualMeasurementBurstActive,
seenKeys: _messageVirtualMeasurementSeenKeys,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
}));
""")
metrics = json.loads(_run_node(source))
assert metrics["internal"] == 2, (
"A->B->A->B oscillation must stay bounded: a repeated key ends the burst. "
f"scheduled {metrics['internal']} internal re-renders (expected 2, then termination)"
)
assert metrics["renders"] == 3, (
"oscillation must not keep scheduling renders: "
f"{metrics['renders']} renders (expected 3 = 1 external + 2 internal)"
)
assert metrics["burstActive"] is False, (
"the burst must end once the repeated key is detected so the next "
f"externally initiated cycle starts fresh: burstActive {metrics['burstActive']}"
)
assert metrics["seenKeys"] == [], (
"termination must clear the seen-key memory: "
f"seenKeys {metrics['seenKeys']}"
)
assert metrics["queuedOrigin"] is None, (
"no internal render may remain queued after termination: "
f"queuedOrigin {metrics['queuedOrigin']}"
)
def test_consecutive_same_key_row_shrink_converges_to_settled():
"""#6717 re-gate: consecutive same-key passes (e.g. A->A->B->settled from
a row shrinking across two measurement passes with unchanged window bounds)
must NOT be cut short as oscillation. Only non-consecutive repeats (A->B->A)
represent cyclic oscillation; consecutive same-key passes continue toward
convergence while remaining bounded by the absolute cap."""
source = _measurement_burst_harness("""
measurePlan = ['A', 'A', 'B']; // R0 measures A, R1 internal measures A (consecutive), R2 internal measures B, then settled
renderMessages({}); // external trigger: measures A -> burst starts
flushRaf(); flushRaf(); // L1 -> L2: internal render measures A again (consecutive same key) -> must proceed!
flushRaf(); flushRaf(); // L1 -> L2: internal render measures B -> proceeds!
flushRaf(); flushRaf(); // L1 -> L2: internal render measures settled
console.log(JSON.stringify({
renders: renderLog.length,
internal: renderLog.filter(r => r.internal).length,
burstActive: _messageVirtualMeasurementBurstActive,
seenKeys: _messageVirtualMeasurementSeenKeys,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
}));
""")
metrics = json.loads(_run_node(source))
assert metrics["internal"] == 3, (
"consecutive same-key pass (row shrink) must NOT be cut short as oscillation: "
f"got {metrics['internal']} internal renders (expected 3)"
)
assert metrics["renders"] == 4, (
f"burst must converge to settled: {metrics['renders']} renders (expected 4 = 1 external + 3 internal)"
)
assert metrics["burstActive"] is False, (
f"burst must settle: burstActive {metrics['burstActive']}"
)
assert metrics["seenKeys"] == [], (
f"settlement must clear seen-key memory: seenKeys {metrics['seenKeys']}"
)
def test_genuine_convergence_exceeding_two_renders_completes():
"""#6717 re-gate (b): a legitimate forward convergence A->B->C->settled
(content reflow, fonts/images loading late, dynamic height) must be allowed
MORE than two measurement passes. The old flat two-render burst cap left
the geometry mis-measured (wrong row heights / clipped content); cycle-aware
tracking lets every UNSEEN key proceed until the measurements settle."""
source = _measurement_burst_harness("""
measurePlan = ['B','C','D']; // three distinct forward keys, then settled
renderMessages({}); // external trigger: measures B
flushRaf(); flushRaf(); // internal render: measures C
flushRaf(); flushRaf(); // internal render: measures D
flushRaf(); flushRaf(); // internal render: measurements settle
console.log(JSON.stringify({
renders: renderLog.length,
internal: renderLog.filter(r => r.internal).length,
lastInternal: renderLog[renderLog.length - 1].internal,
burstActive: _messageVirtualMeasurementBurstActive,
seenKeys: _messageVirtualMeasurementSeenKeys,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
}));
""")
metrics = json.loads(_run_node(source))
assert metrics["internal"] == 3, (
"genuine A->B->C->settled convergence must complete with MORE than the "
"old two-render cap: "
f"{metrics['internal']} internal re-renders (expected 3)"
)
assert metrics["renders"] == 4, (
"the convergence chain must run to completion: "
f"{metrics['renders']} renders (expected 4 = 1 external + 3 internal)"
)
assert metrics["lastInternal"] is True, (
"the final render of the convergence chain must still be part of the "
"internal measurement chain (settled via _markMessageVirtualMeasurementsSettled)"
)
assert metrics["burstActive"] is False, (
"settlement must end the burst: "
f"burstActive {metrics['burstActive']}"
)
assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, (
"settlement must clear the seen-key memory and any queued provenance: "
f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}"
)
def test_overlapping_external_append_receives_fresh_budget():
"""#6717 re-gate (a): an externally initiated render (message append,
scroll-settle, genuine content change) that lands while an internal
measurement callback is still PENDING in the rAF queue must reset the burst
— external provenance overrides a pending internal callback — so the new
cycle receives a fresh budget instead of being starved by the stale burst's
seen-key memory (0 retries).
Phase 1: the append lands between the two rAF layers and measures key B
(already seen in the stale burst). Only the external reset lets B proceed;
without it the repeated key would terminate the append's cycle immediately.
The stale layer-2 callback (queued for the DEAD burst) must then degrade to
an UNMARKED render — the append's own measurement must not revive an
internal marker onto it, because the provenance belongs to the queued
render, not to a global flag (#6717 re-gate MUST-FIX 2) — and the fresh
cycle it starts completes with its own internal render, so the append is
never starved of retries.
Phase 2: an external render that measures SETTLED lands while a layer-2
callback is pending; the stale callback must then degrade to an UNMARKED
render (the external reset consumed its provenance), and the fresh cycle it
starts must complete with its own internal render."""
source = _measurement_burst_harness("""
measurePlan = ['B','B','C', undefined, 'E', undefined, 'F', undefined];
renderMessages({}); // R0 external: burst starts, seen=[B], L1 queued
flushRaf(); // L1 fires -> L2 (internal callback) now pending
renderMessages({}); // R1 EXTERNAL APPEND lands before L2 fires:
// resets the burst -> fresh cycle, seen=[B] again
flushRaf(); flushRaf(); // stale L2 fires UNMARKED (R2, external won),
// fresh cycle completes (R3 internal) -> settles
renderMessages({}); // R4 external: new cycle, L1 queued
flushRaf(); // L1 fires -> L2 (internal callback) now pending
renderMessages({}); // R5 external scroll-settle: resets the burst,
// measures SETTLED (no new schedule)
flushRaf(); // stale L2 fires -> marker consumed as FALSE ->
// R6 UNMARKED render (external provenance won)
flushRaf(); flushRaf(); // R6's fresh cycle completes (R7 internal) -> settles
console.log(JSON.stringify({
renders: renderLog.length,
internal: renderLog.filter(r => r.internal).length,
appendExternal: renderLog[1].internal,
staleL2Unmarked: renderLog[2].internal,
postAppendCycleInternal: renderLog[3].internal,
staleCallbackUnmarked: renderLog[6].internal,
postResetCycleInternal: renderLog[7].internal,
burstActive: _messageVirtualMeasurementBurstActive,
seenKeys: _messageVirtualMeasurementSeenKeys,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
}));
""")
metrics = json.loads(_run_node(source))
assert metrics["renders"] == 8, (
"the overlapping append must NOT starve the new cycle: the chain must "
"continue past the append to completion "
f"({metrics['renders']} renders; expected 8)"
)
assert metrics["appendExternal"] is False, (
"the append is an unmarked external render"
)
assert metrics["staleL2Unmarked"] is False, (
"the stale layer-2 callback (queued for the burst the append reset) "
"must degrade to an UNMARKED render: the append's own measurement "
"must not revive an internal marker onto it — provenance belongs to "
"the queued render, external wins (#6717 re-gate MUST-FIX 2)"
)
assert metrics["postAppendCycleInternal"] is True, (
"the fresh cycle after the degraded stale callback must complete with "
"its own internal render (fresh budget — the repeated key B was not "
"treated as oscillation because the external render reset the burst)"
)
assert metrics["staleCallbackUnmarked"] is False, (
"external provenance must override the pending internal callback: the "
"stale internal rAF callback must degrade to an unmarked render after "
"the external render reset the burst"
)
assert metrics["postResetCycleInternal"] is True, (
"the cycle started by the degraded stale callback must complete with "
"its own internal render (retries were not starved)"
)
assert metrics["burstActive"] is False, (
"the fresh cycles must end settled: "
f"burstActive {metrics['burstActive']}"
)
assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, (
"settlement must clear the seen-key memory and any queued provenance: "
f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}"
)
def test_all_distinct_keys_terminate_burst_with_bounded_storage():
"""#6717 re-gate MUST-FIX 1: an ALL-DISTINCT monotonic key sequence
(A->B->C->D->... never repeating) must still terminate. The seen-key rule
alone only ends the burst on a REPEATED key, so a browser emitting a
never-repeating geometry would loop without limit — the #6654 CPU-runaway
class under a different trigger — while _messageVirtualMeasurementSeenKeys
grows without bound (memory). The absolute per-burst cap ends the burst
regardless of key novelty and bounds the seen-key collection to the cap.
Driven through the real two-rAF-layer chain with MORE distinct keys than
the cap, and asserting the burst stops at exactly the cap with no rAF left
queued and the seen-key memory cleared."""
source = _measurement_burst_harness("""
measurePlan = [];
for(let i=0;i<40;i++) measurePlan.push('K'+i); // 40 distinct keys, well over the cap
renderMessages({}); // external trigger: starts the burst
for(let i=0;i<40;i++) flushRaf(); // drain both rAF layers repeatedly
console.log(JSON.stringify({
renders: renderLog.length,
internal: renderLog.filter(r => r.internal).length,
burstActive: _messageVirtualMeasurementBurstActive,
seenKeys: _messageVirtualMeasurementSeenKeys,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
rafPending: rafQueue.length,
cap: MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS,
}));
""")
metrics = json.loads(_run_node(source))
cap = metrics["cap"]
assert metrics["internal"] == cap, (
"an all-distinct sequence must be cut off by the absolute per-burst "
f"cap: {metrics['internal']} internal renders (expected exactly {cap})"
)
assert metrics["renders"] == cap + 1, (
"the all-distinct burst must terminate after the cap (1 external + cap "
f"internal renders): {metrics['renders']} renders (expected {cap + 1})"
)
assert metrics["rafPending"] == 0, (
"no rAF may remain queued after the cap ends the burst: "
f"rafPending {metrics['rafPending']}"
)
assert metrics["burstActive"] is False, (
"the cap must end the burst: "
f"burstActive {metrics['burstActive']}"
)
assert metrics["seenKeys"] == [], (
"termination must clear the seen-key memory (bounded storage — at most "
f"cap keys were ever retained): seenKeys {metrics['seenKeys']}"
)
assert metrics["queuedOrigin"] is None, (
"no internal provenance may remain queued after the cap ends the burst: "
f"queuedOrigin {metrics['queuedOrigin']}"
)
def test_queued_raf_external_scroll_wins_provenance():
"""#6717 re-gate MUST-FIX 2: when an external render (user scroll /
scroll-settle) overlaps a QUEUED internal rAF, the render that actually
fires must be treated as EXTERNAL. The old global consumable flag
(_messageVirtualMeasurementRenderPending) could be consumed by an
unrelated render, marking external renders as internal so the burst was
never reset — leaving stale virtual-window / spacer geometry until the
next unrelated render.
Sequence: external R0 measures A (L2 queued internal); an external R1
lands while L2 is pending — it resets the burst, measures B, and its own
layer-1 rAF COALESCES into the pending L2 (the early-return path where
the old flag leaked). The L2 that fires must be UNMARKED — external
provenance wins — so the A it measures starts a fresh burst instead of
terminating on the repeated key with zero retry queued."""
source = _measurement_burst_harness("""
measurePlan = ['A','B','A', undefined];
renderMessages({}); // R0 external: measures A -> burst, L1 queued
flushRaf(); // L1 -> L2 queued with internal provenance
renderMessages({}); // R1 EXTERNAL scroll lands while L2 pending:
// resets the burst, measures B -> L1 queued
flushRaf(); // B's L1 coalesces into the pending L2 (no new rAF)
flushRaf(); // L2 fires -> must be UNMARKED (external won),
// measures A -> fresh burst -> L1 queued
flushRaf(); flushRaf(); // A's fresh cycle: L2 internal render -> settles
console.log(JSON.stringify({
renders: renderLog.length,
internal: renderLog.filter(r => r.internal).length,
r0External: renderLog[0].internal,
r1External: renderLog[1].internal,
coalescedL2Unmarked: renderLog[2].internal,
freshCycleInternal: renderLog[3].internal,
burstActive: _messageVirtualMeasurementBurstActive,
seenKeys: _messageVirtualMeasurementSeenKeys,
queuedOrigin: _messageVirtualRenderQueuedOrigin,
}));
""")
metrics = json.loads(_run_node(source))
assert metrics["renders"] == 4, (
"the scroll overlap chain must run to completion: "
f"{metrics['renders']} renders (expected 4)"
)
assert metrics["r0External"] is False and metrics["r1External"] is False, (
"R0 and R1 are unmarked external renders"
)
assert metrics["coalescedL2Unmarked"] is False, (
"the queued internal rAF must degrade to an UNMARKED render when an "
"external request coalesced into it — external provenance wins and an "
"internal marker must never survive onto an external render"
)
assert metrics["freshCycleInternal"] is True, (
"the fresh cycle started by the degraded render must complete with its "
"own internal render: the repeated A key must NOT terminate it because "
"the burst was reset (zero retry queued would leave stale geometry)"
)
assert metrics["burstActive"] is False, (
"the fresh cycle must end settled: "
f"burstActive {metrics['burstActive']}"
)
assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, (
"settlement must clear the seen-key memory and any queued provenance: "
f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}"
)
@@ -423,6 +423,72 @@ def test_auth_retry_success_does_not_append_error_turn(tmp_path, monkeypatch):
assert not any(msg.get("_error") for msg in saved.messages)
def test_auth_retry_success_observes_replacement_agent_runtime(tmp_path, monkeypatch):
from api import run_journal
session = _prepare_session("auth_runtime", "stream_auth_runtime", pending_user_message="Please retry")
Base = _build_auth_failure_agent(token_text="")
class ReplacementAgent(Base):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.model = "original" if self.__class__.runs == 0 else "healed-model"
self.provider = "old-provider" if self.__class__.runs == 0 else "healed-provider"
monkeypatch.setattr(run_journal, "_default_session_dir", lambda: tmp_path / "sessions")
q = queue.Queue()
streaming.STREAMS["stream_auth_runtime"] = q
config.STREAM_PARTIAL_TEXT["stream_auth_runtime"] = ""
heal_rt = {"provider": "test-provider", "api_key": "fresh-key", "base_url": None}
with mock.patch.object(streaming, "get_session", return_value=session), \
mock.patch.object(streaming, "_get_ai_agent", return_value=ReplacementAgent), \
mock.patch.object(streaming, "resolve_model_provider", return_value=("test-model", "test-provider", None)), \
mock.patch("api.config.get_config", return_value={}), \
mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \
mock.patch.object(streaming, "_attempt_credential_self_heal", return_value=heal_rt):
streaming._run_agent_streaming(session.session_id, "Please retry", "test-model", str(tmp_path), "stream_auth_runtime")
events = _queue_events(q)
assert any(name == "done" for name, _ in events)
assert [(item["model"], item["provider"]) for name, item in events if name == "runtime_model"] == [
("healed-model", "healed-provider")]
summary = run_journal.latest_run_summary("auth_runtime", "stream_auth_runtime", session_dir=tmp_path / "sessions")
assert summary["runtime_model"]["model"] == "healed-model"
def test_auth_exception_retry_observes_replacement_agent(tmp_path, monkeypatch):
session = _prepare_session("auth_exception_runtime", "stream_auth_exception_runtime", pending_user_message="Retry")
class RetryAgent(MockAgent):
runs = 0
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.model = "original" if type(self).runs == 0 else "retry-model"
self.provider = "original-provider" if type(self).runs == 0 else "retry-provider"
def run_conversation(self, **kwargs):
type(self).runs += 1
if type(self).runs == 1:
raise RuntimeError("401 unauthorized")
return {"messages": list(kwargs.get("conversation_history") or []) + [
{"role": "assistant", "content": "Recovered answer"}]}
q = queue.Queue()
streaming.STREAMS["stream_auth_exception_runtime"] = q
config.STREAM_PARTIAL_TEXT["stream_auth_exception_runtime"] = ""
heal_rt = {"provider": "test-provider", "api_key": "fresh-key", "base_url": None}
with mock.patch.object(streaming, "get_session", return_value=session), \
mock.patch.object(streaming, "_get_ai_agent", return_value=RetryAgent), \
mock.patch.object(streaming, "resolve_model_provider", return_value=("test-model", "test-provider", None)), \
mock.patch("api.config.get_config", return_value={}), \
mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \
mock.patch.object(streaming, "_attempt_credential_self_heal", return_value=heal_rt):
streaming._run_agent_streaming(session.session_id, "Retry", "test-model", str(tmp_path), "stream_auth_exception_runtime")
events = _queue_events(q)
assert any(name == "done" for name, _ in events)
assert [(item["model"], item["provider"]) for name, item in events if name == "runtime_model"] == [
("retry-model", "retry-provider")]
def test_auth_exception_retry_structured_failure_still_emits_error(
tmp_path,
monkeypatch,
@@ -109,7 +109,6 @@ function buildRuntime() {
globalThis.removeThinking = () => calls.push('removeThinking');
globalThis._flushReasoningToAnchor = () => calls.push('flushReasoning');
globalThis._applyToAnchor = () => calls.push('applyToAnchor');
globalThis._attachProjectedAnchorSceneToLastAssistant = () => calls.push('attachProjected');
globalThis._hydrateTodosFromSession = () => calls.push('hydrateTodos');
globalThis._scheduleAnchorRegistryCleanup = () => calls.push('scheduleAnchorRegistryCleanup');
globalThis._smdEndParser = () => calls.push('smdEndParser');
@@ -141,6 +140,15 @@ function buildRuntime() {
globalThis._messageRenderWindowSize = 20;
globalThis._streamFinalized = !!scenario.streamFinalized;
globalThis._persistTimer = null;
globalThis._oldestIdx = Number.isFinite(scenario.oldestIdx) ? scenario.oldestIdx : 0;
globalThis._messagesTruncated = !!scenario.messagesTruncated;
globalThis.oldestIdxAtAttach = null;
globalThis.messagesTruncatedAtAttach = null;
globalThis._attachProjectedAnchorSceneToLastAssistant = () => {
calls.push('attachProjected');
globalThis.oldestIdxAtAttach = globalThis._oldestIdx;
globalThis.messagesTruncatedAtAttach = globalThis._messagesTruncated;
};
globalThis.api = async () => scenario.apiPayload || { session: null };
globalThis.msgContent = undefined;
globalThis._isPreservedCompressionTaskListMarkerOnlyText = () => false;
@@ -162,6 +170,10 @@ function buildRuntime() {
status,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
terminalMarkerCount,
oldestIdx: globalThis._oldestIdx,
oldestIdxAtAttach: globalThis.oldestIdxAtAttach,
messagesTruncated: !!globalThis._messagesTruncated,
messagesTruncatedAtAttach: !!globalThis.messagesTruncatedAtAttach,
calls,
}));
return;
@@ -179,6 +191,10 @@ function buildRuntime() {
status,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
terminalMarkerCount,
oldestIdx: globalThis._oldestIdx,
oldestIdxAtAttach: globalThis.oldestIdxAtAttach,
messagesTruncated: !!globalThis._messagesTruncated,
messagesTruncatedAtAttach: !!globalThis.messagesTruncatedAtAttach,
calls,
}));
return;
@@ -421,3 +437,109 @@ def test_terminal_error_marker_is_single_instance_and_not_duplicated(driver_path
assert outcome["terminalMarkerCount"] == 1, f"terminal marker should be deduped to one, got {outcome['terminalMarkerCount']}"
assert outcome["messages"][-1]["content"].startswith("**Connection interrupted:**")
def test_bounded_tail_preserves_terminal_marker_when_server_returns_suffix(driver_path):
"""A bounded settle tail is a suffix of the visible window, not a prefix.
Long-session recovery used to compare the 30-row tail against the start of
S.messages. The prefix check failed, so preserveVisibleOnShorterTerminalSnapshot
dropped the client-side Connection-interrupted marker (#7628).
"""
historical = []
for i in range(2):
historical.append({"role": "user", "content": f"Earlier question {i}", "_ts": f"u{i}"})
historical.append({"role": "assistant", "content": f"Earlier answer {i}", "_ts": f"a{i}"})
tail = [
{"role": "user", "content": "Current question", "_ts": "u-cur"},
{"role": "assistant", "content": "Current fragment one", "_ts": "a-cur"},
]
terminal = {
"role": "assistant",
"content": "**Connection interrupted:** The browser lost the live SSE connection before the response finished.",
"_ts": "err",
}
outcome = _run_scenario(driver_path, {
"action": "restore_shorter_terminal",
"oldestIdx": 0,
"messagesTruncated": False,
"state": {
"session": {"session_id": "session-5224", "message_count": 7},
"messages": historical + tail + [terminal],
"activeStreamId": "stream-5224",
},
"apiPayload": {
"session": {
"session_id": "session-5224",
"active_stream_id": None,
"pending_user_message": None,
"messages": tail,
"_messages_truncated": True,
"_messages_offset": 4,
},
},
"activeSid": "session-5224",
"streamId": "stream-5224",
"isActiveSession": True,
"isSessionCurrentPane": True,
"isSessionActivelyViewed": False,
})
observed = [(item["role"], item["content"]) for item in outcome["messages"]]
assert observed == [
("user", "Current question"),
("assistant", "Current fragment one"),
("assistant", "**Connection interrupted:** The browser lost the live SSE connection before the response finished."),
], f"bounded tail must keep the terminal recovery suffix: {observed}"
assert outcome["terminalMarkerCount"] == 1, (
f"expected the terminal marker to survive a suffix-only tail, got {outcome['terminalMarkerCount']}"
)
assert outcome["oldestIdx"] == 4, f"paging offset should come from the bounded response, got {outcome['oldestIdx']}"
assert outcome["oldestIdxAtAttach"] == 4, (
f"anchor persist must see the new offset, got {outcome['oldestIdxAtAttach']}"
)
assert outcome["messagesTruncated"] is True
assert outcome["messagesTruncatedAtAttach"] is True
def test_settle_refreshes_paging_offset_before_anchor_persist(driver_path):
"""_persistSettledAnchorScene reads _oldestIdx; a stale 0 would write the scene to the wrong row."""
outcome = _run_scenario(driver_path, {
"action": "restore_fuller_terminal",
"oldestIdx": 0,
"messagesTruncated": False,
"state": {
"session": {"session_id": "session-5224", "message_count": 1},
"messages": [
{"role": "assistant", "content": "Old interrupted shell", "_ts": "old"},
],
"activeStreamId": "stream-5224",
},
"apiPayload": {
"session": {
"session_id": "session-5224",
"active_stream_id": None,
"pending_user_message": None,
"messages": [
{"role": "user", "content": "Question about data?", "_ts": "u1"},
{"role": "assistant", "content": "Settled first segment", "_ts": "a1"},
{"role": "assistant", "content": "Settled second segment", "_ts": "a2"},
{"role": "assistant", "content": "Settled third segment", "_ts": "a3"},
],
"_messages_truncated": True,
"_messages_offset": 40,
},
},
"activeSid": "session-5224",
"streamId": "stream-5224",
"isActiveSession": True,
"isSessionCurrentPane": True,
"isSessionActivelyViewed": False,
})
assert outcome["status"] == "restored"
assert outcome["oldestIdx"] == 40
assert outcome["oldestIdxAtAttach"] == 40, (
f"stale offset 0 must not leak into anchor persist, got {outcome['oldestIdxAtAttach']}"
)
assert outcome["messagesTruncatedAtAttach"] is True
+1 -1
View File
@@ -161,7 +161,7 @@ class TestMcpList:
monkeypatch.setenv('HERMES_CONFIG_PATH', str(override_path))
monkeypatch.setattr(profiles, 'get_active_hermes_home', lambda: active_home)
monkeypatch.setattr(routes, 'get_active_hermes_home', lambda: active_home)
monkeypatch.setattr(routes, '_mcp_runtime_status_by_name', lambda: {})
monkeypatch.setattr(routes, '_mcp_runtime_status_by_name', lambda *_args, **_kwargs: {})
config.reload_config()
h = _make_handler()
@@ -34,15 +34,25 @@ def test_model_picker_renders_selected_badge_without_replacing_configured_badge(
def test_selected_badge_is_keyed_to_current_model_value():
assert "String((m&&m.value)||'')===String((_selectedModelState&&_selectedModelState.model)||(sel&&sel.value)||'')" in UI_JS
# The selected badge must key on the resolved model identity, not a raw
# string of the dropdown value: catalog rows carry the qualified
# @custom:<slug>:<model> value while the outgoing state model is bare
# (#6884), so both sides are normalized via _modelPickerOptionIdentity
# before comparison.
assert "const _norm=(model,provider)=>typeof _modelPickerOptionIdentity==='function'" in UI_JS
assert "_norm(_rowModel,_rowProvider)===_norm(_stateModel,_stateProvider)" in UI_JS
def test_selected_badge_is_keyed_to_current_model_provider():
assert "const _selectedModelState=(typeof _modelStateForSelect==='function')?_modelStateForSelect(sel,sel.value)" in UI_JS
assert "const _modelProviderForSelectedBadge=(m)=>" in UI_JS
assert "return (_provider&&_provider!=='default')?_provider:null;" in UI_JS
assert "String(_modelProviderForSelectedBadge(m)||'')===String((_selectedModelState&&_selectedModelState.model_provider)||'')" in UI_JS
assert "const _isSelectedModelRow=(m)=>" in UI_JS
# Provider identity still gates the row match — the normalized model
# comparison alone would collapse same-id rows across provider groups.
assert "_rowProvider=String(_modelProviderForSelectedBadge(m)||'')" in UI_JS
assert "_stateProvider=String((_selectedModelState&&_selectedModelState.model_provider)||'')" in UI_JS
assert "_rowProvider===_stateProvider" in UI_JS
assert "const _isSelectedModelRow=(m)=>{" in UI_JS
assert "row.className='model-opt'+(_isSelectedModelRow(m)?' active':'');" in UI_JS
+5 -4
View File
@@ -140,10 +140,11 @@ console.log(JSON.stringify({{
def test_streaming_stamps_used_model_on_assistant_message_and_usage_payload():
assert "_dm['_usedModel'] = _used_model" in STREAMING_PY
# The served model must be read from the agent AFTER the run — the agent
# mutates agent.model when a fallback fires, so the pre-run resolved_model
# would mis-attribute fallback turns.
assert "_used_model = getattr(agent, 'model', None) or resolved_model or model" in STREAMING_PY
# Read the served model from the agent after the run (including fallback),
# without treating a requested/configured selection as proof of service.
assert "_observed_model = getattr(agent, 'model', None)" in STREAMING_PY
assert "_used_model = _observed_model.strip() if isinstance(_observed_model, str) else None" in STREAMING_PY
assert "_used_model = getattr(agent, 'model', None) or resolved_model or model" not in STREAMING_PY
assert "usage['used_model'] = _used_model" in STREAMING_PY
File diff suppressed because it is too large. Load diff
+50 -3
View File
@@ -52,10 +52,57 @@ def test_virtualization_affordances_have_styling_hooks():
assert "border-radius:999px" in CSS
def test_measurement_rerenders_are_bounded_per_virtual_window_cycle():
assert "const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=2;" in UI_JS
def test_measurement_rerenders_are_cycle_aware_per_virtual_window_burst():
assert "function _messageVirtualMeasurementCycleKeyFor(windowMetrics)" in UI_JS
assert "function _scheduleMessageVirtualMeasurementRefresh(windowMetrics)" in UI_JS
assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;" in UI_JS
assert "_scheduleMessageVirtualMeasurementRefresh(virtualWindow);" in UI_JS
assert "_markMessageVirtualMeasurementsSettled(virtualWindow);" in UI_JS
# Cycle-aware burst tracking: unseen keys proceed, repeated keys terminate,
# and the seen-key memory lives in _messageVirtualMeasurementSeenKeys.
assert "_messageVirtualMeasurementSeenKeys.includes(cycleKey)" in UI_JS
assert "_messageVirtualMeasurementSeenKeys.push(cycleKey)" in UI_JS
assert "function _resetMessageVirtualMeasurementBurst()" in UI_JS
def test_measurement_burst_not_reset_by_cycle_key_change():
"""#6654/#6717: the cycle-key branch of _scheduleMessageVirtualMeasurementRefresh
must record the new key but never reset the burst state. Resetting on key
change lets WebKit's A->B->A->B metric oscillation renew the chain forever,
keeping the rAF/measure loop alive. The ONLY resets allowed inside the
scheduler are: the repeated-key termination (oscillation) and the fresh-burst
start (when no burst is active), guarded by if(!_messageVirtualMeasurementBurstActive)."""
idx = UI_JS.index("function _scheduleMessageVirtualMeasurementRefresh(windowMetrics)")
end = UI_JS.index("function _markMessageVirtualMeasurementsSettled", idx)
body = UI_JS[idx:end]
# The key-change branch must only assign the key.
assert "if(_messageVirtualMeasurementCycleKey!==cycleKey){" in body
key_branch_start = body.index("if(_messageVirtualMeasurementCycleKey!==cycleKey){")
key_branch_end = body.index("}", key_branch_start)
key_branch = body[key_branch_start:key_branch_end]
assert "_messageVirtualMeasurementSeenKeys" not in key_branch, (
"cycle-key change must not reset the burst's seen-key memory (issue #6654)"
)
assert "_messageVirtualMeasurementBurstActive=false" not in key_branch, (
"cycle-key change must not end the burst (issue #6654)"
)
# The fresh-burst reset is allowed only at the start of a new external cycle.
assert "if(!_messageVirtualMeasurementBurstActive){" in body
guard_pos = body.index("if(!_messageVirtualMeasurementBurstActive){")
seen_reset_pos = body.index("_messageVirtualMeasurementSeenKeys=[];")
assert guard_pos < seen_reset_pos, (
"the seen-key reset must be guarded by the burst-active check "
"(per-burst lifecycle, issue #6717)"
)
# A NON-consecutive repeat (A -> B -> A) is oscillation and terminates the
# burst. A consecutive repeat (same window needing a second re-measure, e.g.
# a row shrinking 1500 -> 1400 -> 100 px) proceeds; unseen keys proceed.
assert "if(cycleKey !== lastKey && _messageVirtualMeasurementSeenKeys.includes(cycleKey)){" in body
assert "_messageVirtualMeasurementSeenKeys.push(cycleKey);" in body
# The internal-measurement origin travels WITH the scheduled render request
# (threaded through both rAF layers), never as a global consumable flag that
# a coalesced external render could steal (#6717 re-gate MUST-FIX 2).
assert "origin:'internal'" in body
assert "_messageVirtualMeasurementRenderPending" not in UI_JS
# Absolute per-burst ceiling: even an all-distinct key sequence terminates,
# bounding the seen-key memory (#6717 re-gate MUST-FIX 1).
assert "MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS" in UI_JS
@@ -0,0 +1,97 @@
"""Regression test for issue #7548:
CLI-archived cron/webhook/kanban sessions reappear from state.db projection (no sidecar case).
"""
import sqlite3
from pathlib import Path
from api.agent_sessions import read_importable_agent_session_rows
from api.models import get_cli_sessions, _state_projection_sidecar_metadata
def _create_state_db(path: Path, rows: list[tuple]):
conn = sqlite3.connect(str(path))
conn.execute(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT,
message_count INTEGER,
started_at REAL,
source TEXT,
session_source TEXT,
archived INTEGER DEFAULT 0
)
"""
)
conn.execute(
"""
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
session_id TEXT,
role TEXT,
timestamp REAL
)
"""
)
for r in rows:
conn.execute(
"INSERT INTO sessions (id, title, model, message_count, started_at, source, session_source, archived) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
r,
)
# insert a dummy user message so actual_message_count > 0
conn.execute(
"INSERT INTO messages (session_id, role, timestamp) VALUES (?, 'user', ?)",
(r[0], r[4]),
)
conn.commit()
conn.close()
def test_cli_archived_session_without_sidecar_retains_archived_state(tmp_path, monkeypatch):
"""When a session is marked archived in state.db and has no WebUI sidecar,
the projection must preserve archived=True and not reset it to False."""
db_path = tmp_path / "state.db"
session_rows = [
("cron-active-1", "Cron Active", "model", 1, 1000.0, "cron", "cron", 0),
("cron-archived-1", "Cron Archived", "model", 1, 1000.0, "cron", "cron", 1),
]
_create_state_db(db_path, session_rows)
# Test read_importable_agent_session_rows directly
projected = read_importable_agent_session_rows(
db_path,
limit=200,
exclude_sources=None,
include_sources=("cron",),
)
by_id = {r["id"]: r for r in projected}
assert "cron-active-1" in by_id
assert "cron-archived-1" in by_id
assert bool(by_id["cron-active-1"].get("archived")) is False
assert bool(by_id["cron-archived-1"].get("archived")) is True
# Test _state_projection_sidecar_metadata returns archived: None when no sidecar exists
meta = _state_projection_sidecar_metadata("cron-archived-1")
assert meta.get("archived") is None
# Test get_cli_sessions() projection
from api.models import clear_cli_sessions_cache
clear_cli_sessions_cache()
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
try:
import api.profiles # noqa: F401 # makes the monkeypatch target below importable
monkeypatch.setattr("api.profiles.get_active_hermes_home", lambda: str(tmp_path))
except Exception:
pass
monkeypatch.setattr("api.models._active_state_db_path", lambda: db_path)
monkeypatch.setattr("api.models.SESSION_DIR", tmp_path / "webui_sessions")
(tmp_path / "webui_sessions").mkdir(parents=True, exist_ok=True)
cli_sessions = get_cli_sessions(source_filter="cron")
cli_by_id = {s["session_id"]: s for s in cli_sessions}
assert "cron-active-1" in cli_by_id
assert "cron-archived-1" in cli_by_id
assert cli_by_id["cron-active-1"]["archived"] is False
assert cli_by_id["cron-archived-1"]["archived"] is True
@@ -0,0 +1,185 @@
"""Settled writeback must extract inner steer text from typed steer rows (#7600).
A mid-turn ``/steer`` is emitted as a standalone typed user row
(``role: 'user'``, ``display_kind: 'steer'``). The surrounding
``[OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]`` block is
transport-level control data. Settlement unwraps the single validated frame
in place, preserving the user's authored steer instruction while dropping
the wrapper.
Legacy tool rows and rows where markers are multiple, nested, incomplete,
or contain ambiguous delimiters are preserved byte-for-byte.
"""
from __future__ import annotations
import copy
import json
from api.models import Session
from api.streaming import _settle_result_messages, _unwrap_steer_row_oob_marker
from api import streaming as _streaming
PROMPT = "please run the smoke checks"
OOB_OPEN = (
"[OUT-OF-BAND USER MESSAGE — a direct message from the user, delivered once "
"at this position; not tool output and not a new delivery when replayed from "
"conversation history]"
)
OOB_CLOSE = "[/OUT-OF-BAND USER MESSAGE]"
OOB_BLOCK = f"{OOB_OPEN}\nsteer: use the staging bucket this time\n{OOB_CLOSE}"
OOB_VARIANT = f"[OUT-OF-BAND USER MESSAGE]steer: also bump the timeout{OOB_CLOSE}"
def _contains_oob(value) -> bool:
return "OUT-OF-BAND USER MESSAGE" in json.dumps(value)
def _session_with_prior_turn() -> Session:
"""Prior turn already settled: user -> assistant(tool_call) -> tool -> answer."""
display = [
{"role": "user", "content": "deploy the app", "timestamp": 1788439000},
{
"role": "assistant",
"content": "",
"tool_calls": [{"id": "call-1"}],
"timestamp": 1788439005,
},
{
"role": "tool",
"tool_call_id": "call-1",
"content": "deploy finished: revision 41",
"timestamp": 1788439010,
},
{"role": "assistant", "content": "Deploy done (revision 41).", "timestamp": 1788439015},
]
session = Session(session_id="7" * 12, title="steer writeback", messages=copy.deepcopy(display))
session.context_messages = copy.deepcopy(display)
return session
def _settle_turn_with_steer(session, monkeypatch, *, tool_content: str = "checks passed", steer_rows=None):
"""Settle one turn with an optional steer row emitted by the Agent."""
monkeypatch.setattr(
_streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None
)
previous = list(session.messages)
previous_context = list(session.context_messages)
ts = 1788440000
steers = list(steer_rows or [])
result = copy.deepcopy(previous_context) + [
{"role": "user", "content": PROMPT, "timestamp": ts},
{
"role": "assistant",
"content": "",
"tool_calls": [{"id": "call-2"}],
"timestamp": ts + 1,
},
{
"role": "tool",
"tool_call_id": "call-2",
"content": tool_content,
"timestamp": ts + 2,
},
*steers,
{"role": "assistant", "content": "Smoke checks passed.", "timestamp": ts + 3},
]
_settle_result_messages(session, previous, previous_context, result, PROMPT, "webui", None)
return previous
def test_settle_unwraps_steer_row_in_place_preserving_user_text(monkeypatch):
"""A typed steer row is unwrapped in place: inner text preserved, wrapper gone."""
session = _session_with_prior_turn()
steer_row = {
"role": "user",
"display_kind": "steer",
"content": OOB_BLOCK,
"timestamp": 1788440002.5,
}
_settle_turn_with_steer(session, monkeypatch, steer_rows=[steer_row])
# The outer wrapper is dropped from display and context
assert not _contains_oob(session.messages)
assert not _contains_oob(session.context_messages)
# The user's inner steer text is preserved exactly
steer_contents = [
m.get("content")
for m in session.messages
if isinstance(m, dict) and m.get("display_kind") == "steer"
]
assert steer_contents == ["steer: use the staging bucket this time"]
# In-place mutation preserved the dictionary object identity
assert steer_row["content"] == "steer: use the staging bucket this time"
def test_settle_unwraps_list_based_content_steer_row(monkeypatch):
"""List-based content parts on a steer row are unwrapped too."""
session = _session_with_prior_turn()
steer_row = {
"role": "user",
"display_kind": "steer",
"content": [{"type": "text", "text": OOB_VARIANT}],
"timestamp": 1788440002.5,
}
_settle_turn_with_steer(session, monkeypatch, steer_rows=[steer_row])
assert not _contains_oob(session.messages)
assert not _contains_oob(session.context_messages)
assert steer_row["content"] == [{"type": "text", "text": "steer: also bump the timeout"}]
def test_settle_leaves_legacy_tool_rows_alone(monkeypatch):
"""Legacy tool rows carrying raw markers are left alone (not truncated/deleted)."""
session = _session_with_prior_turn()
tool_text = f"checks passed\n\n{OOB_BLOCK}"
_settle_turn_with_steer(session, monkeypatch, tool_content=tool_text)
tool_bodies = [
m.get("content") for m in session.messages if isinstance(m, dict) and m.get("role") == "tool"
]
assert tool_text in tool_bodies
def test_settle_preserves_malformed_nested_and_multiple_blocks_byte_for_byte():
"""Nested markers, multiple frames, and incomplete markers degrade gracefully."""
nested = f"{OOB_OPEN}\nhello {OOB_VARIANT} world\n{OOB_CLOSE}"
multiple = f"{OOB_VARIANT} and {OOB_BLOCK}"
incomplete = "[OUT-OF-BAND USER MESSAGE — truncated"
for malformed in (nested, multiple, incomplete):
steer_row = {
"role": "user",
"display_kind": "steer",
"content": malformed,
"timestamp": 1788440002.5,
}
_unwrap_steer_row_oob_marker(steer_row)
assert steer_row["content"] == malformed, f"malformed content was corrupted: {malformed!r}"
def test_settle_keeps_normal_user_pasted_marker_intact(monkeypatch):
"""A normal user row that quotes a complete marker survives byte-for-byte."""
pasted = (
"our bot log shows this block, is that normal?\n"
f"{OOB_BLOCK}\n"
"the docs say the gateway adds it"
)
session = _session_with_prior_turn()
session.messages[0]["content"] = pasted
session.context_messages = copy.deepcopy(session.messages)
steer_row = {
"role": "user",
"display_kind": "steer",
"content": OOB_BLOCK,
"timestamp": 1788440002.5,
}
_settle_turn_with_steer(session, monkeypatch, steer_rows=[steer_row])
# The normal user row still carries its quoted block intact
assert session.messages[0]["content"] == pasted
# The steer row had its wrapper unwrapped
assert session.messages[-2]["content"] == "steer: use the staging bucket this time"
@@ -816,20 +816,26 @@ def test_stream_end_restore_attaches_projected_anchor_scene_before_render():
filter_idx = restore.index("S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);")
attach_idx = restore.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);")
render_idx = restore.index("syncTopbar();renderMessages({preserveScroll:true})")
assert carry_idx < filter_idx < attach_idx < render_idx
offset_idx = restore.index("_oldestIdx=session._messages_offset||0")
assert offset_idx < carry_idx < filter_idx < attach_idx < render_idx
assert "_stagedMatchesCurrentSuffix" in restore
def test_cancel_settlement_attaches_projected_anchor_scene_before_render():
cancel = _event_listener_body(MESSAGES_JS, "cancel")
fetch_idx = cancel.index("const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);")
offset_idx = cancel.index("_oldestIdx=sessionPayload._messages_offset||0")
attach_idx = cancel.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);")
carry_idx = cancel.index("S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);")
render_idx = cancel.index("renderMessages({preserveScroll:true});")
assert fetch_idx < attach_idx < carry_idx < render_idx
assert fetch_idx < offset_idx < attach_idx < carry_idx < render_idx
embedded_idx = cancel.index("if(_applyCancelSessionPayload(_cancelSessionPayload)) return;")
fallback_get_idx = cancel.index("const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);")
# #7310/#7625: the HTTP fallback is a bounded tail now (full-transcript
# reloads were stacking on every cancel recovery); ordering contract below
# is unchanged.
fallback_get_idx = cancel.index("const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);")
fallback_apply_idx = cancel.index("if(data&&data.session) _applyCancelSessionPayload(data.session);")
assert embedded_idx < fallback_get_idx < fallback_apply_idx
+482
View File
@@ -0,0 +1,482 @@
"""MCP status, inventory and /reload-mcp follow the request profile.
Two profiles configure a stdio MCP server with the same name (``atlassian``):
one read-only, one with write tools. These tests drive the real Hermes Agent MCP
runtime with a local fake MCP server (no network, no credentials) and check
that each profile sees and reloads only its own connection.
Chat turns are simulated the way ``api/streaming.py`` runs them: the session
profile's home is mirrored into ``os.environ['HERMES_HOME']`` and installed as
the context-local Hermes-home override before ``discover_mcp_tools()``.
"""
from __future__ import annotations
import json
import os
import sys
import textwrap
import threading
from pathlib import Path
from unittest.mock import MagicMock
import pytest
try:
import mcp # noqa: F401
import hermes_constants
from agent.secret_scope import current_secret_scope, serves_routed_profile # noqa: F401
from tools import mcp_tool as mcp_core
from tools.mcp_tool_discovery import discover_mcp_tools
from tools.mcp_tool_lifecycle import shutdown_mcp_servers
except Exception as exc: # pragma: no cover - depends on the installed agent
pytest.skip(
f"requires hermes-agent with profile-scoped MCP (serves_routed_profile) and the mcp SDK: {exc}",
allow_module_level=True,
)
from api import profiles
from api.commands import execute_agent_command
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
requires_process_home_pin = pytest.mark.skipif(
not hasattr(hermes_constants, "pin_process_hermes_home"),
reason="streaming turns need hermes_constants.pin_process_hermes_home for profile-scoped MCP keys",
)
SCOPE_PROFILE = "profile"
SCOPE_UNAVAILABLE = "unavailable"
READ_TOOLS = {"mcp__atlassian__jira_get_issue", "mcp__atlassian__confluence_search"}
WRITE_TOOL = "mcp__atlassian__jira_create_issue"
FAKE_SERVER = textwrap.dedent('''
import os
try: # mcp 1.x (CI pins mcp<2)
from mcp.server.fastmcp import FastMCP
except ImportError: # mcp 2.x renamed the high-level server to MCPServer
from mcp.server import MCPServer as FastMCP
mcp = FastMCP("fake-atlassian")
@mcp.tool()
def jira_get_issue(key: str) -> str:
"""Read a Jira issue (fake)."""
return key
@mcp.tool()
def confluence_search(query: str) -> str:
"""Search Confluence (fake)."""
return "[]"
if os.environ.get("READ_ONLY_MODE", "true").lower() == "false":
@mcp.tool()
def jira_create_issue(summary: str) -> str:
"""Create a Jira issue (fake, write)."""
return "created"
mcp.run()
''')
def _server_cfg(script: Path, *, read_only: bool, account: str) -> dict:
# Distinct accounts, like distinct Atlassian credentials: identical routes would
# legitimately share one connection (hermes-agent adopts matching routes).
return {
"command": sys.executable,
"args": [str(script)],
"env": {"READ_ONLY_MODE": "true" if read_only else "false", "FAKE_ACCOUNT": account},
"timeout": 30,
"connect_timeout": 30,
}
def _write_config(home: Path, servers: dict) -> None:
import yaml
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(yaml.safe_dump({"mcp_servers": servers}), encoding="utf-8")
class Env:
def __init__(self, base: Path, script: Path):
self.base = base
self.script = script
def home(self, profile: str) -> Path:
return self.base if profile == "default" else self.base / "profiles" / profile
def configure(self, profile: str, **servers) -> None:
_write_config(self.home(profile), servers)
def atlassian(self, profile: str, *, read_only: bool) -> dict:
return _server_cfg(self.script, read_only=read_only, account=profile)
def chat_turn(self, profile: str) -> list[str]:
home = str(self.home(profile))
old = os.environ.get("HERMES_HOME")
os.environ["HERMES_HOME"] = home
token = hermes_constants.set_hermes_home_override(home)
try:
return discover_mcp_tools()
finally:
hermes_constants.reset_hermes_home_override(token)
os.environ["HERMES_HOME"] = old
def call(self, fn, profile: str) -> dict:
profiles.set_request_profile(profile)
try:
handler = MagicMock()
fn(handler)
return json.loads(handler.wfile.write.call_args[0][0])
finally:
profiles.clear_request_profile()
def servers(self, profile: str) -> dict:
payload = self.call(_handle_mcp_servers_list, profile)
return {srv["name"]: srv for srv in payload["servers"]} | {"_scope": payload.get("runtime_scope")}
def tools(self, profile: str) -> dict:
return self.call(_handle_mcp_tools_list, profile)
def reload(self, profile: str) -> str:
profiles.set_request_profile(profile)
try:
return execute_agent_command("/reload-mcp")
finally:
profiles.clear_request_profile()
def connection(self, profile: str):
"""The live connection object owned by *profile* for ``atlassian``."""
key = (hermes_constants.hermes_home_key(self.home(profile)), "atlassian")
if profile == "default":
key = "atlassian"
with mcp_core._lock:
return mcp_core._servers.get(key)
@pytest.fixture
def mcp_env(tmp_path, monkeypatch):
base = tmp_path / "hermes-home"
base.mkdir()
script = tmp_path / "fake_atlassian.py"
script.write_text(FAKE_SERVER, encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(base))
monkeypatch.delenv("HERMES_CONFIG_PATH", raising=False) # suite-wide config pin
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base)
monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: False)
monkeypatch.setattr(profiles, "_is_root_profile", lambda name: name in ("", "default"))
monkeypatch.setattr(profiles, "_PROCESS_PROFILE_HOME", getattr(profiles, "_PROCESS_PROFILE_HOME", None), raising=False)
if hasattr(hermes_constants, "_PINNED_PROCESS_HERMES_HOME"):
monkeypatch.setattr(
hermes_constants, "_PINNED_PROCESS_HERMES_HOME", hermes_constants._PINNED_PROCESS_HERMES_HOME
)
# What init_profile_state() does at startup for a WebUI launched on ``base``.
pin = getattr(profiles, "_pin_process_profile_home", None) or getattr(
hermes_constants, "pin_process_hermes_home", lambda _home: None
)
pin(base)
shutdown_mcp_servers()
env = Env(base, script)
env.configure("default")
env.configure("profile-read", atlassian=env.atlassian("profile-read", read_only=True))
env.configure("profile-write", atlassian=env.atlassian("profile-write", read_only=False))
yield env
shutdown_mcp_servers()
def _tool_names(payload: dict) -> set[str]:
return {tool["name"] for tool in payload["tools"]}
@requires_process_home_pin
def test_same_named_servers_stay_isolated_per_profile(mcp_env):
read_tools = set(mcp_env.chat_turn("profile-read"))
write_tools = set(mcp_env.chat_turn("profile-write"))
assert WRITE_TOOL not in read_tools
assert WRITE_TOOL in write_tools
assert mcp_env.connection("profile-read") is not mcp_env.connection("profile-write")
for profile, expect_write in (("profile-read", False), ("profile-write", True)):
servers = mcp_env.servers(profile)
inventory = mcp_env.tools(profile)
names = _tool_names(inventory)
assert servers["atlassian"]["status"] == "active"
assert READ_TOOLS <= names
assert (WRITE_TOOL in names) is expect_write
# Status counter and inventory describe the same connection.
assert servers["atlassian"]["tool_count"] == inventory["total"] == len(names)
assert {tool["server"] for tool in inventory["tools"]} == {"atlassian"}
assert servers["_scope"] == SCOPE_PROFILE
# The root profile configures no MCP server and must not see either profile's tools.
default_servers = mcp_env.servers("default")
assert set(default_servers) == {"_scope"}
assert mcp_env.tools("default")["total"] == 0
def test_status_and_inventory_are_passive(mcp_env):
for profile in ("profile-write", "profile-read", "default"):
servers = mcp_env.servers(profile)
inventory = mcp_env.tools(profile)
if profile != "default":
assert servers["atlassian"]["status"] == "configured"
assert not servers["atlassian"]["active"]
assert inventory["unavailable_servers"] == ["atlassian"]
assert inventory["total"] == 0
with mcp_core._lock:
assert not mcp_core._servers
assert not mcp_core._server_connecting
@requires_process_home_pin
def test_reload_applies_the_requested_profiles_config_only(mcp_env):
# profile-write connected while its config was still read-only (stale runtime).
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-write", read_only=True))
mcp_env.chat_turn("profile-read")
mcp_env.chat_turn("profile-write")
read_conn = mcp_env.connection("profile-read")
stale_write_conn = mcp_env.connection("profile-write")
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-write", read_only=False))
stale = mcp_env.servers("profile-write")["atlassian"]
assert stale["env"]["READ_ONLY_MODE"] == "false"
assert WRITE_TOOL not in _tool_names(mcp_env.tools("profile-write"))
output = mcp_env.reload("profile-write")
assert "Reconnected: atlassian" in output
assert mcp_env.connection("profile-write") is not stale_write_conn
assert WRITE_TOOL in _tool_names(mcp_env.tools("profile-write"))
# The other profile's same-named connection was neither stopped nor replaced.
assert mcp_env.connection("profile-read") is read_conn
assert read_conn.session is not None
read_inventory = mcp_env.tools("profile-read")
assert WRITE_TOOL not in _tool_names(read_inventory)
assert mcp_env.servers("profile-read")["atlassian"]["tool_count"] == read_inventory["total"]
@requires_process_home_pin
def test_reloading_an_adopting_profile_keeps_the_owners_connection(mcp_env):
"""Identical routes share one connection; the adopter's reload must not stop it."""
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-read", read_only=True))
mcp_env.chat_turn("profile-read")
mcp_env.chat_turn("profile-write")
owner_conn = mcp_env.connection("profile-read")
assert mcp_env.connection("profile-write") is None # adopted, not owned
# The adopted connection serves profile-write: status, inventory and the
# reload summary all count it (it is not "Added" from the user's point of view).
assert mcp_env.servers("profile-write")["atlassian"]["status"] == "active"
assert READ_TOOLS <= _tool_names(mcp_env.tools("profile-write"))
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-write", read_only=False))
output = mcp_env.reload("profile-write")
assert "Reconnected: atlassian" in output and "Added" not in output
assert mcp_env.connection("profile-read") is owner_conn
assert owner_conn.session is not None
assert WRITE_TOOL in _tool_names(mcp_env.tools("profile-write"))
assert WRITE_TOOL not in _tool_names(mcp_env.tools("profile-read"))
@requires_process_home_pin
def test_deleting_an_adopting_profiles_last_server_detaches_its_tools_on_reload(mcp_env):
"""A profile that adopted another profile's identical connection deletes its only
server: its reload must drop its tools (they were callable after reload before)
while the owner's connection keeps running."""
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-read", read_only=True))
mcp_env.chat_turn("profile-read")
mcp_env.chat_turn("profile-write")
owner_conn = mcp_env.connection("profile-read")
assert mcp_env.connection("profile-write") is None # adopted, not owned
assert READ_TOOLS <= _tool_names(mcp_env.tools("profile-write"))
mcp_env.configure("profile-write") # no server left
output = mcp_env.reload("profile-write")
assert "Removed: atlassian" in output and "Reconnected" not in output
assert mcp_env.tools("profile-write")["total"] == 0
from tools.registry import registry
write_scope = hermes_constants.hermes_home_key(mcp_env.home("profile-write"))
assert all(registry.snapshot_registration(t, scope=write_scope) is None for t in READ_TOOLS)
assert mcp_env.connection("profile-read") is owner_conn
assert owner_conn.session is not None
assert READ_TOOLS <= _tool_names(mcp_env.tools("profile-read"))
@requires_process_home_pin
def test_root_profile_reload_leaves_named_profiles_running(mcp_env):
mcp_env.configure("default", atlassian=mcp_env.atlassian("default", read_only=True))
mcp_env.chat_turn("default")
mcp_env.chat_turn("profile-write")
root_conn = mcp_env.connection("default")
write_conn = mcp_env.connection("profile-write")
assert root_conn is not None and write_conn is not None
assert "Reconnected: atlassian" in mcp_env.reload("default")
assert mcp_env.connection("default") is not root_conn
assert mcp_env.connection("profile-write") is write_conn
assert write_conn.session is not None
assert WRITE_TOOL not in _tool_names(mcp_env.tools("default"))
assert WRITE_TOOL in _tool_names(mcp_env.tools("profile-write"))
@requires_process_home_pin
def test_root_profile_status_never_shows_a_routed_profiles_connection(mcp_env):
"""The agent's launch-profile view is process-wide; the root profile must only
report its own (unscoped) connection, never a routed profile's."""
mcp_env.configure("default", atlassian=mcp_env.atlassian("default", read_only=True))
mcp_env.chat_turn("profile-write")
assert mcp_env.connection("profile-write") is not None
assert mcp_env.connection("default") is None
root = mcp_env.servers("default")
assert root["_scope"] == SCOPE_PROFILE
assert root["atlassian"]["status"] == "configured" and not root["atlassian"]["active"]
assert not root["atlassian"]["tool_count"]
assert mcp_env.tools("default")["total"] == 0
mcp_env.chat_turn("default")
root = mcp_env.servers("default")
root_inventory = mcp_env.tools("default")
assert root["atlassian"]["status"] == "active"
# Status counter and inventory describe the root profile's own connection
# (the agent also registers generic resource/prompt tools per server).
assert root["atlassian"]["tool_count"] == root_inventory["total"]
assert READ_TOOLS <= _tool_names(root_inventory)
assert WRITE_TOOL not in _tool_names(root_inventory)
@requires_process_home_pin
def test_reload_retries_a_server_that_failed_to_spawn(mcp_env):
"""A failed spawn sits under the agent's connect backoff (30s+) and never enters
the ledger, so a scoped shutdown leaves that backoff alone; the profile's reload
must still retry it, without dropping another profile's backoff."""
broken = {"command": str(mcp_env.base / "missing-mcp-binary"), "connect_timeout": 5}
mcp_env.configure("default", atlassian=broken)
mcp_env.configure("profile-write", atlassian=broken)
mcp_env.chat_turn("default")
mcp_env.chat_turn("profile-write")
# A permanent spawn failure is parked (task retained, no session) or dropped,
# depending on the agent; either way nothing is connected.
for profile in ("default", "profile-write"):
parked = mcp_env.connection(profile)
assert parked is None or parked.session is None
assert mcp_env.servers(profile)["atlassian"]["status"] == "configured"
retry_after = getattr(mcp_core, "_server_connect_retry_after", None)
if retry_after is None:
pytest.skip("agent has no connect-retry cooldown ledger")
write_key = (hermes_constants.hermes_home_key(mcp_env.home("profile-write")), "atlassian")
with mcp_core._lock:
assert write_key in retry_after and "atlassian" in retry_after
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-write", read_only=False))
assert "Added: atlassian" in mcp_env.reload("profile-write")
assert mcp_env.connection("profile-write") is not None
assert WRITE_TOOL in _tool_names(mcp_env.tools("profile-write"))
with mcp_core._lock:
assert write_key not in retry_after
assert "atlassian" in retry_after # the root profile's own backoff is untouched
assert mcp_env.servers("default")["atlassian"]["status"] == "configured"
def test_unconfirmed_scope_hides_runtime_and_refuses_reload(mcp_env, monkeypatch):
"""A live turn's HERMES_HOME mirror on an agent without the pin skews routing."""
mcp_env.chat_turn("profile-read")
before = dict(mcp_core._servers)
if hasattr(hermes_constants, "_PINNED_PROCESS_HERMES_HOME"):
monkeypatch.setattr(hermes_constants, "_PINNED_PROCESS_HERMES_HOME", None)
monkeypatch.setenv("HERMES_HOME", str(mcp_env.home("profile-write"))) # mid-turn mirror
servers = mcp_env.servers("profile-write")
inventory = mcp_env.tools("profile-write")
assert servers["atlassian"]["status"] == "configured"
assert servers["atlassian"]["tool_count"] is None
assert inventory["total"] == 0
with pytest.raises(RuntimeError, match="scope could not be confirmed"):
mcp_env.reload("profile-write")
assert dict(mcp_core._servers) == before
assert servers["_scope"] == inventory.get("runtime_scope") == SCOPE_UNAVAILABLE
@requires_process_home_pin
def test_disabled_and_failed_servers_are_reported_per_profile(mcp_env):
mcp_env.configure(
"profile-write",
atlassian=mcp_env.atlassian("profile-write", read_only=False),
off={**mcp_env.atlassian("profile-write", read_only=False), "enabled": False},
broken={"command": str(mcp_env.base / "missing-mcp-binary"), "connect_timeout": 5},
invalid="not-a-mapping",
)
mcp_env.chat_turn("profile-write")
servers = mcp_env.servers("profile-write")
assert servers["atlassian"]["status"] == "active"
assert servers["off"]["status"] == "disabled"
assert servers["broken"]["status"] == "configured"
assert not servers["broken"]["active"]
assert servers["invalid"]["status"] == "invalid_config"
inventory = mcp_env.tools("profile-write")
assert inventory["unavailable_servers"] == ["broken"]
assert {tool["server"] for tool in inventory["tools"]} == {"atlassian"}
def test_runtime_scope_is_restored_after_an_exception(mcp_env):
from api.config import _thread_ctx
from api.mcp_runtime import mcp_runtime_scope
previous_env = dict(getattr(_thread_ctx, "env", {}) or {})
profiles.set_request_profile("profile-write")
try:
with pytest.raises(ValueError):
with mcp_runtime_scope("test") as view:
assert view.trusted
assert hermes_constants.get_hermes_home_override() == str(mcp_env.home("profile-write"))
assert current_secret_scope() is not None
raise ValueError("boom")
finally:
profiles.clear_request_profile()
assert hermes_constants.get_hermes_home_override() is None
assert current_secret_scope() is None
assert dict(getattr(_thread_ctx, "env", {}) or {}) == previous_env
assert os.environ["HERMES_HOME"] == str(mcp_env.base)
@requires_process_home_pin
def test_concurrent_profile_reads_and_reload_never_cross_profiles(mcp_env):
mcp_env.chat_turn("profile-read")
mcp_env.chat_turn("profile-write")
read_count = mcp_env.servers("profile-read")["atlassian"]["tool_count"]
write_count = mcp_env.servers("profile-write")["atlassian"]["tool_count"]
assert read_count != write_count
barrier = threading.Barrier(3)
failures: list[str] = []
def poll(profile: str, expected: int):
barrier.wait()
for _ in range(20):
srv = mcp_env.servers(profile)["atlassian"]
names = _tool_names(mcp_env.tools(profile))
allowed = (expected,) if profile == "profile-read" else (expected, 0, None)
if srv["tool_count"] not in allowed:
failures.append(f"{profile} saw {srv['tool_count']} tools")
if profile == "profile-read" and WRITE_TOOL in names:
failures.append("profile-read inventory leaked a write tool")
def reload_write():
barrier.wait()
mcp_env.reload("profile-write")
threads = [
threading.Thread(target=poll, args=("profile-read", read_count)),
threading.Thread(target=poll, args=("profile-write", write_count)),
threading.Thread(target=reload_write),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=120)
assert not failures
assert mcp_env.servers("profile-read")["atlassian"]["tool_count"] == read_count
assert mcp_env.servers("profile-write")["atlassian"]["tool_count"] == write_count
@@ -0,0 +1,649 @@
"""Agent-free contract tests for profile-scoped MCP status, inventory and /reload-mcp.
``tests/test_mcp_runtime_profile_scope.py`` drives the real Hermes Agent MCP
runtime; CI has no Hermes Agent checkout, so this file models the small agent
surface WebUI relies on (context-local home override, routed-profile predicate,
scoped registry slots, scoped MCP ledger with adoption and connect cooldowns)
and pins the WebUI side of the contract: which profile is bound, which scope is
reset, what the root profile may see, and the fail-closed paths.
"""
from __future__ import annotations
import json
import os
import sys
import threading
import types
from contextvars import ContextVar
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Optional
from unittest.mock import MagicMock
import pytest
import yaml
from api import profiles
class FakeAgent:
"""Minimal model of the hermes-agent MCP surface used by WebUI.
Mirrors the agent's visibility rules: a routed scope sees the connections it
owns or adopted; the launch profile's view (scope ``None``) is process-wide.
"""
def __init__(self, *, pin_supported=True, scoped=True):
self.override: ContextVar[Optional[str]] = ContextVar("fake_home_override", default=None)
self.secret: ContextVar[Optional[dict]] = ContextVar("fake_secret_scope", default=None)
self.pinned = None
self.servers = {} # ledger key -> connection
self.owners = {} # ledger key -> owner scope
self.adopters = {} # ledger key -> {adopter scope}
self.connecting = set()
self.errors = {} # ledger key -> error text
self.retry_after = {} # ledger key -> deadline (connect cooldown)
self.failures = {} # ledger key -> failure count
self.lazy = {} # ledger key -> cfg
self.global_tools = {} # tool name -> (toolset, schema)
self.scoped_tools = {} # scope -> {tool name -> (toolset, schema)}
self.calls = []
self.lock = threading.Lock()
self.modules = self._build_modules(pin_supported, scoped)
# hermes_constants
def home(self):
return self.override.get() or os.environ.get("HERMES_HOME", "")
@staticmethod
def key(path):
return str(Path(path).resolve())
def routed(self):
override = self.override.get()
anchor = self.pinned or os.environ.get("HERMES_HOME", "")
return override is not None and self.key(override) != self.key(anchor)
def scope(self):
return self.key(self.home()) if self.routed() else None
def _merged(self, scope):
return {**self.global_tools, **self.scoped_tools.get(scope, {})}
def _slot(self, scope):
return self.global_tools if scope is None else self.scoped_tools.setdefault(scope, {})
@staticmethod
def ledger_key(name, scope):
return name if scope is None else (scope, name)
def visible(self, key, scope):
"""The agent's ``_server_visible_in_scope``: process-wide for scope ``None``."""
if scope is None:
return True
return self.owners.get(key) == scope or scope in self.adopters.get(key, ())
def connect(self, name, tools, *, scope):
key = self.ledger_key(name, scope)
self.servers[key] = SimpleNamespace(name=name, session=object(), _registered_tool_names=list(tools))
self.owners[key] = scope
for tool in tools:
self._slot(scope)[tool] = (f"mcp-{name}", {"description": f"{tool} ({scope})"})
return self.servers[key]
def adopt(self, name, *, owner, adopter):
"""*adopter* shares *owner*'s identical connection (agent: ``_server_tool_scopes``)."""
key = self.ledger_key(name, owner)
self.adopters.setdefault(key, set()).add(adopter)
for tool in self.servers[key]._registered_tool_names:
self._slot(adopter)[tool] = (f"mcp-{name}", {"description": f"{tool} ({adopter})"})
def park(self, name, *, scope, error="spawn failed"):
"""A permanent spawn failure the agent parks: task retained in ``_servers``
without a session, error + backoff recorded under its key."""
key = self.ledger_key(name, scope)
self.servers[key] = SimpleNamespace(name=name, session=None, _registered_tool_names=[])
self.owners[key] = scope
self.fail(name, scope=scope, error=error)
return self.servers[key]
def fail(self, name, *, scope, error="spawn failed"):
"""A connect failure: never in ``_servers``; error + backoff recorded under its key."""
key = self.ledger_key(name, scope)
self.errors[key] = error
self.failures[key] = self.failures.get(key, 0) + 1
self.retry_after[key] = float("inf")
def _build_modules(self, pin_supported, scoped):
agent = self
hc: Any = types.ModuleType("hermes_constants")
hc.set_hermes_home_override = lambda path: agent.override.set(None if path is None else str(path))
hc.reset_hermes_home_override = lambda token: agent.override.reset(token)
hc.get_hermes_home_override = lambda: agent.override.get()
hc.hermes_home_key = lambda path=None: agent.key(path if path is not None else agent.home())
if pin_supported:
def pin(path):
agent.pinned = None if path is None else str(path)
hc.pin_process_hermes_home = pin
agent_pkg: Any = types.ModuleType("agent")
agent_pkg.__path__ = []
secret: Any = types.ModuleType("agent.secret_scope")
secret.set_secret_scope = lambda mapping: agent.secret.set(dict(mapping))
secret.reset_secret_scope = lambda token: agent.secret.reset(token)
secret.current_secret_scope = lambda: agent.secret.get()
if scoped:
secret.serves_routed_profile = agent.routed
tools_pkg: Any = types.ModuleType("tools")
tools_pkg.__path__ = []
registry_mod: Any = types.ModuleType("tools.registry")
class Registry:
@staticmethod
def current_scope_key():
return agent.key(agent.home())
def _view(self):
return agent._merged(agent.scope() if scoped else None)
def get_all_tool_names(self):
return sorted(self._view())
def get_toolset_for_tool(self, name):
return self._view().get(name, (None, None))[0]
def get_schema(self, name):
return self._view().get(name, (None, None))[1]
def snapshot_registration(self, name, *, scope=None):
slot = agent.global_tools if scope is None else agent.scoped_tools.get(scope, {})
return slot.get(name)
registry_mod.registry = Registry()
scope_mod: Any = types.ModuleType("tools.mcp_tool_scope")
scope_mod._key_name = lambda key: key[1] if isinstance(key, tuple) else key
scope_mod._key_scope = lambda key: key[0] if isinstance(key, tuple) else None
mcp_mod: Any = types.ModuleType("tools.mcp_tool")
mcp_mod._servers = agent.servers
mcp_mod._lock = agent.lock
mcp_mod._server_scope_keys = agent.owners
mcp_mod._server_tool_scopes = agent.adopters
mcp_mod._server_connecting = agent.connecting
mcp_mod._server_connect_errors = agent.errors
mcp_mod._server_connect_retry_after = agent.retry_after
mcp_mod._server_connect_failures = agent.failures
mcp_mod._lazy_server_configs = agent.lazy
def get_mcp_status(configured=None):
agent.calls.append(("status", agent.override.get(), dict(configured or {})))
scope = agent.scope() if scoped else None
with agent.lock:
# As the agent: keyed by bare name, last key wins (process-wide for scope None).
active = {scope_mod._key_name(k): s for k, s in agent.servers.items() if agent.visible(k, scope)}
errors = {scope_mod._key_name(k): e for k, e in agent.errors.items() if agent.visible(k, scope)}
rows = []
for name, cfg in (configured or {}).items():
enabled = cfg.get("enabled", True) is not False
server = active.get(name)
live = server is not None and server.session is not None
status = ("connected" if live else "disabled" if not enabled
else "failed" if name in errors else "configured")
row = {"name": name, "connected": live, "disabled": not enabled, "status": status,
"tools": len(server._registered_tool_names) if live else 0}
if status == "failed":
row["error"] = errors[name]
rows.append(row)
return rows
def shutdown_mcp_servers(*, scope=None, names=None, timeout=15.0):
agent.calls.append(("shutdown", scope, None if names is None else set(names)))
wildcard = scope is None and names is None
with agent.lock:
selected = [k for k in agent.servers
if wildcard or (agent.owners.get(k) == scope
and (names is None or scope_mod._key_name(k) in names))]
for key in selected:
del agent.servers[key]
agent.owners.pop(key, None)
agent.adopters.pop(key, None)
# Cooldown/error state: the wildcard drops everything, a scoped call
# only what it tore down (the agent's ``selected_status``).
for key in (list(agent.errors) if wildcard else selected):
agent.errors.pop(key, None)
agent.retry_after.pop(key, None)
agent.failures.pop(key, None)
def register_mcp_servers(servers):
"""The agent's overlay heal for the current scope: a server serving this
scope through adoption whose config entry is gone (or omitted from
``servers`` and absent from the profile's config) loses only this
profile's overlay; the owner's connection is untouched."""
agent.calls.append(("register", agent.override.get(), dict(servers)))
scope = agent.scope() if scoped else None
if scope is None:
return []
cfg = yaml.safe_load(Path(agent.home(), "config.yaml").read_text()) or {}
configured = cfg.get("mcp_servers") or {}
with agent.lock:
for key, adopters in list(agent.adopters.items()):
name = scope_mod._key_name(key)
if scope in adopters and name not in servers and name not in configured:
adopters.discard(scope)
slot = agent.scoped_tools.get(scope, {})
for tool in [t for t, (ts, _) in slot.items() if ts == f"mcp-{name}"]:
del slot[tool]
return sorted(agent._merged(scope))
def discover_mcp_tools():
home = agent.home()
agent.calls.append(("discover", agent.override.get()))
cfg = yaml.safe_load(Path(home, "config.yaml").read_text()) or {}
scope = agent.scope() if scoped else None
names = []
if not cfg.get("mcp_servers"):
return names # as the agent: nothing configured, no reconcile pass
for name, srv in (cfg.get("mcp_servers") or {}).items():
key = agent.ledger_key(name, scope)
served = any(scope_mod._key_name(k) == name and agent.visible(k, scope) and scope is not None
for k in agent.servers) or key in agent.servers
if served:
names += [t for k, s in agent.servers.items()
if scope_mod._key_name(k) == name for t in s._registered_tool_names]
continue
if key in agent.retry_after: # backoff honoured: no retry
continue
if srv.get("fake_fail"):
agent.fail(name, scope=scope, error=str(srv["fake_fail"]))
continue
agent.connect(name, srv.get("fake_tools", []), scope=scope)
names += srv.get("fake_tools", [])
return names
mcp_mod.get_mcp_status = get_mcp_status
mcp_mod.shutdown_mcp_servers = shutdown_mcp_servers
mcp_mod.discover_mcp_tools = discover_mcp_tools
mcp_mod.register_mcp_servers = register_mcp_servers
return {
"hermes_constants": hc,
"agent": agent_pkg,
"agent.secret_scope": secret,
"tools": tools_pkg,
"tools.registry": registry_mod,
"tools.mcp_tool_scope": scope_mod,
"tools.mcp_tool": mcp_mod,
"tools.mcp_tool_discovery": mcp_mod,
"tools.mcp_tool_lifecycle": mcp_mod,
}
READ = ["mcp__atlassian__jira_get_issue"]
WRITE = READ + ["mcp__atlassian__jira_create_issue"]
def _install(monkeypatch, tmp_path, **agent_kwargs):
agent = FakeAgent(**agent_kwargs)
for name, module in agent.modules.items():
monkeypatch.setitem(sys.modules, name, module)
base = tmp_path / "home"
for profile, tools in (("default", None), ("profile-read", READ), ("profile-write", WRITE)):
home = base if profile == "default" else base / "profiles" / profile
home.mkdir(parents=True, exist_ok=True)
servers = {} if tools is None else {"atlassian": {"command": "fake", "fake_tools": tools}}
(home / "config.yaml").write_text(yaml.safe_dump({"mcp_servers": servers}))
monkeypatch.setenv("HERMES_HOME", str(base))
monkeypatch.delenv("HERMES_CONFIG_PATH", raising=False)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base)
monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: False)
monkeypatch.setattr(profiles, "_is_root_profile", lambda name: name in ("", "default"))
monkeypatch.setattr(profiles, "_secret_scope_available", None)
monkeypatch.setattr(profiles, "_PROCESS_PROFILE_HOME", None)
profiles._pin_process_profile_home(base)
agent.base = base
return agent
def _configure(agent, profile, servers):
home = agent.base if profile == "default" else agent.base / "profiles" / profile
(home / "config.yaml").write_text(yaml.safe_dump({"mcp_servers": servers}))
def _as_profile(profile, fn, *args):
profiles.set_request_profile(profile)
try:
return fn(*args)
finally:
profiles.clear_request_profile()
def _call(profile, handler_fn):
handler = MagicMock()
_as_profile(profile, handler_fn, handler)
return json.loads(handler.wfile.write.call_args[0][0])
def _reload(profile):
from api.commands import execute_agent_command
return _as_profile(profile, execute_agent_command, "/reload-mcp")
def _profile_scope(agent, profile):
return agent.key(agent.base / "profiles" / profile)
def _servers_by_name(payload):
return {row["name"]: row for row in payload["servers"]}
def test_init_pins_the_process_profile_home_for_the_agent(monkeypatch, tmp_path):
agent = _install(monkeypatch, tmp_path)
assert agent.pinned == str(agent.base)
assert profiles.get_process_profile_home() == agent.base
def test_process_wide_home_change_repins_in_the_same_step(monkeypatch, tmp_path):
"""``_set_hermes_home`` is the single writer of the process home: startup and
``switch_profile(process_wide=True)`` both go through it, so the MCP routing
anchor (WebUI and agent pin) can never drift from ``HERMES_HOME``."""
agent = _install(monkeypatch, tmp_path)
new_home = agent.base / "profiles" / "profile-write"
profiles._set_hermes_home(new_home)
assert os.environ["HERMES_HOME"] == str(new_home)
assert profiles.get_process_profile_home() == new_home
assert agent.pinned == str(new_home)
def test_status_and_inventory_bind_the_request_profile(monkeypatch, tmp_path):
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
agent = _install(monkeypatch, tmp_path)
agent.connect("atlassian", READ, scope=_profile_scope(agent, "profile-read"))
agent.connect("atlassian", WRITE, scope=_profile_scope(agent, "profile-write"))
servers = _call("profile-write", _handle_mcp_servers_list)
tools = _call("profile-write", _handle_mcp_tools_list)
assert servers["runtime_scope"] == tools["runtime_scope"] == "profile"
assert servers["servers"][0]["tool_count"] == 2
assert {t["name"] for t in tools["tools"]} == set(WRITE)
# get_mcp_status ran under the profile's home and was handed the displayed config.
status_calls = [c for c in agent.calls if c[0] == "status"]
assert all(c[1] == str(agent.base / "profiles" / "profile-write") for c in status_calls)
assert all(set(c[2]) == {"atlassian"} for c in status_calls)
# Nothing leaked into the request thread after the calls.
assert agent.override.get() is None and agent.secret.get() is None
read_tools = _call("profile-read", _handle_mcp_tools_list)
assert {t["name"] for t in read_tools["tools"]} == set(READ)
assert _call("default", _handle_mcp_tools_list)["total"] == 0
assert not [c for c in agent.calls if c[0] in ("discover", "shutdown")]
def test_inventory_excludes_the_process_profiles_global_slot(monkeypatch, tmp_path):
from api.routes import _handle_mcp_tools_list
agent = _install(monkeypatch, tmp_path)
agent.connect("atlassian", WRITE, scope=None) # launch profile's unscoped connection
agent.connect("atlassian", READ, scope=_profile_scope(agent, "profile-read"))
names = {t["name"] for t in _call("profile-read", _handle_mcp_tools_list)["tools"]}
assert names == set(READ)
def test_root_profile_status_hides_routed_profiles_runtime(monkeypatch, tmp_path):
"""The agent's launch-profile view is process-wide; WebUI narrows it to the
root profile's own (unscoped) connections, so a routed profile's live
connection or connect error never shows up as the root profile's."""
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
agent = _install(monkeypatch, tmp_path)
_configure(agent, "default", {"atlassian": {"command": "fake", "fake_tools": READ}})
agent.connect("atlassian", WRITE, scope=_profile_scope(agent, "profile-write"))
agent.fail("atlassian", scope=_profile_scope(agent, "profile-read"), error="bad token")
servers = _call("default", _handle_mcp_servers_list)
row = _servers_by_name(servers)["atlassian"]
assert servers["runtime_scope"] == "profile"
assert row["status"] == "configured" and row["active"] is False
assert not row["tool_count"]
assert _call("default", _handle_mcp_tools_list)["total"] == 0
# The root profile's own parked task (failed spawn, no session) must not borrow
# the routed profile's live same-named connection: the agent's process-wide
# view indexes by bare name and the last key wins.
agent.park("atlassian", scope=None, error="exec: not found")
row = _servers_by_name(_call("default", _handle_mcp_servers_list))["atlassian"]
assert row["status"] == "configured" and row["active"] is False and not row["tool_count"]
# The root profile's own connection is still reported normally.
agent.connect("atlassian", READ, scope=None)
row = _servers_by_name(_call("default", _handle_mcp_servers_list))["atlassian"]
assert row["status"] == "active" and row["tool_count"] == len(READ)
assert {t["name"] for t in _call("default", _handle_mcp_tools_list)["tools"]} == set(READ)
def test_adopted_connection_serves_status_inventory_and_reload(monkeypatch, tmp_path):
"""A profile that adopted another profile's identical connection sees it as
active; its reload counts it and never tears down the owner's connection."""
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
agent = _install(monkeypatch, tmp_path)
read_scope = _profile_scope(agent, "profile-read")
write_scope = _profile_scope(agent, "profile-write")
owner_conn = agent.connect("atlassian", READ, scope=read_scope)
agent.adopt("atlassian", owner=read_scope, adopter=write_scope)
row = _servers_by_name(_call("profile-write", _handle_mcp_servers_list))["atlassian"]
assert row["status"] == "active" and row["tool_count"] == len(READ)
assert {t["name"] for t in _call("profile-write", _handle_mcp_tools_list)["tools"]} == set(READ)
output = _reload("profile-write")
assert "Reconnected: atlassian" in output
assert "Added" not in output and "No MCP servers connected" not in output
assert ("shutdown", write_scope, {"atlassian"}) in agent.calls
assert agent.servers[(read_scope, "atlassian")] is owner_conn
assert write_scope in agent.adopters[(read_scope, "atlassian")]
def test_reload_with_an_empty_config_detaches_adopted_tools_but_keeps_the_owner(monkeypatch, tmp_path):
"""Deleting a profile's last server must stop its tools on reload, even when the
connection was adopted from another profile: the adopter's overlay goes, the
owner's connection stays (master's wildcard shutdown stopped the owner too)."""
from api.routes import _handle_mcp_tools_list
agent = _install(monkeypatch, tmp_path)
read_scope = _profile_scope(agent, "profile-read")
write_scope = _profile_scope(agent, "profile-write")
owner_conn = agent.connect("atlassian", READ, scope=read_scope)
agent.adopt("atlassian", owner=read_scope, adopter=write_scope)
assert {t["name"] for t in _call("profile-write", _handle_mcp_tools_list)["tools"]} == set(READ)
_configure(agent, "profile-write", {}) # the user deleted the profile's only server
output = _reload("profile-write")
assert "Removed: atlassian" in output and "Reconnected" not in output
assert _call("profile-write", _handle_mcp_tools_list)["total"] == 0
assert not agent.scoped_tools.get(write_scope)
assert write_scope not in agent.adopters.get((read_scope, "atlassian"), ())
# The owner is untouched: same connection, same tools.
assert agent.servers[(read_scope, "atlassian")] is owner_conn
assert {t["name"] for t in _call("profile-read", _handle_mcp_tools_list)["tools"]} == set(READ)
# The teardown stayed scoped to this profile (never the process-wide wildcard) and the
# overlay reconcile ran under this profile's home before rediscovery.
shutdowns = [c for c in agent.calls if c[0] == "shutdown"]
assert shutdowns == [("shutdown", write_scope, {"atlassian"})]
assert ("register", str(agent.base / "profiles" / "profile-write"), {}) in agent.calls
def test_portable_plugin_tools_are_listed_for_their_profile_without_a_config_entry(monkeypatch, tmp_path):
"""The agent merges plugin-provided (portable) MCP servers into the running config;
they never appear in the profile's raw ``mcp_servers``. Their tools must still be
listed for the profile whose registry slot holds them, and for no other profile."""
from api.routes import _handle_mcp_tools_list, _handle_notes_sources_list
agent = _install(monkeypatch, tmp_path)
write_scope = _profile_scope(agent, "profile-write")
agent.connect("notes-plugin", ["mcp__notes-plugin__search_notes"], scope=write_scope)
inventory = _call("profile-write", _handle_mcp_tools_list)
assert [(t["name"], t["server"]) for t in inventory["tools"]] == [
("mcp__notes-plugin__search_notes", "notes-plugin")
]
assert inventory["source"] == "tool_registry"
monkeypatch.setenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", "1")
notes = _call("profile-write", _handle_notes_sources_list)
assert [s["name"] for s in notes["sources"]] == ["notes-plugin"]
assert notes["sources"][0]["tool_count"] == 1
# Isolation still holds: another profile's slot does not list them.
assert _call("profile-read", _handle_mcp_tools_list)["total"] == 0
assert _call("default", _handle_mcp_tools_list)["total"] == 0
def test_skewed_routing_hides_runtime_and_refuses_reload(monkeypatch, tmp_path):
from api.routes import (
_handle_mcp_servers_list,
_handle_mcp_tools_list,
_handle_notes_sources_list,
)
agent = _install(monkeypatch, tmp_path, pin_supported=False)
_configure(agent, "profile-write", {
"atlassian": {"command": "fake", "fake_tools": WRITE},
"joplin": {"command": "fake", "fake_tools": []}, # a notes-drawer source
})
agent.connect("atlassian", READ, scope=None)
monkeypatch.setenv("HERMES_HOME", str(agent.base / "profiles" / "profile-write")) # turn mirror
servers = _call("profile-write", _handle_mcp_servers_list)
assert servers["runtime_scope"] == "unavailable"
assert servers["servers"][0]["status"] == "configured"
assert servers["servers"][0]["tool_count"] is None
assert _call("profile-write", _handle_mcp_tools_list)["total"] == 0
# The notes drawer reads the same inventory and must be told the runtime is withheld.
monkeypatch.setenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", "1")
notes = _call("profile-write", _handle_notes_sources_list)
assert notes["runtime_scope"] == "unavailable"
assert [s["name"] for s in notes["sources"]] == ["joplin"]
assert notes["sources"][0]["active"] is False
with pytest.raises(RuntimeError, match="could not be confirmed"):
_reload("profile-write")
assert not [c for c in agent.calls if c[0] in ("discover", "shutdown")]
assert "atlassian" in agent.servers
def test_reload_resets_only_the_requested_profile(monkeypatch, tmp_path):
agent = _install(monkeypatch, tmp_path)
read_scope = _profile_scope(agent, "profile-read")
write_scope = _profile_scope(agent, "profile-write")
read_conn = agent.connect("atlassian", READ, scope=read_scope)
agent.connect("atlassian", READ, scope=write_scope) # stale write-profile connection
output = _reload("profile-write")
assert "Reconnected: atlassian" in output
assert ("shutdown", write_scope, {"atlassian"}) in agent.calls
assert ("discover", str(agent.base / "profiles" / "profile-write")) in agent.calls
assert agent.servers[(read_scope, "atlassian")] is read_conn
assert agent.servers[(write_scope, "atlassian")]._registered_tool_names == WRITE
def test_reload_retries_the_profiles_failed_servers_only(monkeypatch, tmp_path):
"""A server that failed to spawn is under connect backoff and absent from the
ledger, so a scoped shutdown leaves its cooldown alone; the reload must still
retry it (as the process-wide wildcard did) without touching other owners'."""
agent = _install(monkeypatch, tmp_path)
write_scope = _profile_scope(agent, "profile-write")
agent.fail("atlassian", scope=write_scope, error="exec: not found")
agent.fail("atlassian", scope=None, error="root still broken") # root's own backoff
_configure(agent, "profile-write", {"atlassian": {"command": "fake", "fake_tools": WRITE}})
output = _reload("profile-write")
assert "Added: atlassian" in output
assert ("shutdown", write_scope, set()) in agent.calls
assert agent.servers[(write_scope, "atlassian")]._registered_tool_names == WRITE
assert (write_scope, "atlassian") not in agent.errors
assert "atlassian" in agent.retry_after and "atlassian" in agent.errors # root untouched
def test_root_profile_reload_never_uses_the_wildcard_shutdown(monkeypatch, tmp_path):
agent = _install(monkeypatch, tmp_path)
_configure(agent, "default", {"atlassian": {"command": "fake", "fake_tools": READ}})
agent.connect("atlassian", READ, scope=None)
write_conn = agent.connect("atlassian", WRITE, scope=_profile_scope(agent, "profile-write"))
assert "Reconnected: atlassian" in _reload("default")
shutdowns = [c for c in agent.calls if c[0] == "shutdown"]
assert shutdowns == [("shutdown", None, {"atlassian"})]
assert agent.servers[(_profile_scope(agent, "profile-write"), "atlassian")] is write_conn
def test_legacy_agent_keeps_process_wide_reload_but_reads_the_request_profile(monkeypatch, tmp_path):
from api.routes import _handle_mcp_servers_list
agent = _install(monkeypatch, tmp_path, pin_supported=False, scoped=False)
assert _call("profile-write", _handle_mcp_servers_list)["runtime_scope"] == "legacy_process"
_reload("profile-write")
assert ("shutdown", None, None) in agent.calls
assert ("discover", str(agent.base / "profiles" / "profile-write")) in agent.calls
def test_status_read_survives_an_uninspectable_get_mcp_status(monkeypatch, tmp_path):
"""Signature introspection failing must fall back to the bare call, not blank
every server's status."""
from api.routes import _handle_mcp_servers_list
agent = _install(monkeypatch, tmp_path)
agent.connect("atlassian", WRITE, scope=_profile_scope(agent, "profile-write"))
real = agent.modules["tools.mcp_tool"].get_mcp_status
class Uninspectable:
@property
def __signature__(self):
raise ValueError("no signature")
def __call__(self, *args, **kwargs):
assert not args and not kwargs, "bare call expected"
return real(configured={"atlassian": {"command": "fake"}})
monkeypatch.setattr(agent.modules["tools.mcp_tool"], "get_mcp_status", Uninspectable())
row = _servers_by_name(_call("profile-write", _handle_mcp_servers_list))["atlassian"]
assert row["status"] == "active" and row["tool_count"] == len(WRITE)
def test_ledger_key_helpers_prefer_the_agents_and_fall_back_without_them(monkeypatch, tmp_path):
from api import mcp_runtime
agent = _install(monkeypatch, tmp_path)
assert mcp_runtime.mcp_key_name(("scope-a", "atlassian")) == "atlassian"
assert mcp_runtime.mcp_key_scope(("scope-a", "atlassian")) == "scope-a"
assert mcp_runtime.mcp_key_name("atlassian") == "atlassian"
assert mcp_runtime.mcp_key_scope("atlassian") is None
monkeypatch.delitem(sys.modules, "tools.mcp_tool_scope")
assert mcp_runtime.mcp_key_name(("scope-a", "atlassian")) == "atlassian"
assert mcp_runtime.mcp_key_scope("atlassian") is None
assert mcp_runtime.accepts_keywords(len, "scope") is False
assert mcp_runtime.accepts_keywords(agent.modules["tools.mcp_tool"].shutdown_mcp_servers, "scope", "names")
def test_readonly_profile_scope_default_is_unchanged_and_root_is_opt_in(monkeypatch, tmp_path):
agent = _install(monkeypatch, tmp_path)
with profiles.profile_env_for_active_request_readonly("t") as bound:
assert bound is False and agent.override.get() is None
with profiles.profile_env_for_active_request_readonly("t", include_root=True) as bound:
assert bound is True and agent.override.get() == str(agent.base)
assert agent.override.get() is None
def test_runtime_scope_restores_context_after_exception(monkeypatch, tmp_path):
from api.mcp_runtime import mcp_runtime_scope
agent = _install(monkeypatch, tmp_path)
with pytest.raises(KeyError):
_as_profile("profile-write", lambda: _raise_inside(mcp_runtime_scope, agent))
assert agent.override.get() is None and agent.secret.get() is None
def _raise_inside(scope_factory, agent):
with scope_factory("t") as view:
assert view.trusted and agent.override.get() is not None and agent.secret.get() is not None
raise KeyError("boom")
+566
View File
@@ -0,0 +1,566 @@
"""Regression: mobile soft-keyboard Enter must insert a newline, not send.
This module covers the three production changes in this PR, each with a test
that provably fails when only that change is reverted:
A. ``_isTouchOnlyDevice()`` supplements media-query touch detection for phones.
Several iOS Safari builds report ``(any-pointer:fine)`` as **true** on a
plain iPhone (Apple Pencil / pointer-emulation heuristics), so the upstream
``matchMedia('(pointer:coarse)') && !_hasFinePointerCoexisting()`` test
evaluates false on exactly the devices it is meant to detect, and plain
Enter sends instead of inserting a newline. Phone UAs bypass that unreliable
signal, while tablets still honor it so attached hardware keyboards retain
desktop Enter-to-send behavior.
B. ``window._sendKey`` is initialised synchronously from ``localStorage``
before the composer handler is registered. Without it, on a slow network the
handler runs while ``window._sendKey`` is still undefined, so a user who
configured ``shift+enter`` has plain Enter send the message.
C. The composer textarea carries ``enterkeyhint="enter"`` so the iOS software
keyboard renders a return key rather than a "Go"/"Send" key.
The behavioural tests execute the **real** helpers and composer handler
extracted verbatim from ``static/boot.js`` — reverting the production code
changes what these tests run.
"""
from __future__ import annotations
import re
import tempfile
from pathlib import Path
import pytest
try:
from playwright.sync_api import sync_playwright
except Exception: # pragma: no cover - playwright optional in some envs
sync_playwright = None
REPO = Path(__file__).resolve().parents[1]
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
IPHONE_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
)
IPAD_UA = (
"Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
)
ANDROID_TABLET_UA = (
"Mozilla/5.0 (Linux; Android 14; SM-X910) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
)
TOUCH_MAC_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15"
)
def _require_playwright():
if sync_playwright is None:
pytest.skip("playwright is unavailable; run `playwright install chromium`")
return sync_playwright
# ─── Source extraction ────────────────────────────────────────────────────
def _extract_function(name: str, source: str) -> str:
"""Extract a top-level ``function name(...){...}`` with brace matching."""
start = source.index(f"function {name}(")
brace_start = source.index("{", start)
depth = 0
for i in range(brace_start, len(source)):
c = source[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise AssertionError(f"could not extract function {name}")
def _extract_composer_handler() -> str:
"""Extract the real ``$('msg').addEventListener('keydown', …)`` call."""
signature = "$('msg').addEventListener('keydown',e=>"
start = BOOT_JS.index(signature)
brace = BOOT_JS.index("{", start)
depth = 0
for idx in range(brace, len(BOOT_JS)):
char = BOOT_JS[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
end = BOOT_JS.index(");", idx) + 2
return BOOT_JS[start:end]
raise AssertionError("could not extract composer keydown listener")
def _extract_send_key_init() -> str:
"""Extract the synchronous ``window._sendKey`` bootstrap from boot.js.
Returns an empty string when the line is absent, so reverting change B
removes it from the harness too (which is what makes the test bite).
"""
match = re.search(
r"^try\{\s*window\._sendKey=localStorage\.getItem\([^\n]*$",
BOOT_JS,
re.MULTILINE,
)
return match.group(0) if match else ""
def _available_helpers() -> str:
"""Extract the touch/IME/numpad helpers the composer handler may call.
``_isTouchOnlyDevice`` is this PR's addition and ``_hasFinePointerCoexisting``
is the upstream helper; include whichever exist so the harness stays valid
with the production change reverted.
"""
names = (
"_isImeEnter",
"_isNumpadEnter",
"_hasFinePointerCoexisting",
"_isTouchOnlyDevice",
)
out = []
for name in names:
if f"function {name}(" in BOOT_JS:
out.append(_extract_function(name, BOOT_JS))
return "\n".join(out)
HARNESS_HTML = """
<!doctype html>
<html><head><meta charset="utf-8"></head>
<body>
<textarea id="msg" rows="1"></textarea>
<script>
var _imeComposing = false;
function $(id) { return document.getElementById(id); }
// --- Real helpers extracted from boot.js ---
__HELPERS__
// --- Real synchronous _sendKey bootstrap extracted from boot.js ---
// Empty when that line has been removed from production code.
__SENDKEY_INIT__
window.__sendCount = 0;
window.__defaultPrevented = 0;
window.send = function() { window.__sendCount++; };
var send = window.send;
// --- Real composer handler extracted from boot.js ---
__HANDLER__
// Observe whether the handler suppressed the browser's default newline.
document.getElementById('msg').addEventListener('keydown', function(e){
if (e.defaultPrevented) window.__defaultPrevented++;
});
</script>
</body></html>
"""
def _build_harness_html() -> str:
return (
HARNESS_HTML.replace("__HELPERS__", _available_helpers())
.replace("__SENDKEY_INIT__", _extract_send_key_init())
.replace("__HANDLER__", _extract_composer_handler())
)
def _write_harness() -> Path:
tmp = Path(tempfile.mkstemp(suffix=".html")[1])
tmp.write_text(_build_harness_html(), encoding="utf-8")
return tmp
# ─── Media-query shim ─────────────────────────────────────────────────────
# Reproduces the iOS Safari quirk: a touch-primary phone that ALSO reports
# (any-pointer:fine) as true. Installed before any page script runs.
IOS_SAFARI_MATCHMEDIA_QUIRK = """
(() => {
const real = window.matchMedia.bind(window);
window.matchMedia = (q) => {
if (q === '(pointer:coarse)') return {matches: true, media: q};
if (q === '(any-pointer:fine)') return {matches: true, media: q};
return real(q);
};
})();
"""
# A plain desktop: no coarse pointer, fine pointer present.
DESKTOP_MATCHMEDIA = """
(() => {
const real = window.matchMedia.bind(window);
window.matchMedia = (q) => {
if (q === '(pointer:coarse)') return {matches: false, media: q};
if (q === '(any-pointer:fine)') return {matches: true, media: q};
return real(q);
};
})();
"""
# A tablet / iPadOS-desktop UA with only a software keyboard: a coarse pointer
# is present (touch screen) but no fine pointer (no attached hardware
# keyboard or trackpad). This is the path that the production change leaves
# alone — these devices must keep the mobile "Enter inserts newline" behavior.
TABLET_NO_FINE_POINTER_MATCHMEDIA = """
(() => {
const real = window.matchMedia.bind(window);
window.matchMedia = (q) => {
if (q === '(pointer:coarse)') return {matches: true, media: q};
if (q === '(any-pointer:fine)') return {matches: false, media: q};
return real(q);
};
})();
"""
def _press_enter(page, *, shift=False, ctrl=False, meta=False, numpad=False):
page.evaluate(
"""
({shift, ctrl, meta, code, location}) => {
const el = document.getElementById('msg');
el.focus();
el.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', code, keyCode: 13, which: 13, location,
shiftKey: shift, ctrlKey: ctrl, metaKey: meta,
bubbles: true, cancelable: true,
}));
}
""",
{
"shift": shift,
"ctrl": ctrl,
"meta": meta,
"code": "NumpadEnter" if numpad else "Enter",
"location": 3 if numpad else 0,
},
)
def _send_count(page) -> int:
return page.evaluate("__sendCount")
# ─── Change A: UA-first touch detection ───────────────────────────────────
def test_iphone_reporting_fine_pointer_still_treats_enter_as_newline():
"""Change A. iPhone UA + ``(any-pointer:fine)`` true → Enter must NOT send.
Reverting to ``matchMedia('(pointer:coarse)') && !_hasFinePointerCoexisting()``
makes ``_mobileDefault`` false here, so plain Enter sends and this fails.
"""
playwright = _require_playwright()
harness = _write_harness()
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
user_agent=IPHONE_UA,
viewport={"width": 390, "height": 844},
has_touch=True,
is_mobile=True,
)
ctx.add_init_script(IOS_SAFARI_MATCHMEDIA_QUIRK)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
# Sanity: the quirk is actually in place, otherwise the test is vacuous.
assert page.evaluate("matchMedia('(pointer:coarse)').matches") is True
assert page.evaluate("matchMedia('(any-pointer:fine)').matches") is True
_press_enter(page)
sends = _send_count(page)
prevented = page.evaluate("__defaultPrevented")
browser.close()
assert sends == 0, (
"plain Enter sent the message on an iPhone that reports (any-pointer:fine); "
"the soft keyboard's return key must insert a newline"
)
assert prevented == 0, "Enter should fall through so the browser inserts the newline"
def test_android_phone_treats_enter_as_newline():
"""Change A. Android UA → Enter must NOT send, regardless of media queries."""
playwright = _require_playwright()
harness = _write_harness()
android_ua = (
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36"
)
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
user_agent=android_ua,
viewport={"width": 412, "height": 915},
has_touch=True,
is_mobile=True,
)
ctx.add_init_script(IOS_SAFARI_MATCHMEDIA_QUIRK)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
_press_enter(page)
sends = _send_count(page)
browser.close()
assert sends == 0, "plain Enter must insert a newline on an Android phone"
def _run_enter_case(
playwright,
harness: Path,
*,
user_agent: str,
max_touch_points: int = 0,
matchmedia_shim: str = IOS_SAFARI_MATCHMEDIA_QUIRK,
expect_coarse: bool = True,
expect_fine: bool = True,
) -> tuple[int, int]:
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
user_agent=user_agent,
viewport={"width": 1024, "height": 768},
has_touch=True,
)
ctx.add_init_script(
f"Object.defineProperty(navigator, 'maxTouchPoints', {{value: {max_touch_points}}});"
)
ctx.add_init_script(matchmedia_shim)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
# Sanity: the shim is actually in place, otherwise the test would be vacuous.
assert page.evaluate("matchMedia('(pointer:coarse)').matches") is expect_coarse
assert page.evaluate("matchMedia('(any-pointer:fine)').matches") is expect_fine
_press_enter(page)
sends = _send_count(page)
prevented = page.evaluate("__defaultPrevented")
browser.close()
return sends, prevented
@pytest.mark.parametrize(
("user_agent", "max_touch_points", "device"),
[
(IPAD_UA, 5, "iPad with Magic Keyboard"),
(ANDROID_TABLET_UA, 5, "Android tablet with hardware keyboard"),
(TOUCH_MAC_UA, 5, "touch Macintosh/iPadOS desktop UA"),
],
)
def test_tablet_with_fine_pointer_keeps_enter_to_send(
user_agent: str,
max_touch_points: int,
device: str,
):
"""Tablet/touch-desktop UA + fine pointer must retain desktop Enter=send."""
playwright = _require_playwright()
harness = _write_harness()
sends, prevented = _run_enter_case(
playwright,
harness,
user_agent=user_agent,
max_touch_points=max_touch_points,
)
assert sends == 1, f"plain Enter must send on {device}"
assert prevented == 1, f"plain Enter must be consumed after sending on {device}"
@pytest.mark.parametrize(
("user_agent", "max_touch_points", "device"),
[
(IPAD_UA, 5, "iPad with software keyboard only"),
(ANDROID_TABLET_UA, 5, "Android tablet with software keyboard only"),
(TOUCH_MAC_UA, 5, "touch Macintosh/iPadOS desktop with software keyboard only"),
],
)
def test_tablet_without_fine_pointer_inserts_newline(
user_agent: str,
max_touch_points: int,
device: str,
):
"""Control for the tablet-with-fine-pointer test.
A tablet / iPadOS-desktop UA reporting only a coarse pointer (no fine
pointer — i.e. no attached hardware keyboard) must keep the mobile
"plain Enter inserts a newline" behavior, because the production change
only short-circuits phone UAs and otherwise still falls back to the
media-query test ``(pointer:coarse) && !(any-pointer:fine)``.
Reverting the change to a phone-only match would break this test, and a
tablet shortcut that always returns true would invert
``test_tablet_with_fine_pointer_keeps_enter_to_send``.
"""
playwright = _require_playwright()
harness = _write_harness()
sends, prevented = _run_enter_case(
playwright,
harness,
user_agent=user_agent,
max_touch_points=max_touch_points,
matchmedia_shim=TABLET_NO_FINE_POINTER_MATCHMEDIA,
expect_fine=False,
)
assert sends == 0, (
f"plain Enter must insert a newline on {device} (no fine pointer "
"is exposed, so the media-query path should still classify it as touch-only)"
)
assert prevented == 0, (
f"Enter must fall through so the browser inserts the newline on {device}"
)
def test_mobile_ctrl_enter_still_sends():
"""Change A must not cost mobile users a way to send from the keyboard."""
playwright = _require_playwright()
harness = _write_harness()
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
user_agent=IPHONE_UA,
viewport={"width": 390, "height": 844},
has_touch=True,
is_mobile=True,
)
ctx.add_init_script(IOS_SAFARI_MATCHMEDIA_QUIRK)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
_press_enter(page, ctrl=True)
ctrl_sends = _send_count(page)
_press_enter(page, numpad=True)
total_sends = _send_count(page)
browser.close()
assert ctrl_sends == 1, "Ctrl+Enter must still send on mobile"
assert total_sends == 2, "Numpad Enter must still send on mobile"
def test_desktop_enter_still_sends():
"""Change A must not alter desktop behaviour."""
playwright = _require_playwright()
harness = _write_harness()
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context()
ctx.add_init_script(DESKTOP_MATCHMEDIA)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
_press_enter(page)
sends = _send_count(page)
browser.close()
assert sends == 1, "plain Enter must still send on a desktop browser"
# ─── Change B: synchronous _sendKey bootstrap ─────────────────────────────
def test_stored_shift_enter_preference_applies_before_settings_fetch():
"""Change B. A stored ``shift+enter`` must hold on the very first keypress.
Reverting the synchronous ``window._sendKey`` bootstrap leaves the value
undefined while the async ``/api/settings`` fetch is still in flight, so the
handler falls into the default branch and plain Enter sends.
"""
playwright = _require_playwright()
harness = _write_harness()
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context()
ctx.add_init_script(DESKTOP_MATCHMEDIA)
# Seed the preference the way a returning user's browser would have it,
# before any page script runs.
ctx.add_init_script(
"try{localStorage.setItem('hermes-pref-send_key','shift+enter');}catch(e){}"
)
page = ctx.new_page()
page.goto(harness.as_uri())
page.wait_for_load_state("domcontentloaded")
assert (
page.evaluate("localStorage.getItem('hermes-pref-send_key')")
== "shift+enter"
), "harness failed to seed the preference; test would be vacuous"
_press_enter(page)
plain_sends = _send_count(page)
_press_enter(page, shift=True)
after_shift = _send_count(page)
browser.close()
assert plain_sends == 0, (
"plain Enter sent the message even though the stored preference is "
"shift+enter; window._sendKey was not initialised before the handler ran"
)
assert after_shift == 1, "Shift+Enter must send when the preference is shift+enter"
def test_send_key_bootstrap_precedes_composer_handler():
"""Change B, structural. The bootstrap must sit ABOVE the handler in boot.js.
Ordering is what closes the race; a bootstrap placed after the
``addEventListener`` call would still leave the first keypress undefined.
"""
init = _extract_send_key_init()
assert init, "synchronous window._sendKey bootstrap is missing from boot.js"
handler_idx = BOOT_JS.index("$('msg').addEventListener('keydown',e=>")
assert BOOT_JS.index(init) < handler_idx, (
"window._sendKey bootstrap must appear before the composer keydown "
"listener is registered"
)
# ─── Change C: enterkeyhint on the composer ───────────────────────────────
def test_composer_textarea_declares_enterkeyhint():
"""Change C, structural. iOS renders the return key from ``enterkeyhint``.
The rendered keycap is a native-keyboard behaviour with no DOM-observable
effect in a headless browser, so this asserts the attribute is present and
parsed rather than claiming to verify the keyboard itself.
"""
match = re.search(r"<textarea id=\"msg\"[^>]*>", INDEX_HTML)
assert match, "composer textarea not found in index.html"
assert 'enterkeyhint="enter"' in match.group(0), (
"composer textarea must declare enterkeyhint=\"enter\" so the iOS "
"software keyboard shows a return key instead of Go/Send"
)
def test_enterkeyhint_reaches_the_dom():
"""Change C. The attribute must survive parsing as the DOM property."""
playwright = _require_playwright()
match = re.search(r"<textarea id=\"msg\"[^>]*>", INDEX_HTML)
assert match, "composer textarea not found in index.html"
snippet = (
"<!doctype html><html><head><meta charset='utf-8'></head><body>"
+ match.group(0)
+ "</textarea></body></html>"
)
tmp = Path(tempfile.mkstemp(suffix=".html")[1])
tmp.write_text(snippet, encoding="utf-8")
with playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
page = browser.new_page()
page.goto(tmp.as_uri())
hint = page.evaluate("document.getElementById('msg').enterKeyHint")
browser.close()
assert hint == "enter", f"enterKeyHint resolved to {hint!r}, expected 'enter'"
-9
View File
@@ -1697,15 +1697,6 @@ def test_mobile_enter_newline_does_not_depend_on_viewport_heuristic():
"the viewport height-delta probe must no longer gate the mobile Enter override"
def test_mobile_enter_newline_respects_hardware_keyboard_on_touch_devices():
"""Touch devices with a co-existing fine pointer (hardware keyboard) keep desktop Enter=send."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "any-pointer:fine" in boot_js, \
"boot.js must use any-pointer:fine to detect a co-existing hardware keyboard/trackpad"
assert "!_hasFinePointerCoexisting()" in boot_js, \
"mobile Enter newline override must skip touch devices that also expose a fine pointer"
def test_mobile_enter_newline_only_overrides_enter_default():
"""Mobile newline override must only apply when _sendKey is the default 'enter'."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -70,7 +70,7 @@ let _visWithIdxCacheLen = 0;
let _visWithIdxCacheSrc = null;
const heightStart = src.indexOf('const MESSAGE_RENDER_WINDOW_DEFAULT');
const heightEnd = src.indexOf('const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS', heightStart);
const heightEnd = src.indexOf('let _messageVirtualMeasurementSeenKeys', heightStart);
if(heightStart !== -1 && heightEnd !== -1) eval(src.slice(heightStart, heightEnd));
if(src.indexOf('function _isProcessWakeupMessage') !== -1) eval(extractFunc('_isProcessWakeupMessage'));
eval(extractFunc('_stripWorkspaceDisplayPrefix'));
+162
View File
@@ -0,0 +1,162 @@
"""Composed prompt-owner and real delivery regressions for #7493."""
from tests.test_pwa_notification_controls import MESSAGES_JS, _extract_fn, _run_node
def run(body, extra=()):
names = ['_promptNotifyKey', '_retirePromptNotifyKey', '_notifyPromptCard',
'_approvalPromptGeneration', '_bumpApprovalPromptGeneration',
'_clarifyPromptGeneration', '_bumpClarifyPromptGeneration',
'_rememberApprovalPending', '_clearApprovalPendingForSession',
'_clearClarifyPendingForSession', '_approvalDismissKey', '_legacyApprovalDismissKey',
'_getDismissedApprovals', '_isApprovalDismissed', '_markApprovalDismissed',
'_unmarkApprovalDismissed', 'dismissApprovalCard',
'_startApprovalFallbackPoll', 'showApprovalCard',
'activeSessionHasPendingPromptAttention']
source = '\n'.join(_extract_fn(MESSAGES_JS, n) for n in names + list(extra))
return _run_node('''
const _promptNotifySeen = new Map();
const _approvalPendingBySession = new Map(), _clarifyPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map(), _clarifyPromptGenerationBySession = new Map();
const _DISMISSED_APPROVALS_KEY = 'dismissed';
const store = new Map();
const localStorage = {getItem:k=>store.get(k), setItem:(k,v)=>store.set(k,v)};
const S = {busy:true,session:{session_id:'s'}};
const _approvalPromptBelongsToActiveSession=()=>false;
let _approvalSessionId = 's', _approvalCurrentId = 'id';
let _approvalFallbackPollInFlight=false, _approvalPollTimer;
let _clarifyPollingSessionId, _clarifyPollTimer, _clarifyFallbackPollInFlight=false;
const _promptActiveSessionId=()=> 's';
const _approvalPollingSessionMissingOrMismatched=()=>false;
const _hideApprovalCardIfOwner=()=>{};
const stopApprovalPolling=()=>{}, stopApprovalPollingForSession=()=>{};
const hideApprovalCard=()=>{};
const setInterval=()=>1;
const _isSessionActivelyViewed=()=>false;
const flush=()=>new Promise(r=>setImmediate(r));
''' + source + '\n(async()=>{\n' + body + '\n})().catch(e=>{console.error(e);process.exit(1)});')
def test_legacy_dismissal_does_not_transfer_to_distinct_full_owner():
result = run(r'''
const pending={approval_id:'id',run_id:'run-2',_gateway_mirror_token:'token-2'};
const legacy='s'+'\0'+'id';
store.set(_DISMISSED_APPROVALS_KEY,JSON.stringify([legacy]));
const dismissed=_isApprovalDismissed('s',pending);
const keys=JSON.parse(store.get(_DISMISSED_APPROVALS_KEY));
console.log(JSON.stringify({dismissed,legacyPresent:keys.includes(legacy),fullPresent:keys.includes(_approvalDismissKey('s',pending))}));
''')
assert result == dict(dismissed=False, legacyPresent=False, fullPresent=False)
def test_legacy_dismissal_migrates_when_no_stronger_owner_exists():
result = run(r"""
const pending={approval_id:"id"};
const legacy="s"+"\0"+"id";
store.set(_DISMISSED_APPROVALS_KEY,JSON.stringify([legacy]));
const dismissed=_isApprovalDismissed("s",pending);
const keys=JSON.parse(store.get(_DISMISSED_APPROVALS_KEY));
console.log(JSON.stringify({dismissed,legacyPresent:keys.includes(legacy),fullPresent:keys.includes(_approvalDismissKey("s",pending))}));
""")
assert result == dict(dismissed=True, legacyPresent=False, fullPresent=True)
def test_dismiss_poll_resolution_and_new_run_owner():
result = run('''
const sent=[];
global.sendBrowserNotification=()=>{sent.push(1);return true};
const a={_session_id:'s',approval_id:'id',run_id:'r1',_gateway_mirror_token:'t1'};
global.showApprovalForSession=(sid,p)=>showApprovalCard(p,1);
showApprovalForSession('s',a);
dismissApprovalCard();
const dismissed=_isApprovalDismissed('s',a);
if(activeSessionHasPendingPromptAttention())throw Error('dismissed attention remains');
showApprovalForSession('s',a);
const quiet=sent.length;
const b={...a,run_id:'r2',_gateway_mirror_token:'t2'};
showApprovalForSession('s',b);
const replacement=sent.length;
dismissApprovalCard();
global.api=async()=>({pending:null});
_startApprovalFallbackPoll('s');await flush();
showApprovalForSession('s',b);
console.log(JSON.stringify({dismissed,quiet,replacement,reused:sent.length}));
''')
assert result == dict(dismissed=True, quiet=1, replacement=2, reused=3)
def test_empty_map_terminal_clear_fences_inflight_poll():
result = run('''
let resolve;const shown=[];
global.api=()=>new Promise(r=>resolve=r);
global.showApprovalForSession=(sid,p)=>shown.push(p);
_startApprovalFallbackPoll('s');
_clearApprovalPendingForSession('s');
_clearClarifyPendingForSession('s');
resolve({pending:{approval_id:'stale'}});await flush();
console.log(JSON.stringify({shown,clarifyGeneration:_clarifyPromptGeneration('s')}));
''')
assert result == dict(shown=[], clarifyGeneration=1)
def test_real_delivery_denied_failed_and_inflight_retry():
result = run('''
const window=global.window={_notificationsEnabled:true};
let attempts=0,fail=false;
window.Notification=global.Notification=function(){attempts++;if(fail)throw Error('unavailable')};
Notification.permission='denied';
global.assistantDisplayName=()=> 'Hermes';
global._notificationOptions=()=>({});
Object.defineProperty(global,'navigator',{value:{serviceWorker:{getRegistration:async()=>({active:true,showNotification:async()=>{throw Error('SW failed')}})}}});
const p={approval_id:'id'};
_notifyPromptCard('approval','s',p);await flush();
Notification.permission='granted';fail=true;
_notifyPromptCard('approval','s',p);await flush();
const failedConsumed=_promptNotifySeen.size;
fail=false;
_notifyPromptCard('approval','s',p);
_notifyPromptCard('approval','s',p);await flush();
_notifyPromptCard('approval','s',p);await flush();
console.log(JSON.stringify({attempts,failedConsumed,consumed:_promptNotifySeen.size}));
''', ('sendBrowserNotification', '_showPwaNotification'))
assert result == dict(attempts=2, failedConsumed=0, consumed=1)
def test_empty_clarify_poll_cannot_resurrect_after_terminal_clear():
result = run('''
let _clarifyPollingSessionId, _clarifyPollTimer, _clarifyFallbackPollInFlight=false;
let resolve;const shown=[];
global.api=()=>new Promise(r=>resolve=r);
global.showClarifyForSession=(sid,p)=>shown.push(p);
_startClarifyFallbackPoll('s');
_clearClarifyPendingForSession('s');
resolve({pending:{clarify_id:'stale'}});await flush();
console.log(JSON.stringify({shown}));
''', ('_startClarifyFallbackPoll',))
assert result == dict(shown=[])
def test_default_permission_is_auto_requested_once_not_every_poll_tick():
"""A pending prompt re-surfaces on every 1.5s poll tick; with permission still
'default' (a second browser/device) the automatic, non-gesture request must be
made at most once per page, so the user is not re-prompted and re-toasted
"notifications denied" on every tick. The explicit Send-test path still asks."""
result = run(r'''
const window=global.window={_notificationsEnabled:true};
let requests=0; const toasts=[];
global.t=(k)=>k;
global.showToast=(msg)=>toasts.push(msg);
global.updateNotificationPermissionStatus=()=>{};
window.Notification=global.Notification=function(){};
Notification.permission='default';
Notification.requestPermission=()=>{requests++;return Promise.resolve('default');};
global.assistantDisplayName=()=> 'Hermes';
global._notificationOptions=()=>({});
Object.defineProperty(global,'navigator',{value:{}});
const p={approval_id:'id'};
for (let i=0;i<5;i++){ _notifyPromptCard('approval','s',p); await flush(); await flush(); }
const autoRequests=requests, autoToasts=toasts.length, seenAfterAuto=_promptNotifySeen.size;
await sendBrowserNotification('Test','body',{force:true}); await flush();
console.log(JSON.stringify({autoRequests,autoToasts,seenAfterAuto,forcedRequests:requests-autoRequests}));
''', ('sendBrowserNotification', '_showPwaNotification', 'requestNotificationPermission'))
assert result == dict(autoRequests=1, autoToasts=1, seenAfterAuto=0, forcedRequests=1)
+360 -2
View File
@@ -41,8 +41,366 @@ def test_notification_payload_uses_completion_session_when_provided():
assert "_completionNotificationPreviewText(lastAsst," in MESSAGES_JS
assert "sendBrowserNotification('Response complete',_completionPreview||'Task finished',{forceHidden:_wasEverBackgrounded,sid:activeSid})" in MESSAGES_JS
assert "assistantText?assistantText.slice(0,100)" not in MESSAGES_JS
assert "sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid})" in MESSAGES_JS
assert "sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid})" in MESSAGES_JS
def _extract_fn(src: str, name: str) -> str:
marker = f"function {name}"
start = src.find(marker)
assert start >= 0, f"{name} not found"
brace = src.find("{", src.find(")", start))
depth = 0
for i in range(brace, len(src)):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
if depth == 0:
return src[start : i + 1]
raise AssertionError(f"{name} body did not close")
def _run_node(source: str) -> dict:
import json
import shutil
import subprocess
import tempfile
node = shutil.which("node")
if node is None:
import pytest
pytest.skip("node is required for the executed notification-gate test")
with tempfile.NamedTemporaryFile("w", suffix=".cjs", encoding="utf-8", dir=ROOT, delete=False) as script:
script.write(source)
script_path = Path(script.name)
try:
result = subprocess.run([node, str(script_path)], cwd=str(ROOT), capture_output=True, text=True, timeout=30)
finally:
script_path.unlink(missing_ok=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout.strip())
def test_notify_prompt_card_executed_gate_behavior():
"""Executed node-VM proof of _notifyPromptCard's runtime gates:
(1) dedupes per logical owner (kind + session id + prompt id + gateway
run owner) — a second call for the same owner is a no-op, while the same
prompt id under a different session or gateway run owner notifies again;
(2) suppresses only when the prompt's session is ACTIVELY VIEWED (open in
the pane + tab visible + tab focused) WITHOUT recording, so the same id
still notifies the moment the user looks away (blocking prompt semantics);
(3) notifies for a non-active session EVEN with the tab focused (case 1);
(4) notifies when the tab is hidden or the window unfocused (cases 2-3),
with the right title/body and sid routing."""
helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard")
keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey")
viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed")
current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane")
visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused")
script = f"""
const document = {{ hidden: false, visibilityState: 'visible', hasFocus: () => true }};
const _promptNotifySeen = new Map(); // module-level state closed over by the helper
const _PROMPT_NOTIFY_TTL_MS = 600000; // same, for the stale-key cleanup pass
let _loadingSessionId = null;
const S = {{ session: {{ session_id: 'sid-1' }} }};
{current_pane}
{visible}
{viewed}
{keyfn}
{helper}
const sent = [];
function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }}
const CASES = {{}};
// Case 3: active session sid-1, tab visible, WINDOW unfocused → notifies.
document.hidden = false; document.visibilityState = 'visible'; document.hasFocus = () => false;
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', description: 'window unfocused' }});
CASES.windowUnfocused = sent.length; // 1
// Case 2: tab hidden (different tab selected) → notifies.
document.hidden = true; document.visibilityState = 'hidden';
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a2', description: 'tab hidden' }});
CASES.tabHidden = sent.length; // 2
document.hidden = false; document.visibilityState = 'visible'; document.hasFocus = () => true;
// Case 1: prompt for a NON-active session while tab is fully focused → notifies.
_notifyPromptCard('approval', 'sid-2', {{ approval_id: 'a1', description: 'other session' }});
CASES.otherSession = sent.length; // 3
// Owner-scope regression: same approval_id in a DIFFERENT session must
// notify again — the dedupe key is owner-scoped (kind + sid + id), not
// prompt-id-only.
document.hidden = false; document.visibilityState = 'visible'; document.hasFocus = () => true;
_notifyPromptCard('approval', 'sid-2', {{ approval_id: 'a3', description: 'same id, other session' }});
CASES.sameIdOtherSession = sent.length; // 4 (a3 was already sent for sid-1)
// Gateway owner-scope regression: same session + same approval_id but a
// distinct gateway run owner (run_id + _gateway_mirror_token) must notify
// again; repeating the SAME full owner stays deduped. Window blurred so the
// actively-viewed gate (which would suppress an ACTIVE-session prompt the
// user is looking at) does not interfere with the owner-scope assertions.
document.hasFocus = () => false;
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', run_id: 'run-A', _gateway_mirror_token: 'tok-A', description: 'gateway run A' }});
CASES.gatewayRunA = sent.length; // 5
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', run_id: 'run-B', _gateway_mirror_token: 'tok-B', description: 'gateway run B' }});
CASES.gatewayRunB = sent.length; // 6
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', run_id: 'run-A', _gateway_mirror_token: 'tok-A', description: 'gateway run A repeat' }});
CASES.gatewayRunARepeat = sent.length; // still 6 (same full owner deduped)
document.hasFocus = () => true;
// Suppressed case: prompt for the ACTIVE session with tab visible+focused —
// the user is literally looking at the card. NOT recorded, so...
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a4', description: 'watching' }});
CASES.activelyViewed = sent.length; // still 3 (no ping while watching)
// ...looking away (window blur) pings that same id exactly once.
document.hasFocus = () => false;
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a4', description: 'watching' }});
CASES.afterBlur = sent.length; // 4
document.hasFocus = () => true;
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a4', description: 'watching' }}); // dedupe
// Dedupe holds across focus changes for the first id too.
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', description: 'window unfocused' }});
// Clarify shape routes its own title/body (window blurred again).
document.hasFocus = () => false;
_notifyPromptCard('clarify', 'sid-1', {{ clarify_id: 'c1', question: 'Which one?' }});
CASES.clarify = sent.length; // 5
process.stdout.write(JSON.stringify({{ CASES, sent }}));
"""
result = _run_node(script)
cases = result["CASES"]
sent = result["sent"]
# The user's three notification cases must all fire.
assert cases["windowUnfocused"] == 1, f"case 3 (window unfocused) must notify (got {cases['windowUnfocused']})"
assert cases["tabHidden"] == 2, f"case 2 (tab hidden) must notify (got {cases['tabHidden']})"
assert cases["otherSession"] == 3, f"case 1 (non-active session, tab focused) must notify (got {cases['otherSession']})"
# Suppression only while actively viewing; the suppressed id fires after blur.
assert cases["activelyViewed"] == 6, f"no ping while actively viewing the card (got {cases['activelyViewed']})"
assert cases["afterBlur"] == 7, f"the suppressed id must ping exactly once after blur (got {cases['afterBlur']})"
assert cases["clarify"] == 8, f"clarify routes its own title/body (got {cases['clarify']})"
assert cases["sameIdOtherSession"] == 4, f"same approval_id in another session must notify again (got {cases['sameIdOtherSession']})"
assert cases["gatewayRunA"] == 5, f"distinct gateway run owner must notify again (got {cases['gatewayRunA']})"
assert cases["gatewayRunB"] == 6, f"second distinct gateway run owner must notify again (got {cases['gatewayRunB']})"
assert cases["gatewayRunARepeat"] == 6, f"repeated calls for the same full owner must stay deduped (got {cases['gatewayRunARepeat']})"
assert len(sent) == 8, f"expected exactly 8 pings, got {len(sent)}: {sent}"
assert sent[0]["title"] == "Approval required"
assert sent[0]["options"] == {"sid": "sid-1", "forceHidden": True}
assert sent[2]["options"] == {"sid": "sid-2", "forceHidden": True}
assert sent[2]["body"] == "other session"
assert sent[7]["title"] == "Clarification needed"
assert sent[7]["body"] == "Which one?"
# forceHidden must be set: _notifyPromptCard already made the visibility
# decision, and sendBrowserNotification's live gate ("notify only when
# document.hidden") would otherwise veto the unfocused-but-visible case.
assert all(o["options"].get("forceHidden") for o in sent)
# ── Reviewer reproduction regressions (PR #7493 round 2) ────────────────────
def test_owner_key_is_injective():
"""The dedupe key is built from a typed owner TUPLE serialized with
JSON.stringify, so delimiter-bearing producer strings (the gateway
accepts approval_id as an unrestricted string) cannot collide with a
different owner that merely contains the same delimiters."""
helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard")
keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey")
viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed")
current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane")
visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused")
script = f"""
const document = {{ hidden: true, visibilityState: 'hidden', hasFocus: () => false }};
const _promptNotifySeen = new Map();
let _loadingSessionId = null;
const S = {{ session: {{ session_id: 'sid-1' }} }};
{current_pane}
{visible}
{viewed}
{keyfn}
{helper}
const sent = [];
function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }}
// Owner 1: approval_id='x' with the full gateway-owner pair.
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'x', run_id: 'r', _gateway_mirror_token: 't', description: 'owner one' }});
// Owner 2: approval_id CONTAINING the exact delimiter sequence of owner 1,
// with NO gateway-owner fields - a plain concatenation key would collide
// here and suppress this second, distinct owner.
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'x\u0000r\u0000t', description: 'owner two' }});
process.stdout.write(JSON.stringify({{ count: sent.length, bodies: sent.map(s => s.body) }}));
"""
result = _run_node(script)
assert result["count"] == 2, f"two distinct owners must both notify (got {result})"
assert result["bodies"] == ['owner one', 'owner two']
def test_same_pending_owner_does_not_renotify_on_wall_clock():
"""A prompt left pending beyond the old 10-minute TTL must NOT re-notify:
seen entries retire on prompt lifecycle (resolution), not wall-clock age.
Simulated by backdating the seen entry far past the retired TTL window."""
helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard")
keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey")
viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed")
current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane")
visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused")
script = f"""
const document = {{ hidden: true, visibilityState: 'hidden', hasFocus: () => false }};
const _promptNotifySeen = new Map();
let _loadingSessionId = null;
const S = {{ session: {{ session_id: 'sid-1' }} }};
{current_pane}
{visible}
{viewed}
{keyfn}
{helper}
const sent = [];
function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }}
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'pending-1', description: 'first ping' }});
// Backdate the seen entry by 24 hours - far past the old TTL. With
// lifecycle-based retirement this must NOT re-notify on the next poll tick.
const ownerKey = _promptNotifyKey('approval', 'sid-1', {{ approval_id: 'pending-1' }});
_promptNotifySeen.set(ownerKey, Date.now() - 24 * 60 * 60 * 1000);
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'pending-1', description: 'poll tick 24h later' }});
process.stdout.write(JSON.stringify({{ count: sent.length }}));
"""
result = _run_node(script)
assert result["count"] == 1, f"still-pending owner must not re-notify after 24h (got {result})"
def test_resolved_owner_allows_legitimate_id_reuse():
"""When a prompt's lifecycle ENDS (resolution/dismissal clears pending
state via _clearApprovalPendingForSession), the seen entry is retired, so
a later prompt reusing the same ID notifies again."""
helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard")
keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey")
retire = _extract_fn(MESSAGES_JS, "_retirePromptNotifyKey")
viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed")
current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane")
visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused")
script = f"""
const document = {{ hidden: true, visibilityState: 'hidden', hasFocus: () => false }};
const _promptNotifySeen = new Map();
let _loadingSessionId = null;
const S = {{ session: {{ session_id: 'sid-1' }} }};
{current_pane}
{visible}
{viewed}
{keyfn}
{retire}
{helper}
const sent = [];
function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }}
const pending1 = {{ approval_id: 'id-1', run_id: 'r1', _gateway_mirror_token: 't1', description: 'first' }};
_notifyPromptCard('approval', 'sid-1', pending1);
// Resolution path: the pending map entry clears, which must retire the
// dedupe entry for that exact owner.
_retirePromptNotifyKey('approval', 'sid-1', pending1);
// Same session + same ID + same run owner later: legitimate reuse, notifies.
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'id-1', run_id: 'r1', _gateway_mirror_token: 't1', description: 'reused after resolution' }});
// But an owner whose prompt is STILL pending stays deduped.
_notifyPromptCard('approval', 'sid-1', pending1);
process.stdout.write(JSON.stringify({{ count: sent.length, bodies: sent.map(s => s.body) }}));
"""
result = _run_node(script)
assert result["count"] == 2, f"resolved owner ID reuse must notify again, still-pending owner must not (got {result})"
assert result["bodies"] == ['first', 'reused after resolution']
def test_disabled_notifications_do_not_consume_notify():
"""When notifications are disabled, no seen entry is recorded - so the
same owner still notifies once the user enables notifications mid-prompt.
Failed/denied delivery must not permanently consume the notification."""
helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard")
keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey")
viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed")
current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane")
visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused")
script = f"""
const document = {{ hidden: true, visibilityState: 'hidden', hasFocus: () => false }};
const window = {{ _notificationsEnabled: false }};
const _promptNotifySeen = new Map();
let _loadingSessionId = null;
const S = {{ session: {{ session_id: 'sid-1' }} }};
{current_pane}
{visible}
{viewed}
{keyfn}
{helper}
const sent = [];
function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }}
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'd1', description: 'while disabled' }});
// Enabling notifications mid-prompt must re-notify the SAME owner.
window._notificationsEnabled = true;
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'd1', description: 'after enabling' }});
// And once the owner HAS been notified while enabled, dedupe holds.
_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'd1', description: 'repeat while enabled' }});
// The disabled first call recorded nothing; the enabled second call is now
// the consumed notify for this owner.
const ownerKey = _promptNotifyKey('approval', 'sid-1', {{ approval_id: 'd1' }});
process.stdout.write(JSON.stringify({{ count: sent.length, bodies: sent.map(s => s.body), consumed: _promptNotifySeen.has(ownerKey) }}));
"""
result = _run_node(script)
assert result["count"] == 1, f"unexpected ping count (got {result})"
assert result["bodies"][0] == 'after enabling', f"enabling mid-prompt must re-notify the same owner (got {result})"
assert result["consumed"] is True, "the enabled notify must be recorded (consumed) for the owner"
def test_displaced_pending_prompt_key_is_retired_on_replacement():
"""Greptile round-2 regression: when a NEW approval/clarify replaces the
session pending entry, the DISPLACED prompt notification-dedupe key must
retire. Without retirement (and with TTL-based expiry gone) the stale key
persists forever, so the same externally supplied ID re-surfacing later is
wrongly suppressed as already-notified. A re-render of the SAME owner must
keep its key (still deduped)."""
remember_ap = _extract_fn(MESSAGES_JS, "_rememberApprovalPending")
remember_cl = _extract_fn(MESSAGES_JS, "_rememberClarifyPending")
clear_ap = _extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession")
keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey")
retire = _extract_fn(MESSAGES_JS, "_retirePromptNotifyKey")
active = _extract_fn(MESSAGES_JS, "_promptActiveSessionId")
approval_generation = _extract_fn(MESSAGES_JS, "_approvalPromptGeneration")
bump_approval_generation = _extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration")
clarify_generation = _extract_fn(MESSAGES_JS, "_clarifyPromptGeneration")
bump_clarify_generation = _extract_fn(MESSAGES_JS, "_bumpClarifyPromptGeneration")
script = f"""
const _promptNotifySeen = new Map();
const _isApprovalDismissed = () => false;
const _unmarkApprovalDismissed = () => {{}};
const S = {{ session: {{ session_id: "sid-1" }} }};
{keyfn}
{retire}
{active}
{approval_generation}
{bump_approval_generation}
{clarify_generation}
{bump_clarify_generation}
{remember_ap}
{remember_cl}
{clear_ap}
const _approvalPendingBySession = new Map();
const _clarifyPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map();
const _clarifyPromptGenerationBySession = new Map();
// Displaced approval owner: id-1, then a NEW different approval for sid-1.
_rememberApprovalPending({{ approval_id: "id-1", description: "first" }}, 1);
const key1 = _promptNotifyKey("approval", "sid-1", {{ approval_id: "id-1" }});
_promptNotifySeen.set(key1, Date.now());
_rememberApprovalPending({{ approval_id: "id-2", description: "replacement" }}, 1);
const displacedRetired = !_promptNotifySeen.has(key1);
// Same-owner re-render must NOT retire its own key (seed it as already
// notified, as a prior poll tick would have).
const key2 = _promptNotifyKey("approval", "sid-1", {{ approval_id: "id-2" }});
_promptNotifySeen.set(key2, Date.now());
_rememberApprovalPending({{ approval_id: "id-2", description: "re-render same owner" }}, 1);
const sameOwnerKept = _promptNotifySeen.has(key2);
// Clear path retires the CURRENT entry.
_clearApprovalPendingForSession("sid-1");
const clearedRetired = !_promptNotifySeen.has(_promptNotifyKey("approval", "sid-1", {{ approval_id: "id-2" }}));
// Clarify mirror: displacement retires, same-owner re-render keeps.
_rememberClarifyPending({{ clarify_id: "c-1" }});
const ckey1 = _promptNotifyKey("clarify", "sid-1", {{ clarify_id: "c-1" }});
_promptNotifySeen.set(ckey1, Date.now());
_rememberClarifyPending({{ clarify_id: "c-2" }});
const clarifyDisplacedRetired = !_promptNotifySeen.has(ckey1);
const ckey2 = _promptNotifyKey("clarify", "sid-1", {{ clarify_id: "c-2" }});
_promptNotifySeen.set(ckey2, Date.now());
_rememberClarifyPending({{ clarify_id: "c-2" }});
const clarifySameOwnerKept = _promptNotifySeen.has(ckey2);
process.stdout.write(JSON.stringify({{ displacedRetired, sameOwnerKept, clearedRetired, clarifyDisplacedRetired, clarifySameOwnerKept }}));
"""
result = _run_node(script)
assert result["displacedRetired"] is True, f"displaced approval key must retire on replacement (got {result})"
assert result["sameOwnerKept"] is True, f"same-owner re-render must keep its dedupe key (got {result})"
assert result["clearedRetired"] is True, f"clear path must retire the current entry key (got {result})"
assert result["clarifyDisplacedRetired"] is True, f"displaced clarify key must retire on replacement (got {result})"
assert result["clarifySameOwnerKept"] is True, f"clarify same-owner re-render must keep its key (got {result})"
def test_completion_notification_preview_uses_settled_message_not_live_prefix():
+2 -1
View File
@@ -793,7 +793,8 @@ def test_renderMessages_preserves_loading_placeholder_for_session_switch(cleanup
ui_src = (REPO_ROOT / "static/ui.js").read_text()
fn_start = ui_src.find("function renderMessages")
assert fn_start >= 0, "renderMessages() not found in ui.js"
fn_body = ui_src[fn_start:fn_start + 1400]
# Window sized to reach the render-window reset; renderMessages() preamble grows (#6717).
fn_body = ui_src[fn_start:fn_start + 2400]
compact = re.sub(r"\s+", "", fn_body)
assert (
+206
View File
@@ -0,0 +1,206 @@
"""Observed Agent runtime, durable replay and HTTP reconnect projection."""
import json
import queue
import sys
import types
from unittest import mock
import pytest
from api.run_journal import RunJournalWriter, latest_run_summary, read_run_events
def observation(model="backup", *, session_id="session-a", stream_id="run-a"):
return {"session_id": session_id, "stream_id": stream_id, "model": model,
"provider": "custom", "fallback_active": True, "phase": "observed_output"}
def test_journal_runtime_is_owned_and_invalidated(tmp_path):
writer = RunJournalWriter("session-a", "run-a", session_dir=tmp_path)
writer.append_sse_event("token", {"text": "answer"})
first = writer.append_sse_event("runtime_model", observation())
assert read_run_events("session-a", "run-a", session_dir=tmp_path)["events"][-1] == first
summary = latest_run_summary("session-a", "run-a", session_dir=tmp_path)
assert summary["runtime_model"] == observation()
summary["runtime_model"]["model"] = "corrupted"
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] == observation()
writer.append_sse_event("warning", {"type": "fallback", "message": "Trying another route"})
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] is None
writer.append_sse_event("runtime_model", {**observation(), "stream_id": "foreign"})
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] is None
writer.append_sse_event("runtime_model", observation("recovered"))
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"]["model"] == "recovered"
writer.append_sse_event("runtime_model", "malformed observation")
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] is None
writer.append_sse_event("runtime_model", observation("recovered-again"))
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"]["model"] == "recovered-again"
assert latest_run_summary("session-a", "another-run", session_dir=tmp_path)["runtime_model"] is None
@pytest.mark.parametrize("malformed", ["bad observation", ["bad observation"], None])
def test_non_object_observation_invalidates_summary_and_http_snapshot(tmp_path, monkeypatch, malformed):
from api import routes, run_journal
writer = RunJournalWriter("session-a", "run-a", session_dir=tmp_path)
writer.append_sse_event("runtime_model", observation("first"))
writer.append_sse_event("token", malformed)
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"]["model"] == "first"
writer.append_sse_event("runtime_model", malformed if malformed is not None else {"placeholder": True})
if malformed is None:
# The writer normalizes None to {}; exercise a legacy/raw null journal row.
path = run_journal._run_path("session-a", "run-a", session_dir=tmp_path)
rows = [json.loads(line) for line in path.read_text().splitlines()]
rows[-1]["payload"] = None
path.write_text("\n".join(json.dumps(row) for row in rows) + "\n")
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] is None
monkeypatch.setattr(routes, "find_run_summary", lambda rid: latest_run_summary("session-a", rid, session_dir=tmp_path))
monkeypatch.setattr(routes, "read_run_events", lambda sid, rid: read_run_events(sid, rid, session_dir=tmp_path))
snapshot = routes._run_journal_live_snapshot("run-a")
assert snapshot["runtime_model"] is None
assert routes._runtime_journal_snapshot_for_session_payload(snapshot)["runtime_model"] is None
@pytest.mark.parametrize("suffix,expected", [("", "backup"), ("warning", None), ("foreign", None)])
def test_http_snapshot_projects_exact_journal_window(tmp_path, monkeypatch, suffix, expected):
import api.routes as routes
writer = RunJournalWriter("session-a", "run-a", session_dir=tmp_path)
writer.append_sse_event("runtime_model", observation())
writer.append_sse_event("token", {"text": "answer"})
if suffix == "warning":
writer.append_sse_event("warning", {"type": "fallback"})
elif suffix == "foreign":
writer.append_sse_event("runtime_model", {**observation(), "session_id": "foreign"})
monkeypatch.setattr(routes, "find_run_summary", lambda rid: latest_run_summary("session-a", rid, session_dir=tmp_path))
monkeypatch.setattr(routes, "read_run_events", lambda sid, rid: read_run_events(sid, rid, session_dir=tmp_path))
snapshot = routes._run_journal_live_snapshot("run-a")
assert snapshot["last_assistant_text"] == "answer"
value = snapshot["runtime_model"]
assert (value["model"] if value else None) == expected
assert routes._runtime_journal_snapshot_for_session_payload(snapshot)["runtime_model"] == value
def run_worker(steps, *, fail=False, ephemeral=False, tmp_path=None, monkeypatch=None):
"""Drive the production local worker; fake only external Agent/provider calls."""
from api import streaming, run_journal
from api.config import SESSION_AGENT_CACHE
SESSION_AGENT_CACHE.clear()
monkeypatch.setattr(run_journal, "_default_session_dir", lambda: tmp_path)
class Session:
session_id = "session-a"
title = "Session"
workspace = "/tmp"
model = "primary"
model_provider = "anthropic"
profile = None
personality = None
messages = [{"role": "user", "content": "old"}, {"role": "assistant", "content": "old answer"}]
context_messages = messages
input_tokens = output_tokens = cache_read_tokens = cache_write_tokens = 0
estimated_cost = 0.0
tool_calls = []
gateway_routing = None
gateway_routing_history = []
active_stream_id = "run-a"
pending_user_message = None
pending_attachments = []
pending_started_at = None
context_length = threshold_tokens = last_prompt_tokens = 0
llm_title_generated = True
def save(self, *args, **kwargs): pass
def compact(self): return {"session_id": self.session_id}
class Agent:
def __init__(self, model=None, provider=None, session_id=None,
stream_delta_callback=None, reasoning_callback=None,
status_callback=None, **kwargs):
self.model, self.provider, self.session_id = model, provider, session_id
self.stream_delta_callback = stream_delta_callback
self.reasoning_callback = reasoning_callback
self.status_callback = status_callback
self._provider_fallback_active = False
self.context_compressor = None
self.session_prompt_tokens = self.session_completion_tokens = 0
self.session_estimated_cost_usd = None
self.session_cache_read_tokens = self.session_cache_write_tokens = 0
self.reasoning_config = self.ephemeral_system_prompt = self._last_error = None
def run_conversation(self, **kwargs):
for model, provider, fallback, channel, text in steps:
self.model, self.provider, self._provider_fallback_active = model, provider, fallback
if channel == "token": self.stream_delta_callback(text)
elif channel == "reasoning": self.reasoning_callback(text)
elif channel == "status": self.status_callback("lifecycle", text)
if fail: return {"error": "provider unavailable", "messages": []}
return {"messages": kwargs.get("conversation_history", []) + [
{"role": "user", "content": kwargs["persist_user_message"]},
{"role": "assistant", "content": "served answer"}]}
def interrupt(self, message): pass
runtime_module = types.ModuleType("hermes_cli.runtime_provider")
runtime_module.resolve_runtime_provider = mock.Mock(return_value={
"provider": "anthropic", "base_url": None, "api_key": "sk-test",
"api_mode": "chat_completions", "command": None, "args": [], "credential_pool": None})
cli_module = types.ModuleType("hermes_cli")
cli_module.runtime_provider = runtime_module
state_module = types.ModuleType("hermes_state")
state_module.SessionDB = mock.Mock(return_value=None)
injected = {"hermes_cli": cli_module, "hermes_cli.runtime_provider": runtime_module,
"hermes_state": state_module}
sentinel = object()
saved = {name: sys.modules.get(name, sentinel) for name in injected}
sys.modules.update(injected)
q = queue.Queue()
s = Session()
try:
with mock.patch.object(streaming, "get_session", return_value=s), \
mock.patch.object(streaming, "_get_ai_agent", return_value=Agent), \
mock.patch.object(streaming, "resolve_model_provider", return_value=("primary", "anthropic", None)), \
mock.patch("api.config.get_config", return_value={}), \
mock.patch("api.config._resolve_cli_toolsets", return_value=[]):
streaming.STREAMS["run-a"] = q
streaming._run_agent_streaming("session-a", "new turn", "primary", "/tmp", "run-a", ephemeral=ephemeral)
finally:
for name, original in saved.items():
if original is sentinel: sys.modules.pop(name, None)
else: sys.modules[name] = original
streaming.STREAMS.pop("run-a", None)
SESSION_AGENT_CACHE.clear()
return s, list(q.queue)
def test_worker_observes_serving_runtime_not_requested(tmp_path, monkeypatch):
s, events = run_worker([
("primary", "anthropic", False, "reasoning", "thinking"),
("backup", "custom", True, "token", "answer"),
("backup", "custom", True, "status", "Switched to fallback: backup (custom)"),
], tmp_path=tmp_path, monkeypatch=monkeypatch)
names = [name for name, _ in events]
assert names.count("runtime_model") == 3 # primary, backup, post-warning correction
observed = [value for name, value in events if name == "runtime_model"]
assert [value["model"] for value in observed] == ["primary", "backup", "backup"]
assert observed[-1] == observation()
assert names.index("runtime_model") < names.index("reasoning")
assert names.index("warning") < len(names) - 1 - names[::-1].index("runtime_model")
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] == observation()
assert s.model == "primary" # selection is not overwritten
def test_worker_does_not_observe_attempt_or_missing_model(tmp_path, monkeypatch):
_, events = run_worker([
("backup", "custom", True, "status", "Trying fallback backup"),
(None, "custom", True, "token", "partial"),
], fail=True, tmp_path=tmp_path, monkeypatch=monkeypatch)
assert not any(name == "runtime_model" for name, _ in events)
def test_missing_agent_model_does_not_stamp_requested_as_served(tmp_path, monkeypatch):
s, events = run_worker([(None, "custom", True, "token", "partial")],
tmp_path=tmp_path, monkeypatch=monkeypatch)
assert not any(name == "runtime_model" for name, _ in events)
assert "_usedModel" not in s.messages[-1]
usage = next(value["usage"] for name, value in events if name == "done")
assert not usage.get("used_model")
def test_worker_nonstreaming_success_observes_fallback(tmp_path, monkeypatch):
_, events = run_worker([("backup", "custom", True, "route", "")],
tmp_path=tmp_path, monkeypatch=monkeypatch, ephemeral=True)
assert [value["model"] for name, value in events if name == "runtime_model"] == ["backup"]
+386
View File
@@ -0,0 +1,386 @@
"""A save must not read and parse the whole sidecar just to count its messages.
The #1558 backup safeguard in `Session.save()` compares the on-disk message
count with the incoming one, and writes `<sid>.json.bak` only when the save
would SHRINK the array. Sound. But it obtained the on-disk count by
`json.loads(self.path.read_text())` -- the entire file -- on EVERY save,
including the 99.9% of saves that grow the conversation and never back
anything up. `session_recovery._msg_count()` did the same on every boot, for
every live file that has a `.bak`.
Measured in production on 2026-09-15, in the running container:
sessions/fa3bca34a0c6.json 203,439,398 bytes / 266,940 messages
read_text 17,453 ms
json.loads 2,924 ms
TOTAL to obtain one integer: 20,377 ms <- paid on every save
That was the 39,357 ms `/api/session` request stuck at stage
`t6_after_json_write`, after the read-side re-parse (#4633 recurrence) had
already been fixed. The cost scales linearly with the file, and the file grows
for as long as the conversation does.
What the check needs is ONE integer -- how many messages the file on disk
holds -- and `save()` already writes that integer into the metadata prefix,
before `messages`, as `message_count`, exactly so readers can have it without
parsing the body (that is how `load_metadata_only()` and the sidebar freshness
check work). So `save()` reads that, through the same bounded 64 KiB prefix
reader, and falls back to the full read + parse whenever the prefix does not
carry a usable count. The `.bak` body is the one thing that still needs the
full text, and a shrink is the one time it is needed.
The count is read from the FILE, not cached in the object, and that is the
point. An earlier revision of this PR trusted an in-memory (inode, size,
mtime_ns) signature: "this object wrote the file and stat says nothing changed,
so its length is already known". Review (nesquena-hermes, 2026-09-21) showed
that is not a content identity -- a same-length in-place rewrite inside one
mtime tick keeps all three fields, because ext4 stamps mtime from a coarse
clock, so a stale cached count reads a real shrink as a growth and skips the
#1558 backup. That is the test at the bottom of this file. A count read from
the bytes cannot be fooled that way: any rewrite of the bytes rewrites it.
Fail-open is the contract these tests pin -- whenever the prefix has no usable
count (a legacy pre-#5854 sidecar, a file with no top-level `messages` key, a
corrupt or truncated prefix, metadata that overflows the budget), the behaviour
must be exactly today's full parse, never "assume no shrink".
`session_recovery._msg_count()` pays the same cost at boot and is left alone
on purpose: its "torn file -> -1" contract is what makes recovery restore a
`.bak` over a truncated live file, and no bounded read can prove a file is
not truncated.
"""
import json
import os
import pathlib
from collections import OrderedDict
import pytest
import api.models as M
@pytest.fixture
def session_store(tmp_path, monkeypatch):
sdir = tmp_path / "sessions"
sdir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(M, "SESSION_DIR", sdir)
monkeypatch.setattr(M, "SESSIONS", OrderedDict())
return sdir
def _msgs(n):
return [{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"} for i in range(n)]
def _make(session_store, sid, n):
s = M.Session(session_id=sid, title="T", workspace=str(session_store.parent),
model="glm", messages=_msgs(n))
s.save()
return s
def _spy_full_reads(monkeypatch, target):
"""Count `Path.read_text()` calls on ONE path.
`read_text` is the whole-file read; the metadata-prefix reader uses `open()`
with a byte budget and never goes through it. So this counter is exactly
"how many times was this file read in full", which is the cost being removed.
"""
calls = {"n": 0}
real = pathlib.Path.read_text
target = pathlib.Path(target)
def counting(self, *a, **k):
if pathlib.Path(self) == target:
calls["n"] += 1
return real(self, *a, **k)
monkeypatch.setattr(type(pathlib.Path()), "read_text", counting)
return calls
# ── save(): the grow path must be free of full reads ────────────────────────
def test_grow_save_does_not_read_the_existing_file_in_full(session_store, monkeypatch):
"""The common case. Appending messages must not read the previous file
in full -- this object wrote that file and it is untouched, so the on-disk
count is already known."""
s = _make(session_store, "g1", 5)
calls = _spy_full_reads(monkeypatch, s.path)
s.messages = _msgs(7)
s.save()
assert calls["n"] == 0, f"grow-save read the existing sidecar in full {calls['n']}x"
assert not s.path.with_suffix(".json.bak").exists(), "a grow-save must not produce a backup"
assert len(M.Session.load("g1").messages) == 7
def test_same_size_save_does_not_read_the_existing_file_in_full(session_store, monkeypatch):
"""Metadata-only saves (title, flags, stream state) keep the array as-is.
They are the most frequent save of all and must be just as cheap."""
s = _make(session_store, "g2", 5)
calls = _spy_full_reads(monkeypatch, s.path)
s.title = "renamed"
s.save()
assert calls["n"] == 0
assert not s.path.with_suffix(".json.bak").exists()
# ── save(): the safeguards must be untouched ────────────────────────────────
def test_shrink_save_still_writes_bak_with_the_pre_shrink_content(session_store, monkeypatch):
"""#1558 must keep working. A shrink still produces a `.bak` holding the
pre-shrink array -- and reads the file in full exactly once, for that body.
Not twice: knowing the count must not be followed by a redundant parse."""
s = _make(session_store, "s1", 5)
calls = _spy_full_reads(monkeypatch, s.path)
s.messages = _msgs(3)
s.save()
reads_by_save = calls["n"] # taken before load() below, which reads the file too
bak = s.path.with_suffix(".json.bak")
assert bak.exists(), "a shrinking save must leave a recoverable backup"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 5
assert len(M.Session.load("s1").messages) == 3
assert reads_by_save == 1, f"shrink-save read the sidecar in full {reads_by_save}x; the .bak body needs exactly one"
def test_empty_active_snapshot_is_still_refused(session_store, monkeypatch):
"""The other guard in the same block: an empty array with a live stream or
pending prompt must NOT overwrite a populated file (the #1558 data-loss
shape). This object was neither loaded nor saved by this process, so it has
no signature to trust and refusing may cost today's single full read --
never more, and it must still refuse."""
_make(session_store, "r1", 5)
empty = M.Session(session_id="r1", title="T", workspace=str(session_store.parent),
model="glm", messages=[], active_stream_id="a" * 32,
pending_user_message="still typing")
calls = _spy_full_reads(monkeypatch, empty.path)
empty.save()
reads_by_save = calls["n"] # taken before load() below, which reads the file too
assert len(M.Session.load("r1").messages) == 5, "the populated file must survive"
assert reads_by_save <= 1, f"refusing read the file {reads_by_save}x; an unknown identity costs at most one"
# ── save(): fail-open whenever the prefix carries no usable count ───────────
def _write_legacy_without_count(session_store, sid, n):
"""A pre-#5854 sidecar: no persisted `message_count` at all."""
doc = {"session_id": sid, "title": "Legacy", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"messages": _msgs(n)}
p = session_store / f"{sid}.json"
p.write_text(json.dumps(doc, indent=2), encoding="utf-8")
return p
def test_legacy_sidecar_without_message_count_still_backs_up_on_shrink(session_store):
"""The prefix has no count to give, so this must fall back to the full
parse and keep the safeguard. Silently treating "no count" as "no shrink"
would reopen #1558."""
_write_legacy_without_count(session_store, "l1", 5)
s = M.Session(session_id="l1", title="Legacy", workspace=str(session_store.parent),
model="glm", messages=_msgs(3))
s.save()
bak = s.path.with_suffix(".json.bak")
assert bak.exists(), "fail-open: a legacy file must still be backed up on shrink"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 5
def test_file_changed_on_disk_since_last_save_falls_back_and_still_backs_up(session_store):
"""An externally grown file. This process saved 3 messages; something else
then rewrote the file with 6 (an external appender, another process, a
restore). The next save with 4 is a real shrink relative to DISK, and the
count that governs it is the one the file now carries (6), not the 3 this
process remembers: the `.bak` must hold the 6-message array."""
s = _make(session_store, "x1", 3)
external = {"session_id": "x1", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"message_count": 6, "messages": _msgs(6)}
s.path.write_text(json.dumps(external, indent=2), encoding="utf-8")
s.messages = _msgs(4)
s.save()
bak = s.path.with_suffix(".json.bak")
assert bak.exists(), "an externally grown file must still be backed up on shrink"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 6
def test_file_changed_on_disk_without_a_count_still_backs_up_on_shrink(session_store):
"""The same external rewrite, but the replacement carries no
`message_count` (an older writer, a hand-edited file). No prefix count ->
the full parse runs and the 6-message array is still backed up."""
s = _make(session_store, "x2", 3)
external = {"session_id": "x2", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"messages": _msgs(6)}
s.path.write_text(json.dumps(external, indent=2), encoding="utf-8")
s.messages = _msgs(4)
s.save()
bak = s.path.with_suffix(".json.bak")
assert bak.exists(), "fail-open: a count-less external rewrite must still be backed up"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 6
def test_unmarked_stale_count_from_a_foreign_writer_still_backs_up_on_shrink(session_store):
"""A persisted count with no `_mc_v` marker is not trusted.
The exact shape review (nesquena-hermes, 2026-09-22) pinned: a sidecar
materialized by an OLDER writer can carry a count stale against the
messages array beside it (the recovery writer used to copy a denormalized
state.db count over real rows). Here the foreign file says 2 but really
holds 6; a save of 4 must not read that 2 as "the save grows the file" and
skip the #1558 backup. The marker gate makes the prefix reader refuse the
unmarked count, the full parse sees the real array, and the 6 messages
land in the `.bak`. Remove the gate in `_prefix_message_count` and this
test fails with no backup written.
"""
s = _make(session_store, "x3", 3)
foreign = {"session_id": "x3", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"message_count": 2, "messages": _msgs(6)} # stale count, NO _mc_v
s.path.write_text(json.dumps(foreign, indent=2), encoding="utf-8")
s.messages = _msgs(4)
s.save()
bak = s.path.with_suffix(".json.bak")
assert bak.exists(), "an unmarked stale count must not hide the shrink"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 6
assert len(M.Session.load("x3").messages) == 4
def test_wrong_marker_version_still_backs_up_on_shrink(session_store):
"""A count marked by a DIFFERENT writer contract is equally untrusted."""
s = _make(session_store, "x4", 3)
foreign = {"session_id": "x4", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"_mc_v": M._MESSAGE_COUNT_MARKER + 1,
"message_count": 2, "messages": _msgs(6)}
s.path.write_text(json.dumps(foreign, indent=2), encoding="utf-8")
s.messages = _msgs(4)
s.save()
bak = s.path.with_suffix(".json.bak")
assert bak.exists(), "a count from another writer version must not hide the shrink"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 6
def test_first_save_remarks_the_file_and_the_fast_path_resumes(session_store, monkeypatch):
"""The gate costs one full parse, once. After a save by the current writer
the file carries `_mc_v`, and the cheap prefix path is back in service."""
s = _make(session_store, "x5", 3)
foreign = {"session_id": "x5", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"message_count": 2, "messages": _msgs(6)} # unmarked foreign file
s.path.write_text(json.dumps(foreign, indent=2), encoding="utf-8")
s.messages = _msgs(6)
s.save() # equal-count save; unmarked prefix count ignored, full parse, no shrink
assert json.loads(s.path.read_text(encoding="utf-8"))["_mc_v"] == M._MESSAGE_COUNT_MARKER
calls = _spy_full_reads(monkeypatch, s.path)
s.messages = _msgs(8)
s.save()
assert calls["n"] == 0, "a marked file must take the bounded-prefix path again"
assert not s.path.with_suffix(".json.bak").exists()
def _tool_partial(ts=123):
"""The exact shape the #2592 collapse recognises (copied from its test)."""
return {"role": "assistant", "content": "", "_partial": True, "timestamp": ts,
"reasoning": "same reasoning",
"_partial_tool_calls": [{"name": "execute_code",
"args": {"code": "raise RuntimeError('boom')"},
"done": True, "is_error": True, "duration": 3.87}]}
def test_collapse_self_heal_on_load_still_backs_up_the_pre_collapse_array(session_store):
"""#2592: load() de-duplicates adjacent partials and immediately saves the
shorter transcript, and that save must produce a `.bak` because it shrinks
the array on purpose. The count that decides it comes from the file's own
prefix, which the writer stamped with the length it wrote (5) -- the
ON-DISK length, not the post-collapse length of the object (3). Read the
object's own length instead and this backup silently disappears, so the
fixture carries the count a real save() would have written."""
doc = {"session_id": "h1", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"_mc_v": M._MESSAGE_COUNT_MARKER,
"message_count": 5,
"messages": [{"role": "user", "content": "run this"},
_tool_partial(), _tool_partial(), _tool_partial(),
{"role": "assistant", "content": "**Task cancelled.**", "_error": True}]}
(session_store / "h1.json").write_text(json.dumps(doc), encoding="utf-8")
loaded = M.Session.load("h1")
assert sum(1 for m in loaded.messages if m.get("_partial")) == 1, "the collapse must have fired"
assert len(loaded.messages) == 3
bak = (session_store / "h1.json").with_suffix(".json.bak")
assert bak.exists(), "the self-heal shrink must leave the pre-collapse array recoverable"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 5
# ── the collision a stat identity cannot see (review, 2026-09-21) ───────────
def test_same_length_in_place_rewrite_inside_one_mtime_tick_still_backs_up(session_store):
"""A same-length, same-mtime, same-inode rewrite must not hide a shrink.
The removed revision of this PR trusted an in-memory (inode, size,
mtime_ns) identity: "this object wrote the file, stat says nothing changed,
so the on-disk length is the one I remember". A stat tuple is not a content
identity. Another writer replaces the sidecar IN PLACE with a longer
transcript that occupies the same number of bytes, inside one mtime tick,
so all three fields still match -- and the next shrinking save then reads
its stale count as "growing", overwrites the longer transcript and writes
no `.bak`. That is the #1558 data-loss shape the safeguard exists to
prevent, reachable on the branch, which is why the identity cache is gone.
The count now comes from the file's own metadata prefix, which any rewrite
of the bytes has to carry, so the shrink is seen for what it is.
"""
p = session_store / "i1.json"
base = {"session_id": "i1", "title": "T", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0}
def _render(n):
return json.dumps({**base, "_mc_v": M._MESSAGE_COUNT_MARKER,
"message_count": n, "messages": _msgs(n)},
indent=2, ensure_ascii=False)
width = max(len(_render(2)), len(_render(5)))
short, grown = _render(2).ljust(width), _render(5).ljust(width)
assert len(short) == len(grown) == width
p.write_text(short, encoding="utf-8")
loaded = M.Session.load("i1") # the only version this object knows
before = p.stat()
p.write_text(grown, encoding="utf-8") # same inode, same byte length...
os.utime(p, ns=(before.st_atime_ns, before.st_mtime_ns))
after = p.stat()
assert (after.st_ino, after.st_size, after.st_mtime_ns) == \
(before.st_ino, before.st_size, before.st_mtime_ns), \
"premise: the rewrite must be invisible to (inode, size, mtime_ns)"
assert len(loaded.messages) == 2, "premise: this object remembers 2 messages"
loaded.messages = _msgs(3)
loaded.save()
bak = p.with_suffix(".json.bak")
assert bak.exists(), "the 5-message transcript must be recoverable"
assert len(json.loads(bak.read_text(encoding="utf-8"))["messages"]) == 5
assert len(M.Session.load("i1").messages) == 3
@@ -0,0 +1,287 @@
"""Regression: send-key preference changes take effect without page reload.
This test catches the bug where an inline capture-phase interceptor in
index.html snapshotted ``_sendKey`` into a closure at page-parse time.
When the user later changed the setting (which updates ``window._sendKey``
via panels.js), the stale closure still called ``stopImmediatePropagation()``
and blocked the newly configured shortcut from reaching the boot.js
composer handler.
The fix removes the inline interceptor entirely — boot.js already
initialises ``window._sendKey`` synchronously and its composer handler
reads the live value on every event.
This Playwright test loads a minimal HTML page that includes the real
boot.js composer keydown handler (extracted verbatim), simulates a
preference change at runtime, and verifies the correct behaviour for
each send-key mode — including the transition from one mode to another
without a page reload.
"""
from __future__ import annotations
from pathlib import Path
import pytest
try:
from playwright.sync_api import sync_playwright
except Exception:
sync_playwright = None
REPO = Path(__file__).resolve().parents[1]
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
def _require_playwright():
if sync_playwright is None:
pytest.skip("playwright is unavailable; run `playwright install chromium`")
return sync_playwright
def _extract_composer_handler() -> str:
"""Extract the real ``$('msg').addEventListener('keydown', …)`` handler
from boot.js, including its full body and the closing ``);``."""
signature = "$('msg').addEventListener('keydown',e=>"
start = BOOT_JS.index(signature)
brace = BOOT_JS.index("{", start)
depth = 0
for idx in range(brace, len(BOOT_JS)):
char = BOOT_JS[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
# Include the closing ``);`` that completes the addEventListener call.
end = BOOT_JS.index(");", idx) + 2
return BOOT_JS[start:end]
raise AssertionError("could not extract composer keydown listener")
def _extract_function(name: str, source: str) -> str:
"""Extract a function by name, matching braces properly."""
pattern = f"function {name}("
start = source.index(pattern)
brace_start = source.index("{", start)
depth = 0
for i in range(brace_start, len(source)):
c = source[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise ValueError(f"Could not extract function {name}")
def _extract_helper_functions() -> str:
"""Extract the helper functions the composer handler depends on."""
names = ("_isImeEnter", "_isNumpadEnter", "_isTouchOnlyDevice")
return "\n".join(_extract_function(n, BOOT_JS) for n in names)
# Minimal HTML that loads the real handler logic.
HARNESS_HTML = """
<!doctype html>
<html><head><meta charset="utf-8"></head>
<body>
<textarea id="msg" rows="1"></textarea>
<script>
// _imeComposing is a module-level flag in boot.js that _isImeEnter references.
var _imeComposing = false;
// Minimal $ shim (boot.js uses $('msg') === document.getElementById('msg'))
function $(id) { return document.getElementById(id); }
// --- Real helper functions extracted from boot.js ---
__HELPERS__
// --- Synchronous _sendKey init (from boot.js line ~2362) ---
try { window._sendKey = localStorage.getItem('hermes-pref-send_key') || 'enter'; } catch(_) { window._sendKey = 'enter'; }
// --- Real composer handler extracted from boot.js ---
// send() stub: count invocations instead of doing real work.
window.__sendCount = 0;
window.send = function() { window.__sendCount++; };
var send = window.send; // local alias so extracted handler's send() resolves
__HANDLER__
</script>
</body></html>
"""
def _build_harness_html() -> str:
return HARNESS_HTML.replace("__HELPERS__", _extract_helper_functions()).replace(
"__HANDLER__", _extract_composer_handler()
)
@pytest.fixture
def page():
_require_playwright()
with sync_playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
context = browser.new_context()
pg = context.new_page()
html = _build_harness_html()
import tempfile
tmp = Path(tempfile.mkstemp(suffix=".html")[1])
tmp.write_text(html, encoding="utf-8")
pg.goto(tmp.as_uri())
pg.wait_for_load_state("domcontentloaded")
yield pg
browser.close()
def _set_send_key(page, mode: str):
"""Simulate panels.js applying a saved settings preference at runtime."""
page.evaluate(
f"""() => {{
localStorage.setItem('hermes-pref-send_key', '{mode}');
window._sendKey = '{mode}';
}}"""
)
def _press_enter(page, *, shift=False, ctrl=False, meta=False, numpad=False):
"""Dispatch a real Enter keydown on #msg with optional modifiers."""
code = "NumpadEnter" if numpad else "Enter"
location = 3 if numpad else 0
page.evaluate(
"""
({shift, ctrl, meta, code, location}) => {
const el = document.getElementById('msg');
el.focus();
el.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter',
code: code,
keyCode: 13,
which: 13,
location: location,
shiftKey: shift,
ctrlKey: ctrl,
metaKey: meta,
bubbles: true,
cancelable: true,
}));
}
""",
{"shift": shift, "ctrl": ctrl, "meta": meta, "code": code, "location": location},
)
def _send_count(page) -> int:
return page.evaluate("__sendCount")
# ─── Static guard ─────────────────────────────────────────────────────────
def test_no_inline_interceptor_remains():
"""The stale-snapshot interceptor must be gone from index.html."""
assert "stopImmediatePropagation" not in INDEX_HTML, (
"index.html still contains a capture-phase interceptor with "
"stopImmediatePropagation — this reintroduces the stale _sendKey bug"
)
# ─── Behavioural tests ────────────────────────────────────────────────────
def test_enter_mode_plain_enter_sends(page):
_set_send_key(page, "enter")
_press_enter(page)
assert _send_count(page) == 1
def test_enter_mode_shift_enter_does_not_send(page):
_set_send_key(page, "enter")
_press_enter(page, shift=True)
assert _send_count(page) == 0
def test_shift_enter_mode_plain_enter_does_not_send(page):
_set_send_key(page, "shift+enter")
_press_enter(page)
assert _send_count(page) == 0
def test_shift_enter_mode_shift_enter_sends(page):
_set_send_key(page, "shift+enter")
_press_enter(page, shift=True)
assert _send_count(page) == 1
def test_ctrl_enter_mode_plain_enter_does_not_send(page):
_set_send_key(page, "ctrl+enter")
_press_enter(page)
assert _send_count(page) == 0
def test_ctrl_enter_mode_ctrl_enter_sends(page):
_set_send_key(page, "ctrl+enter")
_press_enter(page, ctrl=True)
assert _send_count(page) == 1
def test_ctrl_enter_mode_cmd_enter_sends(page):
_set_send_key(page, "ctrl+enter")
_press_enter(page, meta=True)
assert _send_count(page) == 1
# ─── Core regression: live preference change ─────────────────────────────
def test_preference_change_enter_to_shift_enter_without_reload(page):
"""The bug: interceptor snapshotted 'enter' at load time. Changing to
'shift+enter' at runtime didn't update the closure, so Shift+Enter was
still blocked. After the fix, the live window._sendKey is read on every
event, so the transition takes effect immediately."""
# Start in 'enter' mode — plain Enter sends.
_set_send_key(page, "enter")
_press_enter(page)
assert _send_count(page) == 1
# Switch to 'shift+enter' without reloading.
_set_send_key(page, "shift+enter")
# Plain Enter should no longer send.
_press_enter(page)
assert _send_count(page) == 1 # unchanged
# Shift+Enter should now send.
_press_enter(page, shift=True)
assert _send_count(page) == 2
def test_preference_change_shift_enter_to_enter_without_reload(page):
"""Reverse transition: 'shift+enter' → 'enter'."""
_set_send_key(page, "shift+enter")
_press_enter(page) # no send
_press_enter(page, shift=True) # sends
assert _send_count(page) == 1
_set_send_key(page, "enter")
_press_enter(page) # now sends
assert _send_count(page) == 2
_press_enter(page, shift=True) # no longer sends
assert _send_count(page) == 2
def test_preference_change_enter_to_ctrl_enter_without_reload(page):
"""Transition: 'enter' → 'ctrl+enter'."""
_set_send_key(page, "enter")
_press_enter(page) # sends
assert _send_count(page) == 1
_set_send_key(page, "ctrl+enter")
_press_enter(page) # no longer sends
assert _send_count(page) == 1
_press_enter(page, ctrl=True) # sends
assert _send_count(page) == 2
@@ -45,9 +45,54 @@ def test_recover_missing_sidecars_from_state_db_materializes_webui_row(tmp_path)
assert data["parent_session_id"] == "parent-1"
assert data["source_tag"] == "webui"
assert data["session_source"] == "webui"
assert data["message_count"] == 2
assert [m["content"] for m in data["messages"]] == ["message 1", "message 2"]
def test_recovered_sidecar_derives_message_count_from_materialized_rows(tmp_path, monkeypatch):
"""Recovery must not copy a stale state.db summary into a live sidecar.
`Session.save()` relies on sidecar `message_count` for both #1558 guards.
A recovery row whose denormalized DB count says zero but whose canonical
message rows contain five messages must therefore refuse an empty active
snapshot and preserve a five-message backup on a later shrink.
"""
import api.models as models
sid = _make_state_db(tmp_path / "state.db", sid="recovered_stale_count", messages=5)
with sqlite3.connect(tmp_path / "state.db") as conn:
conn.execute("UPDATE sessions SET message_count = 0 WHERE id = ?", (sid,))
assert recover_missing_sidecars_from_state_db(tmp_path, tmp_path / "state.db")["materialized"] == 1
sidecar = tmp_path / f"{sid}.json"
assert json.loads(sidecar.read_text(encoding="utf-8"))["message_count"] == 5
monkeypatch.setattr(models, "SESSION_DIR", tmp_path)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", tmp_path / "_index.json")
active_empty = models.Session(
session_id=sid,
title="Recovered from DB",
workspace=str(tmp_path),
model="openai/gpt-5",
messages=[],
active_stream_id="a" * 32,
pending_user_message="still typing",
)
active_empty.save()
assert len(json.loads(sidecar.read_text(encoding="utf-8"))["messages"]) == 5
shrinking = models.Session(
session_id=sid,
title="Recovered from DB",
workspace=str(tmp_path),
model="openai/gpt-5",
messages=[{"role": "user", "content": f"replacement {i}"} for i in range(3)],
)
shrinking.save()
backup = sidecar.with_suffix(".json.bak")
assert len(json.loads(backup.read_text(encoding="utf-8"))["messages"]) == 5
def test_recover_missing_sidecars_from_state_db_skips_deleted_webui_tombstone(tmp_path, monkeypatch):
import api.models as _m
monkeypatch.setattr(_m, "SESSION_DIR", tmp_path)
+229
View File
@@ -0,0 +1,229 @@
"""Tests: GET /api/session full-transcript escape hatch + bounded recovery
calls — #7310 / #7625.
Bare ``/api/session`` reads (no ``msg_limit``) were issued by six frontend
recovery paths (offline/bfcache ``refreshSession``, stream-end settle, cancel
sync, /compress preflight, /retry, /undo). Each used to re-walk, re-redact and
re-serialize the ENTIRE transcript (4-15s / 28MB on a 5k-row session, measured
in #7625). Those call sites now request a bounded tail explicitly
(``msg_limit=30``), while the two paths that address rows by absolute
transcript index (outline jump, jump-to-start) opt in to the full transcript
via the explicit ``msg_limit=all`` escape hatch.
The bare no-limit HTTP shape deliberately keeps its historical full-transcript
contract: upstream contract tests pin tool-row preservation
(``test_state_db_reconciliation_preserves_sidecar_order_when_timestamps_collide``)
and the runtime-journal live snapshot
(``test_paginated_session_followup_does_not_repeat_runtime_snapshot``) on that
shape, so the fix is enforced at the frontend call sites rather than by
changing the server default.
"""
from __future__ import annotations
import re
from pathlib import Path
from api.routes import (
_MAX_MSG_LIMIT,
_parse_msg_limit,
_resolve_effective_msg_limit,
)
_ROOT = Path(__file__).resolve().parents[1]
# ── helper: _resolve_effective_msg_limit ──
def test_bare_request_keeps_full_transcript_contract():
"""A bare reload (no msg_limit, no msg_before) keeps the historical
full-transcript contract — server-side default windowing was rejected
because contract tests pin tool-row preservation and the runtime-journal
snapshot on this shape (#7310/#7625)."""
limit, explicit_all = _resolve_effective_msg_limit(None)
assert limit is None
assert explicit_all is False
def test_empty_and_malformed_keep_contract():
assert _resolve_effective_msg_limit("") == (None, False)
assert _resolve_effective_msg_limit("not-a-number") == (None, False)
def test_explicit_all_resolves_to_full_transcript():
""""msg_limit=all → (None, True): the explicit full-transcript escape
hatch for outline jump / jump-to-start."""
limit, explicit_all = _resolve_effective_msg_limit("all")
assert limit is None
assert explicit_all is True
def test_explicit_all_case_insensitive():
assert _resolve_effective_msg_limit("ALL") == (None, True)
assert _resolve_effective_msg_limit(" All ") == (None, True)
def test_numeric_limit_unchanged():
"""Explicit numeric limits keep the clamp behaviour — real pagination is
unaffected."""
assert _resolve_effective_msg_limit("30") == (30, False)
assert _resolve_effective_msg_limit("9999") == (_MAX_MSG_LIMIT, False)
assert _resolve_effective_msg_limit("0") == (1, False)
def test_parse_msg_limit_all_returns_none():
"""The parse helper returns None for 'all' — the handler distinguishes it
from a bare request via _resolve_effective_msg_limit."""
assert _parse_msg_limit("all") is None
assert _parse_msg_limit("ALL") is None
# ── frontend call sites: bounded shapes (no bare GET /api/session left) ──
_UI_JS = (_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
_MESSAGES_JS = (_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
_SESSIONS_JS = (_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
_COMMANDS_JS = (_ROOT / "static" / "commands.js").read_text(encoding="utf-8")
_OUTLINE_JS = (_ROOT / "static" / "outline.js").read_text(encoding="utf-8")
_BOUNDED = "&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1"
_ALL_QS = "&messages=1&resolve_model=0&msg_limit=all"
def test_refresh_session_uses_bounded_tail():
"""Offline/bfcache recovery refreshSession must not pull the full
transcript (#7310/#7625)."""
assert _BOUNDED in _UI_JS
def test_settle_and_cancel_paths_use_bounded_tail():
"""stream_end settle recovery AND cancel sync must not pull the full
transcript."""
assert _BOUNDED in _MESSAGES_JS
assert _MESSAGES_JS.count(_BOUNDED) >= 2
def test_cancel_payload_restores_truncation_signal():
"""_applyCancelSessionPayload keeps the Load-earlier paging gate honest
after a bounded cancel reload."""
assert "!!sessionPayload._messages_truncated" in _MESSAGES_JS
assert "sessionPayload._messages_offset||0" in _MESSAGES_JS
def test_settle_restores_truncation_signal():
"""_restoreSettledSession keeps the Load-earlier paging gate honest after
a bounded settle reload."""
assert "!!session._messages_truncated" in _MESSAGES_JS
assert "session._messages_offset||0" in _MESSAGES_JS
def _restore_settled_session_body():
start = _MESSAGES_JS.index("async function _restoreSettledSession")
end = _MESSAGES_JS.index("function _handleStreamError", start)
return _MESSAGES_JS[start:end]
def _apply_cancel_session_payload_body():
start = _MESSAGES_JS.index("const _applyCancelSessionPayload=(sessionPayload)=>")
end = _MESSAGES_JS.index("Prefer the canonical session snapshot", start)
return _MESSAGES_JS[start:end]
def test_settle_refreshes_paging_before_anchor_persist():
"""Anchor persist reads _oldestIdx; a bounded tail must refresh it first (#7628)."""
restore = _restore_settled_session_body()
offset_idx = restore.index("_oldestIdx=session._messages_offset||0")
attach_idx = restore.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);")
assert offset_idx < attach_idx
def test_cancel_refreshes_paging_before_anchor_persist():
"""Cancel recovery has the same persist-before-offset trap as settle (#7628)."""
cancel = _apply_cancel_session_payload_body()
offset_idx = cancel.index("_oldestIdx=sessionPayload._messages_offset||0")
attach_idx = cancel.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);")
assert offset_idx < attach_idx
def test_settle_preserves_terminal_marker_against_bounded_suffix():
"""Long-session bounded tails are suffixes of S.messages, not prefixes (#7628)."""
restore = _restore_settled_session_body()
assert "_stagedMatchesCurrentSuffix" in restore
assert "(_truncatedRecovery?_stagedMatchesCurrentSuffix:_stagedMatchesCurrentPrefix)" in restore
def test_settle_truncation_signal_decides_match_strategy():
"""The server's truncation signal must decide prefix-vs-suffix, never an `||`:
bounded settles get suffix-exclusive matching (identical repeated turns make
BOTH comparisons succeed; the prefix branch then splices at the wrong offset
and silently drops/duplicates rows) (#7628)."""
restore = _restore_settled_session_body()
assert "_truncatedRecovery=(typeof _messagesTruncated!=='undefined'&&!!_messagesTruncated)||(typeof _oldestIdx!=='undefined'&&!!(_oldestIdx>0))" in restore
# The splice offset must follow the SAME truncation decision as the match
# strategy — no fallthrough to prefix when both match.
assert "_truncatedRecovery?_stagedSuffixStart+_stagedMessages.length:_stagedMessages.length" in restore
def test_apperror_embedded_session_refreshes_paging_before_anchor_persist():
"""The apperror embedded-session path has the same persist-before-offset trap (#7628)."""
start = _MESSAGES_JS.index("source.addEventListener('apperror'")
end = _MESSAGES_JS.index("source.addEventListener('warning'", start)
block = _MESSAGES_JS[start:end]
offset_idx = block.index("_oldestIdx=d.session._messages_offset||0")
attach_idx = block.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);")
assert offset_idx < attach_idx
def test_compress_preflight_uses_bounded_tail():
"""/compress preflight only needs session existence + the current tail."""
assert _BOUNDED in _COMMANDS_JS
def test_retry_uses_bounded_tail():
"""/retry recovery must not pull the full transcript."""
assert _COMMANDS_JS.count(_BOUNDED) >= 1
def test_undo_uses_bounded_tail():
"""/undo recovery must not pull the full transcript."""
assert _COMMANDS_JS.count(_BOUNDED) >= 2
def test_recovery_paths_restore_truncation_signal():
"""preflight/retry/undo must NOT hardcode _messagesTruncated=false — they
read the server's truncation signal instead."""
assert "!!(live.session._messages_truncated)" in _COMMANDS_JS
assert "!!(data.session._messages_truncated)" in _COMMANDS_JS
def test_jump_to_start_requests_explicit_full_transcript():
"""_ensureAllMessagesLoaded genuinely needs everything (absolute-index
addressing of the earliest rows) — it must opt in via msg_limit=all."""
assert _ALL_QS in _SESSIONS_JS
def test_outline_jump_requests_explicit_full_transcript():
"""Outline jump addresses rows by absolute transcript index — it must opt
in via msg_limit=all."""
assert _ALL_QS in _OUTLINE_JS
def test_no_bare_session_fetch_remains_in_frontend():
"""Regression guard: NO static/ caller may issue a bare GET /api/session
(no messages=/msg_limit= params). Use the bounded tail shape or the
explicit msg_limit=all escape hatch."""
bare_template = re.compile(
r"api\(`/api/session\?session_id=\$\{encodeURIComponent\([^)]*\}\)`\)"
)
bare_concat = re.compile(
r"api\('/api/session\?session_id='\s*\+\s*encodeURIComponent\([^)]*\)\)"
)
for name, src in (
("ui.js", _UI_JS),
("messages.js", _MESSAGES_JS),
("sessions.js", _SESSIONS_JS),
("commands.js", _COMMANDS_JS),
("outline.js", _OUTLINE_JS),
):
assert not bare_template.findall(src), f"{name} still has a bare template-literal /api/session fetch"
assert not bare_concat.findall(src), f"{name} still has a bare concatenated /api/session fetch"
+17 -2
View File
@@ -67,7 +67,22 @@ def _build_status_callback():
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "_agent_status_callback"
)
module = ast.Module(body=[fn], type_ignores=[])
# The callback is a closure inside ``_run_agent_streaming`` and may declare
# ``nonlocal`` state (e.g. the runtime-model dedupe identity it resets on a
# fallback warning). Compiled alone at module level, ``nonlocal`` has no
# binding and raises SyntaxError, so wrap it in an enclosing function that
# binds every nonlocal name it declares, then return the inner callback.
nonlocals = sorted({
name
for node in ast.walk(fn)
if isinstance(node, ast.Nonlocal)
for name in node.names
})
binds = "".join(f" {name} = None\n" for name in nonlocals)
outer = ast.parse(f"def _status_callback_scope():\n{binds} pass\n").body[0]
assert isinstance(outer, ast.FunctionDef)
outer.body[-1:] = [fn, ast.Return(value=ast.Name(id=fn.name, ctx=ast.Load()))]
module = ast.fix_missing_locations(ast.Module(body=[outer], type_ignores=[]))
events = []
ns = {
"put": lambda event, data: events.append((event, data)),
@@ -78,7 +93,7 @@ def _build_status_callback():
"_is_session_lease_wait_message": streaming._is_session_lease_wait_message,
}
exec(compile(module, "streaming_status_callback", "exec"), ns)
return ns["_agent_status_callback"], events
return ns["_status_callback_scope"](), events
@pytest.mark.parametrize(
+6
View File
@@ -925,6 +925,9 @@ eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
eval(extractFunc('_isDelegatedSubagentRow'));
eval(extractFunc('_sidebarProjectResolver'));
eval(extractFunc('_sidebarRowsById'));
eval(extractFunc('_scopedSidebarReferenceRows'));
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
const child = {{session_id:'child', title:'Subagent', parent_session_id:'parent', relationship_type:'child_session', updated_at:20, last_message_at:20, source:'webui'}};
@@ -973,6 +976,9 @@ eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
eval(extractFunc('_isDelegatedSubagentRow'));
eval(extractFunc('_sidebarProjectResolver'));
eval(extractFunc('_sidebarRowsById'));
eval(extractFunc('_scopedSidebarReferenceRows'));
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
const child = {{session_id:'child', title:'Fork in projA', parent_session_id:'parent', relationship_type:'child_session', project_id:'projA', message_count:3, updated_at:20, last_message_at:20, source:'webui'}};
+249 -2
View File
@@ -954,8 +954,9 @@ def test_continuation_classification_timestamp_tolerance_and_guards():
model_config=json.dumps({'_delegate_from': 'some-other-session'}),
),
)
# Unparsable model_config degrades to no markers (no crash, no match).
assert _is_continuation_session(
# Unparsable model_config is untrusted lineage identity: it fails closed
# as a boundary (no crash), even inside the tolerance window.
assert not _is_continuation_session(
make_parent(),
make_child(started_at=999.95, model_config='not-json'),
)
@@ -980,6 +981,58 @@ def test_continuation_classification_timestamp_tolerance_and_guards():
assert not _is_continuation_session(make_parent(), make_child(started_at='not-a-number'))
@pytest.mark.parametrize("parent_source", ["tool", ""])
def test_tool_child_is_not_stitched_into_parent_lineage(_isolate, parent_source):
"""A marker-less tool child stays independent even with a matching/empty source."""
from api.agent_sessions import (
read_importable_agent_session_rows,
read_session_lineage_metadata,
read_session_lineage_report,
)
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
parent_id, child_id = "tool_boundary_parent", "tool_boundary_child"
try:
_insert_state_row(
conn, parent_id, source=parent_source, title="Parent conversation",
started_at=t0, ended_at=t0 + 5, end_reason="compression",
)
_insert_state_row(
conn, child_id, source="tool", title="Tool conversation",
parent=parent_id, started_at=t0 + 4.5,
)
_insert_state_message(
conn, parent_id, role="user", content="parent request", timestamp=t0 + 1,
)
_insert_state_message(
conn, child_id, role="user", content="tool request", timestamp=t0 + 6,
)
projected = read_importable_agent_session_rows(
_isolate, limit=None, exclude_sources=None,
)
rows = {row["id"]: row for row in projected}
assert set(rows) == {parent_id, child_id}
assert rows[child_id]["relationship_type"] == "child_session"
assert "_lineage_root_id" not in rows[child_id]
assert "_lineage_tip_id" not in rows[parent_id]
metadata = read_session_lineage_metadata(_isolate, {parent_id, child_id})
assert metadata[child_id]["relationship_type"] == "child_session"
assert "_lineage_root_id" not in metadata[child_id]
assert read_session_lineage_report(_isolate, parent_id)["total_segments"] == 1
assert read_session_lineage_report(_isolate, child_id)["total_segments"] == 1
messages = models.get_state_db_session_messages(
child_id, stitch_continuations=True,
)
assert [message["content"] for message in messages] == ["tool request"]
finally:
conn.close()
def test_state_db_stitch_keeps_branched_child_out_of_parent_transcript(_isolate):
"""#7021 r2: the open/import transcript stitcher (get_state_db_session_messages
with stitch_continuations=True) must apply the same model_config branch-marker
@@ -1148,3 +1201,197 @@ def test_state_db_stitch_old_schema_without_identity_columns_still_stitches(_iso
assert "parent turn" in contents
finally:
conn.close()
def test_branch_markers_report_fail_closed_unknown_state():
"""Untrusted model_config lineage evidence is 'unknown', never 'no markers'."""
from api.agent_sessions import _branch_markers
assert _branch_markers(None) == ('none', {})
assert _branch_markers({'model_config': None}) == ('none', {})
assert _branch_markers({'model_config': ''}) == ('none', {})
assert _branch_markers({'model_config': ' '}) == ('none', {})
assert _branch_markers({'model_config': json.dumps({'model': 'x'})}) == ('none', {})
assert _branch_markers({'model_config': json.dumps({'_reset_from': None})}) == ('none', {})
assert _branch_markers(
{'model_config': json.dumps({'_reset_from': 'p', '_delegate_from': 'a'})}
) == ('markers', {'_reset_from': 'p', '_delegate_from': 'a'})
assert _branch_markers({'model_config': {'_branched_from': ' p '}}) == (
'markers',
{'_branched_from': 'p'},
)
for raw in ('{not-json', '[]', '"a string"', '17', 'null', '{"a":'):
assert _branch_markers({'model_config': raw}) == ('unknown', {}), raw
assert _branch_markers({'model_config': ['_delegate_from']}) == ('unknown', {})
for hostile in (
{'_delegate_from': 123},
{'_delegate_from': ['p']},
{'_delegate_from': {'id': 'p'}},
{'_branched_from': True},
{'_branched_from': ' '},
{'_reset_from': ''},
# A usable first marker never hides a malformed second one.
{'_delegate_from': 'ancestor', '_branched_from': 123},
):
assert _branch_markers({'model_config': json.dumps(hostile)}) == ('unknown', {}), hostile
# A valid but pathologically deep payload overflows the decoder with
# RecursionError (not ValueError); it must degrade to 'unknown', not crash.
depth = 12_000
deep_payload = '[' * depth + ']' * depth
with pytest.raises(RecursionError):
json.loads(deep_payload)
assert _branch_markers({'model_config': deep_payload}) == ('unknown', {})
def test_untrusted_model_config_markers_fail_closed_inside_tolerance():
"""Master tolerance/predicate shape, but unknown identity is a boundary."""
from api.agent_sessions import _is_continuation_session
parent = {'id': 'fc_parent', 'source': 'webui', 'ended_at': 200.0, 'end_reason': 'compression'}
child = {'id': 'fc_child', 'source': 'webui', 'started_at': 199.7, 'parent_session_id': 'fc_parent'}
# Containment: marker-free and null-marker children still continue.
assert _is_continuation_session(parent, child)
assert _is_continuation_session(
parent, {**child, 'model_config': json.dumps({'_delegate_from': None})}
)
# Master's shape is kept: a marker naming another session (inherited from
# the rotated agent's model_config) does not split a real continuation.
assert _is_continuation_session(
parent, {**child, 'model_config': json.dumps({'_delegate_from': 'somewhere_else'})}
)
for raw in (
'{not-json',
'[]',
json.dumps({'_delegate_from': 123}),
json.dumps({'_branched_from': ''}),
json.dumps({'_reset_from': ['fc_parent']}),
json.dumps({'_delegate_from': 'somewhere_else', '_branched_from': 123}),
'[' * 12_000 + ']' * 12_000,
):
assert not _is_continuation_session(parent, {**child, 'model_config': raw}), raw[:40]
# Legacy rows without ended_at keep continuing, unless identity is untrusted.
legacy_parent = {**parent, 'ended_at': None}
assert _is_continuation_session(legacy_parent, child)
assert not _is_continuation_session(legacy_parent, {**child, 'model_config': '{not-json'})
def test_reset_from_marker_naming_parent_is_a_boundary():
"""Hermes Agent binds ``_reset_from`` like ``_branched_from``/``_delegate_from``.
``gateway/session_recovery.py`` stamps ``model_config._reset_from`` on a
reset child and ``_NON_CONTINUATION_CHILD_FILTER_SQL`` rejects it as a
compression continuation. The WebUI predicate must agree inside the
tolerance window and on legacy rows without ``ended_at``.
"""
from api.agent_sessions import _is_continuation_session
for end_reason in ('compression', 'cli_close'):
parent = {'id': 'reset_parent', 'source': 'telegram', 'ended_at': 300.0, 'end_reason': end_reason}
child = {'id': 'reset_child', 'source': 'telegram', 'started_at': 299.5, 'parent_session_id': 'reset_parent'}
assert _is_continuation_session(parent, child), end_reason
assert not _is_continuation_session(
parent, {**child, 'model_config': json.dumps({'_reset_from': 'reset_parent'})}
), end_reason
assert not _is_continuation_session(
parent, {**child, 'model_config': {'max_iterations': 40, '_reset_from': 'reset_parent'}}
), end_reason
assert not _is_continuation_session(
{**parent, 'ended_at': None},
{**child, 'model_config': json.dumps({'_reset_from': 'reset_parent'})},
), end_reason
# A direct reset marker is not masked by an inherited delegate marker.
assert not _is_continuation_session(
parent,
{
**child,
'model_config': json.dumps(
{'_delegate_from': 'ancestor', '_reset_from': 'reset_parent'}
),
},
), end_reason
# An inherited reset marker naming an ancestor does not split the lineage.
assert _is_continuation_session(
parent, {**child, 'model_config': json.dumps({'_reset_from': 'ancestor'})}
), end_reason
def test_reset_child_inside_tolerance_stays_independent_across_readers(_isolate):
"""Sidebar listing, lineage metadata/report and transcript stitcher agree.
A resumed parent closed by compression within the tolerance window of its
reset child's creation must not absorb that reset conversation.
"""
from api.agent_sessions import (
read_importable_agent_session_rows,
read_session_lineage_metadata,
read_session_lineage_report,
)
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
parent_id = 'reset_reader_parent'
child_id = 'reset_reader_child'
try:
_insert_state_row(
conn,
parent_id,
title='Reset parent conversation',
started_at=t0,
ended_at=t0 + 5,
end_reason='compression',
)
_insert_state_message(conn, parent_id, role='user', content='parent turn', timestamp=t0 + 1)
_insert_state_row(
conn,
child_id,
title='Reset child conversation',
parent=parent_id,
started_at=t0 + 5 - 0.5,
model_config=json.dumps({'_reset_from': parent_id}),
)
_insert_state_message(conn, child_id, role='user', content='reset turn', timestamp=t0 + 6)
projected = {
row['id']: row
for row in read_importable_agent_session_rows(
_isolate, limit=None, exclude_sources=None
)
}
assert {parent_id, child_id} <= set(projected)
assert projected[child_id]['title'] == 'Reset child conversation'
assert projected[child_id]['relationship_type'] == 'child_session'
assert '_lineage_root_id' not in projected[child_id]
assert '_compression_segment_count' not in projected[parent_id]
assert 'model_config' not in projected[child_id]
metadata = read_session_lineage_metadata(_isolate, {parent_id, child_id})
assert metadata[child_id]['relationship_type'] == 'child_session'
assert '_lineage_root_id' not in metadata[child_id]
report = read_session_lineage_report(_isolate, child_id)
assert report['tip_session_id'] == child_id
assert report['total_segments'] == 1
stitched = models.get_state_db_session_messages(child_id, stitch_continuations=True)
assert [m['content'] for m in stitched] == ['reset turn']
# Control: the same row without the reset marker is a continuation.
conn.execute('UPDATE sessions SET model_config = NULL WHERE id = ?', (child_id,))
conn.commit()
stitched = models.get_state_db_session_messages(child_id, stitch_continuations=True)
assert [m['content'] for m in stitched] == ['parent turn', 'reset turn']
projected_ids = {
row['id']
for row in read_importable_agent_session_rows(
_isolate, limit=None, exclude_sources=None
)
}
assert parent_id not in projected_ids and child_id in projected_ids
finally:
conn.close()
@@ -818,6 +818,169 @@ def test_get_session_syncs_sidecar_from_newer_state_db_even_when_stream_not_term
assert reloaded.messages[-1]["content"] == "latest live progress"
def test_get_session_sync_keeps_pending_image_prompt_when_agent_row_is_proven(monkeypatch, hermes_home):
sid = "state_newer_pending_image_sid"
stream_id = "dead_pending_image_stream"
timestamp = time.time() - models._REPAIR_STALE_PENDING_GRACE_SECONDS - 5
prompt = "Describe the attached image"
attachment = {"name": "sample.png", "mime": "image/png", "is_image": True}
agent_only_note = "Agent-only recall note"
agent_user_content = [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,YWJj"}},
{"type": "text", "text": agent_only_note},
]
previous_messages = [
{"role": "user", "content": "old question", "timestamp": timestamp - 2},
{"role": "assistant", "content": "old answer", "timestamp": timestamp - 1},
]
state_messages = [
*previous_messages,
{"role": "user", "content": agent_user_content, "timestamp": timestamp},
{"role": "assistant", "content": "recovered assistant tail", "timestamp": timestamp + 1},
]
s = Session(
session_id=sid,
title="Pending image recovery",
messages=previous_messages,
context_messages=previous_messages,
active_stream_id=stream_id,
pending_user_message=prompt,
pending_attachments=[attachment],
pending_started_at=timestamp,
pending_user_source="webui",
_webui_pending_user_timestamp_identity=(stream_id, timestamp),
)
s.save()
models.SESSIONS.pop(sid, None)
monkeypatch.setattr(
models,
"get_state_db_session_summary",
lambda sid_arg, profile=None: {
"message_count": len(state_messages),
"last_message_at": timestamp + 1,
},
)
monkeypatch.setattr(
models,
"get_state_db_session_messages",
lambda sid_arg, **kwargs: list(state_messages),
)
models.get_session(sid)
reloaded = Session.load(sid)
submitted_rows = [
message for message in reloaded.messages
if message.get("role") == "user" and message.get("content") == prompt
]
assert len(submitted_rows) == 1
assert submitted_rows[0]["timestamp"] == timestamp
assert submitted_rows[0]["attachments"] == [attachment]
assert reloaded.messages[-1] == state_messages[-1]
assert agent_only_note not in str(reloaded.messages)
context_rows = models.reconciled_state_db_messages_for_session(
reloaded,
prefer_context=True,
state_messages=state_messages,
)
enriched_rows = [
message for message in context_rows
if message.get("role") == "user" and message.get("timestamp") == timestamp
]
assert len(enriched_rows) == 1
assert enriched_rows[0]["content"] == agent_user_content
assert agent_only_note in str(enriched_rows[0]["content"])
def test_state_db_pending_image_row_alone_does_not_skip_stale_journal_recovery(
monkeypatch, hermes_home,
):
sid = "state_pending_image_without_output_sid"
stream_id = "dead_pending_image_without_output_stream"
timestamp = time.time() - models._REPAIR_STALE_PENDING_GRACE_SECONDS - 5
prompt = "Describe the attached image"
attachment = {"name": "sample.png", "mime": "image/png", "is_image": True}
old_messages = [
{"role": "user", "content": "old question", "timestamp": timestamp - 2},
{"role": "assistant", "content": "old answer", "timestamp": timestamp - 1},
]
state_messages = [
*old_messages,
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,YWJj"}},
],
"timestamp": timestamp,
},
]
session = Session(
session_id=sid,
title="Pending image without output",
messages=old_messages,
context_messages=old_messages,
active_stream_id=stream_id,
pending_user_message=prompt,
pending_attachments=[attachment],
pending_started_at=timestamp,
pending_user_source="webui",
_webui_pending_user_timestamp_identity=(stream_id, timestamp),
)
session.save()
models.SESSIONS[sid] = session
monkeypatch.setattr(
models,
"get_state_db_session_summary",
lambda sid_arg, profile=None: {
"message_count": len(state_messages),
"last_message_at": timestamp,
},
)
monkeypatch.setattr(
models,
"get_state_db_session_messages",
lambda sid_arg, **kwargs: list(state_messages),
)
append_run_event(sid, stream_id, "token", {"text": "Recovered partial text."})
# Cached get_session runs state.db self-heal, then the real stale-stream
# cleanup must still get the pending turn so it can replay the run journal.
loaded = models.get_session(sid)
assert loaded is session
assert loaded.active_stream_id == stream_id
assert loaded.pending_user_message == prompt
assert loaded.pending_attachments == [attachment]
import api.routes as routes
assert routes._clear_stale_stream_state(loaded) is True
reloaded = Session.load(sid)
submitted_rows = [
message for message in reloaded.messages
if message.get("role") == "user" and message.get("content") == prompt
]
assert len(submitted_rows) == 1
assert submitted_rows[0]["attachments"] == [attachment]
assert any(
message.get("_recovered_from_run_journal")
and "Recovered partial text." in message.get("content", "")
for message in reloaded.messages
)
markers = [
message for message in reloaded.messages
if message.get("_error") and message.get("type") == "interrupted"
]
assert len(markers) == 1
assert "**Response interrupted.**" in markers[0]["content"]
assert "partial output above was recovered" in markers[0]["content"]
def test_get_session_does_not_sync_while_stream_is_still_live(monkeypatch):
"""A still-running worker owns its own writeback; do not race it.
@@ -884,6 +1047,7 @@ def test_sync_save_failure_does_not_mutate_session(monkeypatch):
"""
sid = "state_sync_save_fail_sid"
stream_id = "stream_save_fail_no_done"
attachment = {"name": "sample.png", "mime": "image/png", "is_image": True}
s = Session(
session_id=sid,
title="Save failure",
@@ -893,7 +1057,10 @@ def test_sync_save_failure_does_not_mutate_session(monkeypatch):
],
active_stream_id=stream_id,
pending_user_message="new request",
pending_attachments=[attachment],
pending_started_at=102.0,
pending_user_source="webui",
_webui_pending_user_timestamp_identity=(stream_id, 102.0),
)
s.save()
@@ -926,7 +1093,13 @@ def test_sync_save_failure_does_not_mutate_session(monkeypatch):
assert result is False
assert s.active_stream_id == stream_id
assert s.pending_user_message == "new request"
assert s.pending_attachments == [attachment]
assert [m["content"] for m in s.messages] == ["old question", "old answer"]
reloaded = Session.load(sid)
assert reloaded.active_stream_id == stream_id
assert reloaded.pending_user_message == "new request"
assert reloaded.pending_attachments == s.pending_attachments
assert [m["content"] for m in reloaded.messages] == ["old question", "old answer"]
def test_sync_persists_recovered_state_db_tail_when_stream_dead(monkeypatch):
+5 -5
View File
@@ -31,11 +31,11 @@ def test_render_uses_single_pass_partition_helper():
render_body = _function_block("renderSessionListFromCache")
assert "_partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in render_body
assert "_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView)])" in render_body
assert "_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView, projectIdFor)])" in render_body
assert "const renderedWebuiSessionCount=_serverWebuiSessionCount===null" in render_body
assert "const renderedCliSessionCount=_serverCliSessionCount===null" in render_body
assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length" in render_body
assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length" in render_body
assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false, projectIdFor)]).length" in render_body
assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true, projectIdFor)]).length" in render_body
assert ": null;" in render_body
assert "null is a deliberate \"not computed\" sentinel" in render_body
assert "const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);" in render_body
@@ -72,8 +72,8 @@ def test_partition_helper_keeps_raw_source_counts_while_render_owns_visible_coun
assert "cliSessionsRaw," in _partition_block()
assert "const renderedWebuiSessionCount=_serverWebuiSessionCount===null" in render_body
assert "const renderedCliSessionCount=_serverCliSessionCount===null" in render_body
assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length" in render_body
assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length" in render_body
assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false, projectIdFor)]).length" in render_body
assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true, projectIdFor)]).length" in render_body
assert "function _countRenderedSidebarRowsFromRawSessions" not in SESSIONS_JS
assert "function _renderSidebarRowsFromRawSessions(sessionsRaw, referenceSessionsRaw){" in SESSIONS_JS
assert "_attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw, referenceRows)" in SESSIONS_JS
+2 -2
View File
@@ -61,7 +61,7 @@ def test_unassigned_chip_filter_logic():
assert "if(_activeProject===NO_PROJECT_FILTER){" in js, (
"The Unassigned filter must select sessions without a project_id"
)
assert "if(s.project_id) continue;" in js, (
assert "if(projectId) continue;" in js, (
"The Unassigned filter must skip sessions with a project_id"
)
@@ -76,7 +76,7 @@ def test_unassigned_chip_only_shown_when_relevant():
— though that's vanishingly rare).
"""
js = _js()
assert "const hasUnprojected=profileFiltered.some(s=>!s.project_id);" in js, (
assert "const hasUnprojected=_sidebarHasUnprojectedRows(profileFiltered, projectIdFor);" in js, (
"The render function must compute whether unassigned sessions exist"
)
assert "if(_allProjects.length>0||hasUnprojected){" in js, (
+188
View File
@@ -0,0 +1,188 @@
"""Delegated subagent children must follow their parent's project filter.
Subagent child sessions are created by the agent (state.db) and never carry a
``project_id`` of their own. The sidebar project filter compared each row's own
``project_id``, so selecting a project dropped every subagent child and the
parent rendered with no stacked children, while the Unassigned view kept them.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent.resolve()
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
_PREAMBLE = """
const src = %s;
function extractFunc(name) {
const start = src.search(new RegExp('function\\\\s+' + name + '\\\\s*\\\\('));
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{', start) + 1, depth = 1;
while (depth > 0 && i < src.length) { if (src[i] === '{') depth++; else if (src[i] === '}') depth--; i++; }
return src.slice(start, i);
}
function _isCliSession(s){ return !!(s && (s.is_cli_session || s.session_source==='cli')); }
function _isExternalSession(s){ return !!(s && (s.is_cli_session || s.session_source === 'messaging')); }
function _isMessagingSession(s){ return !!(s && s.session_source==='messaging'); }
function _hasUnreadForSession(s){ return !!(s && s.has_unread); }
function _sessionDisplayTitle(s){ return s && s.title; }
Object.assign(global, {_isCliSession, _isExternalSession, _isMessagingSession, _hasUnreadForSession, _sessionDisplayTitle});
global.INFLIGHT = {};
global.NO_PROJECT_FILTER = '__no_project__';
global.window = {};
global._archivedCliCount = 0; global._archivedWebuiCount = 0;
global._sidebarReferenceSessions = [];
global.S = { session: null, busy: false, activeStreamId: null };
global._showArchived = false;
global._sessionSourceFilter = 'webui';
for (const fn of ['_isSessionLocallyStreaming','_hasPendingUserMessageSignal','_isSessionEffectivelyStreaming',
'_isChildSession','_isForkWithResolvableParent','_sessionLineageKey','_sidebarLineageKeyForRow',
'_collapseSessionLineageForSidebar','_attachChildSessionsToSidebarRows','_sessionAttentionState',
'_sidebarRowHasVisibleMessages','_isDelegatedSubagentRow','_sidebarProjectResolver','_sidebarRowsById','_sidebarHasUnprojectedRows',
'_partitionSidebarSessionRows','_scopedSidebarReferenceRows','_renderSidebarRowsFromRawSessions']) {
eval.call(global, extractFunc(fn));
}
const parent = { session_id:'proj_parent', title:'Parent', session_source:'webui', raw_source:'webui', source_tag:'webui', message_count:5, project_id:'projX', updated_at:100, last_message_at:100 };
const child = { session_id:'sub_child', title:'Subagent Session', parent_session_id:'proj_parent', relationship_type:'child_session', raw_source:'subagent', source_tag:'subagent', session_source:'other', _parent_lineage_root_id:'proj_parent', _cross_surface_child_session:true, message_count:3, updated_at:101, last_message_at:101 };
const grandchild = { session_id:'sub_grandchild', title:'Nested subagent', parent_session_id:'sub_child', relationship_type:'child_session', raw_source:'subagent', source_tag:'subagent', session_source:'other', message_count:2, updated_at:102, last_message_at:102 };
function render(project, rows) {
global._activeProject = project;
const part = _partitionSidebarSessionRows(rows, null);
const out = _renderSidebarRowsFromRawSessions(part.sessionsRaw, part.webuiReferenceRaw);
const p = out.find(r => r.session_id === 'proj_parent') || {};
return { raw: part.sessionsRaw.map(s => s.session_id).sort(), top: out.map(r => r.session_id),
children: (p._child_sessions || []).map(c => c.session_id) };
}
"""
def _run(scenario: str) -> dict:
assert NODE is not None
source = (_PREAMBLE % json.dumps(SESSIONS_JS)) + scenario
result = subprocess.run([NODE], input=source, cwd=str(REPO_ROOT),
capture_output=True, text=True, encoding="utf-8", timeout=30)
if result.returncode != 0:
raise RuntimeError(result.stderr)
return json.loads(result.stdout.strip())
def test_subagent_child_is_shown_inside_parent_project():
out = _run("console.log(JSON.stringify(render('projX', [parent, child])));")
assert out["raw"] == ["proj_parent", "sub_child"]
assert out["top"] == ["proj_parent"]
assert out["children"] == ["sub_child"]
def test_nested_subagent_inherits_project_through_child():
out = _run("console.log(JSON.stringify(render('projX', [parent, child, grandchild])));")
assert out["raw"] == ["proj_parent", "sub_child", "sub_grandchild"]
assert out["top"] == ["proj_parent"]
def test_subagent_of_project_parent_is_not_unassigned():
out = _run("console.log(JSON.stringify(render(NO_PROJECT_FILTER, [parent, child])));")
assert out["raw"] == []
assert out["top"] == []
def test_subagent_excluded_from_other_project_and_own_project_id_wins():
out = _run("""
const other = render('projY', [parent, child]);
const explicit = render('projY', [parent, Object.assign({}, child, {project_id:'projY'})]);
console.log(JSON.stringify({other, explicit}));
""")
assert out["other"]["raw"] == []
assert out["explicit"]["raw"] == ["sub_child"]
def test_parent_resolved_from_reference_rows():
"""Parent only present as a hidden reference row still scopes the child."""
out = _run("""
global._sidebarReferenceSessions = [parent];
const rows = render('projX', [child]);
global._activeProject = 'projX';
const scoped = _scopedSidebarReferenceRows(false).map(s => s.session_id);
console.log(JSON.stringify({raw: rows.raw, scoped}));
""")
assert out["raw"] == ["sub_child"]
assert out["scoped"] == ["proj_parent"]
def test_unassigned_chip_ignores_subagents_of_project_parents():
out = _run("console.log(JSON.stringify(_sidebarHasUnprojectedRows([parent, child], _sidebarProjectResolver(_sidebarRowsById([[parent, child]])))));")
assert out is False
def test_unassigned_chip_uses_reference_parent():
"""A child whose project parent is only a reference row is not unassigned."""
out = _run("""
global._sidebarReferenceSessions = [parent];
global._activeProject = null;
const part = _partitionSidebarSessionRows([child], null);
console.log(JSON.stringify({has: _sidebarHasUnprojectedRows(part.profileFiltered, part.projectIdFor),
unassigned: render(NO_PROJECT_FILTER, [child]).raw}));
""")
assert out["unassigned"] == []
assert out["has"] is False
def test_unassigned_fork_stays_unassigned():
"""A fork moved to "No project" keeps project_id null; only subagents inherit."""
out = _run("""
const fork = { session_id:'fork_child', title:'Fork', parent_session_id:'proj_parent', relationship_type:'child_session', session_source:'fork', raw_source:'webui', source_tag:'webui', project_id:null, message_count:2, updated_at:103, last_message_at:103 };
global._activeProject = null;
const p = _partitionSidebarSessionRows([parent, fork], null);
console.log(JSON.stringify({unassigned: render(NO_PROJECT_FILTER, [parent, fork]).raw,
project: render('projX', [parent, fork]).raw,
chip: _sidebarHasUnprojectedRows(p.profileFiltered, p.projectIdFor)}));
""")
assert out["unassigned"] == ["fork_child"]
assert out["project"] == ["proj_parent"]
assert out["chip"] is True
def test_project_resolution_is_linear_in_lineage_depth():
"""Each lineage resolves once: ancestor lookups stay O(n) for a deep chain."""
out = _run("""
function chain(n) {
const rows = [parent];
for (let i = 0; i < n; i++) rows.push(Object.assign({}, grandchild, {session_id:'c'+i,
parent_session_id: i ? 'c'+(i-1) : 'proj_parent', updated_at:200+i, last_message_at:200+i}));
return rows;
}
function lookups(n) {
const rows = chain(n);
let gets = 0;
const realGet = Map.prototype.get;
Map.prototype.get = function(k) { gets++; return realGet.call(this, k); };
try {
global._activeProject = 'projX';
global._sidebarReferenceSessions = rows.slice(0, 50);
const part = _partitionSidebarSessionRows(rows, null);
_scopedSidebarReferenceRows(false, part.projectIdFor);
_sidebarHasUnprojectedRows(part.profileFiltered, part.projectIdFor);
return {gets, raw: part.sessionsRaw.length};
} finally { Map.prototype.get = realGet; }
}
console.log(JSON.stringify({small: lookups(1000), big: lookups(4000)}));
""")
assert out["small"]["raw"] == 1001 and out["big"]["raw"] == 4001
# 4x the rows must cost ~4x the lookups, not ~16x.
assert out["big"]["gets"] <= 5 * out["small"]["gets"], out
assert out["big"]["gets"] <= 20 * 4001, out
def test_project_resolver_is_cycle_safe():
out = _run("""
const a = Object.assign({}, grandchild, {session_id:'a', parent_session_id:'b'});
const b = Object.assign({}, grandchild, {session_id:'b', parent_session_id:'a'});
const resolve = _sidebarProjectResolver(_sidebarRowsById([[a, b]]));
console.log(JSON.stringify([resolve(a), resolve(b)]));
""")
assert out == [None, None]
+68
View File
@@ -25,6 +25,7 @@ from api.gateway_chat import (
webui_chat_backend_mode,
webui_gateway_chat_enabled,
)
from api.turn_journal import derive_turn_journal_states, read_turn_journal
def test_gateway_chat_backend_is_default_off_for_truthy_values():
@@ -397,6 +398,73 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
assert all(len(item) == 3 and item[2] for item in events)
def test_gateway_chat_worker_records_turn_journal_completion(tmp_path, monkeypatch):
"""#6366 re-gate: a successful Gateway run must record durable
same-stream completion evidence in the crash-safe turn journal.
The stale-cancel recovery predicate derives its completion evidence
from the turn journal, so a Gateway run that persisted its final
answer but lost the run journal's terminal write would otherwise
have no completion evidence at all — and recovery would re-append a
duplicate recovered row after the valid final answer.
"""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'
yield b"data: [DONE]\n\n"
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_API_KEY", "secret-token")
monkeypatch.setattr(
gateway_chat.urllib.request,
"urlopen",
lambda req, timeout=0: FakeResponse(),
)
s = new_session()
stream_id = "stream-gateway-turn-journal-completed"
s.active_stream_id = stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.pending_started_at = 456
s.save()
channel = create_stream_channel()
STREAMS[stream_id] = channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
stream_id,
[],
)
journal = read_turn_journal(s.session_id, session_dir=session_dir)
states, _ = derive_turn_journal_states(journal.get("events") or [])
stream_events = [
event
for event in states.values()
if str(event.get("stream_id") or "") == stream_id
]
assert stream_events, "expected turn journal events for the gateway stream"
assert any(
event.get("event") == "completed" for event in stream_events
), "the gateway success writeback must record a completed turn journal event"
def test_gateway_chat_worker_classifies_terminal_provider_error_without_text(tmp_path, monkeypatch):
"""Gateway terminal errors must survive an empty assistant stream."""
from unittest.mock import MagicMock