Merge remote-tracking branch 'origin/master' into HEAD
This commit is contained in:
commit
96bdeeb9cf
77 files changed
+13563
-462
No files matched your search
+77
-2
@@ -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)
|
||||
|
||||
@@ -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,97 @@
|
||||
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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+80
-11
@@ -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
@@ -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
@@ -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__)
|
||||
|
||||
@@ -1297,7 +1298,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(
|
||||
@@ -1358,6 +1363,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)
|
||||
@@ -1370,6 +1378,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.pending_user_message = None
|
||||
s.pending_attachments = None
|
||||
@@ -1404,6 +1420,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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+61
-7
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+223
-58
@@ -9132,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
|
||||
@@ -9145,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.
|
||||
@@ -9327,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
|
||||
@@ -9387,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 = (
|
||||
@@ -10082,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
|
||||
@@ -10093,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):
|
||||
@@ -10589,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,
|
||||
@@ -10603,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,
|
||||
@@ -13181,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.
|
||||
@@ -13191,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.
|
||||
@@ -13344,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)
|
||||
@@ -13400,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
|
||||
@@ -13498,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,
|
||||
@@ -23145,6 +23223,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
|
||||
|
||||
@@ -25196,10 +25275,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,
|
||||
@@ -25264,6 +25345,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,
|
||||
)
|
||||
@@ -25282,6 +25390,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,
|
||||
@@ -25291,6 +25407,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
|
||||
@@ -29324,17 +29443,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):
|
||||
@@ -29517,10 +29653,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 = []
|
||||
@@ -29536,6 +29680,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,
|
||||
@@ -29547,22 +29695,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()
|
||||
@@ -29573,6 +29744,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,
|
||||
})
|
||||
|
||||
@@ -29762,21 +29934,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),
|
||||
@@ -30067,15 +30233,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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+454
-39
@@ -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,
|
||||
@@ -2105,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):
|
||||
@@ -2141,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
|
||||
@@ -2239,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):
|
||||
@@ -2319,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),
|
||||
@@ -2431,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
|
||||
@@ -2458,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):
|
||||
@@ -5593,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
|
||||
@@ -7429,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
|
||||
@@ -7443,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]
|
||||
@@ -7652,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
|
||||
@@ -7695,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:
|
||||
@@ -8906,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,
|
||||
*,
|
||||
@@ -8917,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
|
||||
@@ -10170,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")
|
||||
@@ -11534,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,
|
||||
@@ -11549,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
|
||||
@@ -11591,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:
|
||||
@@ -12113,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,
|
||||
@@ -12130,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
|
||||
@@ -12492,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
|
||||
@@ -13475,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,
|
||||
@@ -13492,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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);}
|
||||
}
|
||||
|
||||
@@ -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
@@ -591,7 +591,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">
|
||||
|
||||
+276
-43
@@ -6106,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=>{
|
||||
@@ -6114,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=>{
|
||||
@@ -6766,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){
|
||||
@@ -7007,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);
|
||||
@@ -7028,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
|
||||
@@ -7121,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.
|
||||
@@ -7156,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 &&
|
||||
@@ -7171,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=>{
|
||||
@@ -7581,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() {
|
||||
@@ -7604,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) {
|
||||
@@ -7635,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;
|
||||
@@ -7651,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();
|
||||
}
|
||||
}
|
||||
@@ -7794,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(", ") + "]" : "");
|
||||
@@ -7854,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) {
|
||||
@@ -8048,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);
|
||||
@@ -8220,32 +8323,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.
|
||||
@@ -8278,10 +8386,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();
|
||||
@@ -8626,16 +8735,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;
|
||||
@@ -8643,11 +8772,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);
|
||||
}
|
||||
@@ -8935,6 +9072,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)
|
||||
@@ -9194,7 +9332,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) {
|
||||
@@ -9420,6 +9560,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
|
||||
@@ -9432,14 +9647,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
@@ -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
@@ -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>`;
|
||||
|
||||
+59
-10
@@ -4007,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
|
||||
@@ -7671,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=[];
|
||||
@@ -7690,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){
|
||||
@@ -7717,6 +7761,7 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
|
||||
cliReferenceRaw,
|
||||
webuiSessionsRaw,
|
||||
cliSessionsRaw,
|
||||
projectIdFor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7728,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;
|
||||
});
|
||||
}
|
||||
@@ -7842,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);
|
||||
@@ -7916,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
@@ -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
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 ''; }}
|
||||
|
||||
@@ -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 ─────────────────────────────────────
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
@@ -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();
|
||||
|
||||
@@ -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']}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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'"
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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)
|
||||
@@ -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():
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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'}};
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user