Merge branch 'master' into feat/gateway-run-reattach-after-restart
This commit is contained in:
86 files changed
+14694
-479
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
|
are merged; cross-profile scoping, visibility, deduplication, and final route
|
||||||
limits remain downstream responsibilities.
|
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
|
### 4.3 SSE Streaming Engine
|
||||||
|
|
||||||
This is the most architecturally interesting part. Two endpoints cooperate:
|
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
|
compatibility-only: delete it, and import directly from the new homes, once the WebUI
|
||||||
stops supporting Agents that predate the split.
|
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
|
## 5. Frontend Architecture: Current State
|
||||||
@@ -534,7 +600,9 @@ to the active conversation rather than a global app setting.
|
|||||||
|
|
||||||
Session management:
|
Session management:
|
||||||
newSession() POST /api/session/new, update S.session, save to localStorage
|
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
|
deleteSession(sid) POST /api/session/delete, handle active/inactive cases correctly
|
||||||
renderSessionList() GET /api/sessions, rebuild #sessionList DOM
|
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)
|
/ Returns full HTML app (index page)
|
||||||
/index.html Same as /
|
/index.html Same as /
|
||||||
/health {"status":"ok","sessions":N}
|
/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/sessions List of all session compact() dicts, sorted by updated_at
|
||||||
/api/list ?session_id=X&path=. -> directory listing for session workspace
|
/api/list ?session_id=X&path=. -> directory listing for session workspace
|
||||||
/api/file ?session_id=X&path=rel -> file content (text, 200KB limit)
|
/api/file ?session_id=X&path=rel -> file content (text, 200KB limit)
|
||||||
|
|||||||
+122
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
### Added
|
### 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
|
- **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;
|
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
|
zero, negative, non-numeric or out-of-range values fall back to 2). It is process-wide, so a
|
||||||
@@ -17,6 +20,14 @@
|
|||||||
|
|
||||||
### Performance
|
### 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
|
- **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
|
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
|
reasoning text with repeated string concatenation, which is quadratic on long runs. The journal
|
||||||
@@ -25,9 +36,120 @@
|
|||||||
rebuild going from 4.34 s to 0.49 s with a byte-identical snapshot. The interim-echo check now matches a
|
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
|
compact-equivalent suffix without a fixed window, so an echo stretched by interior whitespace is
|
||||||
no longer shown twice. (#7310, #7569 by @happy5318)
|
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
|
### Fixed
|
||||||
|
|
||||||
|
- **Picking a model from a named custom provider sends the right model name.** Choosing a model
|
||||||
|
that belongs to a non-default custom provider (for example `@custom:my-server:model-x`) sent
|
||||||
|
the whole picker id, prefix included, to the provider, which rejected the request. The
|
||||||
|
`@custom:<slug>:` prefix is now stripped before sending and the provider is routed from the
|
||||||
|
slug. Endpoint-style slugs such as `custom:localhost:11434` keep their host and port.
|
||||||
|
(#6895 by @webtecnica, fixes #6884)
|
||||||
|
- **A gateway reset or tool conversation no longer disappears into the parent session.** When a
|
||||||
|
session ended by compression, the sidebar also treated a gateway reset child (stamped
|
||||||
|
`_reset_from`) as its continuation, so that separate conversation vanished from the list and its
|
||||||
|
transcript was stitched into the parent's. Reset and tool children now stay separate, matching
|
||||||
|
Hermes Agent's own continuation rule; a real compression continuation still joins the chain. A
|
||||||
|
lineage marker that can't be read is treated as a boundary, which keeps the row visible rather
|
||||||
|
than merging it. A change to a session's lineage markers alone now also refreshes the sidebar.
|
||||||
|
(#6565 by @ruizanthony)
|
||||||
|
- **A background tab stops polling a session that's gone, and still recovers after a profile
|
||||||
|
switch in another tab.** A hidden tab polling a deleted session used to loop on 404s every six
|
||||||
|
seconds. It now stops after three consecutive 404s but keeps the session as its resume target,
|
||||||
|
so if the 404 came from switching profile in another tab and you switch back, the tab reattaches
|
||||||
|
when you return to it. A queued poll response can no longer stop a replacement poll for the same
|
||||||
|
session. (#7301, @laitekin; completes the #7299 fix)
|
||||||
|
|
||||||
|
- **An image turn's provider context stays out of your message bubble, and Edit/Undo no longer
|
||||||
|
brings a removed image turn back.** When an image turn was mirrored into the Agent's state.db,
|
||||||
|
its rich provider payload could appear in the user bubble, and after Edit or Undo a removed
|
||||||
|
image-turn row could come back in the full, paginated and model-context reads. The bubble now
|
||||||
|
shows what you typed, the payload stays in model context only, and removed rows stay removed,
|
||||||
|
including a same-timestamp duplicate row and a reply that exists only in state.db after an
|
||||||
|
edited checkpoint. (#7754, @starship-s)
|
||||||
|
|
||||||
|
- **MCP status, tool inventory and `/reload-mcp` follow the profile you're using.** With several
|
||||||
|
profiles in one WebUI, a chat turn mirrored its profile into the process environment, so the Agent
|
||||||
|
saw every profile as the launch profile. The MCP panel could then show another profile's servers,
|
||||||
|
and `/reload-mcp` could restart them. Status, tool listing and reload now resolve through the
|
||||||
|
request's profile, and a reload in one profile leaves another profile's live MCP connections and
|
||||||
|
in-flight tool calls alone. This needs the Agent's `pin_process_hermes_home` (hermes-agent #120103).
|
||||||
|
On an older Agent the panel says live status is unavailable while a turn runs, and `/reload-mcp`
|
||||||
|
refuses, rather than guessing. (#7720 by @tancou, fixes #7721)
|
||||||
|
- **On a phone, Enter in the composer inserts a newline.** Some iPhones report a fine pointer to the
|
||||||
|
browser, so the phone-keyboard check fell through and plain Enter sent the message mid-sentence.
|
||||||
|
Phones (iPhone, iPod, and Android phones) now always get a newline on Enter; Ctrl/Cmd+Enter and
|
||||||
|
the Send button still send, and the Send-key setting still wins. Tablets and touch laptops keep
|
||||||
|
the existing check, so an iPad with a Magic Keyboard or an Android tablet with a Bluetooth
|
||||||
|
keyboard still sends on Enter (#3076). The saved Send-key preference is also read before the
|
||||||
|
first keypress, so the first Enter after a slow page load no longer sends. (#6746 by @happy5318)
|
||||||
|
- **Sessions archived in the CLI stay archived in the WebUI.** A cron, webhook, Kanban or CLI
|
||||||
|
session archived from the CLI came back as active in the sidebar whenever it had no WebUI sidecar,
|
||||||
|
because the projection treated a missing sidecar as "not archived". A missing sidecar now means no
|
||||||
|
opinion, so the state.db `archived` flag applies; archiving or unarchiving in the WebUI still wins.
|
||||||
|
In all-profiles mode, a CLI archive in another profile now refreshes the cached session list.
|
||||||
|
(#7548, #7798 by @webtecnica)
|
||||||
|
|
||||||
|
- **Approval and clarify prompts send a browser notification whenever you aren't looking at them.**
|
||||||
|
A card that surfaced through the normal prompt path never produced a notification, and the
|
||||||
|
visibility gate muted cards in a tab that was visible but unfocused. Now every approval or clarify
|
||||||
|
surfacing notifies unless it belongs to the session open in the pane and the tab is both visible
|
||||||
|
and focused. Notifications from other sessions still arrive while you work. When permission is
|
||||||
|
still undecided, the browser is asked at most once per page load, so a pending prompt's 1.5 s
|
||||||
|
re-surfacing no longer repeats the permission request or the "notifications denied" toast. (#7493 by
|
||||||
|
@CharlesMcquade)
|
||||||
|
- **Delegated subagent sessions show inside their parent's project.** With a project selected in the
|
||||||
|
sidebar, subagent sessions disappeared because `state.db` never gives them a `project_id`. Only
|
||||||
|
subagent rows now inherit their parent's project, resolved once per lineage, so forks keep their
|
||||||
|
own "No project" assignment and deep lineages stay fast. (#7765 by @carlotestor)
|
||||||
|
- **Saving a cron job with the model picker at "Default" keeps its provider-only pin.** Editing a job
|
||||||
|
pinned to a provider with no model (the usual shape for a self-hosted OpenAI-compatible router)
|
||||||
|
sent `provider: null` and wiped the pin. The editor now preserves a provider-only pin when the
|
||||||
|
model picker is left at Default. (#7779 by @cushingw)
|
||||||
|
- **A consumed mid-turn `/steer` no longer leaves its out-of-band wrapper in the settled chat (#7600).**
|
||||||
|
A `/steer` reaches the agent as an `[OUT-OF-BAND USER MESSAGE …]` block appended to the turn's
|
||||||
|
last tool result. After the turn settled, that wrapper stayed visible in the chat transcript. The
|
||||||
|
settled-transcript writeback now scrubs the consumed wrapper from the rows it is built from.
|
||||||
|
(#7610 by @webtecnica)
|
||||||
|
- **Saving a very large session no longer reads and parses the whole file just to count messages.**
|
||||||
|
The #1558 backup safeguard in `Session.save()` needs the on-disk message count; it obtained it by
|
||||||
|
loading the entire sidecar. On a real 203 MB / 266,940-message session that made every save
|
||||||
|
expensive. The count is now taken without a full parse, and the shrink-backup behaviour is
|
||||||
|
unchanged. (#7578 by @rodrigogs)
|
||||||
|
- **A cancelled or recovered turn no longer duplicates an answer that was already saved (#6366).**
|
||||||
|
When a completed assistant turn had been persisted and a later cancel/recovery path ran for the
|
||||||
|
same pending turn, recovery could append a duplicate user turn plus a `_partial` clone of the
|
||||||
|
journal. Recovery now stops once the transcript has already advanced past the pending turn.
|
||||||
|
(#7682 by @happy5318)
|
||||||
|
|
||||||
|
- **The live stream reports which model actually served the turn.** A new additive `runtime_model`
|
||||||
|
SSE event carries the model (and provider, when known) that the Agent reported while producing
|
||||||
|
output, separately from the model that was requested. It is journaled for replay, is exposed as
|
||||||
|
`runtime_journal_snapshot.runtime_model`, never falls back to the configured selection when
|
||||||
|
unknown, and never changes the model requested for the next turn. Existing clients ignore it.
|
||||||
|
(#7767 by @ruizanthony)
|
||||||
|
- **Reconnecting to a new turn no longer resumes from the previous turn's replay cursor.** A new
|
||||||
|
turn copies the full transcript into the in-flight state, so the previous turn's assistant reply
|
||||||
|
could seed the replay floor and the reattached stream skipped the current reply's early events.
|
||||||
|
Replay now seeds only from the current live assistant row, and falls back to a full replay that
|
||||||
|
rebuilds the assistant body when a cursor outlives its live state. (#7651 by @happy5318)
|
||||||
|
- **Context-length lookup keeps the configured base URL for ownerless and underscore-named
|
||||||
|
providers.** The #7535 ownership guard also dropped the global `model.base_url` for a `model:`
|
||||||
|
section with a `base_url` but no `provider`, and for provider IDs written with an underscore
|
||||||
|
(`opencode_go` vs `opencode-go`), so those sessions could resolve the wrong context window. Both
|
||||||
|
now keep the URL, while a different declared owner still does not leak its endpoint.
|
||||||
|
(#7743 by @webtecnica)
|
||||||
|
- **A session deleted during a restart no longer produces a spurious recovery warning.** When
|
||||||
|
WebUI startup recovery re-attached background processes, a session that had vanished between
|
||||||
|
enumeration and rebind raised a `KeyError` that was logged as a warning. It now follows the
|
||||||
|
existing skip path, confined to the session lookup, so the vanished owner is skipped, live owners
|
||||||
|
still rebind, and registry errors still warn. (#7753, #7774 by @happy5318)
|
||||||
|
|
||||||
- **Waiting on the Agent's session lease is shown as a warning instead of looking stuck.** When
|
- **Waiting on the Agent's session lease is shown as a warning instead of looking stuck.** When
|
||||||
another Hermes process (gateway, CLI or cron) holds the session's turn lease, the Agent's
|
another Hermes process (gateway, CLI or cron) holds the session's turn lease, the Agent's
|
||||||
"another Hermes process is using this session" notices now reach the chat as a warning status
|
"another Hermes process is using this session" notices now reach the chat as a warning status
|
||||||
|
|||||||
@@ -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
|
- Session actions via `⋯` dropdown per session — pin, move to project, archive, duplicate, delete
|
||||||
- Pin/star sessions to the top of the sidebar (gold indicator)
|
- Pin/star sessions to the top of the sidebar (gold indicator)
|
||||||
- Archive sessions (hide without deleting, toggle to show)
|
- 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
|
- Session tags -- add #tag to titles for colored chips and click-to-filter
|
||||||
- Grouped by Today / Yesterday / Earlier in the sidebar (collapsible date groups)
|
- Grouped by Today / Yesterday / Earlier in the sidebar (collapsible date groups)
|
||||||
- Download as Markdown transcript, full JSON export, or import from JSON
|
- 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
|
### 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)
|
- **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: 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)
|
- Show/hide CLI sessions toggle (enabled by default)
|
||||||
- Token usage display toggle (off by default, also via `/usage` command)
|
- Token usage display toggle (off by default, also via `/usage` command)
|
||||||
- Control Center always opens on the Conversation tab; resets on close
|
- Control Center always opens on the Conversation tab; resets on close
|
||||||
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
|
- 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
|
- 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
|
- 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
|
### Slash commands
|
||||||
- Type `/` in the composer for autocomplete dropdown
|
- 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_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_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_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_WORKSPACE` | `~/workspace` | Default workspace |
|
||||||
| `HERMES_WEBUI_DEFAULT_MODEL` | *(provider default)* | Optional model override; leave unset to use the active Hermes provider default |
|
| `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 |
|
| `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
|
return _count_user_turns(row) >= CLI_MIN_UNTITLED_USER_MESSAGE_COUNT
|
||||||
|
|
||||||
|
|
||||||
def _branch_markers(row: dict | None) -> tuple[str | None, str | None]:
|
# Every model_config marker Hermes Agent binds to ``parent_session_id`` in its
|
||||||
"""Return ``(_branched_from, _delegate_from)`` from a row's ``model_config``.
|
# 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``);
|
def _branch_markers(row: dict | None) -> tuple[str, dict[str, str]]:
|
||||||
``session_source='fork'`` alone only covers WebUI-created forks. Missing,
|
"""Return ``(state, markers)`` for a row's ``model_config`` lineage identity.
|
||||||
empty, or unparsable ``model_config`` degrades to ``(None, None)``.
|
|
||||||
|
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:
|
if not row:
|
||||||
return None, None
|
return 'none', {}
|
||||||
raw = row.get('model_config')
|
raw = row.get('model_config')
|
||||||
if isinstance(raw, dict):
|
if raw is None:
|
||||||
config = raw
|
return 'none', {}
|
||||||
elif isinstance(raw, str) and raw.strip():
|
if isinstance(raw, (str, bytes, bytearray)):
|
||||||
|
if not raw.strip():
|
||||||
|
return 'none', {}
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw)
|
config = json.loads(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError, RecursionError):
|
||||||
parsed = None
|
# RecursionError: a valid but pathologically deep payload exceeds
|
||||||
config = parsed if isinstance(parsed, dict) else {}
|
# the decoder's recursion limit; it is untrusted identity evidence
|
||||||
|
# like any other undecodable payload, never an escaping crash.
|
||||||
|
return 'unknown', {}
|
||||||
else:
|
else:
|
||||||
config = {}
|
config = raw
|
||||||
return config.get('_branched_from'), config.get('_delegate_from')
|
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:
|
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
|
conversation; otherwise the tip inherits the root's title/source metadata and
|
||||||
can disappear under messaging/sidebar policies.
|
can disappear under messaging/sidebar policies.
|
||||||
|
|
||||||
Explicit branches/delegates never continue: a child whose ``model_config``
|
Explicit branches/delegates/resets never continue: a child whose
|
||||||
``_branched_from`` / ``_delegate_from`` points at this parent is a fork of
|
``model_config`` ``_branched_from`` / ``_delegate_from`` / ``_reset_from``
|
||||||
the conversation and must stay visible. Beyond the bounded tolerance window
|
points at this parent is a separate conversation and must stay visible.
|
||||||
there is no reliable continuation signal — titles are user-controlled and
|
Unparsable or non-string marker evidence fails closed as a boundary.
|
||||||
non-unique, so they are never used to widen the window (#7021 re-gate).
|
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:
|
if not parent or not child:
|
||||||
return False
|
return False
|
||||||
if str(child.get('session_source') or '').strip().lower() == 'fork':
|
if str(child.get('session_source') or '').strip().lower() == 'fork':
|
||||||
return False
|
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
|
# session_source, which only WebUI-created forks carry). The marker must
|
||||||
# reference THIS parent: compression continuations inherit the rotated
|
# reference THIS parent: compression continuations inherit the rotated
|
||||||
# agent's model_config verbatim, so presence alone (a delegate's
|
# agent's model_config verbatim, so presence alone (a delegate's
|
||||||
# continuation still carries the delegate's own ``_delegate_from``) would
|
# continuation still carries the delegate's own ``_delegate_from``) would
|
||||||
# misclassify real continuations.
|
# misclassify real continuations. Untrusted identity evidence fails closed.
|
||||||
child_branched_from, child_delegate_from = _branch_markers(child)
|
marker_state, child_markers = _branch_markers(child)
|
||||||
parent_id = parent.get('id')
|
if marker_state == 'unknown':
|
||||||
if parent_id and (
|
return False
|
||||||
child_branched_from == parent_id or child_delegate_from == parent_id
|
parent_id = str(parent.get('id') or '').strip()
|
||||||
):
|
if parent_id and parent_id in child_markers.values():
|
||||||
return False
|
return False
|
||||||
parent_source = str(parent.get('source') or '').strip().lower()
|
parent_source = str(parent.get('source') or '').strip().lower()
|
||||||
child_source = str(child.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:
|
if parent_source and child_source and parent_source != child_source:
|
||||||
return False
|
return False
|
||||||
if parent.get('end_reason') not in {'compression', 'cli_close'}:
|
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.
|
# expects from "Show agent sessions" sorted by activity.
|
||||||
for key in (
|
for key in (
|
||||||
'id', 'model', 'message_count', 'actual_message_count', 'actual_user_message_count',
|
'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:
|
if key in tip:
|
||||||
merged[key] = tip[key]
|
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_chat_id_expr = _optional_col('origin_chat_id', session_cols)
|
||||||
origin_user_id_expr = _optional_col('origin_user_id', session_cols)
|
origin_user_id_expr = _optional_col('origin_user_id', session_cols)
|
||||||
platform_expr = _optional_col('platform', 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,
|
# Older/minimal state.db schemas can have NO ``messages`` table at all,
|
||||||
# or a ``messages`` table without a ``session_id`` / ``timestamp`` column.
|
# or a ``messages`` table without a ``session_id`` / ``timestamp`` column.
|
||||||
# The projection SQL below joins ``messages`` and aggregates
|
# The projection SQL below joins ``messages`` and aggregates
|
||||||
@@ -793,6 +831,7 @@ def read_importable_agent_session_rows(
|
|||||||
{parent_expr},
|
{parent_expr},
|
||||||
{ended_expr},
|
{ended_expr},
|
||||||
{end_reason_expr},
|
{end_reason_expr},
|
||||||
|
{archived_expr},
|
||||||
{actual_count_expr} AS actual_message_count,
|
{actual_count_expr} AS actual_message_count,
|
||||||
{user_message_count_expr} AS actual_user_message_count,
|
{user_message_count_expr} AS actual_user_message_count,
|
||||||
{last_activity_expr} AS last_activity
|
{last_activity_expr} AS last_activity
|
||||||
|
|||||||
@@ -1883,7 +1883,22 @@ def recover_processes_for_webui(process_registry=None, get_session_fn=None) -> i
|
|||||||
try:
|
try:
|
||||||
proc_session = process_registry.get(process_id)
|
proc_session = process_registry.get(process_id)
|
||||||
session_key = str(getattr(proc_session, "session_key", "") or "")
|
session_key = str(getattr(proc_session, "session_key", "") or "")
|
||||||
if not session_key or get_session_fn(session_key, metadata_only=True) is None:
|
if not session_key:
|
||||||
|
continue
|
||||||
|
# The session resolver (``api.models._resolve_session_once``)
|
||||||
|
# raises ``KeyError(sid)`` for a missing session — that is its
|
||||||
|
# documented contract for the "owner gone" outcome, not a
|
||||||
|
# fault. Treat it identically to a ``None`` return: skip the
|
||||||
|
# process without logging, and let a neighbouring live
|
||||||
|
# process still rebind. The narrow ``except KeyError`` lives
|
||||||
|
# INSIDE the outer try so an unrelated ``KeyError`` from
|
||||||
|
# ``process_registry.get()`` (or any routing-index work) still
|
||||||
|
# surfaces in the existing warning path.
|
||||||
|
try:
|
||||||
|
resolved = get_session_fn(session_key, metadata_only=True)
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
if resolved is None:
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
+80
-11
@@ -257,11 +257,28 @@ def _run_codex_runtime_command(arg_string: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _run_reload_mcp_command() -> 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:
|
with _RELOAD_MCP_LOCK:
|
||||||
try:
|
try:
|
||||||
|
import importlib
|
||||||
from tools.mcp_tool import _servers, _lock
|
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.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(
|
shutdown_mcp_servers = agent_attr(
|
||||||
"tools.mcp_tool", "shutdown_mcp_servers", "tools.mcp_tool_lifecycle"
|
"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(
|
discover_mcp_tools = agent_attr(
|
||||||
"tools.mcp_tool", "discover_mcp_tools", "tools.mcp_tool_discovery"
|
"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:
|
except Exception as exc:
|
||||||
logger.warning("Failed to import MCP runtime for /reload-mcp", exc_info=True)
|
logger.warning("Failed to import MCP runtime for /reload-mcp", exc_info=True)
|
||||||
raise RuntimeError("MCP runtime unavailable") from exc
|
raise RuntimeError("MCP runtime unavailable") from exc
|
||||||
|
|
||||||
try:
|
with mcp_runtime_scope("/reload-mcp") as view:
|
||||||
with _lock:
|
if not view.trusted:
|
||||||
old_servers = set(_servers.keys())
|
# 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()
|
def live_names() -> set:
|
||||||
new_tools = discover_mcp_tools()
|
# 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:
|
with _lock:
|
||||||
connected_servers = set(_servers.keys())
|
old_servers = live_names()
|
||||||
except Exception as exc:
|
# Every ledger entry serving this profile, parked ones included:
|
||||||
logger.warning("Failed to reload MCP servers", exc_info=True)
|
# a parked task left in place would make discovery skip the server.
|
||||||
raise RuntimeError("Failed to reload MCP servers") from exc
|
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
|
added = connected_servers - old_servers
|
||||||
removed = old_servers - connected_servers
|
removed = old_servers - connected_servers
|
||||||
|
|||||||
+10
-1
@@ -98,10 +98,19 @@ STATE_DIR = (
|
|||||||
.resolve()
|
.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"
|
SESSION_DIR = STATE_DIR / "sessions"
|
||||||
WORKSPACES_FILE = STATE_DIR / "workspaces.json"
|
WORKSPACES_FILE = STATE_DIR / "workspaces.json"
|
||||||
SESSION_INDEX_FILE = SESSION_DIR / "_index.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"
|
LAST_WORKSPACE_FILE = STATE_DIR / "last_workspace.txt"
|
||||||
PROJECTS_FILE = STATE_DIR / "projects.json"
|
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.helpers import _redact_text, redact_session_data
|
||||||
from api.models import clear_process_wakeup_pause, get_session, merge_session_messages_append_only
|
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.run_journal import RunJournalWriter, bound_run_journal_snapshot_args
|
||||||
|
from api.turn_journal import append_turn_journal_event_for_stream
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1515,7 +1516,11 @@ def _run_gateway_chat_streaming(
|
|||||||
# role/content ordering instead of turn order.
|
# role/content ordering instead of turn order.
|
||||||
assistant_ts = now + 0.000001
|
assistant_ts = now + 0.000001
|
||||||
pending_source = getattr(s, "pending_user_source", None) or "webui"
|
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)
|
active_turn_identity = _active_turn_authority(s, stream_id, msg_text)
|
||||||
user_msg = _materialize_active_turn_user(
|
user_msg = _materialize_active_turn_user(
|
||||||
@@ -1576,6 +1581,9 @@ def _run_gateway_chat_streaming(
|
|||||||
s.context_messages,
|
s.context_messages,
|
||||||
str(msg_text or ""),
|
str(msg_text or ""),
|
||||||
source=pending_source,
|
source=pending_source,
|
||||||
|
verification_nudge_provenance={
|
||||||
|
"active_turn_identity": active_turn_identity,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to merge gateway display transcript", exc_info=True)
|
logger.debug("Failed to merge gateway display transcript", exc_info=True)
|
||||||
@@ -1588,6 +1596,14 @@ def _run_gateway_chat_streaming(
|
|||||||
if latest_text == msg_norm:
|
if latest_text == msg_norm:
|
||||||
display = display[:-1]
|
display = display[:-1]
|
||||||
s.messages = display + [user_msg, assistant_msg]
|
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.active_stream_id = None
|
||||||
s.gateway_run = None
|
s.gateway_run = None
|
||||||
s.pending_user_message = None
|
s.pending_user_message = None
|
||||||
@@ -1623,6 +1639,21 @@ def _run_gateway_chat_streaming(
|
|||||||
if cancel_event.is_set():
|
if cancel_event.is_set():
|
||||||
_restore_cancelled_success_writeback()
|
_restore_cancelled_success_writeback()
|
||||||
return
|
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
|
success_writeback_committed = True
|
||||||
try:
|
try:
|
||||||
from api.goals import evaluate_goal_after_turn, has_active_goal
|
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.
|
requiring any changes to hermes-agent.
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
@@ -27,12 +28,27 @@ logger = logging.getLogger(__name__)
|
|||||||
# ── State hash tracking ─────────────────────────────────────────────────────
|
# ── State hash tracking ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _snapshot_hash(sessions: list) -> str:
|
def _snapshot_hash(sessions: list) -> str:
|
||||||
"""Create a lightweight hash of session IDs and timestamps for change detection."""
|
"""Hash the complete published session payload for change detection.
|
||||||
key = '|'.join(
|
|
||||||
f"{s['session_id']}:{s.get('updated_at', 0)}:{s.get('message_count', 0)}"
|
Every emitted field participates, not only the id / activity timestamp /
|
||||||
for s in sorted(sessions, key=lambda x: x['session_id'])
|
message count triple. Projection authority (compression collapse,
|
||||||
)
|
``model_config`` lineage markers, title promotion) can change a row's
|
||||||
return hashlib.md5(key.encode(), usedforsecurity=False).hexdigest()
|
``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
|
# 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
|
# are always present (``source`` is required for the projection to run at
|
||||||
# all); the rest are optional on older agent schemas and filtered below.
|
# all); the rest are optional on older agent schemas and filtered below.
|
||||||
_PROJECTION_SESSION_COLS = (
|
_PROJECTION_SESSION_COLS = (
|
||||||
'id', 'source', 'session_source', 'title', 'model', 'message_count',
|
'id', 'source', 'session_source', 'model_config', 'title', 'model',
|
||||||
'started_at', 'ended_at', 'end_reason', 'parent_session_id', 'archived',
|
'message_count', 'started_at', 'ended_at', 'end_reason',
|
||||||
'user_id', 'chat_id', 'chat_type', 'thread_id', 'session_key',
|
'parent_session_id', 'archived', 'user_id', 'chat_id', 'chat_type',
|
||||||
'origin_chat_id', 'origin_user_id', 'platform',
|
'thread_id', 'session_key', 'origin_chat_id', 'origin_user_id', 'platform',
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with closing(open_state_db_readonly(db_path)) as conn:
|
with closing(open_state_db_readonly(db_path)) as conn:
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ _PUBLIC_MESSAGE_INTERNAL_FIELDS = frozenset({
|
|||||||
"_active_turn_token",
|
"_active_turn_token",
|
||||||
"_active_turn_user",
|
"_active_turn_user",
|
||||||
"_fork_child_turn",
|
"_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.
|
# process-global _active_profile.
|
||||||
_tls = threading.local()
|
_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_MODULES = ("tools.skills_tool", "tools.skill_manager_tool")
|
||||||
_SKILL_HOME_MODULE_PATCH_LOCK = threading.RLock()
|
_SKILL_HOME_MODULE_PATCH_LOCK = threading.RLock()
|
||||||
|
|
||||||
@@ -1360,6 +1365,8 @@ def profile_env_for_background_worker(
|
|||||||
def profile_env_for_active_request_readonly(
|
def profile_env_for_active_request_readonly(
|
||||||
purpose: str = "provider/model read",
|
purpose: str = "provider/model read",
|
||||||
logger_override: Optional[logging.Logger] = None,
|
logger_override: Optional[logging.Logger] = None,
|
||||||
|
*,
|
||||||
|
include_root: bool = False,
|
||||||
):
|
):
|
||||||
"""Apply the active per-request profile's env to thread-local state only (#3957).
|
"""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``.
|
process-global ``os.environ``.
|
||||||
|
|
||||||
No-ops for the default/root profile, which is the common single-profile
|
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()
|
profile = (get_active_profile_name() or "").strip()
|
||||||
if not profile or _is_root_profile(profile):
|
if include_root and not profile:
|
||||||
yield
|
profile = "default"
|
||||||
|
if not profile or (_is_root_profile(profile) and not include_root):
|
||||||
|
yield False
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from api.config import _clear_thread_env, _set_thread_env, _thread_ctx
|
from api.config import _clear_thread_env, _set_thread_env, _thread_ctx
|
||||||
@@ -1396,7 +1410,7 @@ def profile_env_for_active_request_readonly(
|
|||||||
purpose,
|
purpose,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
yield
|
yield False
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from hermes_constants import (
|
from hermes_constants import (
|
||||||
@@ -1440,7 +1454,7 @@ def profile_env_for_active_request_readonly(
|
|||||||
purpose,
|
purpose,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
yield
|
yield home_override_installed
|
||||||
finally:
|
finally:
|
||||||
if _has_scope and _secret_scope_mod is not None:
|
if _has_scope and _secret_scope_mod is not None:
|
||||||
try:
|
try:
|
||||||
@@ -1529,8 +1543,14 @@ def profile_scope_for_detached_worker(
|
|||||||
|
|
||||||
|
|
||||||
def _set_hermes_home(home: Path):
|
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)
|
os.environ['HERMES_HOME'] = str(home)
|
||||||
|
_pin_process_profile_home(home)
|
||||||
|
|
||||||
patch_skill_home_modules(home)
|
patch_skill_home_modules(home)
|
||||||
|
|
||||||
@@ -1610,11 +1630,45 @@ def init_profile_state() -> None:
|
|||||||
else:
|
else:
|
||||||
_active_profile = _read_active_profile_file()
|
_active_profile = _read_active_profile_file()
|
||||||
home = get_active_hermes_home()
|
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()
|
install_cron_scheduler_profile_isolation()
|
||||||
_reload_dotenv(home)
|
_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:
|
def switch_profile(name: str, *, process_wide: bool = True) -> dict:
|
||||||
"""Switch the active profile.
|
"""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"
|
session_index_path = _session_list_cache_session_dir() / "_index.json"
|
||||||
except Exception:
|
except Exception:
|
||||||
session_index_path = None
|
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 (
|
return (
|
||||||
_session_list_cache_path_stamp(state_db_path),
|
_session_list_cache_path_stamp(state_db_path),
|
||||||
_session_list_cache_path_stamp(state_db_wal_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
|
# frame size), so without this a freshly-committed CLI/gateway session
|
||||||
# could be served stale for the cache TTL. Mirrors the models-layer fix.
|
# could be served stale for the cache TTL. Mirrors the models-layer fix.
|
||||||
_session_list_cache_state_db_fingerprint(state_db_path),
|
_session_list_cache_state_db_fingerprint(state_db_path),
|
||||||
|
all_profiles_state_stamp,
|
||||||
swv,
|
swv,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+254
-59
@@ -4067,6 +4067,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
|||||||
"tool_calls": tool_calls,
|
"tool_calls": tool_calls,
|
||||||
"last_assistant_text": assistant_text,
|
"last_assistant_text": assistant_text,
|
||||||
"last_reasoning_text": reasoning_text,
|
"last_reasoning_text": reasoning_text,
|
||||||
|
"runtime_model": runtime_model_from_events(session_id, stream_id, events),
|
||||||
"activity_burst_anchors": activity_burst_anchors,
|
"activity_burst_anchors": activity_burst_anchors,
|
||||||
"current_activity_burst_id": current_activity_burst_id,
|
"current_activity_burst_id": current_activity_burst_id,
|
||||||
"current_live_segment_seq": current_live_segment_seq,
|
"current_live_segment_seq": current_live_segment_seq,
|
||||||
@@ -7183,7 +7184,35 @@ def _context_length_lookup_inputs_for_model(
|
|||||||
if not effective_provider:
|
if not effective_provider:
|
||||||
effective_provider = _canonical_context_provider(model_cfg.get("provider"))
|
effective_provider = _canonical_context_provider(model_cfg.get("provider"))
|
||||||
if not effective_base_url:
|
if not effective_base_url:
|
||||||
effective_base_url = str(model_cfg.get("base_url") or "").strip()
|
# #7535: the global model.base_url may only fill an empty slot when
|
||||||
|
# the session provider IS the configured model.provider owner
|
||||||
|
# (mirror the ownership predicate used for model_cfg's API key in
|
||||||
|
# _context_length_config_api_key_for_provider). A built-in registry
|
||||||
|
# provider (empty base_url by design) must keep the slot empty so
|
||||||
|
# the registry endpoint resolves instead of another provider's URL.
|
||||||
|
#
|
||||||
|
# Two shapes cannot own the slot and therefore cannot conflict, so
|
||||||
|
# they keep master's backfill: a config that declares no provider
|
||||||
|
# at all (the profile-setup path writes model.base_url without one)
|
||||||
|
# and the two spellings of the same built-in id (opencode_go ==
|
||||||
|
# opencode-go), folded through api.config._canonicalise_provider_id
|
||||||
|
# so distinct custom:* slugs stay distinct.
|
||||||
|
_model_cfg_provider = _canonical_context_provider(model_cfg.get("provider"))
|
||||||
|
_owner_provider = _model_cfg_provider
|
||||||
|
_session_provider = effective_provider
|
||||||
|
try:
|
||||||
|
from api.config import _canonicalise_provider_id as _canon_provider_id
|
||||||
|
|
||||||
|
_owner_provider = _canon_provider_id(_owner_provider) or _owner_provider
|
||||||
|
_session_provider = _canon_provider_id(_session_provider) or _session_provider
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if (
|
||||||
|
not effective_provider
|
||||||
|
or not _model_cfg_provider
|
||||||
|
or _providers_match_for_context(_owner_provider, _session_provider)
|
||||||
|
):
|
||||||
|
effective_base_url = str(model_cfg.get("base_url") or "").strip()
|
||||||
|
|
||||||
custom_providers = cfg.get("custom_providers") if isinstance(cfg, dict) else None
|
custom_providers = cfg.get("custom_providers") if isinstance(cfg, dict) else None
|
||||||
if not isinstance(custom_providers, list):
|
if not isinstance(custom_providers, list):
|
||||||
@@ -9103,9 +9132,11 @@ def _parse_msg_limit(raw):
|
|||||||
"""Parse and clamp the ``?msg_limit=`` query value.
|
"""Parse and clamp the ``?msg_limit=`` query value.
|
||||||
|
|
||||||
Returns a positive int clamped to ``[1, _MAX_MSG_LIMIT]``, or ``None`` when
|
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
|
the value is absent/empty/malformed. ``?msg_limit=all`` also returns
|
||||||
intentionally returns the full transcript for callers that need it).
|
``None`` — an explicit escape hatch for the full-transcript paths the
|
||||||
Extracted from the handler so the clamp expression has direct test coverage.
|
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:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
@@ -9116,6 +9147,26 @@ def _parse_msg_limit(raw):
|
|||||||
return max(1, min(value, _MAX_MSG_LIMIT))
|
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
|
# If a sidecar JSON file exceeds this threshold, the display-path tail
|
||||||
# optimization fires regardless of message count. Sessions with few messages
|
# optimization fires regardless of message count. Sessions with few messages
|
||||||
# but large tool outputs (multi-MB JSON) should not force a full-scan merge.
|
# but large tool outputs (multi-MB JSON) should not force a full-scan merge.
|
||||||
@@ -9298,6 +9349,13 @@ def _limited_webui_messages_for_display_with_sidecar(
|
|||||||
state_db_messages = list(state_db_messages or [])
|
state_db_messages = list(state_db_messages or [])
|
||||||
if not state_db_messages:
|
if not state_db_messages:
|
||||||
return sidecar_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
|
# 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
|
# 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
|
# newest (recovery / edited-in-place / missing-timestamp cases) is still
|
||||||
@@ -9358,6 +9416,11 @@ def _limited_webui_messages_for_display_with_sidecar(
|
|||||||
truncation_boundary=getattr(session, "truncation_boundary", None),
|
truncation_boundary=getattr(session, "truncation_boundary", None),
|
||||||
incoming_provenance="state_db",
|
incoming_provenance="state_db",
|
||||||
)
|
)
|
||||||
|
merged = _project_native_image_payload_conflicts_for_display(
|
||||||
|
sidecar_messages,
|
||||||
|
state_db_messages,
|
||||||
|
merged,
|
||||||
|
)
|
||||||
if cache_key is not None:
|
if cache_key is not None:
|
||||||
_state_key = cache_key[4]
|
_state_key = cache_key[4]
|
||||||
_streaming_key = (
|
_streaming_key = (
|
||||||
@@ -10053,7 +10116,35 @@ def _merged_session_messages_for_display(session, cli_messages=None) -> list:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _merged_webui_lineage_messages_for_display(session, messages=None) -> list:
|
_LINEAGE_PARENT_SESSION_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _webui_lineage_parent_session_for_display(session):
|
||||||
|
"""Load the immediate parent only for display-eligible continuations."""
|
||||||
|
parent_id = str(getattr(session, "parent_session_id", "") or "").strip()
|
||||||
|
if not parent_id:
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
str(getattr(session, "compression_recovery_source_session_id", "") or "").strip()
|
||||||
|
and str(getattr(session, "compression_recovery_action", "") or "").strip()
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
source = str(getattr(session, "session_source", "") or "").strip().lower()
|
||||||
|
relationship = str(getattr(session, "relationship_type", "") or "").strip().lower()
|
||||||
|
if source == "fork" or relationship == "child_session":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return get_session(parent_id, metadata_only=False)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _merged_webui_lineage_messages_for_display(
|
||||||
|
session,
|
||||||
|
messages=None,
|
||||||
|
*,
|
||||||
|
parent_session=_LINEAGE_PARENT_SESSION_UNSET,
|
||||||
|
) -> list:
|
||||||
"""Include immediate parent-only rows when a WebUI continuation sidecar is partial.
|
"""Include immediate parent-only rows when a WebUI continuation sidecar is partial.
|
||||||
|
|
||||||
Compression/continuation sessions should render as one conversation. Most
|
Compression/continuation sessions should render as one conversation. Most
|
||||||
@@ -10064,23 +10155,9 @@ def _merged_webui_lineage_messages_for_display(session, messages=None) -> list:
|
|||||||
subset of their parent.
|
subset of their parent.
|
||||||
"""
|
"""
|
||||||
primary_messages = list(messages if messages is not None else (getattr(session, "messages", []) or []))
|
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 parent_session is _LINEAGE_PARENT_SESSION_UNSET:
|
||||||
if not parent_id:
|
parent_session = _webui_lineage_parent_session_for_display(session)
|
||||||
return primary_messages
|
parent_messages = list(getattr(parent_session, "messages", []) or [])
|
||||||
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 not parent_messages:
|
if not parent_messages:
|
||||||
return primary_messages
|
return primary_messages
|
||||||
if _messages_start_with_visible_prefix(primary_messages, parent_messages):
|
if _messages_start_with_visible_prefix(primary_messages, parent_messages):
|
||||||
@@ -10560,7 +10637,6 @@ from api.models import (
|
|||||||
new_session,
|
new_session,
|
||||||
all_sessions,
|
all_sessions,
|
||||||
title_from,
|
title_from,
|
||||||
_write_session_index,
|
|
||||||
SESSION_INDEX_FILE,
|
SESSION_INDEX_FILE,
|
||||||
_active_state_db_path,
|
_active_state_db_path,
|
||||||
load_projects,
|
load_projects,
|
||||||
@@ -10574,6 +10650,8 @@ from api.models import (
|
|||||||
get_state_db_session_message_keys_before_timestamp,
|
get_state_db_session_message_keys_before_timestamp,
|
||||||
get_state_db_session_summary,
|
get_state_db_session_summary,
|
||||||
merge_session_messages_append_only,
|
merge_session_messages_append_only,
|
||||||
|
_project_native_image_payload_conflicts_for_display,
|
||||||
|
_suppress_native_image_display_mirrors,
|
||||||
_reconcile_api_content_sidecars,
|
_reconcile_api_content_sidecars,
|
||||||
_enrich_sidebar_lineage_metadata,
|
_enrich_sidebar_lineage_metadata,
|
||||||
_active_stream_ids,
|
_active_stream_ids,
|
||||||
@@ -10839,6 +10917,7 @@ from api.streaming import (
|
|||||||
)
|
)
|
||||||
from api.gateway_chat import _run_gateway_chat_streaming, webui_gateway_chat_enabled
|
from api.gateway_chat import _run_gateway_chat_streaming, webui_gateway_chat_enabled
|
||||||
from api.run_journal import (
|
from api.run_journal import (
|
||||||
|
runtime_model_from_events,
|
||||||
_parse_run_journal_event_id as _shared_parse_run_journal_event_id,
|
_parse_run_journal_event_id as _shared_parse_run_journal_event_id,
|
||||||
_summary_from_events,
|
_summary_from_events,
|
||||||
bound_run_journal_snapshot_args,
|
bound_run_journal_snapshot_args,
|
||||||
@@ -13151,8 +13230,10 @@ def _handle_session_get(handler, parsed) -> bool:
|
|||||||
# payload; the existing _messages_truncated signal covers the clamped
|
# payload; the existing _messages_truncated signal covers the clamped
|
||||||
# case (the client sees there are more rows than returned). Parsing +
|
# case (the client sees there are more rows than returned). Parsing +
|
||||||
# clamping live in _parse_msg_limit so the expression has direct test
|
# clamping live in _parse_msg_limit so the expression has direct test
|
||||||
# coverage; None means the bare no-msg_limit path (full transcript).
|
# coverage. The frontend recovery paths request a bounded tail
|
||||||
msg_limit = _parse_msg_limit(query.get("msg_limit", [None])[0])
|
# 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.
|
# ?msg_before=N — 0-based index into the full message array.
|
||||||
# Returns messages before this index (for scroll-to-top lazy loading).
|
# Returns messages before this index (for scroll-to-top lazy loading).
|
||||||
# Combined with msg_limit for paging.
|
# Combined with msg_limit for paging.
|
||||||
@@ -13161,6 +13242,9 @@ def _handle_session_get(handler, parsed) -> bool:
|
|||||||
msg_before = int(_msg_before) if _msg_before else None
|
msg_before = int(_msg_before) if _msg_before else None
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
msg_before = None
|
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
|
# ?expand_renderable=1 is retained for compatibility with older
|
||||||
# frontends. msg_limit now counts visible transcript rows by default, so
|
# frontends. msg_limit now counts visible transcript rows by default, so
|
||||||
# the flag no longer changes the server-side pagination semantics.
|
# the flag no longer changes the server-side pagination semantics.
|
||||||
@@ -13314,13 +13398,33 @@ def _handle_session_get(handler, parsed) -> bool:
|
|||||||
msg_before=msg_before,
|
msg_before=msg_before,
|
||||||
)
|
)
|
||||||
else:
|
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(
|
_all_msgs = merge_session_messages_append_only(
|
||||||
_webui_sidecar_lineage_messages_for_display(s),
|
sidecar_messages,
|
||||||
state_db_messages,
|
state_db_messages,
|
||||||
truncation_watermark=getattr(s, "truncation_watermark", None),
|
truncation_watermark=getattr(s, "truncation_watermark", None),
|
||||||
truncation_boundary=getattr(s, "truncation_boundary", 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:
|
else:
|
||||||
if is_messaging_session and cli_messages:
|
if is_messaging_session and cli_messages:
|
||||||
_all_msgs = _merged_session_messages_for_display(s, cli_messages)
|
_all_msgs = _merged_session_messages_for_display(s, cli_messages)
|
||||||
@@ -13370,9 +13474,13 @@ def _handle_session_get(handler, parsed) -> bool:
|
|||||||
_messages_offset = 0
|
_messages_offset = 0
|
||||||
# Index of the first returned message in the full message array.
|
# Index of the first returned message in the full message array.
|
||||||
# Frontend uses this as cursor for scroll-to-top paging.
|
# 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 = (
|
_windowed_messages = (
|
||||||
load_messages
|
load_messages
|
||||||
and msg_limit is not None
|
|
||||||
and (msg_before is not None or len(_truncated_msgs) < len(_all_msgs))
|
and (msg_before is not None or len(_truncated_msgs) < len(_all_msgs))
|
||||||
)
|
)
|
||||||
# Resolve effective context_length with model-metadata fallback so
|
# Resolve effective context_length with model-metadata fallback so
|
||||||
@@ -13468,7 +13576,7 @@ def _handle_session_get(handler, parsed) -> bool:
|
|||||||
"tool_calls": _session_tool_calls,
|
"tool_calls": _session_tool_calls,
|
||||||
"active_stream_id": getattr(s, "active_stream_id", None),
|
"active_stream_id": getattr(s, "active_stream_id", None),
|
||||||
"pending_user_message": getattr(s, "pending_user_message", 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_started_at": getattr(s, "pending_started_at", None),
|
||||||
"pending_user_source": getattr(s, "pending_user_source", None),
|
"pending_user_source": getattr(s, "pending_user_source", None),
|
||||||
"context_length": _persisted_cl,
|
"context_length": _persisted_cl,
|
||||||
@@ -23106,6 +23214,7 @@ def _prepare_chat_start_session_for_stream(
|
|||||||
s.pending_attachments = attachments
|
s.pending_attachments = attachments
|
||||||
s.pending_started_at = started_at if started_at is not None else time.time()
|
s.pending_started_at = started_at if started_at is not None else time.time()
|
||||||
s.pending_user_source = effective_source
|
s.pending_user_source = effective_source
|
||||||
|
s._webui_pending_user_timestamp_identity = None
|
||||||
if retained_user is not None:
|
if retained_user is not None:
|
||||||
from api.process_event_utils import build_active_turn_token
|
from api.process_event_utils import build_active_turn_token
|
||||||
|
|
||||||
@@ -25157,10 +25266,12 @@ def _handle_chat_sync(handler, body):
|
|||||||
_active_turn_boundary,
|
_active_turn_boundary,
|
||||||
_assign_stable_message_ids,
|
_assign_stable_message_ids,
|
||||||
_dedupe_replayed_context_messages,
|
_dedupe_replayed_context_messages,
|
||||||
|
_find_active_turn_checkpoint_index,
|
||||||
_merge_display_messages_after_agent_result,
|
_merge_display_messages_after_agent_result,
|
||||||
_resolve_active_turn_authority,
|
_resolve_active_turn_authority,
|
||||||
_restore_display_reasoning_metadata,
|
_restore_display_reasoning_metadata,
|
||||||
_restore_reasoning_metadata_before_boundary,
|
_restore_reasoning_metadata_before_boundary,
|
||||||
|
_settle_current_turn_boundary,
|
||||||
_sanitize_messages_for_agent,
|
_sanitize_messages_for_agent,
|
||||||
_compact_session_image_parts_for_persistence,
|
_compact_session_image_parts_for_persistence,
|
||||||
_context_messages_for_new_turn,
|
_context_messages_for_new_turn,
|
||||||
@@ -25225,6 +25336,33 @@ def _handle_chat_sync(handler, body):
|
|||||||
result=result,
|
result=result,
|
||||||
agent=agent,
|
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(
|
_turn_boundary = _active_turn_boundary(
|
||||||
_result_messages, _previous_context_messages, _active_turn_identity, msg,
|
_result_messages, _previous_context_messages, _active_turn_identity, msg,
|
||||||
)
|
)
|
||||||
@@ -25243,6 +25381,14 @@ def _handle_chat_sync(handler, body):
|
|||||||
_next_context_messages,
|
_next_context_messages,
|
||||||
msg,
|
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.context_messages = _next_context_messages
|
||||||
s.messages = _merge_display_messages_after_agent_result(
|
s.messages = _merge_display_messages_after_agent_result(
|
||||||
_previous_messages,
|
_previous_messages,
|
||||||
@@ -25252,6 +25398,9 @@ def _handle_chat_sync(handler, body):
|
|||||||
),
|
),
|
||||||
msg,
|
msg,
|
||||||
source=getattr(s, "pending_user_source", None) or "webui",
|
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)
|
_compact_session_image_parts_for_persistence(s)
|
||||||
# Only auto-generate title when still default; preserves user renames
|
# Only auto-generate title when still default; preserves user renames
|
||||||
@@ -29285,17 +29434,34 @@ def _parse_mcp_enabled(value) -> bool:
|
|||||||
return True
|
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.
|
"""Return already-known MCP runtime status without starting servers.
|
||||||
|
|
||||||
``tools.mcp_tool.get_mcp_status()`` only reads the existing MCP registry and
|
``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
|
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.
|
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:
|
try:
|
||||||
from api.agent_compat import agent_attr
|
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")
|
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:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
if not isinstance(statuses, list):
|
if not isinstance(statuses, list):
|
||||||
@@ -29478,10 +29644,18 @@ def _mcp_tools_from_runtime_status(runtime_by_name, server_summaries):
|
|||||||
return tools
|
return tools
|
||||||
|
|
||||||
|
|
||||||
def _mcp_tools_from_registry(server_summaries):
|
def _mcp_tools_from_registry(server_summaries, view=None):
|
||||||
"""Read already-registered MCP tool schemas without probing MCP servers."""
|
"""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:
|
try:
|
||||||
from tools.registry import registry
|
from tools.registry import registry
|
||||||
|
from api.mcp_runtime import registry_tool_owned_by_view
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
tools = []
|
tools = []
|
||||||
@@ -29497,6 +29671,10 @@ def _mcp_tools_from_registry(server_summaries):
|
|||||||
if not isinstance(toolset, str) or not toolset.startswith("mcp-"):
|
if not isinstance(toolset, str) or not toolset.startswith("mcp-"):
|
||||||
continue
|
continue
|
||||||
server_name = toolset[len("mcp-"):]
|
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 {}
|
schema = registry.get_schema(tool_name) or {}
|
||||||
server_summary = server_summaries.get(server_name, {
|
server_summary = server_summaries.get(server_name, {
|
||||||
"name": server_name,
|
"name": server_name,
|
||||||
@@ -29508,22 +29686,45 @@ def _mcp_tools_from_registry(server_summaries):
|
|||||||
return tools
|
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):
|
def _handle_mcp_tools_list(handler):
|
||||||
"""List known MCP tools from already-available runtime inventory only."""
|
"""List known MCP tools from already-available runtime inventory only."""
|
||||||
cfg = get_config_for_profile_home(get_active_hermes_home())
|
cfg = get_config_for_profile_home(get_active_hermes_home())
|
||||||
servers = cfg.get("mcp_servers", {})
|
servers = cfg.get("mcp_servers", {})
|
||||||
if not isinstance(servers, dict):
|
if not isinstance(servers, dict):
|
||||||
servers = {}
|
servers = {}
|
||||||
runtime = _mcp_runtime_status_by_name()
|
server_summaries, tools, source, runtime_scope = _mcp_profile_runtime_inventory(
|
||||||
server_summaries = {
|
servers, "/api/mcp/tools"
|
||||||
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"
|
|
||||||
tools.sort(key=lambda row: (row.get("server", ""), row.get("name", "")))
|
tools.sort(key=lambda row: (row.get("server", ""), row.get("name", "")))
|
||||||
unavailable_servers = [
|
unavailable_servers = [
|
||||||
summary["name"] for summary in server_summaries.values()
|
summary["name"] for summary in server_summaries.values()
|
||||||
@@ -29534,6 +29735,7 @@ def _handle_mcp_tools_list(handler):
|
|||||||
"total": len(tools),
|
"total": len(tools),
|
||||||
"source": source,
|
"source": source,
|
||||||
"inventory_scope": "already_known_runtime_only",
|
"inventory_scope": "already_known_runtime_only",
|
||||||
|
"runtime_scope": runtime_scope,
|
||||||
"unavailable_servers": unavailable_servers,
|
"unavailable_servers": unavailable_servers,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -29723,21 +29925,15 @@ def _handle_notes_sources_list(handler):
|
|||||||
servers = cfg.get("mcp_servers", {})
|
servers = cfg.get("mcp_servers", {})
|
||||||
if not isinstance(servers, dict):
|
if not isinstance(servers, dict):
|
||||||
servers = {}
|
servers = {}
|
||||||
runtime = _mcp_runtime_status_by_name()
|
server_summaries, tools, source, runtime_scope = _mcp_profile_runtime_inventory(
|
||||||
server_summaries = {
|
servers, "/api/notes/sources"
|
||||||
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"
|
|
||||||
return j(handler, {
|
return j(handler, {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"sources": _notes_sources_from_mcp_inventory(server_summaries, tools),
|
"sources": _notes_sources_from_mcp_inventory(server_summaries, tools),
|
||||||
"source": source,
|
"source": source,
|
||||||
"inventory_scope": "already_known_runtime_only",
|
"inventory_scope": "already_known_runtime_only",
|
||||||
|
"runtime_scope": runtime_scope,
|
||||||
"attach_supported": False,
|
"attach_supported": False,
|
||||||
"automatic_recall_unchanged": True,
|
"automatic_recall_unchanged": True,
|
||||||
"recent_ai_notes": _joplin_recent_ai_notes(limit=6),
|
"recent_ai_notes": _joplin_recent_ai_notes(limit=6),
|
||||||
@@ -30028,15 +30224,14 @@ def _handle_mcp_servers_list(handler):
|
|||||||
servers = cfg.get("mcp_servers", {})
|
servers = cfg.get("mcp_servers", {})
|
||||||
if not isinstance(servers, dict):
|
if not isinstance(servers, dict):
|
||||||
servers = {}
|
servers = {}
|
||||||
runtime = _mcp_runtime_status_by_name()
|
server_summaries, _tools, _source, runtime_scope = _mcp_profile_runtime_inventory(
|
||||||
result = [
|
servers, "/api/mcp/servers", include_tools=False
|
||||||
_server_summary(name, scfg, runtime.get(str(name)))
|
)
|
||||||
for name, scfg in servers.items()
|
|
||||||
]
|
|
||||||
return j(handler, {
|
return j(handler, {
|
||||||
"servers": result,
|
"servers": list(server_summaries.values()),
|
||||||
"toggle_supported": True,
|
"toggle_supported": True,
|
||||||
"reload_required": True,
|
"reload_required": True,
|
||||||
|
"runtime_scope": runtime_scope,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+35
-2
@@ -10,6 +10,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from copy import deepcopy
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
@@ -130,7 +131,7 @@ def _get_cached_summary(path: Path) -> dict | None:
|
|||||||
_SUMMARY_CACHE.pop(key, None)
|
_SUMMARY_CACHE.pop(key, None)
|
||||||
return None
|
return None
|
||||||
_SUMMARY_CACHE.move_to_end(key)
|
_SUMMARY_CACHE.move_to_end(key)
|
||||||
return dict(summary)
|
return deepcopy(summary)
|
||||||
|
|
||||||
|
|
||||||
def _cache_summary(
|
def _cache_summary(
|
||||||
@@ -147,7 +148,7 @@ def _cache_summary(
|
|||||||
return
|
return
|
||||||
key = str(path)
|
key = str(path)
|
||||||
with _SUMMARY_CACHE_LOCK:
|
with _SUMMARY_CACHE_LOCK:
|
||||||
_SUMMARY_CACHE[key] = (signature, dict(summary))
|
_SUMMARY_CACHE[key] = (signature, deepcopy(summary))
|
||||||
_SUMMARY_CACHE.move_to_end(key)
|
_SUMMARY_CACHE.move_to_end(key)
|
||||||
while len(_SUMMARY_CACHE) > _SUMMARY_CACHE_MAX_ENTRIES:
|
while len(_SUMMARY_CACHE) > _SUMMARY_CACHE_MAX_ENTRIES:
|
||||||
_SUMMARY_CACHE.popitem(last=False)
|
_SUMMARY_CACHE.popitem(last=False)
|
||||||
@@ -534,6 +535,37 @@ def select_authoritative_terminal_event(events: Iterable[dict]) -> dict | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_model_from_events(session_id: str, stream_id: str, events: Iterable[dict]) -> dict | None:
|
||||||
|
"""Project only observed serving identity for this journal owner.
|
||||||
|
|
||||||
|
A fallback warning or malformed later observation invalidates old evidence;
|
||||||
|
the configured selection and free-text status never supply serving identity.
|
||||||
|
"""
|
||||||
|
observed = None
|
||||||
|
for event in events:
|
||||||
|
if not isinstance(event, dict) or event.get("session_id") != session_id or event.get("run_id") != stream_id:
|
||||||
|
continue
|
||||||
|
payload = event.get("payload")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
if event.get("event") == "runtime_model":
|
||||||
|
observed = None
|
||||||
|
continue
|
||||||
|
if event.get("event") == "warning" and payload.get("type") == "fallback":
|
||||||
|
observed = None
|
||||||
|
elif event.get("event") == "runtime_model":
|
||||||
|
observed = None
|
||||||
|
if (payload.get("session_id") != session_id or payload.get("stream_id") != stream_id
|
||||||
|
or not isinstance(payload.get("model"), str) or not payload["model"].strip()
|
||||||
|
or not isinstance(payload.get("fallback_active"), bool)
|
||||||
|
or payload.get("phase") not in ("observed_output", "route_observed")):
|
||||||
|
continue
|
||||||
|
observed = {key: payload[key] for key in (
|
||||||
|
"session_id", "stream_id", "model", "fallback_active", "phase")}
|
||||||
|
if isinstance(payload.get("provider"), str) and payload["provider"].strip():
|
||||||
|
observed["provider"] = payload["provider"]
|
||||||
|
return observed
|
||||||
|
|
||||||
|
|
||||||
def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -> dict:
|
def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -> dict:
|
||||||
ordered = [event for event in events if isinstance(event, dict)]
|
ordered = [event for event in events if isinstance(event, dict)]
|
||||||
last = ordered[-1] if ordered else None
|
last = ordered[-1] if ordered else None
|
||||||
@@ -549,6 +581,7 @@ def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -
|
|||||||
"terminal": bool(terminal),
|
"terminal": bool(terminal),
|
||||||
"terminal_state": status,
|
"terminal_state": status,
|
||||||
"last_event": (last or {}).get("event"),
|
"last_event": (last or {}).get("event"),
|
||||||
|
"runtime_model": runtime_model_from_events(session_id, run_id, ordered),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -601,7 +601,14 @@ def _state_db_row_to_sidecar(row: dict) -> dict:
|
|||||||
'session_id': row.get('id'),
|
'session_id': row.get('id'),
|
||||||
'title': row.get('title') or 'Recovered WebUI Session',
|
'title': row.get('title') or 'Recovered WebUI Session',
|
||||||
'workspace': workspace_value if isinstance(workspace_value, str) else '',
|
'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_path': row.get('worktree_path') or None,
|
||||||
'worktree_branch': row.get('worktree_branch') or None,
|
'worktree_branch': row.get('worktree_branch') or None,
|
||||||
'worktree_repo_root': row.get('worktree_repo_root') or None,
|
'worktree_repo_root': row.get('worktree_repo_root') or None,
|
||||||
|
|||||||
+508
-40
@@ -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.usage import prompt_cache_hit_percent
|
||||||
from api.models import (
|
from api.models import (
|
||||||
StateDBSessionMessagesSnapshot,
|
StateDBSessionMessagesSnapshot,
|
||||||
|
_WEBUI_TRUSTED_AGENT_INPUT_FIELD,
|
||||||
_is_empty_partial_activity_message,
|
_is_empty_partial_activity_message,
|
||||||
|
_message_exact_timestamp_details,
|
||||||
|
_message_private_identity_compatible,
|
||||||
|
_validated_webui_pending_user_timestamp_identity,
|
||||||
_evict_sessions_over_cap,
|
_evict_sessions_over_cap,
|
||||||
clear_process_wakeup_pause,
|
clear_process_wakeup_pause,
|
||||||
get_state_db_session_messages,
|
get_state_db_session_messages,
|
||||||
@@ -891,6 +895,9 @@ def _is_fallback_lifecycle_message(kind: str, message: str) -> bool:
|
|||||||
or 'falling back' in m
|
or 'falling back' in m
|
||||||
or 'fallback activated' in m
|
or 'fallback activated' in m
|
||||||
or 'trying fallback' in m
|
or 'trying fallback' in m
|
||||||
|
or 'model fallback:' in m
|
||||||
|
or 'switched to fallback' in m
|
||||||
|
or 'primary model restored:' in m
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2102,6 +2109,58 @@ def _mark_active_turn_checkpoint_in_history(messages, identity, msg_text, *, all
|
|||||||
return messages, True
|
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):
|
def _owner_projection_current_turn_row(messages, identity):
|
||||||
messages = list(messages or [])
|
messages = list(messages or [])
|
||||||
if not isinstance(identity, dict):
|
if not isinstance(identity, dict):
|
||||||
@@ -2138,16 +2197,19 @@ def _find_active_turn_checkpoint_index(result_messages, previous_context, identi
|
|||||||
return idx
|
return idx
|
||||||
if not _active_turn_boundary_is_valid(identity):
|
if not _active_turn_boundary_is_valid(identity):
|
||||||
return None
|
return None
|
||||||
expected_text = identity.get('text') if identity.get('text') is not None else msg_text
|
|
||||||
idx = identity['current_turn_user_idx']
|
idx = identity['current_turn_user_idx']
|
||||||
if idx < 0 or idx >= len(result_messages):
|
if idx < 0 or idx >= len(result_messages):
|
||||||
return None
|
return None
|
||||||
message = result_messages[idx]
|
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 (
|
if (
|
||||||
isinstance(message, dict)
|
identity.get('text') == msg_text
|
||||||
and message.get('role') == 'user'
|
and isinstance(trusted_agent_input, str)
|
||||||
and _normalize_user_text(_message_text(message.get('content')))
|
and trusted_agent_input != expected_text
|
||||||
== _normalize_user_text(expected_text)
|
and _active_turn_user_text_matches(message, trusted_agent_input)
|
||||||
):
|
):
|
||||||
return idx
|
return idx
|
||||||
return None
|
return None
|
||||||
@@ -2236,17 +2298,19 @@ def _settle_current_turn_boundary(previous_context, result_messages, identity, m
|
|||||||
)
|
)
|
||||||
if _checkpoint_idx is not None:
|
if _checkpoint_idx is not None:
|
||||||
existing_checkpoint = result_messages[_checkpoint_idx]
|
existing_checkpoint = result_messages[_checkpoint_idx]
|
||||||
if isinstance(identity.get('checkpoint'), dict):
|
_mark_active_turn_checkpoint(existing_checkpoint, identity)
|
||||||
retained_checkpoint = _materialize_active_turn_user(identity, msg_text, source)
|
checkpoint = identity.get('checkpoint')
|
||||||
if (
|
if isinstance(checkpoint, dict):
|
||||||
retained_checkpoint.get('id') is None
|
for key in ('id', 'timestamp'):
|
||||||
and isinstance(existing_checkpoint, dict)
|
if existing_checkpoint.get(key) is None and checkpoint.get(key) is not None:
|
||||||
and existing_checkpoint.get('id') is not None
|
existing_checkpoint[key] = copy.deepcopy(checkpoint[key])
|
||||||
):
|
if checkpoint.get('attachments'):
|
||||||
retained_checkpoint['id'] = existing_checkpoint['id']
|
existing_checkpoint['attachments'] = copy.deepcopy(checkpoint['attachments'])
|
||||||
result_messages[_checkpoint_idx] = retained_checkpoint
|
stamp_message_source(
|
||||||
else:
|
existing_checkpoint,
|
||||||
_mark_active_turn_checkpoint(existing_checkpoint, identity)
|
identity.get('source') or source or 'webui',
|
||||||
|
active_turn_token=identity.get('token'),
|
||||||
|
)
|
||||||
return result_messages
|
return result_messages
|
||||||
previous_context = list(previous_context or [])
|
previous_context = list(previous_context or [])
|
||||||
if _messages_have_prefix(result_messages, previous_context):
|
if _messages_have_prefix(result_messages, previous_context):
|
||||||
@@ -2316,6 +2380,14 @@ def _prepare_marker_clean_writeback(
|
|||||||
cleaned, has_verification_nudge = _clean_synthetic_control_messages_with_provenance(
|
cleaned, has_verification_nudge = _clean_synthetic_control_messages_with_provenance(
|
||||||
result_messages
|
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 = {
|
provenance = {
|
||||||
'verification_nudge_seen': has_verification_nudge,
|
'verification_nudge_seen': has_verification_nudge,
|
||||||
'active_turn_identity': copy.deepcopy(active_turn_identity),
|
'active_turn_identity': copy.deepcopy(active_turn_identity),
|
||||||
@@ -2428,6 +2500,50 @@ def _settle_result_messages(
|
|||||||
source=source,
|
source=source,
|
||||||
verification_nudge_provenance=verification_nudge_provenance,
|
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)
|
_annotate_media_snapshots_for_settled_messages(session.messages)
|
||||||
_compact_session_image_parts_for_persistence(session)
|
_compact_session_image_parts_for_persistence(session)
|
||||||
_advance_truncation_watermark_after_commit(session) # #3831
|
_advance_truncation_watermark_after_commit(session) # #3831
|
||||||
@@ -2455,6 +2571,61 @@ def _current_turn_already_has_visible_assistant_answer(messages, *, active_turn_
|
|||||||
return False
|
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:
|
def _agent_result_tool_limit_reached(result) -> bool:
|
||||||
"""Return True when current-turn metadata says the tool iteration cap fired."""
|
"""Return True when current-turn metadata says the tool iteration cap fired."""
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
@@ -5590,6 +5761,94 @@ def _strip_oob_blocks(content):
|
|||||||
return 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:
|
def _content_has_reasoning_only_parts(content) -> bool:
|
||||||
if not isinstance(content, list) or not content:
|
if not isinstance(content, list) or not content:
|
||||||
return False
|
return False
|
||||||
@@ -7426,6 +7685,46 @@ def _merge_display_messages_after_agent_result(
|
|||||||
result_messages = _drop_synthetic_control_messages(result_messages)
|
result_messages = _drop_synthetic_control_messages(result_messages)
|
||||||
if not result_messages:
|
if not result_messages:
|
||||||
return previous_display
|
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))
|
previous_user_tail = _stale_user_tail_candidate(_last_user_row(previous_context))
|
||||||
|
|
||||||
# ── Backfill normal turns from previous_context that are missing from
|
# ── Backfill normal turns from previous_context that are missing from
|
||||||
@@ -7440,16 +7739,34 @@ def _merge_display_messages_after_agent_result(
|
|||||||
# at/after a cursor. Any context messages between the cursor and that
|
# 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.
|
# match are context-only gaps that get spliced in before the display msg.
|
||||||
if previous_display and previous_context:
|
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}
|
_display_id_set = {_message_identity(m) for m in previous_display}
|
||||||
_context_id_set = {
|
_context_id_set = {
|
||||||
_message_identity(m)
|
_message_identity(m)
|
||||||
for m in previous_context
|
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)
|
if not _is_context_compression_marker(m)
|
||||||
and not _is_compressed_context_tool_result_summary_message(m)
|
and not _is_compressed_context_tool_result_summary_message(m)
|
||||||
}
|
}
|
||||||
_has_context_only_turns = bool(_context_id_set - _display_id_set)
|
_has_context_only_turns = bool(_context_id_set - _display_id_set)
|
||||||
if _has_context_only_turns:
|
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
|
# Precompute display keys once; avoids repeated json.dumps calls inside
|
||||||
# the inner any() loop (was O(D²·C) — see perf fix below).
|
# the inner any() loop (was O(D²·C) — see perf fix below).
|
||||||
_display_keys = [_message_identity(m) for m in previous_display]
|
_display_keys = [_message_identity(m) for m in previous_display]
|
||||||
@@ -7649,10 +7966,55 @@ def _merge_display_messages_after_agent_result(
|
|||||||
or _is_compressed_context_tool_result_summary_message(msg)
|
or _is_compressed_context_tool_result_summary_message(msg)
|
||||||
):
|
):
|
||||||
continue
|
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)
|
key = _message_identity(msg)
|
||||||
is_current_user_turn = _looks_like_current_user_turn(msg, msg_text)
|
is_current_user_turn = _looks_like_current_user_turn(msg, msg_text)
|
||||||
if (
|
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 merged
|
||||||
and (
|
and (
|
||||||
_message_identity(merged[-1]) == current_user_key
|
_message_identity(merged[-1]) == current_user_key
|
||||||
@@ -7692,12 +8054,14 @@ def _merge_display_messages_after_agent_result(
|
|||||||
continue
|
continue
|
||||||
display_msg = msg
|
display_msg = msg
|
||||||
if (
|
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 isinstance(msg, dict)
|
||||||
and msg.get('role') == 'user'
|
and msg.get('role') == 'user'
|
||||||
):
|
):
|
||||||
display_msg = copy.deepcopy(msg)
|
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)
|
stamp_message_source(display_msg, source)
|
||||||
merged.append(copy.deepcopy(display_msg))
|
merged.append(copy.deepcopy(display_msg))
|
||||||
if key is not None:
|
if key is not None:
|
||||||
@@ -8903,6 +9267,39 @@ def _add_supported_run_conversation_kwarg(callable_obj, kwargs, name, value):
|
|||||||
return False
|
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(
|
def _build_run_conversation_kwargs(
|
||||||
callable_obj,
|
callable_obj,
|
||||||
*,
|
*,
|
||||||
@@ -8914,7 +9311,7 @@ def _build_run_conversation_kwargs(
|
|||||||
persist_user_message,
|
persist_user_message,
|
||||||
persist_user_timestamp,
|
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
|
``persist_user_timestamp`` is signature-gated (#6935): an older
|
||||||
hermes-agent whose ``run_conversation()`` predates the kwarg must not
|
hermes-agent whose ``run_conversation()`` predates the kwarg must not
|
||||||
@@ -9760,6 +10157,37 @@ def _run_agent_streaming(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to put event to queue")
|
logger.debug("Failed to put event to queue")
|
||||||
|
|
||||||
|
_last_runtime_model_identity = None
|
||||||
|
_runtime_model_session_id = session_id
|
||||||
|
|
||||||
|
def _observe_runtime_model():
|
||||||
|
"""Publish this turn's Agent identity at output, never at an attempted route."""
|
||||||
|
nonlocal _last_runtime_model_identity
|
||||||
|
raw_model = getattr(agent, 'model', None)
|
||||||
|
if not isinstance(raw_model, str) or not raw_model.strip():
|
||||||
|
return
|
||||||
|
raw_provider = getattr(agent, 'provider', None)
|
||||||
|
provider = str(raw_provider).strip().lstrip('@').lower() if isinstance(raw_provider, str) else ''
|
||||||
|
# The Agent may carry a provider-qualified routing hint in its model.
|
||||||
|
from api.config import _parse_provider_qualified_model_id
|
||||||
|
parsed = _parse_provider_qualified_model_id(raw_model.strip())
|
||||||
|
model_id = (parsed[0] if parsed else raw_model).strip()
|
||||||
|
if not model_id:
|
||||||
|
return
|
||||||
|
fallback_active = getattr(agent, '_provider_fallback_active', None) is True
|
||||||
|
identity = (provider, model_id.lower(), fallback_active)
|
||||||
|
if identity == _last_runtime_model_identity:
|
||||||
|
return
|
||||||
|
payload = {
|
||||||
|
'session_id': _runtime_model_session_id, 'stream_id': stream_id,
|
||||||
|
'model': model_id, 'fallback_active': fallback_active,
|
||||||
|
'phase': 'observed_output',
|
||||||
|
}
|
||||||
|
if provider:
|
||||||
|
payload['provider'] = provider
|
||||||
|
put('runtime_model', payload)
|
||||||
|
_last_runtime_model_identity = identity
|
||||||
|
|
||||||
# #5940: capture a terminal (non-retryable) provider error the Agent emits via
|
# #5940: capture a terminal (non-retryable) provider error the Agent emits via
|
||||||
# its lifecycle status_callback. The Agent aborts a non-retryable API error
|
# its lifecycle status_callback. The Agent aborts a non-retryable API error
|
||||||
# (e.g. HTTP 400 "invalid model / no credentials") with
|
# (e.g. HTTP 400 "invalid model / no credentials") with
|
||||||
@@ -9780,6 +10208,7 @@ def _run_agent_streaming(
|
|||||||
turn-completion classifier can report the real cause instead of the
|
turn-completion classifier can report the real cause instead of the
|
||||||
generic no_response fallback. All other lifecycle messages are dropped.
|
generic no_response fallback. All other lifecycle messages are dropped.
|
||||||
"""
|
"""
|
||||||
|
nonlocal _last_runtime_model_identity
|
||||||
_message = str(message or '').strip()
|
_message = str(message or '').strip()
|
||||||
_kind = str(kind or '').strip().lower()
|
_kind = str(kind or '').strip().lower()
|
||||||
if not _message:
|
if not _message:
|
||||||
@@ -9809,6 +10238,7 @@ def _run_agent_streaming(
|
|||||||
return
|
return
|
||||||
_is_fallback_notice = _is_fallback_lifecycle_message(_kind, _message)
|
_is_fallback_notice = _is_fallback_lifecycle_message(_kind, _message)
|
||||||
if _is_fallback_notice:
|
if _is_fallback_notice:
|
||||||
|
_last_runtime_model_identity = None
|
||||||
put('warning', {'type': 'fallback', 'message': _message})
|
put('warning', {'type': 'fallback', 'message': _message})
|
||||||
|
|
||||||
# xsession wakeup misroute root fix (Option 1): pre-init so the outer
|
# xsession wakeup misroute root fix (Option 1): pre-init so the outer
|
||||||
@@ -10134,14 +10564,11 @@ def _run_agent_streaming(
|
|||||||
# loaded the default profile's `mcp_servers`, even when the session
|
# loaded the default profile's `mcp_servers`, even when the session
|
||||||
# was stamped with a non-default profile. See issue #1968.
|
# was stamped with a non-default profile. See issue #1968.
|
||||||
#
|
#
|
||||||
# NOTE: `_servers` in `tools/mcp_tool.py` is a process-global registry
|
# NOTE: hermes-agent keys MCP connections by `(profile_home, name)` only
|
||||||
# keyed by server name. This means once profile A registers a server
|
# for a routed profile (override != process home). The HERMES_HOME
|
||||||
# named e.g. `postgres`, profile B's discovery sees it as already
|
# mirror above would make this turn's own profile look like the process
|
||||||
# connected and skips it — even if B's config points at a different
|
# profile, falling back to bare, cross-profile names, unless the agent
|
||||||
# binary. Fully fixing multi-profile concurrent use requires keying
|
# honours the pin from `api.profiles._pin_process_profile_home()`.
|
||||||
# `_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.
|
|
||||||
try:
|
try:
|
||||||
from api.agent_compat import agent_attr
|
from api.agent_compat import agent_attr
|
||||||
discover_mcp_tools = agent_attr("tools.mcp_tool", "discover_mcp_tools", "tools.mcp_tool_discovery")
|
discover_mcp_tools = agent_attr("tools.mcp_tool", "discover_mcp_tools", "tools.mcp_tool_discovery")
|
||||||
@@ -10356,6 +10783,8 @@ def _run_agent_streaming(
|
|||||||
nonlocal _token_sent
|
nonlocal _token_sent
|
||||||
if text is None:
|
if text is None:
|
||||||
return # end-of-stream sentinel
|
return # end-of-stream sentinel
|
||||||
|
if text:
|
||||||
|
_observe_runtime_model()
|
||||||
# #4729: visible output is starting — flush any buffered reasoning tail
|
# #4729: visible output is starting — flush any buffered reasoning tail
|
||||||
# first so the live Thinking stream is complete before/at the transition.
|
# first so the live Thinking stream is complete before/at the transition.
|
||||||
_flush_reasoning_buffer()
|
_flush_reasoning_buffer()
|
||||||
@@ -10399,6 +10828,8 @@ def _run_agent_streaming(
|
|||||||
# partial window is not lost when the reasoning phase ends.
|
# partial window is not lost when the reasoning phase ends.
|
||||||
_flush_reasoning_buffer()
|
_flush_reasoning_buffer()
|
||||||
return
|
return
|
||||||
|
if text:
|
||||||
|
_observe_runtime_model()
|
||||||
_tool_boundary_advanced = False
|
_tool_boundary_advanced = False
|
||||||
reasoning_delta = str(text)
|
reasoning_delta = str(text)
|
||||||
# Some runtimes mirror user-visible progress text through the
|
# Some runtimes mirror user-visible progress text through the
|
||||||
@@ -11494,6 +11925,11 @@ def _run_agent_streaming(
|
|||||||
_agent_msg_text = "\n\n".join([*_process_notifications, msg_text]).strip()
|
_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)))
|
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)
|
_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(
|
_run_conversation_kwargs = _build_run_conversation_kwargs(
|
||||||
agent.run_conversation,
|
agent.run_conversation,
|
||||||
user_message=user_message,
|
user_message=user_message,
|
||||||
@@ -11509,7 +11945,7 @@ def _run_agent_streaming(
|
|||||||
conversation_history_revision=_conversation_history_revision,
|
conversation_history_revision=_conversation_history_revision,
|
||||||
task_id=session_id,
|
task_id=session_id,
|
||||||
persist_user_message=msg_text,
|
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
|
# 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
|
# normal send never trips a TypeError on an older hermes-agent whose
|
||||||
@@ -11551,6 +11987,11 @@ def _run_agent_streaming(
|
|||||||
profile=(getattr(s, "profile", None) or Path(_profile_home)),
|
profile=(getattr(s, "profile", None) or Path(_profile_home)),
|
||||||
)
|
)
|
||||||
_run_conversation_kwargs["user_message"] = user_message
|
_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)
|
_result_partial_pre_call_context = list(_previous_context_messages)
|
||||||
if not _agent_can_invoke(agent):
|
if not _agent_can_invoke(agent):
|
||||||
with _agent_lock:
|
with _agent_lock:
|
||||||
@@ -11601,6 +12042,11 @@ def _run_agent_streaming(
|
|||||||
if isinstance(_m, dict) and _m.get('role') == 'assistant':
|
if isinstance(_m, dict) and _m.get('role') == 'assistant':
|
||||||
_answer = str(_m.get('content', ''))
|
_answer = str(_m.get('content', ''))
|
||||||
break
|
break
|
||||||
|
if (_answer.strip() and not result.get('error')
|
||||||
|
and not _agent_result_terminal_failure(result)
|
||||||
|
and not getattr(agent, '_last_error', None)
|
||||||
|
and not _captured_terminal_error[0]):
|
||||||
|
_observe_runtime_model()
|
||||||
# /btw is intentionally non-persistent, but its terminal SSE
|
# /btw is intentionally non-persistent, but its terminal SSE
|
||||||
# payload is still public output. Project the ephemeral
|
# payload is still public output. Project the ephemeral
|
||||||
# session before enqueueing it so raw Agent ``api_content`` or
|
# session before enqueueing it so raw Agent ``api_content`` or
|
||||||
@@ -12068,6 +12514,15 @@ def _run_agent_streaming(
|
|||||||
_heal_context_messages,
|
_heal_context_messages,
|
||||||
_heal_conversation_history_revision,
|
_heal_conversation_history_revision,
|
||||||
) = _refresh_context_and_revision_from_state_db()
|
) = _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(
|
_heal_kwargs = _build_run_conversation_kwargs(
|
||||||
agent.run_conversation,
|
agent.run_conversation,
|
||||||
user_message=user_message,
|
user_message=user_message,
|
||||||
@@ -12085,7 +12540,7 @@ def _run_agent_streaming(
|
|||||||
),
|
),
|
||||||
task_id=session_id,
|
task_id=session_id,
|
||||||
persist_user_message=msg_text,
|
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:
|
if moa_config is not None:
|
||||||
_heal_kwargs["moa_config"] = moa_config
|
_heal_kwargs["moa_config"] = moa_config
|
||||||
@@ -12303,6 +12758,8 @@ def _run_agent_streaming(
|
|||||||
# the catch-all label, hint, and provider details.
|
# the catch-all label, hint, and provider details.
|
||||||
return # apperror already closes the stream on the client side
|
return # apperror already closes the stream on the client side
|
||||||
|
|
||||||
|
_observe_runtime_model()
|
||||||
|
|
||||||
# ── Handle context compression side effects ──
|
# ── Handle context compression side effects ──
|
||||||
# Also detect compression via the result dict or compressor state
|
# Also detect compression via the result dict or compressor state
|
||||||
if not _compressed:
|
if not _compressed:
|
||||||
@@ -12445,13 +12902,9 @@ def _run_agent_streaming(
|
|||||||
if attachments:
|
if attachments:
|
||||||
display_attachments = [_attachment_name(a) for a in attachments if _attachment_name(a)]
|
display_attachments = [_attachment_name(a) for a in attachments if _attachment_name(a)]
|
||||||
for m in reversed(s.messages):
|
for m in reversed(s.messages):
|
||||||
if m.get('role') == 'user':
|
if _active_turn_token_matches(m, _active_turn_identity):
|
||||||
content = str(m.get('content', ''))
|
m['attachments'] = display_attachments
|
||||||
# Match if content is part of the sent message or vice-versa
|
break
|
||||||
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
|
|
||||||
# Persist reasoning trace in the session so it survives reload.
|
# Persist reasoning trace in the session so it survives reload.
|
||||||
# Must run BEFORE s.save() — otherwise the mutation lives only in
|
# Must run BEFORE s.save() — otherwise the mutation lives only in
|
||||||
# memory until the next turn's save, and the last-turn thinking card
|
# memory until the next turn's save, and the last-turn thinking card
|
||||||
@@ -12509,7 +12962,9 @@ def _run_agent_streaming(
|
|||||||
# mutates agent.model when a fallback fires, so the pre-run
|
# mutates agent.model when a fallback fires, so the pre-run
|
||||||
# resolved_model would mis-attribute exactly the turns where
|
# resolved_model would mis-attribute exactly the turns where
|
||||||
# attribution matters most.
|
# attribution matters most.
|
||||||
_used_model = getattr(agent, 'model', None) or resolved_model or model
|
# The configured selection is not proof that it served this turn.
|
||||||
|
_observed_model = getattr(agent, 'model', None)
|
||||||
|
_used_model = _observed_model.strip() if isinstance(_observed_model, str) else None
|
||||||
if _gateway_routing:
|
if _gateway_routing:
|
||||||
s.gateway_routing = _gateway_routing
|
s.gateway_routing = _gateway_routing
|
||||||
_history = list(getattr(s, 'gateway_routing_history', None) or [])
|
_history = list(getattr(s, 'gateway_routing_history', None) or [])
|
||||||
@@ -13395,6 +13850,9 @@ def _run_agent_streaming(
|
|||||||
if 'credential_pool' in _agent_params:
|
if 'credential_pool' in _agent_params:
|
||||||
_heal_kwargs['credential_pool'] = _runtime_bundle['credential_pool']
|
_heal_kwargs['credential_pool'] = _runtime_bundle['credential_pool']
|
||||||
_heal_agent = _AIAgent(**_heal_kwargs)
|
_heal_agent = _AIAgent(**_heal_kwargs)
|
||||||
|
# Delta callbacks close over `agent`; the replacement, not
|
||||||
|
# the failed original, owns any successful retry output.
|
||||||
|
agent = _heal_agent
|
||||||
_agent_sig = _compute_agent_cache_signature(
|
_agent_sig = _compute_agent_cache_signature(
|
||||||
resolved_model,
|
resolved_model,
|
||||||
resolved_api_key,
|
resolved_api_key,
|
||||||
@@ -13423,6 +13881,15 @@ def _run_agent_streaming(
|
|||||||
_heal_context_messages,
|
_heal_context_messages,
|
||||||
_heal_conversation_history_revision,
|
_heal_conversation_history_revision,
|
||||||
) = _refresh_context_and_revision_from_state_db()
|
) = _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_kwargs2 = _build_run_conversation_kwargs(
|
||||||
_heal_agent.run_conversation,
|
_heal_agent.run_conversation,
|
||||||
user_message=user_message,
|
user_message=user_message,
|
||||||
@@ -13440,7 +13907,7 @@ def _run_agent_streaming(
|
|||||||
),
|
),
|
||||||
task_id=session_id,
|
task_id=session_id,
|
||||||
persist_user_message=msg_text,
|
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:
|
if moa_config is not None:
|
||||||
_heal_kwargs2["moa_config"] = moa_config
|
_heal_kwargs2["moa_config"] = moa_config
|
||||||
@@ -13522,6 +13989,7 @@ def _run_agent_streaming(
|
|||||||
s, tool_calls=s.tool_calls
|
s, tool_calls=s.tool_calls
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
_observe_runtime_model()
|
||||||
if _done_session_payload is not None:
|
if _done_session_payload is not None:
|
||||||
put('done', {
|
put('done', {
|
||||||
'session': _done_session_payload,
|
'session': _done_session_payload,
|
||||||
|
|||||||
@@ -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
|
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.
|
`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
|
### Heartbeat
|
||||||
|
|
||||||
`_SSE_HEARTBEAT_INTERVAL_SECONDS = 5` (defined in `api/routes.py`) is the current
|
`_SSE_HEARTBEAT_INTERVAL_SECONDS = 5` (defined in `api/routes.py`) is the current
|
||||||
@@ -162,7 +197,7 @@ gate rather than inventing values without source support.
|
|||||||
## Authoritative emitted events (`/api/chat/stream`)
|
## Authoritative emitted events (`/api/chat/stream`)
|
||||||
|
|
||||||
These are the **real wire `event:` names** emitted by `api/streaming.py` today
|
These are the **real wire `event:` names** emitted by `api/streaming.py` today
|
||||||
(23 names). Clients and docs must use this table — not the semantic draft above —
|
(24 names). Clients and docs must use this table — not the semantic draft above —
|
||||||
when integrating with the live chat SSE relay.
|
when integrating with the live chat SSE relay.
|
||||||
|
|
||||||
| Wire name | Role |
|
| Wire name | Role |
|
||||||
@@ -179,6 +214,7 @@ when integrating with the live chat SSE relay.
|
|||||||
| `title` | Session title update (often after `done`) |
|
| `title` | Session title update (often after `done`) |
|
||||||
| `title_status` | Title generation status / skip reason |
|
| `title_status` | Title generation status / skip reason |
|
||||||
| `warning` | Non-fatal provider/fallback warning |
|
| `warning` | Non-fatal provider/fallback warning |
|
||||||
|
| `runtime_model` | Local Agent serving identity observed at output or successful completion |
|
||||||
| `apperror` | Terminal application error (no trailing `stream_end`) |
|
| `apperror` | Terminal application error (no trailing `stream_end`) |
|
||||||
| `cancel` | Run cancelled |
|
| `cancel` | Run cancelled |
|
||||||
| `done` | Turn finalized (session payload); title/`stream_end` may follow |
|
| `done` | Turn finalized (session payload); title/`stream_end` may follow |
|
||||||
@@ -199,6 +235,31 @@ The semantic taxonomy table remains a draft for the proposed per-session
|
|||||||
endpoint vocabulary and must be confirmed during maintainer review before that
|
endpoint vocabulary and must be confirmed during maintainer review before that
|
||||||
endpoint claims parity.
|
endpoint claims parity.
|
||||||
|
|
||||||
|
### Observed local runtime model
|
||||||
|
|
||||||
|
The local worker emits `runtime_model` with
|
||||||
|
`{session_id, stream_id, model, provider?, fallback_active, phase}`. The IDs
|
||||||
|
refer to the original run-journal owner even if compression rotates the Agent's
|
||||||
|
session. `phase="observed_output"` means the Agent's own model was read at a
|
||||||
|
nonempty token/reasoning callback or after successful completion (including a
|
||||||
|
non-streaming reply or credential self-heal). It does not promise that a turn
|
||||||
|
which emitted partial output will finish successfully. Missing Agent model
|
||||||
|
sends no observation; the configured selection is not used as serving proof.
|
||||||
|
`fallback_active` is true only when the Agent explicitly reports its fallback
|
||||||
|
flag. Consecutive identical observations are deduplicated within the turn.
|
||||||
|
|
||||||
|
Fallback lifecycle warnings invalidate older serving evidence and reset the
|
||||||
|
deduplication; status text alone never establishes the replacement model. A
|
||||||
|
fresh observation after the warning reestablishes it, including when a buffered
|
||||||
|
success notice arrives after output. Durable replay retains the event IDs.
|
||||||
|
The journal summary and HTTP `runtime_journal_snapshot.runtime_model` project
|
||||||
|
the latest valid observation from the same session/stream event window, or null
|
||||||
|
if a warning or malformed latest observation invalidated it. No previous run's
|
||||||
|
footer or selected route fills an unknown current run. This is a local-worker
|
||||||
|
producer; gateway attribution and frontend presentation are separate concerns
|
||||||
|
(see #6272 and #7181). `route_observed` remains reserved for route-only data,
|
||||||
|
not successful output.
|
||||||
|
|
||||||
## Cursor and resume semantics
|
## Cursor and resume semantics
|
||||||
|
|
||||||
`Last-Event-ID` is the standard SSE reconnect header. Clients send the last
|
`Last-Event-ID` is the standard SSE reconnect header. Clients send the last
|
||||||
|
|||||||
@@ -215,6 +215,14 @@ settle.
|
|||||||
Steer must not exist only as a toast or transient DOM state. It must survive
|
Steer must not exist only as a toast or transient DOM state. It must survive
|
||||||
refresh, session switch, replay, and settled render.
|
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
|
### Relationship to system control events
|
||||||
|
|
||||||
Steer is similar to system-delivered control events such as tool-iteration-limit
|
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;
|
do not conflict, and the pairing is unambiguous. Keep the rich sidecar row;
|
||||||
if any requirement is missing or contradictory, preserve both rows rather
|
if any requirement is missing or contradictory, preserve both rows rather
|
||||||
than deduplicating. Literal scalar `[screenshot]` text alone is not identity
|
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
|
Visible interim assistant progress must remain visible timeline content; a
|
||||||
compact Activity disclosure may summarize adjacent tool/debug detail, but it
|
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.
|
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
|
## 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**.
|
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('');
|
else setStatus('');
|
||||||
// /api/chat/cancel only exposes `cancelled:bool`, so we cannot
|
// /api/chat/cancel only exposes `cancelled:bool`, so we cannot
|
||||||
// distinguish reasons — keep the toast generic and short.
|
// 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);
|
if(typeof showToast==='function') showToast('Stream is no longer active',2000);
|
||||||
}
|
}
|
||||||
return respOk;
|
return respOk;
|
||||||
@@ -91,6 +92,9 @@ async function cancelSessionStream(session){
|
|||||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||||
else setStatus('');
|
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){
|
if(typeof _approvalSessionId!=='undefined' && _approvalSessionId===sid){
|
||||||
stopApprovalPolling();
|
stopApprovalPolling();
|
||||||
hideApprovalCard(true);
|
hideApprovalCard(true);
|
||||||
@@ -2388,9 +2392,29 @@ window._isImeEnter=_isImeEnter;
|
|||||||
function _hasFinePointerCoexisting(){
|
function _hasFinePointerCoexisting(){
|
||||||
try{ return matchMedia('(any-pointer:fine)').matches; }catch(_){ return false; }
|
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){
|
function _isNumpadEnter(e){
|
||||||
return e.key==='Enter'&&(e.code==='NumpadEnter'||e.location===KeyboardEvent.DOM_KEY_LOCATION_NUMPAD);
|
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=>{
|
$('msg').addEventListener('keydown',e=>{
|
||||||
// Autocomplete navigation when dropdown is open
|
// Autocomplete navigation when dropdown is open
|
||||||
const dd=$('cmdDropdown');
|
const dd=$('cmdDropdown');
|
||||||
@@ -2424,9 +2448,8 @@ $('msg').addEventListener('keydown',e=>{
|
|||||||
if(e.key==='Enter'){
|
if(e.key==='Enter'){
|
||||||
if(_isImeEnter(e)){return;}
|
if(_isImeEnter(e)){return;}
|
||||||
const isNumpadEnter=_isNumpadEnter(e);
|
const isNumpadEnter=_isNumpadEnter(e);
|
||||||
const _mobileDefault=matchMedia('(pointer:coarse)').matches
|
const _mobileDefault=_isTouchOnlyDevice()
|
||||||
&&!_hasFinePointerCoexisting()
|
&&(window._sendKey==='enter'||typeof window._sendKey==='undefined');
|
||||||
&&window._sendKey==='enter';
|
|
||||||
if(window._sendKey==='shift+enter'){
|
if(window._sendKey==='shift+enter'){
|
||||||
if(e.shiftKey){e.preventDefault();send();}
|
if(e.shiftKey){e.preventDefault();send();}
|
||||||
} else if(window._sendKey==='ctrl+enter'||_mobileDefault){
|
} else if(window._sendKey==='ctrl+enter'||_mobileDefault){
|
||||||
|
|||||||
+20
-6
@@ -930,6 +930,12 @@ async function _applyManualCompressionResult(data, focusTopic, visibleCount, com
|
|||||||
clearLiveToolCards();
|
clearLiveToolCards();
|
||||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||||
|
// 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();
|
syncTopbar();
|
||||||
renderMessages();
|
renderMessages();
|
||||||
await renderSessionList();
|
await renderSessionList();
|
||||||
@@ -1028,14 +1034,18 @@ async function _runManualCompression(focusTopic){
|
|||||||
// Preflight: verify the viewed session still exists before compressing.
|
// Preflight: verify the viewed session still exists before compressing.
|
||||||
// This avoids a confusing "not found" toast when the UI is stale.
|
// This avoids a confusing "not found" toast when the UI is stale.
|
||||||
try{
|
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){
|
if(!live||!live.session||live.session.session_id!==sid){
|
||||||
throw new Error('session no longer available');
|
throw new Error('session no longer available');
|
||||||
}
|
}
|
||||||
S.session=live.session;
|
S.session=live.session;
|
||||||
S.messages=live.session.messages||[];
|
S.messages=live.session.messages||[];
|
||||||
S.toolCalls=live.session.tool_calls||[];
|
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){
|
}catch(preflightErr){
|
||||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
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})});
|
const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})});
|
||||||
if(r&&r.error){showToast(r.error);return;}
|
if(r&&r.error){showToast(r.error);return;}
|
||||||
if(!S.session||S.session.session_id!==activeSid)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
|
// #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.
|
// this recovery apply session A's intent to whatever session is now visible.
|
||||||
if(!S.session||S.session.session_id!==activeSid)return;
|
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();
|
$('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize();
|
||||||
// Re-arm the single-shot explicit-pick marker from the captured non-default
|
// 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
|
// 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})});
|
const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})});
|
||||||
if(r&&r.error){showToast(r.error);return;}
|
if(r&&r.error){showToast(r.error);return;}
|
||||||
if(!S.session||S.session.session_id!==activeSid)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
|
||||||
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();}
|
// 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')}`);
|
showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
|
||||||
}catch(e){showToast(t('undo_failed')+e.message);}
|
}catch(e){showToast(t('undo_failed')+e.message);}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'MCP Servers',
|
mcp_servers_title: 'MCP Servers',
|
||||||
mcp_servers_desc: 'View MCP servers configured in config.yaml.',
|
mcp_servers_desc: 'View MCP servers configured in config.yaml.',
|
||||||
mcp_no_servers: 'No MCP servers configured.',
|
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_add_server: '+ Add Server',
|
||||||
mcp_field_name: 'Server Name',
|
mcp_field_name: 'Server Name',
|
||||||
mcp_transport_label: 'Transport Type',
|
mcp_transport_label: 'Transport Type',
|
||||||
@@ -1945,6 +1946,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'Server MCP',
|
mcp_servers_title: 'Server MCP',
|
||||||
mcp_servers_desc: 'Visualizza i server MCP configurati in config.yaml.',
|
mcp_servers_desc: 'Visualizza i server MCP configurati in config.yaml.',
|
||||||
mcp_no_servers: 'Nessun server MCP configurato.',
|
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_add_server: '+ Aggiungi Server',
|
||||||
mcp_field_name: 'Nome Server',
|
mcp_field_name: 'Nome Server',
|
||||||
mcp_transport_label: 'Tipo Trasporto',
|
mcp_transport_label: 'Tipo Trasporto',
|
||||||
@@ -3750,6 +3752,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'MCPサーバー',
|
mcp_servers_title: 'MCPサーバー',
|
||||||
mcp_servers_desc: 'config.yaml で設定された MCP サーバーを管理します。',
|
mcp_servers_desc: 'config.yaml で設定された MCP サーバーを管理します。',
|
||||||
mcp_no_servers: 'MCPサーバーは設定されていません。',
|
mcp_no_servers: 'MCPサーバーは設定されていません。',
|
||||||
|
mcp_runtime_scope_unavailable: 'このプロファイルのライブステータスは現在利用できません(チャットのターンが実行中の可能性があります)。完了後に更新してください。',
|
||||||
mcp_add_server: '+ サーバーを追加',
|
mcp_add_server: '+ サーバーを追加',
|
||||||
mcp_field_name: 'サーバー名',
|
mcp_field_name: 'サーバー名',
|
||||||
mcp_transport_label: 'トランスポート種別',
|
mcp_transport_label: 'トランスポート種別',
|
||||||
@@ -5557,6 +5560,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'Серверы MCP',
|
mcp_servers_title: 'Серверы MCP',
|
||||||
mcp_servers_desc: 'Просмотр серверов MCP, настроенных в config.yaml.',
|
mcp_servers_desc: 'Просмотр серверов MCP, настроенных в config.yaml.',
|
||||||
mcp_no_servers: 'Серверы MCP не настроены.',
|
mcp_no_servers: 'Серверы MCP не настроены.',
|
||||||
|
mcp_runtime_scope_unavailable: 'Актуальный статус этого профиля сейчас недоступен (возможно, выполняется ход чата). Обновите, когда он завершится.',
|
||||||
mcp_add_server: '+ Добавить сервер',
|
mcp_add_server: '+ Добавить сервер',
|
||||||
mcp_field_name: 'Имя сервера',
|
mcp_field_name: 'Имя сервера',
|
||||||
mcp_transport_label: 'Тип транспорта',
|
mcp_transport_label: 'Тип транспорта',
|
||||||
@@ -7329,6 +7333,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'MCP 服务器',
|
mcp_servers_title: 'MCP 服务器',
|
||||||
mcp_servers_desc: '管理 config.yaml 中配置的 MCP 服务器。',
|
mcp_servers_desc: '管理 config.yaml 中配置的 MCP 服务器。',
|
||||||
mcp_no_servers: '未配置 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_add_server: '+ 添加服务器',
|
||||||
mcp_field_name: '服务器名称',
|
mcp_field_name: '服务器名称',
|
||||||
mcp_transport_label: '传输类型',
|
mcp_transport_label: '传输类型',
|
||||||
@@ -9080,6 +9085,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'MCP-Server',
|
mcp_servers_title: 'MCP-Server',
|
||||||
mcp_servers_desc: 'MCP-Server in config.yaml verwalten.',
|
mcp_servers_desc: 'MCP-Server in config.yaml verwalten.',
|
||||||
mcp_no_servers: 'Keine MCP-Server konfiguriert.',
|
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_add_server: '+ Server hinzufügen',
|
||||||
mcp_field_name: 'Servername',
|
mcp_field_name: 'Servername',
|
||||||
mcp_transport_label: 'Transporttyp',
|
mcp_transport_label: 'Transporttyp',
|
||||||
@@ -10825,6 +10831,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'MCP 服务器',
|
mcp_servers_title: 'MCP 服务器',
|
||||||
mcp_servers_desc: '管理 config.yaml 中配置的 MCP 服务器。',
|
mcp_servers_desc: '管理 config.yaml 中配置的 MCP 服务器。',
|
||||||
mcp_no_servers: '未配置 MCP 服务器。',
|
mcp_no_servers: '未配置 MCP 服务器。',
|
||||||
|
mcp_runtime_scope_unavailable: '当前无法获取此配置的实时状态(可能有对话回合正在进行)。请在其完成后刷新。',
|
||||||
mcp_add_server: '+ 添加服务器',
|
mcp_add_server: '+ 添加服务器',
|
||||||
mcp_field_name: '服务器名称',
|
mcp_field_name: '服务器名称',
|
||||||
mcp_transport_label: '传输类型',
|
mcp_transport_label: '传输类型',
|
||||||
@@ -12580,6 +12587,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'MCP 伺服器',
|
mcp_servers_title: 'MCP 伺服器',
|
||||||
mcp_servers_desc: '檢視 config.yaml 中設定的 MCP 伺服器。',
|
mcp_servers_desc: '檢視 config.yaml 中設定的 MCP 伺服器。',
|
||||||
mcp_no_servers: '未設定 MCP 伺服器。',
|
mcp_no_servers: '未設定 MCP 伺服器。',
|
||||||
|
mcp_runtime_scope_unavailable: '目前無法取得此設定檔的即時狀態(可能有對話回合正在進行)。請在其完成後重新整理。',
|
||||||
mcp_add_server: '+ 新增伺服器',
|
mcp_add_server: '+ 新增伺服器',
|
||||||
mcp_field_name: '伺服器名稱',
|
mcp_field_name: '伺服器名稱',
|
||||||
mcp_transport_label: '傳輸類型',
|
mcp_transport_label: '傳輸類型',
|
||||||
@@ -15996,6 +16004,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'MCP Servers',
|
mcp_servers_title: 'MCP Servers',
|
||||||
mcp_servers_desc: 'Manage Model Context Protocol servers configured in config.yaml.',
|
mcp_servers_desc: 'Manage Model Context Protocol servers configured in config.yaml.',
|
||||||
mcp_no_servers: 'No MCP servers configured.',
|
mcp_no_servers: 'No MCP servers configured.',
|
||||||
|
mcp_runtime_scope_unavailable: '이 프로필의 실시간 상태를 현재 확인할 수 없습니다(채팅 턴이 진행 중일 수 있음). 완료된 후 새로 고치세요.',
|
||||||
mcp_add_server: '+ Add Server',
|
mcp_add_server: '+ Add Server',
|
||||||
mcp_field_name: 'Server Name',
|
mcp_field_name: 'Server Name',
|
||||||
mcp_transport_label: 'Transport Type',
|
mcp_transport_label: 'Transport Type',
|
||||||
@@ -17801,6 +17810,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'Serveurs MCP',
|
mcp_servers_title: 'Serveurs MCP',
|
||||||
mcp_servers_desc: 'Afficher les serveurs MCP configurés dans config.yaml.',
|
mcp_servers_desc: 'Afficher les serveurs MCP configurés dans config.yaml.',
|
||||||
mcp_no_servers: 'Aucun serveur MCP configuré.',
|
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_add_server: '+ Ajouter un serveur',
|
||||||
mcp_field_name: 'Nom du serveur',
|
mcp_field_name: 'Nom du serveur',
|
||||||
mcp_transport_label: 'Type de transport',
|
mcp_transport_label: 'Type de transport',
|
||||||
@@ -20909,6 +20919,7 @@ const LOCALES = {
|
|||||||
mcp_load_failed: 'Načtení serverů MCP se nezdařilo.',
|
mcp_load_failed: 'Načtení serverů MCP se nezdařilo.',
|
||||||
mcp_name_required: 'Je vyžadováno jméno serveru.',
|
mcp_name_required: 'Je vyžadováno jméno serveru.',
|
||||||
mcp_no_servers: 'Nejsou nakonfigurovány žádné servery MCP.',
|
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_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: 'Uložit',
|
||||||
mcp_save_failed: 'Uložení serveru MCP se nezdařilo.',
|
mcp_save_failed: 'Uložení serveru MCP se nezdařilo.',
|
||||||
@@ -21350,6 +21361,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'MCP Sunucuları',
|
mcp_servers_title: 'MCP Sunucuları',
|
||||||
mcp_servers_desc: 'config.yaml dosyasında yapılandırılmış MCP sunucularını görüntüleyin.',
|
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_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_add_server: '+ Sunucu Ekle',
|
||||||
mcp_field_name: 'Sunucu Adı',
|
mcp_field_name: 'Sunucu Adı',
|
||||||
mcp_transport_label: 'Taşıma Türü',
|
mcp_transport_label: 'Taşıma Türü',
|
||||||
@@ -23148,6 +23160,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'Serwery MCP',
|
mcp_servers_title: 'Serwery MCP',
|
||||||
mcp_servers_desc: 'Wyświetl serwery MCP skonfigurowane w config.yaml.',
|
mcp_servers_desc: 'Wyświetl serwery MCP skonfigurowane w config.yaml.',
|
||||||
mcp_no_servers: 'Brak skonfigurowanych serwerów MCP.',
|
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_add_server: '+ Dodaj serwer',
|
||||||
mcp_field_name: 'Nazwa serwera',
|
mcp_field_name: 'Nazwa serwera',
|
||||||
mcp_transport_label: 'Typ transportu',
|
mcp_transport_label: 'Typ transportu',
|
||||||
@@ -24941,6 +24954,7 @@ const LOCALES = {
|
|||||||
mcp_servers_title: 'Máy chủ MCP',
|
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_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_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_add_server: '+ Thêm máy chủ',
|
||||||
mcp_field_name: 'Tên máy chủ',
|
mcp_field_name: 'Tên máy chủ',
|
||||||
mcp_transport_label: 'Loại kết nối',
|
mcp_transport_label: 'Loại kết nối',
|
||||||
|
|||||||
+1
-1
@@ -592,7 +592,7 @@
|
|||||||
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
|
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
|
||||||
<span class="voice-mode-label" id="voiceModeLabel"></span>
|
<span class="voice-mode-label" id="voiceModeLabel"></span>
|
||||||
</div>
|
</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 id="composerWorkspaceContext" class="sr-only"></div>
|
||||||
<div class="composer-footer">
|
<div class="composer-footer">
|
||||||
<div class="composer-left">
|
<div class="composer-left">
|
||||||
|
|||||||
+287
-47
@@ -2760,11 +2760,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
let _currentActivityBurstId=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentActivityBurstId)||0)||0;
|
let _currentActivityBurstId=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentActivityBurstId)||0)||0;
|
||||||
let _currentLiveSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0;
|
let _currentLiveSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0;
|
||||||
let _assistantSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0;
|
let _assistantSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0;
|
||||||
let _lastRunJournalSeq=reconnecting
|
// #7640: the replay floor is only as trustworthy as the recovery state behind
|
||||||
? Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalSeq)||0)
|
// it. A cache that kept the cursor but lost the live assistant projection must
|
||||||
|
// not raise `after_seq`: the server would then replay only the tail (often just
|
||||||
|
// `stream_end`), and the missing journal range never gets a chance to rebuild
|
||||||
|
// the body — the settled footer paints over a blank message until a reload.
|
||||||
|
// Fall back to the zero floor whenever the state cannot be validated.
|
||||||
|
const _replayCursorInflight=reconnecting?INFLIGHT[activeSid]:null;
|
||||||
|
let _lastRunJournalSeq=(typeof _runJournalReplayFloorForInflight==='function')
|
||||||
|
? _runJournalReplayFloorForInflight(_replayCursorInflight)
|
||||||
: 0;
|
: 0;
|
||||||
let _lastRunJournalEventId=reconnecting
|
let _lastRunJournalEventId=(typeof _runJournalReplayEventIdForInflight==='function')
|
||||||
? String((INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId)||'')
|
? _runJournalReplayEventIdForInflight(_replayCursorInflight)
|
||||||
: '';
|
: '';
|
||||||
const _STREAM_FADE_MS=620;
|
const _STREAM_FADE_MS=620;
|
||||||
const _STREAM_FADE_MAX_MS=900;
|
const _STREAM_FADE_MAX_MS=900;
|
||||||
@@ -6099,7 +6106,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
_applyToAnchor('approval',d,e);
|
_applyToAnchor('approval',d,e);
|
||||||
showApprovalForSession(activeSid, d, d.pending_count || 1);
|
showApprovalForSession(activeSid, d, d.pending_count || 1);
|
||||||
playAttentionSound(_attentionSoundKey(activeSid,'approval',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=>{
|
source.addEventListener('clarify',e=>{
|
||||||
@@ -6107,7 +6116,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
_applyToAnchor('clarify',d,e);
|
_applyToAnchor('clarify',d,e);
|
||||||
showClarifyForSession(activeSid, d);
|
showClarifyForSession(activeSid, d);
|
||||||
playAttentionSound(_attentionSoundKey(activeSid,'clarify',1));
|
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=>{
|
source.addEventListener('state_saved',e=>{
|
||||||
@@ -6759,6 +6768,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
S.session=d.session;
|
S.session=d.session;
|
||||||
const _nextMsgs3018=(d.session.messages||[]).filter(m=>m&&m.role);
|
const _nextMsgs3018=(d.session.messages||[]).filter(m=>m&&m.role);
|
||||||
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(d.session);
|
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);
|
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
|
||||||
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
||||||
if(S.session&&S.session.session_id){
|
if(S.session&&S.session.session_id){
|
||||||
@@ -7000,6 +7013,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
S.session=sessionPayload;
|
S.session=sessionPayload;
|
||||||
const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);
|
const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);
|
||||||
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(sessionPayload);
|
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);
|
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
|
||||||
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
||||||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||||
@@ -7021,8 +7039,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
if(_applyCancelSessionPayload(_cancelSessionPayload)) return;
|
if(_applyCancelSessionPayload(_cancelSessionPayload)) return;
|
||||||
// Fetch latest session from server to get accurate message list (includes cancel status)
|
// 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
|
// This ensures messages stay in sync with server, fixing race condition where local
|
||||||
// "*Task cancelled.*" message gets lost when done event overwrites S.messages
|
// "*Task cancelled.*" message gets lost when done event overwrites S.messages.
|
||||||
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 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);
|
if(data&&data.session) _applyCancelSessionPayload(data.session);
|
||||||
}catch(_){
|
}catch(_){
|
||||||
// Fallback to local cancel message if API fails
|
// Fallback to local cancel message if API fails
|
||||||
@@ -7114,7 +7134,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
return returnStatus?'stale':false;
|
return returnStatus?'stale':false;
|
||||||
}
|
}
|
||||||
try{
|
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;
|
if(isCurrent&&!isCurrent()) return returnStatus?'stale':false;
|
||||||
// Opus #2852 race-fix: if a late `done` event ran the finalize path while
|
// 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.
|
// we were awaiting the network roundtrip, bail out — done already settled.
|
||||||
@@ -7149,6 +7172,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role);
|
const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role);
|
||||||
const _currentMessages=Array.isArray(S.messages)?S.messages:[];
|
const _currentMessages=Array.isArray(S.messages)?S.messages:[];
|
||||||
const _currentVisibleMessages=_filterRecoveryControlMessages(_currentMessages || []);
|
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 _stagedMessages=_carryForwardEphemeralTurnFields(_currentMessages, _nextMsgs3018);
|
||||||
const _currentVisibleEndsWithTerminalMarker=(
|
const _currentVisibleEndsWithTerminalMarker=(
|
||||||
_currentVisibleMessages.length>0 &&
|
_currentVisibleMessages.length>0 &&
|
||||||
@@ -7164,18 +7197,40 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
return !!stagedKey && stagedKey===currentKey;
|
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
|
const _resolvedMessages=_preserveCurrentTranscript
|
||||||
? [..._stagedMessages,..._currentVisibleMessages.slice(_stagedMessages.length)]
|
? [..._stagedMessages,..._currentVisibleMessages.slice(_truncatedRecovery?_stagedSuffixStart+_stagedMessages.length:_stagedMessages.length)]
|
||||||
: _stagedMessages;
|
: _stagedMessages;
|
||||||
S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);
|
S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);
|
||||||
_attachProjectedAnchorSceneToLastAssistant(S.messages);
|
_attachProjectedAnchorSceneToLastAssistant(S.messages);
|
||||||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
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);
|
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
|
||||||
if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error');
|
if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error');
|
||||||
const hasMessageToolMetadata=S.messages.some(m=>{
|
const hasMessageToolMetadata=S.messages.some(m=>{
|
||||||
@@ -7574,19 +7629,25 @@ function hideApprovalCard(force=false) {
|
|||||||
let _approvalSessionId = null;
|
let _approvalSessionId = null;
|
||||||
let _approvalCurrentId = null; // approval_id of the card currently shown
|
let _approvalCurrentId = null; // approval_id of the card currently shown
|
||||||
let _approvalPendingBySession = new Map();
|
let _approvalPendingBySession = new Map();
|
||||||
|
const _approvalPromptGenerationBySession = new Map();
|
||||||
let _approvalResponding = null;
|
let _approvalResponding = null;
|
||||||
let _approvalClearedOwner = null;
|
let _approvalClearedOwner = null;
|
||||||
let _approvalDisplayedOwner = null;
|
let _approvalDisplayedOwner = null;
|
||||||
|
|
||||||
const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
|
const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
|
||||||
|
|
||||||
// Dismissed approvals are namespaced by session so that two sessions carrying
|
// Dismissal uses the same injective full-owner tuple as notifications.
|
||||||
// the SAME approval_id (e.g. a gateway/run source that reuses externally
|
// Legacy session/ID-only tombstones cannot safely identify a gateway run.
|
||||||
// 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>".
|
|
||||||
function _approvalDismissKey(sid, approvalId) {
|
function _approvalDismissKey(sid, approvalId) {
|
||||||
if (!approvalId) return '';
|
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() {
|
function _getDismissedApprovals() {
|
||||||
@@ -7597,7 +7658,23 @@ function _getDismissedApprovals() {
|
|||||||
function _isApprovalDismissed(sid, approvalId) {
|
function _isApprovalDismissed(sid, approvalId) {
|
||||||
const key = _approvalDismissKey(sid, approvalId);
|
const key = _approvalDismissKey(sid, approvalId);
|
||||||
if (!key) return false;
|
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) {
|
function _markApprovalDismissed(sid, approvalId) {
|
||||||
@@ -7628,15 +7705,38 @@ function _approvalPromptBelongsToActiveSession(sid) {
|
|||||||
function activeSessionHasPendingPromptAttention() {
|
function activeSessionHasPendingPromptAttention() {
|
||||||
const sid = _promptActiveSessionId();
|
const sid = _promptActiveSessionId();
|
||||||
return !!(sid && (
|
return !!(sid && (
|
||||||
_approvalPendingBySession.has(sid) ||
|
(_approvalPendingBySession.has(sid) &&
|
||||||
|
!_isApprovalDismissed(sid, _approvalPendingBySession.get(sid).pending)) ||
|
||||||
_clarifyPendingBySession.has(sid)
|
_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) {
|
function _rememberApprovalPending(pending, pendingCount) {
|
||||||
if (!pending) return null;
|
if (!pending) return null;
|
||||||
const sid = pending._session_id || _promptActiveSessionId();
|
const sid = pending._session_id || _promptActiveSessionId();
|
||||||
if (!sid) return null;
|
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};
|
const nextPending = {...pending, _session_id: sid};
|
||||||
_approvalPendingBySession.set(sid, {pending: nextPending, pendingCount: pendingCount || 1});
|
_approvalPendingBySession.set(sid, {pending: nextPending, pendingCount: pendingCount || 1});
|
||||||
return sid;
|
return sid;
|
||||||
@@ -7644,7 +7744,11 @@ function _rememberApprovalPending(pending, pendingCount) {
|
|||||||
|
|
||||||
function _clearApprovalPendingForSession(sid) {
|
function _clearApprovalPendingForSession(sid) {
|
||||||
if (sid) {
|
if (sid) {
|
||||||
|
const entry = _approvalPendingBySession.get(sid);
|
||||||
_approvalPendingBySession.delete(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();
|
if (typeof syncTopbar === 'function') syncTopbar();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7787,8 +7891,9 @@ function showApprovalForSession(sid, pending, pendingCount) {
|
|||||||
|
|
||||||
function showApprovalCard(pending, pendingCount) {
|
function showApprovalCard(pending, pendingCount) {
|
||||||
const sid = _rememberApprovalPending(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 (!_approvalPromptBelongsToActiveSession(sid)) return;
|
||||||
if (pending && pending.approval_id && _isApprovalDismissed(sid, pending.approval_id)) return;
|
|
||||||
_approvalClearedOwner = null;
|
_approvalClearedOwner = null;
|
||||||
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
|
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
|
||||||
const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
|
const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
|
||||||
@@ -7847,9 +7952,12 @@ function showApprovalCard(pending, pendingCount) {
|
|||||||
|
|
||||||
function dismissApprovalCard() {
|
function dismissApprovalCard() {
|
||||||
const sid = _approvalSessionId;
|
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);
|
hideApprovalCard(true);
|
||||||
if (sid) _clearApprovalPendingForSession(sid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _syncApprovalCollapseButton(card) {
|
function _syncApprovalCollapseButton(card) {
|
||||||
@@ -8041,7 +8149,9 @@ function _startApprovalFallbackPoll(sid) {
|
|||||||
if (_approvalFallbackPollInFlight) return;
|
if (_approvalFallbackPollInFlight) return;
|
||||||
_approvalFallbackPollInFlight = true;
|
_approvalFallbackPollInFlight = true;
|
||||||
try {
|
try {
|
||||||
|
const generation = _approvalPromptGeneration(sid);
|
||||||
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
|
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); }
|
if (data.pending) { showApprovalForSession(sid, data.pending, data.pending_count||1); }
|
||||||
else if (!_approvalPollingSessionMissingOrMismatched(sid)) {
|
else if (!_approvalPollingSessionMissingOrMismatched(sid)) {
|
||||||
const _resolvedEntry = _approvalPendingBySession.get(sid);
|
const _resolvedEntry = _approvalPendingBySession.get(sid);
|
||||||
@@ -8205,32 +8315,37 @@ function _startHiddenActiveStreamPoll(sid) {
|
|||||||
if (!sid) return;
|
if (!sid) return;
|
||||||
_stopHiddenActiveStreamPoll();
|
_stopHiddenActiveStreamPoll();
|
||||||
_sessionStreamHiddenPollSid = sid;
|
_sessionStreamHiddenPollSid = sid;
|
||||||
|
let notFoundCount = 0;
|
||||||
|
let pollTimer = null;
|
||||||
|
const ownsPoll = () => _sessionStreamHiddenPollSid === sid &&
|
||||||
|
_sessionStreamHiddenPollTimer === pollTimer;
|
||||||
const tick = () => {
|
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
|
// Stop conditions: tab became visible (real SSE takes over), session
|
||||||
// switched, or we're already rendering a stream.
|
// switched, or we're already rendering a stream.
|
||||||
if (typeof document !== 'undefined' && !document.hidden) { _stopHiddenActiveStreamPoll(); return; }
|
if (typeof document !== 'undefined' && !document.hidden) { _stopHiddenActiveStreamPoll(); return; }
|
||||||
if (_sessionStreamHiddenPollSid !== sid) { _stopHiddenActiveStreamPoll(); return; }
|
|
||||||
if (S.activeStreamId) return; // already rendering; wait it out
|
if (S.activeStreamId) return; // already rendering; wait it out
|
||||||
try {
|
try {
|
||||||
fetch(_apiUrl('api/session/status?session_id=' + encodeURIComponent(sid)), {credentials: 'same-origin'})
|
fetch(_apiUrl('api/session/status?session_id=' + encodeURIComponent(sid)), {credentials: 'same-origin'})
|
||||||
.then(r => {
|
.then(r => {
|
||||||
// #7299: 404 Not Found / 410 Gone are TERMINAL for this
|
if (!ownsPoll()) return null;
|
||||||
// session-owned poll. The session has been deleted or no
|
if (r && r.status === 404) {
|
||||||
// longer exists in the active state directory, so further
|
// Profile visibility also returns 404. Bound repeated misses, but
|
||||||
// polls are guaranteed to fail. Stop the poll immediately
|
// retain the resume owner so a profile flip back can self-heal.
|
||||||
// to avoid the infinite 404 loop on stale background tabs
|
if (++notFoundCount >= 3) _stopHiddenActiveStreamPoll();
|
||||||
// (one tab could fire ~10 such requests per minute; multiple
|
return null;
|
||||||
// tabs multiply the noise). Transient failures (5xx, rate
|
}
|
||||||
// limit, network error) keep polling — only the missing
|
notFoundCount = 0;
|
||||||
// session itself is terminal.
|
if (r && r.status === 410) {
|
||||||
if ((r.status === 404 || r.status === 410) && _sessionStreamHiddenPollSid === sid) {
|
if (_sessionStreamHiddenSid === sid) _sessionStreamHiddenSid = null;
|
||||||
_stopHiddenActiveStreamPoll();
|
_stopHiddenActiveStreamPoll();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return r.ok ? r.json() : null;
|
return r && r.ok ? r.json() : null;
|
||||||
})
|
})
|
||||||
.then(d => {
|
.then(d => {
|
||||||
if (!d || _sessionStreamHiddenPollSid !== sid) return;
|
if (!d || !ownsPoll()) return;
|
||||||
const streamId = d.active_stream_id;
|
const streamId = d.active_stream_id;
|
||||||
if (streamId && S.activeStreamId !== String(streamId)) {
|
if (streamId && S.activeStreamId !== String(streamId)) {
|
||||||
// Server-initiated turn in flight while hidden → attach as replay.
|
// Server-initiated turn in flight while hidden → attach as replay.
|
||||||
@@ -8263,10 +8378,11 @@ function _startHiddenActiveStreamPoll(sid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => { notFoundCount = 0; });
|
||||||
} catch (_) {}
|
} 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
|
// Fire one immediately so a turn already running when we go hidden is caught
|
||||||
// without waiting a full interval.
|
// without waiting a full interval.
|
||||||
tick();
|
tick();
|
||||||
@@ -8611,16 +8727,36 @@ let _clarifyMissingEndpointWarned = false;
|
|||||||
let _clarifyCountdownTimer = null;
|
let _clarifyCountdownTimer = null;
|
||||||
let _clarifyExpiresAt = 0;
|
let _clarifyExpiresAt = 0;
|
||||||
let _clarifyPendingBySession = new Map();
|
let _clarifyPendingBySession = new Map();
|
||||||
|
const _clarifyPromptGenerationBySession = new Map();
|
||||||
const CLARIFY_MIN_VISIBLE_MS = 30000;
|
const CLARIFY_MIN_VISIBLE_MS = 30000;
|
||||||
|
|
||||||
function _clarifyPromptBelongsToActiveSession(sid) {
|
function _clarifyPromptBelongsToActiveSession(sid) {
|
||||||
return !!(sid && _promptActiveSessionId() === 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) {
|
function _rememberClarifyPending(pending) {
|
||||||
if (!pending) return null;
|
if (!pending) return null;
|
||||||
const sid = pending._session_id || _promptActiveSessionId();
|
const sid = pending._session_id || _promptActiveSessionId();
|
||||||
if (!sid) return null;
|
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};
|
const nextPending = {...pending, _session_id: sid};
|
||||||
_clarifyPendingBySession.set(sid, {pending: nextPending});
|
_clarifyPendingBySession.set(sid, {pending: nextPending});
|
||||||
return sid;
|
return sid;
|
||||||
@@ -8628,11 +8764,19 @@ function _rememberClarifyPending(pending) {
|
|||||||
|
|
||||||
function _clearClarifyPendingForSession(sid) {
|
function _clearClarifyPendingForSession(sid) {
|
||||||
if (sid) {
|
if (sid) {
|
||||||
|
const entry = _clarifyPendingBySession.get(sid);
|
||||||
_clarifyPendingBySession.delete(sid);
|
_clarifyPendingBySession.delete(sid);
|
||||||
|
_bumpClarifyPromptGeneration(sid);
|
||||||
|
if (entry && entry.pending) _retirePromptNotifyKey('clarify', sid, entry.pending);
|
||||||
if (typeof syncTopbar === 'function') syncTopbar();
|
if (typeof syncTopbar === 'function') syncTopbar();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _clearPendingPromptsForSession(sid) {
|
||||||
|
_clearApprovalPendingForSession(sid);
|
||||||
|
_clearClarifyPendingForSession(sid);
|
||||||
|
}
|
||||||
|
|
||||||
function _hideClarifyCardIfOwner(sid, force=false, reason="dismissed") {
|
function _hideClarifyCardIfOwner(sid, force=false, reason="dismissed") {
|
||||||
if (!sid || _clarifySessionId === sid) hideClarifyCard(force, reason);
|
if (!sid || _clarifySessionId === sid) hideClarifyCard(force, reason);
|
||||||
}
|
}
|
||||||
@@ -8920,6 +9064,7 @@ function _clarifySetControlsDisabled(disabled, loading=false) {
|
|||||||
|
|
||||||
function showClarifyCard(pending) {
|
function showClarifyCard(pending) {
|
||||||
const sid = _rememberClarifyPending(pending);
|
const sid = _rememberClarifyPending(pending);
|
||||||
|
if(typeof _notifyPromptCard==='function') _notifyPromptCard('clarify', sid, pending);
|
||||||
if (!_clarifyPromptBelongsToActiveSession(sid)) return;
|
if (!_clarifyPromptBelongsToActiveSession(sid)) return;
|
||||||
const question = pending.question || pending.description || '';
|
const question = pending.question || pending.description || '';
|
||||||
const choices = Array.isArray(pending.choices_offered)
|
const choices = Array.isArray(pending.choices_offered)
|
||||||
@@ -9179,7 +9324,9 @@ function _startClarifyFallbackPoll(sid) {
|
|||||||
if (_clarifyFallbackPollInFlight) return;
|
if (_clarifyFallbackPollInFlight) return;
|
||||||
_clarifyFallbackPollInFlight = true;
|
_clarifyFallbackPollInFlight = true;
|
||||||
try {
|
try {
|
||||||
|
const generation = _clarifyPromptGeneration(sid);
|
||||||
const data = await api("/api/clarify/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
|
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); }
|
if (data.pending) { showClarifyForSession(sid, data.pending); }
|
||||||
else { _clearClarifyPendingForSession(sid); _hideClarifyCardIfOwner(sid, false, 'expired'); }
|
else { _clearClarifyPendingForSession(sid); _hideClarifyCardIfOwner(sid, false, 'expired'); }
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
@@ -9398,6 +9545,81 @@ function requestNotificationPermission(){
|
|||||||
return p;
|
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={}){
|
function sendBrowserNotification(title,body,options={}){
|
||||||
const force=!!(options&&options.force);
|
const force=!!(options&&options.force);
|
||||||
// #4416: `forceHidden` means the caller already determined the tab was hidden
|
// #4416: `forceHidden` means the caller already determined the tab was hidden
|
||||||
@@ -9410,14 +9632,32 @@ function sendBrowserNotification(title,body,options={}){
|
|||||||
if(!force&&!window._notificationsEnabled) return;
|
if(!force&&!window._notificationsEnabled) return;
|
||||||
if(!force&&!forceHidden&&!_isBackgroundedForBrowserNotification()) return;
|
if(!force&&!forceHidden&&!_isBackgroundedForBrowserNotification()) return;
|
||||||
if(!('Notification' in window)) return;
|
if(!('Notification' in window)) return;
|
||||||
if(Notification.permission==='granted'){
|
const deliver = async () => {
|
||||||
_showPwaNotification(title,body,options).catch(()=>{try{new Notification(title||assistantDisplayName(),_notificationOptions(body,options));}catch(_err){}});
|
try {
|
||||||
}else if(Notification.permission==='denied'){
|
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.
|
// Explicit "Send test" (force) deserves feedback instead of a silent no-op.
|
||||||
if(force&&typeof showToast==='function') showToast(t('notifications_denied'),3500,'error');
|
if(force&&typeof showToast==='function') showToast(t('notifications_denied'),3500,'error');
|
||||||
}else{
|
return false;
|
||||||
requestNotificationPermission().then(p=>{if(p==='granted') _showPwaNotification(title,body,options).catch(()=>{try{new Notification(title||assistantDisplayName(),_notificationOptions(body,options));}catch(_err){}});});
|
|
||||||
}
|
}
|
||||||
|
// 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 ────────────────────────────────────────────────────
|
// ── /btw ephemeral stream ────────────────────────────────────────────────────
|
||||||
|
|||||||
+6
-7
@@ -104,16 +104,15 @@ function _jumpToMessage(rawIdx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Row is outside the render window — reload the full session and retry.
|
// 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
|
// Request the COMPLETE transcript via the explicit msg_limit=all escape
|
||||||
// COMPLETE transcript: the target row is addressed by absolute index
|
// hatch: the target row is addressed by absolute index (msg-user-<rawIdx>),
|
||||||
// (msg-user-<rawIdx>), so a bounded tail window would miss early rows.
|
// so a bounded tail window would miss early rows. The bare no-limit shape
|
||||||
// (A previous version sent msg_limit=9999 as a "give me everything" hack,
|
// is not used here intentionally — full-transcript intent must be explicit
|
||||||
// but the server now clamps msg_limit, so the bare path is the correct way
|
// so recovery paths stay bounded (#7310/#7625).
|
||||||
// to request the full transcript here.)
|
|
||||||
if (typeof api !== 'function') return;
|
if (typeof api !== 'function') return;
|
||||||
if (S.busy || S.activeStreamId) return;
|
if (S.busy || S.activeStreamId) return;
|
||||||
api('/api/session?session_id=' + encodeURIComponent(sid) +
|
api('/api/session?session_id=' + encodeURIComponent(sid) +
|
||||||
'&messages=1&resolve_model=0')
|
'&messages=1&resolve_model=0&msg_limit=all')
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data || !data.session) return;
|
if (!data || !data.session) return;
|
||||||
if (!S.session || S.session.session_id !== sid) return; // session switched
|
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 _currentCronDetailKey = '';
|
||||||
let _cronMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit'
|
let _cronMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit'
|
||||||
let _cronPreFormDetail = null; // snapshot of prior selection when entering a form
|
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 _showAllCronProfiles = false;
|
||||||
let _cronOtherProfileCount = 0;
|
let _cronOtherProfileCount = 0;
|
||||||
let _currentWorkspaceDetail = null; // { path, name, is_default }
|
let _currentWorkspaceDetail = null; // { path, name, is_default }
|
||||||
@@ -1550,6 +1551,7 @@ function duplicateCurrentCron(){
|
|||||||
const job = _currentCronDetail;
|
const job = _currentCronDetail;
|
||||||
if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
|
if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
|
||||||
_cronPreFormDetail = { ...job };
|
_cronPreFormDetail = { ...job };
|
||||||
|
_cronModelPickerTouched = false;
|
||||||
_editingCronId = null;
|
_editingCronId = null;
|
||||||
_cronMode = 'create';
|
_cronMode = 'create';
|
||||||
_cronIsDuplicate = true;
|
_cronIsDuplicate = true;
|
||||||
@@ -1606,6 +1608,7 @@ let _cronDeliveryOptionsCache=null;
|
|||||||
function openCronCreate(){
|
function openCronCreate(){
|
||||||
if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
|
if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
|
||||||
_cronPreFormDetail = _currentCronDetail ? { ..._currentCronDetail } : null;
|
_cronPreFormDetail = _currentCronDetail ? { ..._currentCronDetail } : null;
|
||||||
|
_cronModelPickerTouched = false;
|
||||||
_editingCronId = null;
|
_editingCronId = null;
|
||||||
_cronMode = 'create';
|
_cronMode = 'create';
|
||||||
_cronIsDuplicate = false;
|
_cronIsDuplicate = false;
|
||||||
@@ -1623,6 +1626,7 @@ function openCronCreate(){
|
|||||||
function openCronEdit(job){
|
function openCronEdit(job){
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
_cronPreFormDetail = { ...job };
|
_cronPreFormDetail = { ...job };
|
||||||
|
_cronModelPickerTouched = false;
|
||||||
_editingCronId = job.id;
|
_editingCronId = job.id;
|
||||||
_cronMode = 'edit';
|
_cronMode = 'edit';
|
||||||
_cronSelectedSkills = Array.isArray(job.skills) ? [...job.skills] : [];
|
_cronSelectedSkills = Array.isArray(job.skills) ? [...job.skills] : [];
|
||||||
@@ -1855,6 +1859,9 @@ async function _populateCronFormModelSelect(selectedModel, selectedProvider, dis
|
|||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
}
|
}
|
||||||
sel.dataset.loaded = '1';
|
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) {
|
} catch (e) {
|
||||||
console.warn('Failed to load cron model picker:', e.message);
|
console.warn('Failed to load cron model picker:', e.message);
|
||||||
// Load failed: dataset.loaded stays unset so saveCronForm omits model/provider
|
// Load failed: dataset.loaded stays unset so saveCronForm omits model/provider
|
||||||
@@ -1940,6 +1947,18 @@ function _cronModelBareName(model, provider) {
|
|||||||
return _modelBareNameForProvider(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(){
|
async function saveCronForm(){
|
||||||
const nameEl=$('cronFormName');
|
const nameEl=$('cronFormName');
|
||||||
const schEl=$('cronFormSchedule');
|
const schEl=$('cronFormSchedule');
|
||||||
@@ -1977,8 +1996,11 @@ async function saveCronForm(){
|
|||||||
updates.model = _cronModelBareName(modelState.model, modelState.model_provider) || null;
|
updates.model = _cronModelBareName(modelState.model, modelState.model_provider) || null;
|
||||||
updates.provider = modelState.model_provider || null;
|
updates.provider = modelState.model_provider || null;
|
||||||
} else if (modelLoaded) {
|
} 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.model = null;
|
||||||
updates.provider = null;
|
updates.provider = _cronProviderForClear(_cronPreFormDetail, _cronModelPickerTouched);
|
||||||
}
|
}
|
||||||
// else: select not yet populated — omit model/provider to preserve saved value
|
// 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
|
const recall = data.automatic_recall_unchanged !== false
|
||||||
? `<div class="memory-detail-mtime">${esc(t('external_notes_auto_recall_hint'))}</div>`
|
? `<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) {
|
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 {
|
} else {
|
||||||
const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0];
|
const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0];
|
||||||
_notesSelectedSource = (selected && selected.name) || 'joplin';
|
_notesSelectedSource = (selected && selected.name) || 'joplin';
|
||||||
@@ -5441,7 +5468,7 @@ function _renderExternalNotesSources() {
|
|||||||
${searchError}
|
${searchError}
|
||||||
${resultHtml}
|
${resultHtml}
|
||||||
</section>`;
|
</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 = '';
|
body.style.display = '';
|
||||||
if (empty) empty.style.display = 'none';
|
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>`;
|
list.innerHTML=`<div class="mcp-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('mcp_no_servers'))}</div>`;
|
||||||
return;
|
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 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 transportClass=s.transport==='http'?'mcp-http':s.transport==='stdio'?'mcp-stdio':'mcp-unknown';
|
||||||
const transportBadge=`<span class="mcp-transport-badge ${transportClass}">${esc(transportLabel)}</span>`;
|
const transportBadge=`<span class="mcp-transport-badge ${transportClass}">${esc(transportLabel)}</span>`;
|
||||||
|
|||||||
+134
-10
@@ -1035,6 +1035,74 @@ function _inflightHasVisibleLiveState(inflight) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #7640: a run-journal replay cursor is only safe when the recovery object can
|
||||||
|
// still repaint the live assistant turn. `_inflightHasVisibleLiveState()` above
|
||||||
|
// deliberately counts a plain user row (the optimistic row has to survive a
|
||||||
|
// mid-turn reload), but such an object carries no assistant projection at all:
|
||||||
|
// seeding `after_seq` from it tells the server to skip every journal event that
|
||||||
|
// would rebuild the body, so the settled footer lands over a blank message until
|
||||||
|
// a manual reload replays the journal from the zero floor. Only recoverable live
|
||||||
|
// output may raise the replay floor.
|
||||||
|
function _inflightCanSeedJournalReplay(inflight){
|
||||||
|
if(!inflight||typeof inflight!=='object') return false;
|
||||||
|
if(String(inflight.lastAssistantText||'').trim()) return true;
|
||||||
|
if(String(inflight.lastReasoningText||'').trim()) return true;
|
||||||
|
if(String(inflight.liveTurnHtml||'').trim()) return true;
|
||||||
|
if(Array.isArray(inflight.toolCalls)&&inflight.toolCalls.length) return true;
|
||||||
|
if(Array.isArray(inflight.activityBurstAnchors)&&inflight.activityBurstAnchors.length) return true;
|
||||||
|
const anchorScene=inflight.anchorActivityScene;
|
||||||
|
if(anchorScene&&Array.isArray(anchorScene.activity_rows)&&anchorScene.activity_rows.length) return true;
|
||||||
|
if(Array.isArray(inflight.messages)){
|
||||||
|
// The transcript copy carries every earlier turn (#7651): in an established
|
||||||
|
// conversation the last assistant row is the PREVIOUS turn's reply, not the
|
||||||
|
// one the cursor belongs to, so it must never authorize a nonzero floor.
|
||||||
|
// Message evidence counts only from a current `_live` assistant standing
|
||||||
|
// after the latest user boundary; anything historical fails closed to zero.
|
||||||
|
const list=inflight.messages;
|
||||||
|
let latestUserIdx=-1;
|
||||||
|
for(let i=list.length-1;i>=0;i--){
|
||||||
|
const row=list[i];
|
||||||
|
if(row&&row.role==='user'){latestUserIdx=i;break;}
|
||||||
|
}
|
||||||
|
return list.some((msg,idx)=>{
|
||||||
|
if(!msg||msg.role!=='assistant') return false;
|
||||||
|
if(!msg._live) return false;
|
||||||
|
if(idx<=latestUserIdx) return false;
|
||||||
|
const content=msg.content;
|
||||||
|
if(typeof content==='string') return Boolean(content.trim());
|
||||||
|
if(Array.isArray(content)) return content.length>0;
|
||||||
|
return Boolean(content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _runJournalReplayFloorForInflight(inflight){
|
||||||
|
if(!_inflightCanSeedJournalReplay(inflight)) return 0;
|
||||||
|
return Math.max(0,Number((inflight&&inflight.lastRunJournalSeq)||0)||0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _runJournalReplayEventIdForInflight(inflight){
|
||||||
|
if(!_runJournalReplayFloorForInflight(inflight)) return '';
|
||||||
|
return String((inflight&&inflight.lastRunJournalEventId)||'');
|
||||||
|
}
|
||||||
|
|
||||||
|
// #7640: re-validate the selected recovery object immediately before reattach
|
||||||
|
// instead of trusting its existence. A cursor that outlived its assistant
|
||||||
|
// projection is dropped here so it cannot reach the wire as a replay floor; the
|
||||||
|
// replay then restarts from zero, which is the path a manual reload already
|
||||||
|
// proves out. Normalising the object (not just the floor) also keeps a later
|
||||||
|
// persist from resurrecting the stale cursor on the next reattach.
|
||||||
|
function _normalizeInflightReplayCursorForReattach(inflight){
|
||||||
|
if(!inflight||typeof inflight!=='object') return inflight;
|
||||||
|
const seq=Math.max(0,Number(inflight.lastRunJournalSeq||0)||0);
|
||||||
|
if(seq>0&&!_inflightCanSeedJournalReplay(inflight)){
|
||||||
|
inflight.lastRunJournalSeq=0;
|
||||||
|
inflight.lastRunJournalEventId='';
|
||||||
|
}
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
function _serverLiveSnapshotToolId(tc){
|
function _serverLiveSnapshotToolId(tc){
|
||||||
return String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim();
|
return String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim();
|
||||||
}
|
}
|
||||||
@@ -2208,6 +2276,11 @@ async function loadSession(sid){
|
|||||||
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
|
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
|
||||||
INFLIGHT[sid].reattach=false;
|
INFLIGHT[sid].reattach=false;
|
||||||
if (!_isCurrentLoad()) return;
|
if (!_isCurrentLoad()) return;
|
||||||
|
// #7640: validate the selected recovery object at the moment of reattach
|
||||||
|
// rather than trusting its existence. A cache that kept `lastRunJournalSeq`
|
||||||
|
// but lost the live assistant projection would otherwise seed the replay
|
||||||
|
// floor and skip the journal range that rebuilds the message body.
|
||||||
|
_normalizeInflightReplayCursorForReattach(INFLIGHT[sid]);
|
||||||
didReconnect=true;
|
didReconnect=true;
|
||||||
attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||||||
}
|
}
|
||||||
@@ -2337,6 +2410,8 @@ async function loadSession(sid){
|
|||||||
if(activeStreamId){
|
if(activeStreamId){
|
||||||
S.busy=true;
|
S.busy=true;
|
||||||
S.activeStreamId=activeStreamId;
|
S.activeStreamId=activeStreamId;
|
||||||
|
// #7640: same reattach validation as the `reattach` branch above.
|
||||||
|
if(INFLIGHT[sid]) _normalizeInflightReplayCursorForReattach(INFLIGHT[sid]);
|
||||||
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||||||
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
|
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
|
||||||
updateSendBtn();
|
updateSendBtn();
|
||||||
@@ -3932,7 +4007,7 @@ async function _ensureAllMessagesLoaded() {
|
|||||||
_loadingOlder = true;
|
_loadingOlder = true;
|
||||||
try {
|
try {
|
||||||
const sid = S.session.session_id;
|
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.
|
// Guard: api() may have redirected (401) and returned undefined.
|
||||||
if (!data || !data.session) return;
|
if (!data || !data.session) return;
|
||||||
// Session may have been switched while we awaited. Bail rather than
|
// Session may have been switched while we awaited. Bail rather than
|
||||||
@@ -7596,7 +7671,50 @@ function _sidebarRowHasVisibleMessages(s, activeSidForSidebar){
|
|||||||
(S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0);
|
(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){
|
function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
|
||||||
|
const projectIdFor=_sidebarProjectResolver(_sidebarRowsById([allMatched, typeof _sidebarReferenceSessions!=='undefined'?_sidebarReferenceSessions:null]));
|
||||||
let cliSessionCount=0;
|
let cliSessionCount=0;
|
||||||
const webuiProfileFiltered=[];
|
const webuiProfileFiltered=[];
|
||||||
const cliProfileFiltered=[];
|
const cliProfileFiltered=[];
|
||||||
@@ -7615,10 +7733,11 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
|
|||||||
const referenceRaw=isCli ? cliReferenceRaw : webuiReferenceRaw;
|
const referenceRaw=isCli ? cliReferenceRaw : webuiReferenceRaw;
|
||||||
const sessionsRaw=isCli ? cliSessionsRaw : webuiSessionsRaw;
|
const sessionsRaw=isCli ? cliSessionsRaw : webuiSessionsRaw;
|
||||||
profileFiltered.push(s);
|
profileFiltered.push(s);
|
||||||
|
const projectId=projectIdFor(s);
|
||||||
if(_activeProject===NO_PROJECT_FILTER){
|
if(_activeProject===NO_PROJECT_FILTER){
|
||||||
if(s.project_id) continue;
|
if(projectId) continue;
|
||||||
} else if(_activeProject){
|
} else if(_activeProject){
|
||||||
if(s.project_id!==_activeProject) continue;
|
if(projectId!==_activeProject) continue;
|
||||||
}
|
}
|
||||||
referenceRaw.push(s);
|
referenceRaw.push(s);
|
||||||
if(s.archived){
|
if(s.archived){
|
||||||
@@ -7642,6 +7761,7 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
|
|||||||
cliReferenceRaw,
|
cliReferenceRaw,
|
||||||
webuiSessionsRaw,
|
webuiSessionsRaw,
|
||||||
cliSessionsRaw,
|
cliSessionsRaw,
|
||||||
|
projectIdFor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7653,15 +7773,18 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
|
|||||||
// suppression context — silently hiding a visible child/fork whose archived
|
// suppression context — silently hiding a visible child/fork whose archived
|
||||||
// ancestor lives outside the current view. Scope the references to the same
|
// ancestor lives outside the current view. Scope the references to the same
|
||||||
// project + source bucket as the render they feed before using them.
|
// 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 [];
|
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=>{
|
return _sidebarReferenceSessions.filter(s=>{
|
||||||
if(!s) return false;
|
if(!s) return false;
|
||||||
// Source scope: only references in the same webui/cli bucket as this render.
|
// Source scope: only references in the same webui/cli bucket as this render.
|
||||||
if(_isCliSession(s)!==!!isCli) return false;
|
if(_isCliSession(s)!==!!isCli) return false;
|
||||||
// Project scope: mirror _partitionSidebarSessionRows exactly.
|
// Project scope: mirror _partitionSidebarSessionRows exactly.
|
||||||
if(_activeProject===NO_PROJECT_FILTER){ if(s.project_id) return false; }
|
const projectId=resolve(s);
|
||||||
else if(_activeProject){ if(s.project_id!==_activeProject) return false; }
|
if(_activeProject===NO_PROJECT_FILTER){ if(projectId) return false; }
|
||||||
|
else if(_activeProject){ if(projectId!==_activeProject) return false; }
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -7767,19 +7890,20 @@ function renderSessionListFromCache(){
|
|||||||
cliReferenceRaw,
|
cliReferenceRaw,
|
||||||
webuiSessionsRaw,
|
webuiSessionsRaw,
|
||||||
cliSessionsRaw,
|
cliSessionsRaw,
|
||||||
|
projectIdFor,
|
||||||
}=_partitionSidebarSessionRows(allMatched, activeSidForSidebar);
|
}=_partitionSidebarSessionRows(allMatched, activeSidForSidebar);
|
||||||
const referenceRaw=_sessionSourceFilter==='cli'?cliReferenceRaw:webuiReferenceRaw;
|
const referenceRaw=_sessionSourceFilter==='cli'?cliReferenceRaw:webuiReferenceRaw;
|
||||||
const isCliView=_sessionSourceFilter==='cli';
|
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
|
// Server-provided source bucket counts are authoritative for the current
|
||||||
// payload. When present, skip the expensive cross-bucket render/count pass;
|
// payload. When present, skip the expensive cross-bucket render/count pass;
|
||||||
// null is a deliberate "not computed" sentinel consumed only by
|
// null is a deliberate "not computed" sentinel consumed only by
|
||||||
// _sessionSourceTabCount's fallback path below.
|
// _sessionSourceTabCount's fallback path below.
|
||||||
const renderedWebuiSessionCount=_serverWebuiSessionCount===null
|
const renderedWebuiSessionCount=_serverWebuiSessionCount===null
|
||||||
? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length
|
? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false, projectIdFor)]).length
|
||||||
: null;
|
: null;
|
||||||
const renderedCliSessionCount=_serverCliSessionCount===null
|
const renderedCliSessionCount=_serverCliSessionCount===null
|
||||||
? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length
|
? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true, projectIdFor)]).length
|
||||||
: null;
|
: null;
|
||||||
const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);
|
const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);
|
||||||
const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount);
|
const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount);
|
||||||
@@ -7841,7 +7965,7 @@ function renderSessionListFromCache(){
|
|||||||
}
|
}
|
||||||
// Project filter bar — show when there are real projects OR there are
|
// Project filter bar — show when there are real projects OR there are
|
||||||
// unassigned sessions (so the Unassigned chip has something to filter to).
|
// 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){
|
if(_allProjects.length>0||hasUnprojected){
|
||||||
const bar=document.createElement('div');
|
const bar=document.createElement('div');
|
||||||
bar.className='project-bar';
|
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'
|
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 _messageRenderWindowSid=null;
|
||||||
let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
|
let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
|
||||||
let _messageVirtualHeightCache=[];
|
let _messageVirtualHeightCache=[];
|
||||||
@@ -567,7 +584,19 @@ let _messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('defau
|
|||||||
let _messageVirtualScrollRaf=0;
|
let _messageVirtualScrollRaf=0;
|
||||||
let _messageVirtualWindowKey='';
|
let _messageVirtualWindowKey='';
|
||||||
let _messageVirtualMeasurementCycleKey='';
|
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 _messageVirtualScrollActive=false;
|
||||||
let _messageVirtualScrollSettleTimer=0;
|
let _messageVirtualScrollSettleTimer=0;
|
||||||
let _messageVirtualDeferredMeasurement=null;
|
let _messageVirtualDeferredMeasurement=null;
|
||||||
@@ -614,7 +643,7 @@ function _clearMessageVirtualHeightCache(){
|
|||||||
_messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('default');
|
_messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('default');
|
||||||
_messageVirtualWindowKey='';
|
_messageVirtualWindowKey='';
|
||||||
_messageVirtualMeasurementCycleKey='';
|
_messageVirtualMeasurementCycleKey='';
|
||||||
_messageVirtualMeasurementRetryCount=0;
|
_resetMessageVirtualMeasurementBurst();
|
||||||
_messageVirtualScrollActive=false;
|
_messageVirtualScrollActive=false;
|
||||||
clearTimeout(_messageVirtualScrollSettleTimer);
|
clearTimeout(_messageVirtualScrollSettleTimer);
|
||||||
_messageVirtualScrollSettleTimer=0;
|
_messageVirtualScrollSettleTimer=0;
|
||||||
@@ -742,6 +771,13 @@ function _messageVirtualMeasurementCycleKeyFor(windowMetrics){
|
|||||||
windowMetrics.tailStart||0,
|
windowMetrics.tailStart||0,
|
||||||
].join(':');
|
].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){
|
function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){
|
||||||
if(_messageVirtualScrollActive){
|
if(_messageVirtualScrollActive){
|
||||||
_messageVirtualDeferredMeasurement=windowMetrics;
|
_messageVirtualDeferredMeasurement=windowMetrics;
|
||||||
@@ -750,15 +786,55 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){
|
|||||||
const cycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics);
|
const cycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics);
|
||||||
if(_messageVirtualMeasurementCycleKey!==cycleKey){
|
if(_messageVirtualMeasurementCycleKey!==cycleKey){
|
||||||
_messageVirtualMeasurementCycleKey=cycleKey;
|
_messageVirtualMeasurementCycleKey=cycleKey;
|
||||||
_messageVirtualMeasurementRetryCount=0;
|
|
||||||
}
|
}
|
||||||
if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;
|
// Cycle-aware burst tracking (#6717 re-gate): the burst follows ONLY the
|
||||||
_messageVirtualMeasurementRetryCount++;
|
// internally measurement-scheduled chain (requestAnimationFrame ->
|
||||||
requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true); });
|
// _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){
|
function _markMessageVirtualMeasurementsSettled(windowMetrics){
|
||||||
_messageVirtualMeasurementCycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics);
|
_messageVirtualMeasurementCycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics);
|
||||||
_messageVirtualMeasurementRetryCount=0;
|
_resetMessageVirtualMeasurementBurst();
|
||||||
}
|
}
|
||||||
function _messageVirtualHeightEntryMatches(previousEntry, nextEntry){
|
function _messageVirtualHeightEntryMatches(previousEntry, nextEntry){
|
||||||
return !!(
|
return !!(
|
||||||
@@ -1474,7 +1550,7 @@ function _rememberRenderedUserRowIntrinsicHeights(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function _scheduleMessageVirtualizedRender(force){
|
function _scheduleMessageVirtualizedRender(force, request){
|
||||||
const container=$('messages');
|
const container=$('messages');
|
||||||
const inner=$('msgInner');
|
const inner=$('msgInner');
|
||||||
if(!container||!inner) return;
|
if(!container||!inner) return;
|
||||||
@@ -1486,9 +1562,28 @@ function _scheduleMessageVirtualizedRender(force){
|
|||||||
_messageVirtualWindowKey=nextKey;
|
_messageVirtualWindowKey=nextKey;
|
||||||
return;
|
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=requestAnimationFrame(()=>{
|
||||||
_messageVirtualScrollRaf=0;
|
_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 liveVisWithIdx=_getVisibleMessagesWithIdx();
|
||||||
const liveWindow=_currentMessageVirtualWindow(liveVisWithIdx,_messageVirtualKeepTailCount());
|
const liveWindow=_currentMessageVirtualWindow(liveVisWithIdx,_messageVirtualKeepTailCount());
|
||||||
const liveKey=_messageVirtualWindowKeyFor(liveWindow);
|
const liveKey=_messageVirtualWindowKeyFor(liveWindow);
|
||||||
@@ -1496,14 +1591,14 @@ function _scheduleMessageVirtualizedRender(force){
|
|||||||
if(_scrollbarDragActive){
|
if(_scrollbarDragActive){
|
||||||
_programmaticScroll=true;
|
_programmaticScroll=true;
|
||||||
_programmaticScrollSetAt=performance.now();
|
_programmaticScrollSetAt=performance.now();
|
||||||
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); });
|
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true, _internalMeasurement: internalMeasurement }); });
|
||||||
_deferClearProgrammaticScroll();
|
_deferClearProgrammaticScroll();
|
||||||
_messageVirtualWindowKey=liveKey;
|
_messageVirtualWindowKey=liveKey;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_msgNodeRecycleEnabled=true;
|
_msgNodeRecycleEnabled=true;
|
||||||
try{
|
try{
|
||||||
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); });
|
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true, _internalMeasurement: internalMeasurement }); });
|
||||||
}
|
}
|
||||||
finally{ _msgNodeRecycleEnabled=false; }
|
finally{ _msgNodeRecycleEnabled=false; }
|
||||||
});
|
});
|
||||||
@@ -3011,12 +3106,54 @@ function _getOptionProviderId(opt){
|
|||||||
return group.dataset.provider;
|
return group.dataset.provider;
|
||||||
}
|
}
|
||||||
const value=String(opt.value||'');
|
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 '';
|
return '';
|
||||||
}
|
}
|
||||||
function _providerFromModelValue(modelId){
|
function _providerFromModelValue(modelId){
|
||||||
const value=String(modelId||'').trim();
|
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 '';
|
return '';
|
||||||
}
|
}
|
||||||
function _modelPickerOptionIdentity(modelId, providerId){
|
function _modelPickerOptionIdentity(modelId, providerId){
|
||||||
@@ -3027,9 +3164,19 @@ function _modelPickerOptionIdentity(modelId, providerId){
|
|||||||
if(exactPrefix && value.toLowerCase().startsWith(exactPrefix.toLowerCase())){
|
if(exactPrefix && value.toLowerCase().startsWith(exactPrefix.toLowerCase())){
|
||||||
value=value.substring(exactPrefix.length);
|
value=value.substring(exactPrefix.length);
|
||||||
}else if(value.startsWith('@custom:')){
|
}else if(value.startsWith('@custom:')){
|
||||||
const namedProvider=value.substring('@custom:'.length);
|
const afterCustom=value.substring('@custom:'.length);
|
||||||
const splitAt=namedProvider.indexOf(':');
|
const parts=afterCustom.split(':');
|
||||||
value=splitAt>=0 ? namedProvider.substring(splitAt+1) : namedProvider;
|
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{
|
}else{
|
||||||
value=value.substring(value.indexOf(':')+1);
|
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
|
// 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).
|
// otherwise mis-parse to provider "custom:backup:model-a" (#6221 re-gate).
|
||||||
const routedProvider=selected?String(_getOptionProviderId(selected)||'').trim():'';
|
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
|
// Resolve the provider from the option whose VALUE matches the requested
|
||||||
// model — never blindly from sel.selectedOptions[0] (#5567). During a profile
|
// 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();
|
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;
|
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)
|
const _selectedModelBadge=(m)=>_isSelectedModelRow(m)
|
||||||
?`<span class="model-opt-badge model-opt-badge--selected">${esc(t('model_badge_selected')||'Selected')}</span>`
|
?`<span class="model-opt-badge model-opt-badge--selected">${esc(t('model_badge_selected')||'Selected')}</span>`
|
||||||
:'';
|
:'';
|
||||||
@@ -10353,7 +10534,11 @@ async function refreshSession() {
|
|||||||
dismissReconnect();
|
dismissReconnect();
|
||||||
if (!S.session) return;
|
if (!S.session) return;
|
||||||
try {
|
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;
|
S.session = data.session;
|
||||||
if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);
|
if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);
|
||||||
S.messages = data.session.messages || [];
|
S.messages = data.session.messages || [];
|
||||||
@@ -17126,6 +17311,8 @@ function _insertSegmentBlock(seg, html){
|
|||||||
}
|
}
|
||||||
function renderMessages(options){
|
function renderMessages(options){
|
||||||
_lastMessageRenderAt=performance.now();
|
_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 preserveScroll=!!(options&&options.preserveScroll);
|
||||||
const virtualFallback=!!(options&&options._virtualFallback);
|
const virtualFallback=!!(options&&options._virtualFallback);
|
||||||
// Capture the pre-wipe scroll position when preserving OR when the reader has
|
// 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('_attachChildSessionsToSidebarRows'));
|
||||||
eval(extractFunc('_sessionAttentionState'));
|
eval(extractFunc('_sessionAttentionState'));
|
||||||
eval(extractFunc('_sidebarRowHasVisibleMessages'));
|
eval(extractFunc('_sidebarRowHasVisibleMessages'));
|
||||||
|
eval(extractFunc('_isDelegatedSubagentRow'));
|
||||||
|
eval(extractFunc('_sidebarProjectResolver'));
|
||||||
|
eval(extractFunc('_sidebarRowsById'));
|
||||||
eval(extractFunc('_partitionSidebarSessionRows'));
|
eval(extractFunc('_partitionSidebarSessionRows'));
|
||||||
eval(extractFunc('_scopedSidebarReferenceRows'));
|
eval(extractFunc('_scopedSidebarReferenceRows'));
|
||||||
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
|
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
|
||||||
@@ -216,9 +219,9 @@ console.log(JSON.stringify({
|
|||||||
}));
|
}));
|
||||||
"""
|
"""
|
||||||
out = json.loads(_run_node(source))
|
out = json.loads(_run_node(source))
|
||||||
# Child survives the visibility/project scope into sessionsRaw (parent does not)...
|
# The child inherits its parent's project, so the "no project" filter drops both...
|
||||||
assert out["sessionsRaw"] == ["subagent_child"]
|
assert out["sessionsRaw"] == []
|
||||||
# ...but is NOT rendered as a top-level orphan.
|
# ...and it is NOT rendered as a top-level orphan.
|
||||||
assert out["topLevel"] == []
|
assert out["topLevel"] == []
|
||||||
assert out["orphans"] == []
|
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 _scrollAfterMessageRender() {{}}
|
||||||
function _maybeRecoverVirtualizedBlankViewport() {{ return false; }}
|
function _maybeRecoverVirtualizedBlankViewport() {{ return false; }}
|
||||||
function _updateMessageVirtualMeasurements() {{}}
|
function _updateMessageVirtualMeasurements() {{}}
|
||||||
|
function _resetMessageVirtualMeasurementBurst() {{}}
|
||||||
function postProcessRenderedMessages() {{}}
|
function postProcessRenderedMessages() {{}}
|
||||||
function _postProcessWithAnchorSuppression() {{}}
|
function _postProcessWithAnchorSuppression() {{}}
|
||||||
function _formatGatewayModelLabel() {{ return ''; }}
|
function _formatGatewayModelLabel() {{ return ''; }}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Regression coverage for notify_on_complete across a WebUI restart."""
|
"""Regression coverage for notify_on_complete across a WebUI restart."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -159,3 +160,102 @@ def test_recovery_is_fail_soft_without_agent(monkeypatch):
|
|||||||
|
|
||||||
assert bp.recover_processes_for_webui() == 0
|
assert bp.recover_processes_for_webui() == 0
|
||||||
assert bp._PROCESS_RECOVERY_DONE is False
|
assert bp._PROCESS_RECOVERY_DONE is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_skips_process_whose_session_is_gone(monkeypatch, caplog):
|
||||||
|
"""A checkpointed process whose owner has been pruned (the resolver's
|
||||||
|
``KeyError(sid)`` contract) must be silently skipped, while a neighbouring
|
||||||
|
live process in the same recovery sweep still rebinds. Regression for
|
||||||
|
issue #7753: previously every vanished owner logged a full WARNING
|
||||||
|
traceback, indistinguishable from a real fault.
|
||||||
|
"""
|
||||||
|
calls = {"registered": []}
|
||||||
|
|
||||||
|
class FakeRegistry:
|
||||||
|
def recover_from_checkpoint(self):
|
||||||
|
return 2 # two checkpointed processes survive restart
|
||||||
|
|
||||||
|
def list_sessions(self):
|
||||||
|
return [
|
||||||
|
{"session_id": "proc_vanished", "detached": True},
|
||||||
|
{"session_id": "proc_live", "detached": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, process_id):
|
||||||
|
if process_id == "proc_vanished":
|
||||||
|
return _FakeProcessSession("vanished-session")
|
||||||
|
if process_id == "proc_live":
|
||||||
|
return _FakeProcessSession("live-session")
|
||||||
|
raise AssertionError(process_id)
|
||||||
|
|
||||||
|
fake_registry = FakeRegistry()
|
||||||
|
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
|
||||||
|
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bp,
|
||||||
|
"register_process_session",
|
||||||
|
lambda key, sid: calls["registered"].append((key, sid)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_session(sid, metadata_only=False):
|
||||||
|
# Mirrors api.models._resolve_session_once's contract: missing
|
||||||
|
# sessions raise KeyError, NOT None.
|
||||||
|
if sid == "vanished-session":
|
||||||
|
raise KeyError(sid)
|
||||||
|
return SimpleNamespace(id=sid)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="api.background_process"):
|
||||||
|
result = bp.recover_processes_for_webui(fake_registry, get_session)
|
||||||
|
|
||||||
|
assert result == 2
|
||||||
|
# The live process is rebound; the vanished one is silently skipped.
|
||||||
|
assert calls["registered"] == [("live-session", "live-session")]
|
||||||
|
# No WARNING emitted for the vanished-owner case.
|
||||||
|
warning_records = [
|
||||||
|
r for r in caplog.records if r.levelno >= logging.WARNING
|
||||||
|
]
|
||||||
|
assert warning_records == [], (
|
||||||
|
"vanished-session KeyError must not produce a WARNING, got: "
|
||||||
|
f"{[r.getMessage() for r in warning_records]}"
|
||||||
|
)
|
||||||
|
assert "Could not resolve recovered WebUI process" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_still_warns_on_unexpected_resolve_failure(monkeypatch, caplog):
|
||||||
|
"""Non-KeyError failures from the resolver (e.g. ``OSError`` on an
|
||||||
|
unreadable sidecar) must keep entering the existing WARNING path. This
|
||||||
|
pins that the fix for #7753 is narrow and does not over-broaden the
|
||||||
|
silent-skip contract.
|
||||||
|
"""
|
||||||
|
class FakeRegistry:
|
||||||
|
def recover_from_checkpoint(self):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def list_sessions(self):
|
||||||
|
return [{"session_id": "proc_unexpected", "detached": True}]
|
||||||
|
|
||||||
|
def get(self, process_id):
|
||||||
|
assert process_id == "proc_unexpected"
|
||||||
|
return _FakeProcessSession("webui-session")
|
||||||
|
|
||||||
|
fake_registry = FakeRegistry()
|
||||||
|
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
|
||||||
|
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
|
||||||
|
|
||||||
|
def get_session(sid, metadata_only=False):
|
||||||
|
raise OSError("sidecar unreadable")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="api.background_process"):
|
||||||
|
result = bp.recover_processes_for_webui(fake_registry, get_session)
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
warning_records = [
|
||||||
|
r for r in caplog.records if r.levelno >= logging.WARNING
|
||||||
|
]
|
||||||
|
assert any(
|
||||||
|
"Could not resolve recovered WebUI process 'proc_unexpected'" in r.getMessage()
|
||||||
|
for r in warning_records
|
||||||
|
), (
|
||||||
|
"OSError from the resolver must still emit a WARNING, got: "
|
||||||
|
f"{[r.getMessage() for r in warning_records]}"
|
||||||
|
)
|
||||||
@@ -41,12 +41,13 @@ class TestRootWorkspaceUnblocked:
|
|||||||
assert "'/etc'" in src or 'Path("/etc")' in src or "Path('/etc')" in src
|
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
|
assert "'/proc'" in src or 'Path("/proc")' in src or "Path('/proc')" in src
|
||||||
|
|
||||||
def test_split_guard_present(self):
|
def test_attached_files_suffix_is_optional(self):
|
||||||
src = read("api/streaming.py")
|
from api.models import _strip_attached_files_marker
|
||||||
assert "'\\n\\n[Attached files:' in msg_text" in src, (
|
|
||||||
"base_text split must guard against missing '[Attached files:' "
|
assert _strip_attached_files_marker("Plain message") == "Plain message"
|
||||||
"to avoid empty-string on plain messages"
|
assert _strip_attached_files_marker(
|
||||||
)
|
"Question\n\n[Attached files: photo.png]"
|
||||||
|
) == "Question"
|
||||||
|
|
||||||
|
|
||||||
# ── Group B: custom_providers visibility ─────────────────────────────────────
|
# ── Group B: custom_providers visibility ─────────────────────────────────────
|
||||||
|
|||||||
@@ -219,7 +219,9 @@ class TestCancelledTurnPersistenceGuards:
|
|||||||
assert "const _applyCancelSessionPayload=(sessionPayload)=>" in block
|
assert "const _applyCancelSessionPayload=(sessionPayload)=>" in block
|
||||||
assert "const _cancelSessionPayload=_cancelData&&typeof _cancelData.session==='object'?_cancelData.session:null;" in block
|
assert "const _cancelSessionPayload=_cancelData&&typeof _cancelData.session==='object'?_cancelData.session:null;" in block
|
||||||
assert "if(_applyCancelSessionPayload(_cancelSessionPayload)) return;" 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("), (
|
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 "
|
"Cancel handler must apply the terminal SSE session payload before falling back "
|
||||||
"to /api/session so captured _partial reasoning/tool rows are visible immediately."
|
"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,
|
messages=prior_messages,
|
||||||
context_messages=prior_messages,
|
context_messages=prior_messages,
|
||||||
)
|
)
|
||||||
|
_session.pending_user_source = "webui"
|
||||||
revisions = []
|
revisions = []
|
||||||
|
|
||||||
class AuthThenRecoverAgent:
|
class AuthThenRecoverAgent:
|
||||||
@@ -1080,8 +1081,14 @@ def test_auth_self_heal_refreshes_revision_after_first_agent_persists_user(
|
|||||||
self._last_error = None
|
self._last_error = None
|
||||||
self.stream_delta_callback = _kwargs.get("stream_delta_callback")
|
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
|
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"])
|
revisions.append(kwargs["conversation_history_revision"])
|
||||||
history = list(kwargs.get("conversation_history") or [])
|
history = list(kwargs.get("conversation_history") or [])
|
||||||
if type(self).runs == 1:
|
if type(self).runs == 1:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import pathlib
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
from api import config as webui_config
|
||||||
from tests._pytest_port import BASE
|
from tests._pytest_port import BASE
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
@@ -42,6 +43,16 @@ def make_session(created, title):
|
|||||||
return sid
|
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():
|
def test_pin_limit_setting_is_exposed_and_wired_through_ui():
|
||||||
assert '"pinned_sessions_limit": 3' in CONFIG_PY
|
assert '"pinned_sessions_limit": 3' in CONFIG_PY
|
||||||
assert '"pinned_sessions_limit": (1, 99)' 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"""
|
_RENDERED_CLICK_DRIVER = r"""
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import sys
|
|||||||
import types
|
import types
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[1]
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
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]
|
catch_block = body.split("} catch (e)", 1)[1]
|
||||||
assert "sel.dataset.loaded = '1'" in try_block
|
assert "sel.dataset.loaded = '1'" in try_block
|
||||||
assert "sel.dataset.loaded = '1'" not in catch_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.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||||
from tests._pytest_port import BASE
|
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})
|
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():
|
def test_compression_lineage_prefers_freshest_descendant_over_newer_direct_sibling():
|
||||||
"""A later-started stale sibling must not hide a deeper active branch."""
|
"""A later-started stale sibling must not hide a deeper active branch."""
|
||||||
conn = _ensure_state_db()
|
conn = _ensure_state_db()
|
||||||
|
|||||||
@@ -21,8 +21,14 @@ tests pinning the contract:
|
|||||||
tab that transitions to hidden via the ``visibilitychange`` hook.
|
tab that transitions to hidden via the ``visibilitychange`` hook.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import textwrap
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
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")
|
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)")
|
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
|
||||||
assert start != -1
|
assert start != -1
|
||||||
# #7299 added a 404/410 terminal-stop branch to the response handler.
|
body = MESSAGES_JS[start:MESSAGES_JS.index("function _stopHiddenActiveStreamPoll()", start)]
|
||||||
# The window must cover the original fetch chain + the new branch.
|
|
||||||
body = MESSAGES_JS[start:start + 8000]
|
|
||||||
assert "api/session/status?session_id=" in body
|
assert "api/session/status?session_id=" in body
|
||||||
assert "d.active_stream_id" in body
|
assert "d.active_stream_id" in body
|
||||||
# attaches as replay (recovered=true) — turn is already mid-flight
|
# 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)")
|
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
|
||||||
assert start != -1, "_startHiddenActiveStreamPoll signature not found"
|
assert start != -1, "_startHiddenActiveStreamPoll signature not found"
|
||||||
# Window 2400: the multi-pane follow-up adds an explanatory comment block
|
body = MESSAGES_JS[start:MESSAGES_JS.index("function _stopHiddenActiveStreamPoll()", start)]
|
||||||
# before the attach call, pushing it past a narrower slice.
|
|
||||||
body = MESSAGES_JS[start:start + 8000]
|
|
||||||
assert "const attached = _attachServerInitiatedStream(sid, streamId, true)" in body
|
assert "const attached = _attachServerInitiatedStream(sid, streamId, true)" in body
|
||||||
# Stop the poll only on a true attach (the false branch keeps polling within
|
# Stop the poll only on a true attach (the false branch keeps polling within
|
||||||
# the bounded-retry budget rather than stopping).
|
# 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.
|
# The bounded-retry give-up: a never-current pane stops after the budget.
|
||||||
assert "_sessionStreamHiddenPollFalseCount" in body
|
assert "_sessionStreamHiddenPollFalseCount" in body
|
||||||
assert "_SESSION_STREAM_HIDDEN_POLL_MAX_FALSE" 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}
|
||||||
@@ -1060,6 +1060,305 @@ def test_load_session_discards_cursor_only_inflight_before_reattach():
|
|||||||
assert 0 <= guard_pos < inflight_branch_pos
|
assert 0 <= guard_pos < inflight_branch_pos
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_only_inflight_must_not_seed_nonzero_replay_floor():
|
||||||
|
"""#7640: a plain user row must not authorize a nonzero replay floor.
|
||||||
|
|
||||||
|
After a mid-stream SSE drop in transparent mode the client re-attaches with
|
||||||
|
`replay=1&after_seq=<cursor>`. When the cached INFLIGHT entry kept the
|
||||||
|
optimistic user row plus `lastRunJournalSeq` but lost the live assistant
|
||||||
|
projection, that cursor told the server to skip every journal event that
|
||||||
|
rebuilds the message body: only the tail (often just `stream_end`) came back
|
||||||
|
and the settled footer painted over a blank message until a manual reload.
|
||||||
|
"""
|
||||||
|
assert NODE, "node not on PATH"
|
||||||
|
script = "\n".join(
|
||||||
|
[
|
||||||
|
"const assert=require('assert');",
|
||||||
|
_function_decl(SESSIONS_JS, "_inflightCanSeedJournalReplay"),
|
||||||
|
_function_decl(SESSIONS_JS, "_runJournalReplayFloorForInflight"),
|
||||||
|
_function_decl(SESSIONS_JS, "_runJournalReplayEventIdForInflight"),
|
||||||
|
"""
|
||||||
|
const userOnly={
|
||||||
|
messages:[{role:'user', content:'a very long ttft prompt'}],
|
||||||
|
lastRunJournalSeq:206,
|
||||||
|
lastRunJournalEventId:'stream-e75e:206',
|
||||||
|
};
|
||||||
|
assert.strictEqual(_inflightCanSeedJournalReplay(userOnly), false);
|
||||||
|
assert.strictEqual(_runJournalReplayFloorForInflight(userOnly), 0);
|
||||||
|
assert.strictEqual(_runJournalReplayEventIdForInflight(userOnly), '');
|
||||||
|
// The floor helper reports, it does not mutate the recovery object.
|
||||||
|
assert.strictEqual(userOnly.lastRunJournalSeq, 206);
|
||||||
|
|
||||||
|
// A user row with a populated but assistant-less transcript is still user-only.
|
||||||
|
assert.strictEqual(
|
||||||
|
_runJournalReplayFloorForInflight({
|
||||||
|
messages:[{role:'user', content:'hi'}, {role:'assistant', content:''}],
|
||||||
|
lastRunJournalSeq:206,
|
||||||
|
}),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
_runJournalReplayFloorForInflight({
|
||||||
|
messages:[{role:'user', content:'hi'}, {role:'assistant', content:' '}],
|
||||||
|
lastRunJournalSeq:206,
|
||||||
|
}),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Positive inverse: any recoverable live output keeps the cursor.
|
||||||
|
const recovering=[
|
||||||
|
{lastAssistantText:'partial answer'},
|
||||||
|
{lastReasoningText:'thinking...'},
|
||||||
|
{liveTurnHtml:'<div>projected turn</div>'},
|
||||||
|
{toolCalls:[{name:'read_file'}]},
|
||||||
|
{activityBurstAnchors:[{seq:12}]},
|
||||||
|
{anchorActivityScene:{activity_rows:[{kind:'tool'}]}},
|
||||||
|
{messages:[{role:'user', content:'p'}, {role:'assistant', content:'answer', _live:true}]},
|
||||||
|
];
|
||||||
|
for (const extra of recovering) {
|
||||||
|
const state={messages:[{role:'user', content:'p'}], ...extra};
|
||||||
|
state.lastRunJournalSeq=206;
|
||||||
|
state.lastRunJournalEventId='stream-e75e:206';
|
||||||
|
assert.strictEqual(
|
||||||
|
_runJournalReplayFloorForInflight(state),
|
||||||
|
206,
|
||||||
|
'recoverable live output must keep the cursor: '+JSON.stringify(extra)
|
||||||
|
);
|
||||||
|
assert.strictEqual(_runJournalReplayEventIdForInflight(state), 'stream-e75e:206');
|
||||||
|
}
|
||||||
|
|
||||||
|
// #7651: an ESTABLISHED conversation copies the whole transcript, so the last
|
||||||
|
// assistant row is the previous turn's reply. Historical assistant content must
|
||||||
|
// never authorize the stale cursor that belongs to the still-running turn.
|
||||||
|
const established={
|
||||||
|
messages:[
|
||||||
|
{role:'user', content:'first question'},
|
||||||
|
{role:'assistant', content:'the previous turn answer'},
|
||||||
|
{role:'user', content:'current question'},
|
||||||
|
],
|
||||||
|
lastRunJournalSeq:206,
|
||||||
|
lastRunJournalEventId:'stream-e75e:206',
|
||||||
|
};
|
||||||
|
assert.strictEqual(
|
||||||
|
_inflightCanSeedJournalReplay(established),
|
||||||
|
false,
|
||||||
|
'a historical assistant row must not seed the replay floor'
|
||||||
|
);
|
||||||
|
assert.strictEqual(_runJournalReplayFloorForInflight(established), 0);
|
||||||
|
assert.strictEqual(_runJournalReplayEventIdForInflight(established), '');
|
||||||
|
|
||||||
|
// Even after the latest user boundary, evidence must be LIVE output: a
|
||||||
|
// settled/non-live assistant row at the current position still yields zero.
|
||||||
|
assert.strictEqual(
|
||||||
|
_runJournalReplayFloorForInflight({
|
||||||
|
messages:[
|
||||||
|
{role:'user', content:'current question'},
|
||||||
|
{role:'assistant', content:'settled reply without _live'},
|
||||||
|
],
|
||||||
|
lastRunJournalSeq:206,
|
||||||
|
}),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Degenerate inputs must stay on the zero floor.
|
||||||
|
assert.strictEqual(_runJournalReplayFloorForInflight(null), 0);
|
||||||
|
assert.strictEqual(_runJournalReplayFloorForInflight({}), 0);
|
||||||
|
assert.strictEqual(_runJournalReplayFloorForInflight({lastRunJournalSeq:206, messages:[]}), 0);
|
||||||
|
assert.strictEqual(
|
||||||
|
_runJournalReplayFloorForInflight({lastAssistantText:'partial', lastRunJournalSeq:-4}),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.strictEqual(_runJournalReplayEventIdForInflight({}), '');
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_reattach_discards_replay_cursor_without_live_assistant_state():
|
||||||
|
"""#7640: validate the recovery object immediately before reattach.
|
||||||
|
|
||||||
|
Trusting the object's existence (rather than its contents) is what let a
|
||||||
|
cursor-only cache reach the wire as a replay floor. Reattach now drops the
|
||||||
|
cursor so the journal replays from zero, which is the path a manual reload
|
||||||
|
already proves out, while a cache that still holds live assistant output
|
||||||
|
keeps its cursor and avoids re-rendering the whole journal.
|
||||||
|
"""
|
||||||
|
assert NODE, "node not on PATH"
|
||||||
|
script = "\n".join(
|
||||||
|
[
|
||||||
|
"const assert=require('assert');",
|
||||||
|
_function_decl(SESSIONS_JS, "_inflightCanSeedJournalReplay"),
|
||||||
|
_function_decl(SESSIONS_JS, "_runJournalReplayFloorForInflight"),
|
||||||
|
_function_decl(SESSIONS_JS, "_normalizeInflightReplayCursorForReattach"),
|
||||||
|
"""
|
||||||
|
const cursorOnly={
|
||||||
|
streamId:'stream-e75e',
|
||||||
|
reattach:true,
|
||||||
|
messages:[{role:'user', content:'a very long ttft prompt'}],
|
||||||
|
lastRunJournalSeq:206,
|
||||||
|
lastRunJournalEventId:'stream-e75e:206',
|
||||||
|
};
|
||||||
|
assert.strictEqual(
|
||||||
|
_normalizeInflightReplayCursorForReattach(cursorOnly),
|
||||||
|
cursorOnly,
|
||||||
|
'must return the same object so the caller keeps mutating the live cache'
|
||||||
|
);
|
||||||
|
assert.strictEqual(cursorOnly.lastRunJournalSeq, 0);
|
||||||
|
assert.strictEqual(cursorOnly.lastRunJournalEventId, '');
|
||||||
|
assert.strictEqual(_runJournalReplayFloorForInflight(cursorOnly), 0);
|
||||||
|
assert.strictEqual(
|
||||||
|
cursorOnly.messages.length,
|
||||||
|
1,
|
||||||
|
'the optimistic user row must survive so pending dedupe still works'
|
||||||
|
);
|
||||||
|
|
||||||
|
const recovering={
|
||||||
|
streamId:'stream-e75e',
|
||||||
|
reattach:true,
|
||||||
|
messages:[{role:'user', content:'p'}],
|
||||||
|
lastAssistantText:'partial answer',
|
||||||
|
lastRunJournalSeq:206,
|
||||||
|
lastRunJournalEventId:'stream-e75e:206',
|
||||||
|
};
|
||||||
|
_normalizeInflightReplayCursorForReattach(recovering);
|
||||||
|
assert.strictEqual(recovering.lastRunJournalSeq, 206);
|
||||||
|
assert.strictEqual(recovering.lastRunJournalEventId, 'stream-e75e:206');
|
||||||
|
|
||||||
|
// #7651: a historical assistant row must not protect the cursor either, or an
|
||||||
|
// established conversation settles with a blank body over a painted footer.
|
||||||
|
const established={
|
||||||
|
streamId:'stream-e75e',
|
||||||
|
reattach:true,
|
||||||
|
messages:[
|
||||||
|
{role:'user', content:'first question'},
|
||||||
|
{role:'assistant', content:'the previous turn answer'},
|
||||||
|
{role:'user', content:'current question'},
|
||||||
|
],
|
||||||
|
lastRunJournalSeq:206,
|
||||||
|
lastRunJournalEventId:'stream-e75e:206',
|
||||||
|
};
|
||||||
|
_normalizeInflightReplayCursorForReattach(established);
|
||||||
|
assert.strictEqual(established.lastRunJournalSeq, 0);
|
||||||
|
assert.strictEqual(established.lastRunJournalEventId, '');
|
||||||
|
assert.strictEqual(_runJournalReplayFloorForInflight(established), 0);
|
||||||
|
assert.strictEqual(
|
||||||
|
established.messages.length,
|
||||||
|
3,
|
||||||
|
'the transcript must survive; only the cursor halves are dropped'
|
||||||
|
);
|
||||||
|
|
||||||
|
const noCursor={streamId:'stream-e75e', messages:[{role:'user', content:'p'}]};
|
||||||
|
_normalizeInflightReplayCursorForReattach(noCursor);
|
||||||
|
assert.strictEqual(noCursor.lastRunJournalSeq, undefined);
|
||||||
|
assert.strictEqual(_runJournalReplayFloorForInflight(noCursor), 0);
|
||||||
|
|
||||||
|
assert.strictEqual(_normalizeInflightReplayCursorForReattach(null), null);
|
||||||
|
assert.strictEqual(_normalizeInflightReplayCursorForReattach(undefined), undefined);
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_reattach_replay_params_from_a_cursor_only_cache_replay_from_zero():
|
||||||
|
"""#7640: end-to-end wire contract for a cursor-only reattach.
|
||||||
|
|
||||||
|
Derives the replay params from the real floor helpers (node) and feeds them
|
||||||
|
to the real server cursor parser. Tying both halves together matters because
|
||||||
|
`api.routes._parse_run_journal_after_seq()` reads `after_event_id` BEFORE
|
||||||
|
`after_seq`: a fix that zeroed only the seq would still resume at the stale
|
||||||
|
event id and skip every journal event that rebuilds the message body.
|
||||||
|
"""
|
||||||
|
assert NODE, "node not on PATH"
|
||||||
|
script = "\n".join(
|
||||||
|
[
|
||||||
|
"const assert=require('assert');",
|
||||||
|
_function_decl(SESSIONS_JS, "_inflightCanSeedJournalReplay"),
|
||||||
|
_function_decl(SESSIONS_JS, "_runJournalReplayFloorForInflight"),
|
||||||
|
_function_decl(SESSIONS_JS, "_runJournalReplayEventIdForInflight"),
|
||||||
|
"""
|
||||||
|
const runId='stream-7640';
|
||||||
|
const userOnly={
|
||||||
|
messages:[{role:'user', content:'a very long ttft prompt'}],
|
||||||
|
lastRunJournalSeq:206,
|
||||||
|
lastRunJournalEventId:runId+':205',
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
after_seq:String(_runJournalReplayFloorForInflight(userOnly)),
|
||||||
|
after_event_id:String(_runJournalReplayEventIdForInflight(userOnly)||''),
|
||||||
|
}));
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
params = json.loads(result.stdout.strip().splitlines()[-1])
|
||||||
|
|
||||||
|
# What the fixed client puts on the wire for a cache with no live projection.
|
||||||
|
assert params == {"after_seq": "0", "after_event_id": ""}
|
||||||
|
|
||||||
|
from api.routes import _parse_run_journal_after_seq
|
||||||
|
|
||||||
|
run_id = "stream-7640"
|
||||||
|
assert (
|
||||||
|
_parse_run_journal_after_seq(
|
||||||
|
{"after_seq": [params["after_seq"]], "after_event_id": [params["after_event_id"]]},
|
||||||
|
run_id,
|
||||||
|
)
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Precedence proof: dropping the seq alone would still resume at the stale id.
|
||||||
|
assert (
|
||||||
|
_parse_run_journal_after_seq(
|
||||||
|
{"after_seq": ["0"], "after_event_id": [f"{run_id}:205"]}, run_id
|
||||||
|
)
|
||||||
|
== 205
|
||||||
|
)
|
||||||
|
# ...and the pre-fix pair, for the record.
|
||||||
|
assert (
|
||||||
|
_parse_run_journal_after_seq(
|
||||||
|
{"after_seq": ["206"], "after_event_id": [f"{run_id}:205"]}, run_id
|
||||||
|
)
|
||||||
|
== 205
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_and_reattach_use_the_validated_replay_floor():
|
||||||
|
"""#7640 wiring guard: the validated floor is what reaches the wire.
|
||||||
|
|
||||||
|
The behavioral coverage for the floor helpers lives in
|
||||||
|
`test_user_only_inflight_must_not_seed_nonzero_replay_floor` and
|
||||||
|
`test_reattach_discards_replay_cursor_without_live_assistant_state`; this
|
||||||
|
test pins that `attachLiveStream()` and both `loadSession()` reattach paths
|
||||||
|
actually go through them instead of reading the raw cursor field.
|
||||||
|
"""
|
||||||
|
body = _function_body(MESSAGES_JS, "attachLiveStream")
|
||||||
|
init_pos = body.index("const _replayCursorInflight=")
|
||||||
|
init_block = body[init_pos : body.index(";", body.index("let _lastRunJournalEventId=", init_pos))]
|
||||||
|
assert "INFLIGHT[activeSid]" in init_block, "must still read the live cache"
|
||||||
|
assert "_runJournalReplayFloorForInflight(_replayCursorInflight)" in init_block
|
||||||
|
assert "_runJournalReplayEventIdForInflight(_replayCursorInflight)" in init_block
|
||||||
|
assert "INFLIGHT[activeSid].lastRunJournalSeq" not in init_block, (
|
||||||
|
"attachLiveStream must not read the raw cursor field anymore"
|
||||||
|
)
|
||||||
|
assert "INFLIGHT[activeSid].lastRunJournalEventId" not in init_block
|
||||||
|
|
||||||
|
load_body = re.sub(r"\s+", "", _function_body(SESSIONS_JS, "loadSession"))
|
||||||
|
assert load_body.count("_normalizeInflightReplayCursorForReattach(INFLIGHT[sid])") == 2, (
|
||||||
|
"both reattach paths must re-validate the recovery object"
|
||||||
|
)
|
||||||
|
first = load_body.index("_normalizeInflightReplayCursorForReattach(INFLIGHT[sid])")
|
||||||
|
first_attach = load_body.index("attachLiveStream(sid,activeStreamId", first)
|
||||||
|
assert first < first_attach, "validation must run before the stream is attached"
|
||||||
|
last = load_body.rindex("_normalizeInflightReplayCursorForReattach(INFLIGHT[sid])")
|
||||||
|
last_attach = load_body.rindex("attachLiveStream(sid,activeStreamId", 0, last)
|
||||||
|
assert last_attach < last
|
||||||
|
|
||||||
|
|
||||||
def test_live_recovery_prefers_newer_durable_run_journal_snapshot():
|
def test_live_recovery_prefers_newer_durable_run_journal_snapshot():
|
||||||
"""Recovery chooses by stream identity and durable journal progress."""
|
"""Recovery chooses by stream identity and durable journal progress."""
|
||||||
assert NODE, "node not on PATH"
|
assert NODE, "node not on PATH"
|
||||||
@@ -1174,7 +1473,10 @@ assert.strictEqual(inflight.lastRunJournalEventId, 'run-a:7');
|
|||||||
assert "lastRunJournalEventId:String(stored.lastRunJournalEventId||'')" in load_body
|
assert "lastRunJournalEventId:String(stored.lastRunJournalEventId||'')" in load_body
|
||||||
assert "lastRunJournalEventId:state.lastRunJournalEventId||''" in compact_body
|
assert "lastRunJournalEventId:state.lastRunJournalEventId||''" in compact_body
|
||||||
assert "inflight.lastRunJournalEventId||''" in attach_body
|
assert "inflight.lastRunJournalEventId||''" in attach_body
|
||||||
assert "INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId" in attach_body
|
# #7640: the cursor's event id is gated by the same validation as the seq —
|
||||||
|
# reading the raw field here let a cursor outlive its assistant projection.
|
||||||
|
assert "_runJournalReplayEventIdForInflight(_replayCursorInflight)" in attach_body
|
||||||
|
assert "INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId" not in attach_body
|
||||||
assert "inflight.lastRunJournalEventId=raw" in attach_body
|
assert "inflight.lastRunJournalEventId=raw" in attach_body
|
||||||
assert "INFLIGHT[activeSid].streamId=streamId" in attach_body
|
assert "INFLIGHT[activeSid].streamId=streamId" in attach_body
|
||||||
assert "INFLIGHT[activeSid].lastRunJournalEventId=''" in attach_body
|
assert "INFLIGHT[activeSid].lastRunJournalEventId=''" in attach_body
|
||||||
|
|||||||
@@ -631,3 +631,165 @@ def test_sse_done_path_broadened_to_any_model_window_mismatch():
|
|||||||
assert "_model_matches_configured_default as _mmcd_cc" not in _STREAMING_SRC, (
|
assert "_model_matches_configured_default as _mmcd_cc" not in _STREAMING_SRC, (
|
||||||
"the old default-only save matcher (_mmcd_cc) must be removed (#4618)"
|
"the old default-only save matcher (_mmcd_cc) must be removed (#4618)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- #7535: provider-ownership guard on the global model.base_url backfill ---
|
||||||
|
# Same family as #3256: a global value scoped to its owner must not leak onto
|
||||||
|
# sessions owned by a different provider.
|
||||||
|
|
||||||
|
|
||||||
|
def test_builtin_provider_lookup_does_not_inherit_other_providers_base_url():
|
||||||
|
"""#7535: a session on a built-in registry provider (whose base_url is empty
|
||||||
|
by design because the endpoint lives in PROVIDER_REGISTRY, not config.yaml)
|
||||||
|
must NOT inherit the global ``model.base_url`` owned by a different (custom)
|
||||||
|
provider. The unguarded backfill handed the custom endpoint to the
|
||||||
|
context-length resolver, which probed the wrong ``/models`` catalog and fell
|
||||||
|
back to the generic 128K catalog entry for a 1M-context model, while the
|
||||||
|
on-disk session and SSE stream both carried 1,000,000."""
|
||||||
|
from api.routes import _context_length_lookup_inputs_for_model
|
||||||
|
|
||||||
|
ark_base = "https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
|
cfg = {
|
||||||
|
"model": {
|
||||||
|
"provider": "custom:ark",
|
||||||
|
"base_url": ark_base,
|
||||||
|
"default": "ark-code-latest",
|
||||||
|
"context_length": 1000000,
|
||||||
|
},
|
||||||
|
"custom_providers": [
|
||||||
|
{
|
||||||
|
"name": "ark",
|
||||||
|
"base_url": ark_base,
|
||||||
|
"api_key": "ark-key",
|
||||||
|
"model": "ark-code-latest",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup = _context_length_lookup_inputs_for_model(
|
||||||
|
"@opencode-go:deepseek-v4.1-flash",
|
||||||
|
"opencode-go",
|
||||||
|
cfg=cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lookup.provider == "opencode-go"
|
||||||
|
assert lookup.base_url == "", (
|
||||||
|
"the global model.base_url belongs to custom:ark; the opencode-go "
|
||||||
|
"registry slot must stay empty so PROVIDER_REGISTRY's endpoint "
|
||||||
|
"resolves the context window (#7535)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_provider_session_keeps_global_base_url_backfill():
|
||||||
|
"""#7535 control (compat): when the session provider IS the configured
|
||||||
|
owner of ``model.base_url``, the backfill behavior is preserved."""
|
||||||
|
from api.routes import _context_length_lookup_inputs_for_model
|
||||||
|
|
||||||
|
ark_base = "https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||||
|
cfg = {
|
||||||
|
"model": {
|
||||||
|
"provider": "custom:ark",
|
||||||
|
"base_url": ark_base,
|
||||||
|
"default": "ark-code-latest",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup = _context_length_lookup_inputs_for_model(
|
||||||
|
"ark-code-latest",
|
||||||
|
"custom:ark",
|
||||||
|
cfg=cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lookup.provider == "custom:ark"
|
||||||
|
assert lookup.base_url == ark_base, (
|
||||||
|
"same-owner sessions must keep the model.base_url backfill (#7535 compat)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_url_only_config_without_provider_keeps_endpoint():
|
||||||
|
"""Regression (PR #7743 gate): a ``model`` section that declares
|
||||||
|
``base_url`` but NO ``provider`` has no owner that could conflict, so any
|
||||||
|
session must still receive the URL — exactly as master did. The profile
|
||||||
|
setup path writes this shape (api/profiles.py), and dropping the URL made
|
||||||
|
the lookup skip the endpoint and silently fall back to 256,000."""
|
||||||
|
from api.routes import _context_length_lookup_inputs_for_model
|
||||||
|
|
||||||
|
only_base_url = "http://127.0.0.1:9/v1"
|
||||||
|
cfg = {
|
||||||
|
"model": {
|
||||||
|
"base_url": only_base_url,
|
||||||
|
"default": "some-model",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for session_provider in ("custom", "openrouter"):
|
||||||
|
lookup = _context_length_lookup_inputs_for_model(
|
||||||
|
"some-model",
|
||||||
|
session_provider,
|
||||||
|
cfg=cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lookup.provider == session_provider
|
||||||
|
assert lookup.base_url == only_base_url, (
|
||||||
|
f"a config with no declared provider owner cannot conflict with the "
|
||||||
|
f"{session_provider!r} session, so the base_url must survive the "
|
||||||
|
f"ownership guard"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_underscore_provider_owner_matches_hyphen_session():
|
||||||
|
"""Regression (PR #7743 gate): ``opencode_go`` and ``opencode-go`` are two
|
||||||
|
spellings of the SAME built-in provider, so the owner session must still
|
||||||
|
receive ``model.base_url``. Comparing the ids through the repo's
|
||||||
|
canonicaliser folds the underscore without collapsing distinct
|
||||||
|
``custom:*`` slugs."""
|
||||||
|
from api.routes import _context_length_lookup_inputs_for_model
|
||||||
|
|
||||||
|
owner_base = "http://127.0.0.1:9/v1"
|
||||||
|
cfg = {
|
||||||
|
"model": {
|
||||||
|
"provider": "opencode_go",
|
||||||
|
"base_url": owner_base,
|
||||||
|
"default": "some-model",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup = _context_length_lookup_inputs_for_model(
|
||||||
|
"some-model",
|
||||||
|
"opencode-go",
|
||||||
|
cfg=cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lookup.provider == "opencode-go"
|
||||||
|
assert lookup.base_url == owner_base, (
|
||||||
|
"opencode_go and opencode-go are the same built-in provider, so the "
|
||||||
|
"owner session must keep the base_url backfill"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_underscore_provider_still_blocks_distinct_custom_owner():
|
||||||
|
"""Counterpart to test_underscore_provider_owner_matches_hyphen_session:
|
||||||
|
folding underscores must not open the #7535 guard. ``custom:my_box`` and a
|
||||||
|
session on the unrelated built-in ``opencode-go`` are still different
|
||||||
|
providers, so the custom owner's URL stays out of the registry slot."""
|
||||||
|
from api.routes import _context_length_lookup_inputs_for_model
|
||||||
|
|
||||||
|
cfg = {
|
||||||
|
"model": {
|
||||||
|
"provider": "custom:my_box",
|
||||||
|
"base_url": "http://127.0.0.1:9/v1",
|
||||||
|
"default": "some-model",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup = _context_length_lookup_inputs_for_model(
|
||||||
|
"some-model",
|
||||||
|
"opencode-go",
|
||||||
|
cfg=cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lookup.provider == "opencode-go"
|
||||||
|
assert lookup.base_url == "", (
|
||||||
|
"underscore folding must not let a distinct custom provider's "
|
||||||
|
"base_url leak onto another provider's session (#7535 guard)"
|
||||||
|
)
|
||||||
@@ -159,6 +159,7 @@ def _make_db(tmp_path: Path):
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
source TEXT NOT NULL,
|
source TEXT NOT NULL,
|
||||||
session_source TEXT,
|
session_source TEXT,
|
||||||
|
model_config TEXT,
|
||||||
model TEXT,
|
model TEXT,
|
||||||
started_at REAL NOT NULL,
|
started_at REAL NOT NULL,
|
||||||
ended_at REAL,
|
ended_at REAL,
|
||||||
@@ -355,6 +356,165 @@ def test_periodic_projection_recovers_role_only_sidebar_visibility_change(tmp_pa
|
|||||||
assert subscriber.empty()
|
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):
|
def test_initial_missing_db_does_not_publish_an_empty_snapshot(tmp_path):
|
||||||
gw = importlib.import_module("api.gateway_watcher")
|
gw = importlib.import_module("api.gateway_watcher")
|
||||||
watcher = gw.GatewayWatcher(state_db_path=tmp_path / "missing.db")
|
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 ──
|
// ── module-scope state the extracted functions close over ──
|
||||||
let _clarifyPendingBySession = new Map();
|
let _clarifyPendingBySession = new Map();
|
||||||
let _approvalPendingBySession = new Map();
|
let _approvalPendingBySession = new Map();
|
||||||
|
let _clarifyPromptGenerationBySession = new Map();
|
||||||
|
let _approvalPromptGenerationBySession = new Map();
|
||||||
let _clarifySessionId = null;
|
let _clarifySessionId = null;
|
||||||
let _approvalSessionId = null;
|
let _approvalSessionId = null;
|
||||||
let S = { session: null };
|
let S = { session: null };
|
||||||
@@ -109,6 +111,10 @@ function hideApprovalCard(force){
|
|||||||
|
|
||||||
// ── real functions under test ──
|
// ── real functions under test ──
|
||||||
eval(extractFunc('_promptActiveSessionId'));
|
eval(extractFunc('_promptActiveSessionId'));
|
||||||
|
eval(extractFunc('_clarifyPromptGeneration'));
|
||||||
|
eval(extractFunc('_bumpClarifyPromptGeneration'));
|
||||||
|
eval(extractFunc('_approvalPromptGeneration'));
|
||||||
|
eval(extractFunc('_bumpApprovalPromptGeneration'));
|
||||||
eval(extractFunc('_clarifyPromptBelongsToActiveSession'));
|
eval(extractFunc('_clarifyPromptBelongsToActiveSession'));
|
||||||
eval(extractFunc('_rememberClarifyPending'));
|
eval(extractFunc('_rememberClarifyPending'));
|
||||||
eval(extractFunc('_clearClarifyPendingForSession'));
|
eval(extractFunc('_clearClarifyPendingForSession'));
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ def test_js_recycle_flag_lifecycle():
|
|||||||
"""_scheduleMessageVirtualizedRender sets _msgNodeRecycleEnabled=true
|
"""_scheduleMessageVirtualizedRender sets _msgNodeRecycleEnabled=true
|
||||||
before the compensate call and clears it in finally."""
|
before the compensate call and clears it in finally."""
|
||||||
fn_match = re.search(
|
fn_match = re.search(
|
||||||
r'function _scheduleMessageVirtualizedRender\(force\)\{(.+?)^(?=function )',
|
r'function _scheduleMessageVirtualizedRender\(force(?:, request)?\)\{(.+?)^(?=function )',
|
||||||
JS, re.DOTALL | re.MULTILINE
|
JS, re.DOTALL | re.MULTILINE
|
||||||
)
|
)
|
||||||
assert fn_match, "_scheduleMessageVirtualizedRender not found"
|
assert fn_match, "_scheduleMessageVirtualizedRender not found"
|
||||||
@@ -1101,7 +1101,7 @@ class TestScrollbarDragRenderDuringDrag:
|
|||||||
"""_scheduleMessageVirtualizedRender must have a _scrollbarDragActive
|
"""_scheduleMessageVirtualizedRender must have a _scrollbarDragActive
|
||||||
branch that runs a full render with scroll compensation."""
|
branch that runs a full render with scroll compensation."""
|
||||||
fn_match = re.search(
|
fn_match = re.search(
|
||||||
r'function _scheduleMessageVirtualizedRender\(force\)\{(.+?)^(?=function )',
|
r'function _scheduleMessageVirtualizedRender\(force(?:, request)?\)\{(.+?)^(?=function )',
|
||||||
JS, re.DOTALL | re.MULTILINE
|
JS, re.DOTALL | re.MULTILINE
|
||||||
)
|
)
|
||||||
assert fn_match, "_scheduleMessageVirtualizedRender not found"
|
assert fn_match, "_scheduleMessageVirtualizedRender not found"
|
||||||
|
|||||||
@@ -53,45 +53,12 @@ def test_dismissed_set_capped_at_100():
|
|||||||
# Guard in showApprovalCard
|
# 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():
|
def test_dismiss_approval_card_defined():
|
||||||
assert "function dismissApprovalCard(" in MESSAGES_JS
|
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():
|
def test_dismiss_approval_card_hides_card():
|
||||||
compact = _compact(MESSAGES_JS)
|
compact = _compact(MESSAGES_JS)
|
||||||
@@ -102,19 +69,6 @@ def test_dismiss_approval_card_hides_card():
|
|||||||
assert "hideApprovalCard(true)" in body
|
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():
|
def test_dismiss_approval_card_captures_sid_before_hide():
|
||||||
# The session ID must be captured before hideApprovalCard(true) nulls out
|
# 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")
|
pytest.skip("node not available")
|
||||||
helpers = "\n".join(
|
helpers = "\n".join(
|
||||||
_extract_fn(MESSAGES_JS, n)
|
_extract_fn(MESSAGES_JS, n)
|
||||||
for n in ("_approvalDismissKey", "_getDismissedApprovals",
|
for n in ("_promptNotifyKey", "_approvalDismissKey", "_getDismissedApprovals",
|
||||||
"_isApprovalDismissed", "_markApprovalDismissed", "_unmarkApprovalDismissed")
|
"_isApprovalDismissed", "_markApprovalDismissed", "_unmarkApprovalDismissed")
|
||||||
)
|
)
|
||||||
script = (
|
script = (
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ def _run_failure_case(api_js: str) -> dict:
|
|||||||
_extract_fn(MESSAGES_JS, "_getDismissedApprovals"),
|
_extract_fn(MESSAGES_JS, "_getDismissedApprovals"),
|
||||||
_extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
|
_extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
|
||||||
_extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"),
|
_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, "_promptActiveSessionId"),
|
||||||
_extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
|
_extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
|
||||||
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
|
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
|
||||||
@@ -103,6 +107,7 @@ let renderCalls = 0;
|
|||||||
let _approvalSessionId = 'sess-1';
|
let _approvalSessionId = 'sess-1';
|
||||||
let _approvalCurrentId = 'appr-1';
|
let _approvalCurrentId = 'appr-1';
|
||||||
let _approvalPendingBySession = new Map();
|
let _approvalPendingBySession = new Map();
|
||||||
|
const _approvalPromptGenerationBySession = new Map();
|
||||||
let _loadSessionGeneration = 1;
|
let _loadSessionGeneration = 1;
|
||||||
let _approvalResponding = null;
|
let _approvalResponding = null;
|
||||||
let _approvalClearedOwner = null;
|
let _approvalClearedOwner = null;
|
||||||
@@ -111,6 +116,7 @@ let _approvalSignature = '';
|
|||||||
let _approvalVisibleSince = 0;
|
let _approvalVisibleSince = 0;
|
||||||
let _approvalHideTimer = null;
|
let _approvalHideTimer = null;
|
||||||
const _clarifyPendingBySession = new Map();
|
const _clarifyPendingBySession = new Map();
|
||||||
|
const _promptNotifySeen = new Map();
|
||||||
const buttons = new Map();
|
const buttons = new Map();
|
||||||
function makeButton(id) {{
|
function makeButton(id) {{
|
||||||
return {{
|
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, "_getDismissedApprovals"),
|
||||||
_extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
|
_extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
|
||||||
_extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"),
|
_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, "_promptActiveSessionId"),
|
||||||
_extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
|
_extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
|
||||||
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
|
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
|
||||||
@@ -289,6 +299,7 @@ const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
|
|||||||
let _approvalSessionId = 'sess-1';
|
let _approvalSessionId = 'sess-1';
|
||||||
let _approvalCurrentId = 'appr-1';
|
let _approvalCurrentId = 'appr-1';
|
||||||
let _approvalPendingBySession = new Map();
|
let _approvalPendingBySession = new Map();
|
||||||
|
const _approvalPromptGenerationBySession = new Map();
|
||||||
let _loadSessionGeneration = 1;
|
let _loadSessionGeneration = 1;
|
||||||
let _approvalResponding = null;
|
let _approvalResponding = null;
|
||||||
let _approvalClearedOwner = null;
|
let _approvalClearedOwner = null;
|
||||||
@@ -297,6 +308,7 @@ let _approvalSignature = '';
|
|||||||
let _approvalVisibleSince = 0;
|
let _approvalVisibleSince = 0;
|
||||||
let _approvalHideTimer = null;
|
let _approvalHideTimer = null;
|
||||||
const _clarifyPendingBySession = new Map();
|
const _clarifyPendingBySession = new Map();
|
||||||
|
const _promptNotifySeen = new Map();
|
||||||
let resolveApi;
|
let resolveApi;
|
||||||
let apiCalls = 0;
|
let apiCalls = 0;
|
||||||
const buttons = new Map();
|
const buttons = new Map();
|
||||||
|
|||||||
@@ -199,7 +199,9 @@ def test_missing_sidecar_returns_default_without_caching_growth(tmp_path):
|
|||||||
session_dir.mkdir()
|
session_dir.mkdir()
|
||||||
with mock.patch("api.models.SESSION_DIR", session_dir):
|
with mock.patch("api.models.SESSION_DIR", session_dir):
|
||||||
meta = models._state_projection_sidecar_metadata("cron_nope_999")
|
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).
|
# No file → nothing cached (so the cache can't be poisoned by absent files).
|
||||||
assert len(models._SIDECAR_METADATA_CACHE) == 0
|
assert len(models._SIDECAR_METADATA_CACHE) == 0
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Regression coverage for issue #500 transcript virtualization."""
|
"""Regression coverage for issue #500 transcript virtualization."""
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -1345,7 +1346,9 @@ let _messageVirtualHeightCacheSrc = {};
|
|||||||
let _messageVirtualEstimatedRowHeight = 200;
|
let _messageVirtualEstimatedRowHeight = 200;
|
||||||
let _messageVirtualWindowKey = 'old-key';
|
let _messageVirtualWindowKey = 'old-key';
|
||||||
let _messageVirtualMeasurementCycleKey = 'old-cycle';
|
let _messageVirtualMeasurementCycleKey = 'old-cycle';
|
||||||
let _messageVirtualMeasurementRetryCount = 3;
|
let _messageVirtualMeasurementSeenKeys = ['stale-key'];
|
||||||
|
let _messageVirtualMeasurementBurstActive = true;
|
||||||
|
let _messageVirtualRenderQueuedOrigin = 'internal';
|
||||||
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
|
||||||
// #4367 introduced per-role default heights; the combined _clearMessageVirtualHeightCache
|
// #4367 introduced per-role default heights; the combined _clearMessageVirtualHeightCache
|
||||||
// resets the estimate via _messageVirtualDefaultHeightForRole('default'), so the harness
|
// 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};
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = {user:120, assistant:160, tool_call:400, default:140};
|
||||||
function clearTimeout(id){ timerCleared = (id === 99); }
|
function clearTimeout(id){ timerCleared = (id === 99); }
|
||||||
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
|
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
|
||||||
|
eval(extractFunc('_resetMessageVirtualMeasurementBurst'));
|
||||||
eval(extractFunc('_clearMessageVirtualHeightCache'));
|
eval(extractFunc('_clearMessageVirtualHeightCache'));
|
||||||
_clearMessageVirtualHeightCache();
|
_clearMessageVirtualHeightCache();
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
@@ -1360,6 +1364,9 @@ console.log(JSON.stringify({
|
|||||||
settleTimer: _messageVirtualScrollSettleTimer,
|
settleTimer: _messageVirtualScrollSettleTimer,
|
||||||
deferred: _messageVirtualDeferredMeasurement,
|
deferred: _messageVirtualDeferredMeasurement,
|
||||||
timerCleared: timerCleared,
|
timerCleared: timerCleared,
|
||||||
|
seenKeys: _messageVirtualMeasurementSeenKeys,
|
||||||
|
burstActive: _messageVirtualMeasurementBurstActive,
|
||||||
|
queuedOrigin: _messageVirtualRenderQueuedOrigin,
|
||||||
}));
|
}));
|
||||||
"""
|
"""
|
||||||
metrics = json.loads(_run_node(source))
|
metrics = json.loads(_run_node(source))
|
||||||
@@ -1375,3 +1382,425 @@ console.log(JSON.stringify({
|
|||||||
assert metrics["timerCleared"] is True, (
|
assert metrics["timerCleared"] is True, (
|
||||||
"_clearMessageVirtualHeightCache must call clearTimeout on the pending settle timer"
|
"_clearMessageVirtualHeightCache must call clearTimeout on the pending settle timer"
|
||||||
)
|
)
|
||||||
|
assert metrics["seenKeys"] == [], (
|
||||||
|
"_clearMessageVirtualHeightCache must clear the measurement-burst seen-key "
|
||||||
|
f"memory: seenKeys {metrics['seenKeys']}"
|
||||||
|
)
|
||||||
|
assert metrics["burstActive"] is False, (
|
||||||
|
"_clearMessageVirtualHeightCache must end any active measurement burst: "
|
||||||
|
f"burstActive {metrics['burstActive']}"
|
||||||
|
)
|
||||||
|
assert metrics["queuedOrigin"] is None, (
|
||||||
|
"_clearMessageVirtualHeightCache must clear any queued internal-measurement "
|
||||||
|
f"provenance: queuedOrigin {metrics['queuedOrigin']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _measurement_burst_harness(scenario_js: str) -> str:
|
||||||
|
"""Shared queued-rAF harness for the measurement-burst chain (#6654/#6717).
|
||||||
|
|
||||||
|
Drives the REAL production chain: the cycle-aware scheduler queues a first
|
||||||
|
rAF, _scheduleMessageVirtualizedRender queues a second rAF (the
|
||||||
|
internal-measurement provenance marker is threaded through BOTH layers),
|
||||||
|
and the renderMessages stub applies the production burst-reset contract for
|
||||||
|
every unmarked (external) render, then simulates the measure pass from the
|
||||||
|
caller-provided plan (measurePlan). Unlike the earlier synchronous-rAF
|
||||||
|
harnesses, external renders can land BETWEEN queued rAF callbacks — exactly
|
||||||
|
where the overlap/convergence regressions lived.
|
||||||
|
"""
|
||||||
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
||||||
|
cap_match = re.search(
|
||||||
|
r"MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS\s*=\s*(\d+)", js
|
||||||
|
)
|
||||||
|
assert cap_match, (
|
||||||
|
"MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS must be defined in ui.js "
|
||||||
|
"(absolute per-burst cap, #6717 re-gate)"
|
||||||
|
)
|
||||||
|
cap = int(cap_match.group(1))
|
||||||
|
return _extract_func_script(js) + (
|
||||||
|
"// Absolute per-burst ceiling, read from production ui.js (#6717 re-gate).\n"
|
||||||
|
f"const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = {cap};\n"
|
||||||
|
) + """
|
||||||
|
// ── queued-rAF harness ────────────────────────────────────────────────────────
|
||||||
|
let renderLog = []; // every renderMessages call: {internal, preserveScroll}
|
||||||
|
let rafQueue = [];
|
||||||
|
function requestAnimationFrame(cb){ rafQueue.push(cb); return rafQueue.length; }
|
||||||
|
function flushRaf(){ const q = rafQueue; rafQueue = []; for (const cb of q) cb(); }
|
||||||
|
let _messageVirtualScrollRaf = 0;
|
||||||
|
let _messageVirtualWindowKey = '';
|
||||||
|
let _messageVirtualMeasurementCycleKey = '';
|
||||||
|
let _messageVirtualMeasurementSeenKeys = [];
|
||||||
|
let _messageVirtualMeasurementBurstActive = false;
|
||||||
|
let _messageVirtualRenderQueuedOrigin = null;
|
||||||
|
let _messageVirtualScrollActive = false;
|
||||||
|
let _messageVirtualDeferredMeasurement = null;
|
||||||
|
let _scrollbarDragActive = false;
|
||||||
|
let _msgNodeRecycleEnabled = false;
|
||||||
|
let _programmaticScroll = false;
|
||||||
|
let _programmaticScrollSetAt = 0;
|
||||||
|
function _deferClearProgrammaticScroll(){}
|
||||||
|
function $(id){ return {}; }
|
||||||
|
function _getVisibleMessagesWithIdx(){ return []; }
|
||||||
|
function _messageVirtualKeepTailCount(){ return 0; }
|
||||||
|
function _currentMessageVirtualWindow(){
|
||||||
|
return { virtualized:true, start:0, end:10, topPad:0, bottomPad:0, tailStart:10, total:20 };
|
||||||
|
}
|
||||||
|
function _messageVirtualMeasurementCycleKeyFor(w){ return (w && w.key) || ''; }
|
||||||
|
function _compensateScrollForMeasurementDelta(fn){ return fn(); }
|
||||||
|
// Simulated measure pass: after each render, the plan yields the next measured
|
||||||
|
// window cycle key; undefined means the measurements settled (unchanged).
|
||||||
|
let measurePlan = [];
|
||||||
|
let measureIdx = 0;
|
||||||
|
function renderMessages(options){
|
||||||
|
const internal = !!(options && options._internalMeasurement);
|
||||||
|
renderLog.push({ internal: internal, preserveScroll: !!(options && options.preserveScroll) });
|
||||||
|
// Contract of production renderMessages(): every UNMARKED (external) render
|
||||||
|
// trigger starts a fresh measurement burst, overriding any pending internal
|
||||||
|
// callback.
|
||||||
|
if(!internal) _resetMessageVirtualMeasurementBurst();
|
||||||
|
const next = measurePlan[measureIdx++];
|
||||||
|
if(next === undefined){
|
||||||
|
_markMessageVirtualMeasurementsSettled({ key: 'settled' });
|
||||||
|
}else{
|
||||||
|
_scheduleMessageVirtualMeasurementRefresh({ key: next });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Production functions under test.
|
||||||
|
eval(extractFunc('_resetMessageVirtualMeasurementBurst'));
|
||||||
|
eval(extractFunc('_messageVirtualWindowKeyFor'));
|
||||||
|
eval(extractFunc('_scheduleMessageVirtualMeasurementRefresh'));
|
||||||
|
eval(extractFunc('_scheduleMessageVirtualizedRender'));
|
||||||
|
eval(extractFunc('_markMessageVirtualMeasurementsSettled'));
|
||||||
|
""" + scenario_js
|
||||||
|
|
||||||
|
|
||||||
|
def test_oscillating_cycle_keys_terminate_burst_with_queued_raf():
|
||||||
|
"""#6654/#6717 re-gate (c): a repeated oscillating window key (A->B->A->B)
|
||||||
|
must terminate the measurement burst even though every key was unseen on
|
||||||
|
first sight — the burst tracks SEEN keys, so geometry that flaps between
|
||||||
|
two states can never renew the chain and keep the rAF/measure loop alive
|
||||||
|
(~30% WebContent CPU, issue #6654). The queued-rAF harness drives the
|
||||||
|
oscillation through the real two-rAF-layer chain."""
|
||||||
|
source = _measurement_burst_harness("""
|
||||||
|
measurePlan = ['B','A','B','A','B','A'];
|
||||||
|
renderMessages({}); // external trigger: measures B -> burst starts
|
||||||
|
flushRaf(); flushRaf(); // layer-1 then layer-2 rAF -> internal render (A)
|
||||||
|
flushRaf(); flushRaf(); // next cycle -> internal render (B repeats) -> burst ends
|
||||||
|
flushRaf(); flushRaf(); // nothing left queued
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
renders: renderLog.length,
|
||||||
|
internal: renderLog.filter(r => r.internal).length,
|
||||||
|
burstActive: _messageVirtualMeasurementBurstActive,
|
||||||
|
seenKeys: _messageVirtualMeasurementSeenKeys,
|
||||||
|
queuedOrigin: _messageVirtualRenderQueuedOrigin,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
metrics = json.loads(_run_node(source))
|
||||||
|
assert metrics["internal"] == 2, (
|
||||||
|
"A->B->A->B oscillation must stay bounded: a repeated key ends the burst. "
|
||||||
|
f"scheduled {metrics['internal']} internal re-renders (expected 2, then termination)"
|
||||||
|
)
|
||||||
|
assert metrics["renders"] == 3, (
|
||||||
|
"oscillation must not keep scheduling renders: "
|
||||||
|
f"{metrics['renders']} renders (expected 3 = 1 external + 2 internal)"
|
||||||
|
)
|
||||||
|
assert metrics["burstActive"] is False, (
|
||||||
|
"the burst must end once the repeated key is detected so the next "
|
||||||
|
f"externally initiated cycle starts fresh: burstActive {metrics['burstActive']}"
|
||||||
|
)
|
||||||
|
assert metrics["seenKeys"] == [], (
|
||||||
|
"termination must clear the seen-key memory: "
|
||||||
|
f"seenKeys {metrics['seenKeys']}"
|
||||||
|
)
|
||||||
|
assert metrics["queuedOrigin"] is None, (
|
||||||
|
"no internal render may remain queued after termination: "
|
||||||
|
f"queuedOrigin {metrics['queuedOrigin']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_consecutive_same_key_row_shrink_converges_to_settled():
|
||||||
|
"""#6717 re-gate: consecutive same-key passes (e.g. A->A->B->settled from
|
||||||
|
a row shrinking across two measurement passes with unchanged window bounds)
|
||||||
|
must NOT be cut short as oscillation. Only non-consecutive repeats (A->B->A)
|
||||||
|
represent cyclic oscillation; consecutive same-key passes continue toward
|
||||||
|
convergence while remaining bounded by the absolute cap."""
|
||||||
|
source = _measurement_burst_harness("""
|
||||||
|
measurePlan = ['A', 'A', 'B']; // R0 measures A, R1 internal measures A (consecutive), R2 internal measures B, then settled
|
||||||
|
renderMessages({}); // external trigger: measures A -> burst starts
|
||||||
|
flushRaf(); flushRaf(); // L1 -> L2: internal render measures A again (consecutive same key) -> must proceed!
|
||||||
|
flushRaf(); flushRaf(); // L1 -> L2: internal render measures B -> proceeds!
|
||||||
|
flushRaf(); flushRaf(); // L1 -> L2: internal render measures settled
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
renders: renderLog.length,
|
||||||
|
internal: renderLog.filter(r => r.internal).length,
|
||||||
|
burstActive: _messageVirtualMeasurementBurstActive,
|
||||||
|
seenKeys: _messageVirtualMeasurementSeenKeys,
|
||||||
|
queuedOrigin: _messageVirtualRenderQueuedOrigin,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
metrics = json.loads(_run_node(source))
|
||||||
|
assert metrics["internal"] == 3, (
|
||||||
|
"consecutive same-key pass (row shrink) must NOT be cut short as oscillation: "
|
||||||
|
f"got {metrics['internal']} internal renders (expected 3)"
|
||||||
|
)
|
||||||
|
assert metrics["renders"] == 4, (
|
||||||
|
f"burst must converge to settled: {metrics['renders']} renders (expected 4 = 1 external + 3 internal)"
|
||||||
|
)
|
||||||
|
assert metrics["burstActive"] is False, (
|
||||||
|
f"burst must settle: burstActive {metrics['burstActive']}"
|
||||||
|
)
|
||||||
|
assert metrics["seenKeys"] == [], (
|
||||||
|
f"settlement must clear seen-key memory: seenKeys {metrics['seenKeys']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_genuine_convergence_exceeding_two_renders_completes():
|
||||||
|
"""#6717 re-gate (b): a legitimate forward convergence A->B->C->settled
|
||||||
|
(content reflow, fonts/images loading late, dynamic height) must be allowed
|
||||||
|
MORE than two measurement passes. The old flat two-render burst cap left
|
||||||
|
the geometry mis-measured (wrong row heights / clipped content); cycle-aware
|
||||||
|
tracking lets every UNSEEN key proceed until the measurements settle."""
|
||||||
|
source = _measurement_burst_harness("""
|
||||||
|
measurePlan = ['B','C','D']; // three distinct forward keys, then settled
|
||||||
|
renderMessages({}); // external trigger: measures B
|
||||||
|
flushRaf(); flushRaf(); // internal render: measures C
|
||||||
|
flushRaf(); flushRaf(); // internal render: measures D
|
||||||
|
flushRaf(); flushRaf(); // internal render: measurements settle
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
renders: renderLog.length,
|
||||||
|
internal: renderLog.filter(r => r.internal).length,
|
||||||
|
lastInternal: renderLog[renderLog.length - 1].internal,
|
||||||
|
burstActive: _messageVirtualMeasurementBurstActive,
|
||||||
|
seenKeys: _messageVirtualMeasurementSeenKeys,
|
||||||
|
queuedOrigin: _messageVirtualRenderQueuedOrigin,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
metrics = json.loads(_run_node(source))
|
||||||
|
assert metrics["internal"] == 3, (
|
||||||
|
"genuine A->B->C->settled convergence must complete with MORE than the "
|
||||||
|
"old two-render cap: "
|
||||||
|
f"{metrics['internal']} internal re-renders (expected 3)"
|
||||||
|
)
|
||||||
|
assert metrics["renders"] == 4, (
|
||||||
|
"the convergence chain must run to completion: "
|
||||||
|
f"{metrics['renders']} renders (expected 4 = 1 external + 3 internal)"
|
||||||
|
)
|
||||||
|
assert metrics["lastInternal"] is True, (
|
||||||
|
"the final render of the convergence chain must still be part of the "
|
||||||
|
"internal measurement chain (settled via _markMessageVirtualMeasurementsSettled)"
|
||||||
|
)
|
||||||
|
assert metrics["burstActive"] is False, (
|
||||||
|
"settlement must end the burst: "
|
||||||
|
f"burstActive {metrics['burstActive']}"
|
||||||
|
)
|
||||||
|
assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, (
|
||||||
|
"settlement must clear the seen-key memory and any queued provenance: "
|
||||||
|
f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlapping_external_append_receives_fresh_budget():
|
||||||
|
"""#6717 re-gate (a): an externally initiated render (message append,
|
||||||
|
scroll-settle, genuine content change) that lands while an internal
|
||||||
|
measurement callback is still PENDING in the rAF queue must reset the burst
|
||||||
|
— external provenance overrides a pending internal callback — so the new
|
||||||
|
cycle receives a fresh budget instead of being starved by the stale burst's
|
||||||
|
seen-key memory (0 retries).
|
||||||
|
|
||||||
|
Phase 1: the append lands between the two rAF layers and measures key B
|
||||||
|
(already seen in the stale burst). Only the external reset lets B proceed;
|
||||||
|
without it the repeated key would terminate the append's cycle immediately.
|
||||||
|
The stale layer-2 callback (queued for the DEAD burst) must then degrade to
|
||||||
|
an UNMARKED render — the append's own measurement must not revive an
|
||||||
|
internal marker onto it, because the provenance belongs to the queued
|
||||||
|
render, not to a global flag (#6717 re-gate MUST-FIX 2) — and the fresh
|
||||||
|
cycle it starts completes with its own internal render, so the append is
|
||||||
|
never starved of retries.
|
||||||
|
Phase 2: an external render that measures SETTLED lands while a layer-2
|
||||||
|
callback is pending; the stale callback must then degrade to an UNMARKED
|
||||||
|
render (the external reset consumed its provenance), and the fresh cycle it
|
||||||
|
starts must complete with its own internal render."""
|
||||||
|
source = _measurement_burst_harness("""
|
||||||
|
measurePlan = ['B','B','C', undefined, 'E', undefined, 'F', undefined];
|
||||||
|
renderMessages({}); // R0 external: burst starts, seen=[B], L1 queued
|
||||||
|
flushRaf(); // L1 fires -> L2 (internal callback) now pending
|
||||||
|
renderMessages({}); // R1 EXTERNAL APPEND lands before L2 fires:
|
||||||
|
// resets the burst -> fresh cycle, seen=[B] again
|
||||||
|
flushRaf(); flushRaf(); // stale L2 fires UNMARKED (R2, external won),
|
||||||
|
// fresh cycle completes (R3 internal) -> settles
|
||||||
|
renderMessages({}); // R4 external: new cycle, L1 queued
|
||||||
|
flushRaf(); // L1 fires -> L2 (internal callback) now pending
|
||||||
|
renderMessages({}); // R5 external scroll-settle: resets the burst,
|
||||||
|
// measures SETTLED (no new schedule)
|
||||||
|
flushRaf(); // stale L2 fires -> marker consumed as FALSE ->
|
||||||
|
// R6 UNMARKED render (external provenance won)
|
||||||
|
flushRaf(); flushRaf(); // R6's fresh cycle completes (R7 internal) -> settles
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
renders: renderLog.length,
|
||||||
|
internal: renderLog.filter(r => r.internal).length,
|
||||||
|
appendExternal: renderLog[1].internal,
|
||||||
|
staleL2Unmarked: renderLog[2].internal,
|
||||||
|
postAppendCycleInternal: renderLog[3].internal,
|
||||||
|
staleCallbackUnmarked: renderLog[6].internal,
|
||||||
|
postResetCycleInternal: renderLog[7].internal,
|
||||||
|
burstActive: _messageVirtualMeasurementBurstActive,
|
||||||
|
seenKeys: _messageVirtualMeasurementSeenKeys,
|
||||||
|
queuedOrigin: _messageVirtualRenderQueuedOrigin,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
metrics = json.loads(_run_node(source))
|
||||||
|
assert metrics["renders"] == 8, (
|
||||||
|
"the overlapping append must NOT starve the new cycle: the chain must "
|
||||||
|
"continue past the append to completion "
|
||||||
|
f"({metrics['renders']} renders; expected 8)"
|
||||||
|
)
|
||||||
|
assert metrics["appendExternal"] is False, (
|
||||||
|
"the append is an unmarked external render"
|
||||||
|
)
|
||||||
|
assert metrics["staleL2Unmarked"] is False, (
|
||||||
|
"the stale layer-2 callback (queued for the burst the append reset) "
|
||||||
|
"must degrade to an UNMARKED render: the append's own measurement "
|
||||||
|
"must not revive an internal marker onto it — provenance belongs to "
|
||||||
|
"the queued render, external wins (#6717 re-gate MUST-FIX 2)"
|
||||||
|
)
|
||||||
|
assert metrics["postAppendCycleInternal"] is True, (
|
||||||
|
"the fresh cycle after the degraded stale callback must complete with "
|
||||||
|
"its own internal render (fresh budget — the repeated key B was not "
|
||||||
|
"treated as oscillation because the external render reset the burst)"
|
||||||
|
)
|
||||||
|
assert metrics["staleCallbackUnmarked"] is False, (
|
||||||
|
"external provenance must override the pending internal callback: the "
|
||||||
|
"stale internal rAF callback must degrade to an unmarked render after "
|
||||||
|
"the external render reset the burst"
|
||||||
|
)
|
||||||
|
assert metrics["postResetCycleInternal"] is True, (
|
||||||
|
"the cycle started by the degraded stale callback must complete with "
|
||||||
|
"its own internal render (retries were not starved)"
|
||||||
|
)
|
||||||
|
assert metrics["burstActive"] is False, (
|
||||||
|
"the fresh cycles must end settled: "
|
||||||
|
f"burstActive {metrics['burstActive']}"
|
||||||
|
)
|
||||||
|
assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, (
|
||||||
|
"settlement must clear the seen-key memory and any queued provenance: "
|
||||||
|
f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_distinct_keys_terminate_burst_with_bounded_storage():
|
||||||
|
"""#6717 re-gate MUST-FIX 1: an ALL-DISTINCT monotonic key sequence
|
||||||
|
(A->B->C->D->... never repeating) must still terminate. The seen-key rule
|
||||||
|
alone only ends the burst on a REPEATED key, so a browser emitting a
|
||||||
|
never-repeating geometry would loop without limit — the #6654 CPU-runaway
|
||||||
|
class under a different trigger — while _messageVirtualMeasurementSeenKeys
|
||||||
|
grows without bound (memory). The absolute per-burst cap ends the burst
|
||||||
|
regardless of key novelty and bounds the seen-key collection to the cap.
|
||||||
|
Driven through the real two-rAF-layer chain with MORE distinct keys than
|
||||||
|
the cap, and asserting the burst stops at exactly the cap with no rAF left
|
||||||
|
queued and the seen-key memory cleared."""
|
||||||
|
source = _measurement_burst_harness("""
|
||||||
|
measurePlan = [];
|
||||||
|
for(let i=0;i<40;i++) measurePlan.push('K'+i); // 40 distinct keys, well over the cap
|
||||||
|
renderMessages({}); // external trigger: starts the burst
|
||||||
|
for(let i=0;i<40;i++) flushRaf(); // drain both rAF layers repeatedly
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
renders: renderLog.length,
|
||||||
|
internal: renderLog.filter(r => r.internal).length,
|
||||||
|
burstActive: _messageVirtualMeasurementBurstActive,
|
||||||
|
seenKeys: _messageVirtualMeasurementSeenKeys,
|
||||||
|
queuedOrigin: _messageVirtualRenderQueuedOrigin,
|
||||||
|
rafPending: rafQueue.length,
|
||||||
|
cap: MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
metrics = json.loads(_run_node(source))
|
||||||
|
cap = metrics["cap"]
|
||||||
|
assert metrics["internal"] == cap, (
|
||||||
|
"an all-distinct sequence must be cut off by the absolute per-burst "
|
||||||
|
f"cap: {metrics['internal']} internal renders (expected exactly {cap})"
|
||||||
|
)
|
||||||
|
assert metrics["renders"] == cap + 1, (
|
||||||
|
"the all-distinct burst must terminate after the cap (1 external + cap "
|
||||||
|
f"internal renders): {metrics['renders']} renders (expected {cap + 1})"
|
||||||
|
)
|
||||||
|
assert metrics["rafPending"] == 0, (
|
||||||
|
"no rAF may remain queued after the cap ends the burst: "
|
||||||
|
f"rafPending {metrics['rafPending']}"
|
||||||
|
)
|
||||||
|
assert metrics["burstActive"] is False, (
|
||||||
|
"the cap must end the burst: "
|
||||||
|
f"burstActive {metrics['burstActive']}"
|
||||||
|
)
|
||||||
|
assert metrics["seenKeys"] == [], (
|
||||||
|
"termination must clear the seen-key memory (bounded storage — at most "
|
||||||
|
f"cap keys were ever retained): seenKeys {metrics['seenKeys']}"
|
||||||
|
)
|
||||||
|
assert metrics["queuedOrigin"] is None, (
|
||||||
|
"no internal provenance may remain queued after the cap ends the burst: "
|
||||||
|
f"queuedOrigin {metrics['queuedOrigin']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_queued_raf_external_scroll_wins_provenance():
|
||||||
|
"""#6717 re-gate MUST-FIX 2: when an external render (user scroll /
|
||||||
|
scroll-settle) overlaps a QUEUED internal rAF, the render that actually
|
||||||
|
fires must be treated as EXTERNAL. The old global consumable flag
|
||||||
|
(_messageVirtualMeasurementRenderPending) could be consumed by an
|
||||||
|
unrelated render, marking external renders as internal so the burst was
|
||||||
|
never reset — leaving stale virtual-window / spacer geometry until the
|
||||||
|
next unrelated render.
|
||||||
|
|
||||||
|
Sequence: external R0 measures A (L2 queued internal); an external R1
|
||||||
|
lands while L2 is pending — it resets the burst, measures B, and its own
|
||||||
|
layer-1 rAF COALESCES into the pending L2 (the early-return path where
|
||||||
|
the old flag leaked). The L2 that fires must be UNMARKED — external
|
||||||
|
provenance wins — so the A it measures starts a fresh burst instead of
|
||||||
|
terminating on the repeated key with zero retry queued."""
|
||||||
|
source = _measurement_burst_harness("""
|
||||||
|
measurePlan = ['A','B','A', undefined];
|
||||||
|
renderMessages({}); // R0 external: measures A -> burst, L1 queued
|
||||||
|
flushRaf(); // L1 -> L2 queued with internal provenance
|
||||||
|
renderMessages({}); // R1 EXTERNAL scroll lands while L2 pending:
|
||||||
|
// resets the burst, measures B -> L1 queued
|
||||||
|
flushRaf(); // B's L1 coalesces into the pending L2 (no new rAF)
|
||||||
|
flushRaf(); // L2 fires -> must be UNMARKED (external won),
|
||||||
|
// measures A -> fresh burst -> L1 queued
|
||||||
|
flushRaf(); flushRaf(); // A's fresh cycle: L2 internal render -> settles
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
renders: renderLog.length,
|
||||||
|
internal: renderLog.filter(r => r.internal).length,
|
||||||
|
r0External: renderLog[0].internal,
|
||||||
|
r1External: renderLog[1].internal,
|
||||||
|
coalescedL2Unmarked: renderLog[2].internal,
|
||||||
|
freshCycleInternal: renderLog[3].internal,
|
||||||
|
burstActive: _messageVirtualMeasurementBurstActive,
|
||||||
|
seenKeys: _messageVirtualMeasurementSeenKeys,
|
||||||
|
queuedOrigin: _messageVirtualRenderQueuedOrigin,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
metrics = json.loads(_run_node(source))
|
||||||
|
assert metrics["renders"] == 4, (
|
||||||
|
"the scroll overlap chain must run to completion: "
|
||||||
|
f"{metrics['renders']} renders (expected 4)"
|
||||||
|
)
|
||||||
|
assert metrics["r0External"] is False and metrics["r1External"] is False, (
|
||||||
|
"R0 and R1 are unmarked external renders"
|
||||||
|
)
|
||||||
|
assert metrics["coalescedL2Unmarked"] is False, (
|
||||||
|
"the queued internal rAF must degrade to an UNMARKED render when an "
|
||||||
|
"external request coalesced into it — external provenance wins and an "
|
||||||
|
"internal marker must never survive onto an external render"
|
||||||
|
)
|
||||||
|
assert metrics["freshCycleInternal"] is True, (
|
||||||
|
"the fresh cycle started by the degraded render must complete with its "
|
||||||
|
"own internal render: the repeated A key must NOT terminate it because "
|
||||||
|
"the burst was reset (zero retry queued would leave stale geometry)"
|
||||||
|
)
|
||||||
|
assert metrics["burstActive"] is False, (
|
||||||
|
"the fresh cycle must end settled: "
|
||||||
|
f"burstActive {metrics['burstActive']}"
|
||||||
|
)
|
||||||
|
assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, (
|
||||||
|
"settlement must clear the seen-key memory and any queued provenance: "
|
||||||
|
f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}"
|
||||||
|
)
|
||||||
@@ -423,6 +423,72 @@ def test_auth_retry_success_does_not_append_error_turn(tmp_path, monkeypatch):
|
|||||||
assert not any(msg.get("_error") for msg in saved.messages)
|
assert not any(msg.get("_error") for msg in saved.messages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_retry_success_observes_replacement_agent_runtime(tmp_path, monkeypatch):
|
||||||
|
from api import run_journal
|
||||||
|
|
||||||
|
session = _prepare_session("auth_runtime", "stream_auth_runtime", pending_user_message="Please retry")
|
||||||
|
Base = _build_auth_failure_agent(token_text="")
|
||||||
|
|
||||||
|
class ReplacementAgent(Base):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.model = "original" if self.__class__.runs == 0 else "healed-model"
|
||||||
|
self.provider = "old-provider" if self.__class__.runs == 0 else "healed-provider"
|
||||||
|
|
||||||
|
monkeypatch.setattr(run_journal, "_default_session_dir", lambda: tmp_path / "sessions")
|
||||||
|
q = queue.Queue()
|
||||||
|
streaming.STREAMS["stream_auth_runtime"] = q
|
||||||
|
config.STREAM_PARTIAL_TEXT["stream_auth_runtime"] = ""
|
||||||
|
heal_rt = {"provider": "test-provider", "api_key": "fresh-key", "base_url": None}
|
||||||
|
with mock.patch.object(streaming, "get_session", return_value=session), \
|
||||||
|
mock.patch.object(streaming, "_get_ai_agent", return_value=ReplacementAgent), \
|
||||||
|
mock.patch.object(streaming, "resolve_model_provider", return_value=("test-model", "test-provider", None)), \
|
||||||
|
mock.patch("api.config.get_config", return_value={}), \
|
||||||
|
mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \
|
||||||
|
mock.patch.object(streaming, "_attempt_credential_self_heal", return_value=heal_rt):
|
||||||
|
streaming._run_agent_streaming(session.session_id, "Please retry", "test-model", str(tmp_path), "stream_auth_runtime")
|
||||||
|
|
||||||
|
events = _queue_events(q)
|
||||||
|
assert any(name == "done" for name, _ in events)
|
||||||
|
assert [(item["model"], item["provider"]) for name, item in events if name == "runtime_model"] == [
|
||||||
|
("healed-model", "healed-provider")]
|
||||||
|
summary = run_journal.latest_run_summary("auth_runtime", "stream_auth_runtime", session_dir=tmp_path / "sessions")
|
||||||
|
assert summary["runtime_model"]["model"] == "healed-model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_exception_retry_observes_replacement_agent(tmp_path, monkeypatch):
|
||||||
|
session = _prepare_session("auth_exception_runtime", "stream_auth_exception_runtime", pending_user_message="Retry")
|
||||||
|
|
||||||
|
class RetryAgent(MockAgent):
|
||||||
|
runs = 0
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.model = "original" if type(self).runs == 0 else "retry-model"
|
||||||
|
self.provider = "original-provider" if type(self).runs == 0 else "retry-provider"
|
||||||
|
def run_conversation(self, **kwargs):
|
||||||
|
type(self).runs += 1
|
||||||
|
if type(self).runs == 1:
|
||||||
|
raise RuntimeError("401 unauthorized")
|
||||||
|
return {"messages": list(kwargs.get("conversation_history") or []) + [
|
||||||
|
{"role": "assistant", "content": "Recovered answer"}]}
|
||||||
|
|
||||||
|
q = queue.Queue()
|
||||||
|
streaming.STREAMS["stream_auth_exception_runtime"] = q
|
||||||
|
config.STREAM_PARTIAL_TEXT["stream_auth_exception_runtime"] = ""
|
||||||
|
heal_rt = {"provider": "test-provider", "api_key": "fresh-key", "base_url": None}
|
||||||
|
with mock.patch.object(streaming, "get_session", return_value=session), \
|
||||||
|
mock.patch.object(streaming, "_get_ai_agent", return_value=RetryAgent), \
|
||||||
|
mock.patch.object(streaming, "resolve_model_provider", return_value=("test-model", "test-provider", None)), \
|
||||||
|
mock.patch("api.config.get_config", return_value={}), \
|
||||||
|
mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \
|
||||||
|
mock.patch.object(streaming, "_attempt_credential_self_heal", return_value=heal_rt):
|
||||||
|
streaming._run_agent_streaming(session.session_id, "Retry", "test-model", str(tmp_path), "stream_auth_exception_runtime")
|
||||||
|
events = _queue_events(q)
|
||||||
|
assert any(name == "done" for name, _ in events)
|
||||||
|
assert [(item["model"], item["provider"]) for name, item in events if name == "runtime_model"] == [
|
||||||
|
("retry-model", "retry-provider")]
|
||||||
|
|
||||||
|
|
||||||
def test_auth_exception_retry_structured_failure_still_emits_error(
|
def test_auth_exception_retry_structured_failure_still_emits_error(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ function buildRuntime() {
|
|||||||
globalThis.removeThinking = () => calls.push('removeThinking');
|
globalThis.removeThinking = () => calls.push('removeThinking');
|
||||||
globalThis._flushReasoningToAnchor = () => calls.push('flushReasoning');
|
globalThis._flushReasoningToAnchor = () => calls.push('flushReasoning');
|
||||||
globalThis._applyToAnchor = () => calls.push('applyToAnchor');
|
globalThis._applyToAnchor = () => calls.push('applyToAnchor');
|
||||||
globalThis._attachProjectedAnchorSceneToLastAssistant = () => calls.push('attachProjected');
|
|
||||||
globalThis._hydrateTodosFromSession = () => calls.push('hydrateTodos');
|
globalThis._hydrateTodosFromSession = () => calls.push('hydrateTodos');
|
||||||
globalThis._scheduleAnchorRegistryCleanup = () => calls.push('scheduleAnchorRegistryCleanup');
|
globalThis._scheduleAnchorRegistryCleanup = () => calls.push('scheduleAnchorRegistryCleanup');
|
||||||
globalThis._smdEndParser = () => calls.push('smdEndParser');
|
globalThis._smdEndParser = () => calls.push('smdEndParser');
|
||||||
@@ -141,6 +140,15 @@ function buildRuntime() {
|
|||||||
globalThis._messageRenderWindowSize = 20;
|
globalThis._messageRenderWindowSize = 20;
|
||||||
globalThis._streamFinalized = !!scenario.streamFinalized;
|
globalThis._streamFinalized = !!scenario.streamFinalized;
|
||||||
globalThis._persistTimer = null;
|
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.api = async () => scenario.apiPayload || { session: null };
|
||||||
globalThis.msgContent = undefined;
|
globalThis.msgContent = undefined;
|
||||||
globalThis._isPreservedCompressionTaskListMarkerOnlyText = () => false;
|
globalThis._isPreservedCompressionTaskListMarkerOnlyText = () => false;
|
||||||
@@ -162,6 +170,10 @@ function buildRuntime() {
|
|||||||
status,
|
status,
|
||||||
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
terminalMarkerCount,
|
terminalMarkerCount,
|
||||||
|
oldestIdx: globalThis._oldestIdx,
|
||||||
|
oldestIdxAtAttach: globalThis.oldestIdxAtAttach,
|
||||||
|
messagesTruncated: !!globalThis._messagesTruncated,
|
||||||
|
messagesTruncatedAtAttach: !!globalThis.messagesTruncatedAtAttach,
|
||||||
calls,
|
calls,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
@@ -179,6 +191,10 @@ function buildRuntime() {
|
|||||||
status,
|
status,
|
||||||
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
terminalMarkerCount,
|
terminalMarkerCount,
|
||||||
|
oldestIdx: globalThis._oldestIdx,
|
||||||
|
oldestIdxAtAttach: globalThis.oldestIdxAtAttach,
|
||||||
|
messagesTruncated: !!globalThis._messagesTruncated,
|
||||||
|
messagesTruncatedAtAttach: !!globalThis.messagesTruncatedAtAttach,
|
||||||
calls,
|
calls,
|
||||||
}));
|
}));
|
||||||
return;
|
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["terminalMarkerCount"] == 1, f"terminal marker should be deduped to one, got {outcome['terminalMarkerCount']}"
|
||||||
assert outcome["messages"][-1]["content"].startswith("**Connection interrupted:**")
|
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.setenv('HERMES_CONFIG_PATH', str(override_path))
|
||||||
monkeypatch.setattr(profiles, 'get_active_hermes_home', lambda: active_home)
|
monkeypatch.setattr(profiles, 'get_active_hermes_home', lambda: active_home)
|
||||||
monkeypatch.setattr(routes, '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()
|
config.reload_config()
|
||||||
|
|
||||||
h = _make_handler()
|
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():
|
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():
|
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 _selectedModelState=(typeof _modelStateForSelect==='function')?_modelStateForSelect(sel,sel.value)" in UI_JS
|
||||||
assert "const _modelProviderForSelectedBadge=(m)=>" in UI_JS
|
assert "const _modelProviderForSelectedBadge=(m)=>" in UI_JS
|
||||||
assert "return (_provider&&_provider!=='default')?_provider:null;" 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
|
# Provider identity still gates the row match — the normalized model
|
||||||
assert "const _isSelectedModelRow=(m)=>" in UI_JS
|
# 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
|
assert "row.className='model-opt'+(_isSelectedModelRow(m)?' active':'');" in UI_JS
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -140,10 +140,11 @@ console.log(JSON.stringify({{
|
|||||||
|
|
||||||
def test_streaming_stamps_used_model_on_assistant_message_and_usage_payload():
|
def test_streaming_stamps_used_model_on_assistant_message_and_usage_payload():
|
||||||
assert "_dm['_usedModel'] = _used_model" in STREAMING_PY
|
assert "_dm['_usedModel'] = _used_model" in STREAMING_PY
|
||||||
# The served model must be read from the agent AFTER the run — the agent
|
# Read the served model from the agent after the run (including fallback),
|
||||||
# mutates agent.model when a fallback fires, so the pre-run resolved_model
|
# without treating a requested/configured selection as proof of service.
|
||||||
# would mis-attribute fallback turns.
|
assert "_observed_model = getattr(agent, 'model', None)" in STREAMING_PY
|
||||||
assert "_used_model = getattr(agent, 'model', None) or resolved_model or model" in STREAMING_PY
|
assert "_used_model = _observed_model.strip() if isinstance(_observed_model, str) else None" in STREAMING_PY
|
||||||
|
assert "_used_model = getattr(agent, 'model', None) or resolved_model or model" not in STREAMING_PY
|
||||||
assert "usage['used_model'] = _used_model" in STREAMING_PY
|
assert "usage['used_model'] = _used_model" in STREAMING_PY
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
|
assert "border-radius:999px" in CSS
|
||||||
|
|
||||||
|
|
||||||
def test_measurement_rerenders_are_bounded_per_virtual_window_cycle():
|
def test_measurement_rerenders_are_cycle_aware_per_virtual_window_burst():
|
||||||
assert "const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=2;" in UI_JS
|
|
||||||
assert "function _messageVirtualMeasurementCycleKeyFor(windowMetrics)" in UI_JS
|
assert "function _messageVirtualMeasurementCycleKeyFor(windowMetrics)" in UI_JS
|
||||||
assert "function _scheduleMessageVirtualMeasurementRefresh(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 "_scheduleMessageVirtualMeasurementRefresh(virtualWindow);" in UI_JS
|
||||||
assert "_markMessageVirtualMeasurementsSettled(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 || []);")
|
filter_idx = restore.index("S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);")
|
||||||
attach_idx = restore.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);")
|
attach_idx = restore.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);")
|
||||||
render_idx = restore.index("syncTopbar();renderMessages({preserveScroll:true})")
|
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():
|
def test_cancel_settlement_attaches_projected_anchor_scene_before_render():
|
||||||
cancel = _event_listener_body(MESSAGES_JS, "cancel")
|
cancel = _event_listener_body(MESSAGES_JS, "cancel")
|
||||||
|
|
||||||
fetch_idx = cancel.index("const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);")
|
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);")
|
attach_idx = cancel.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);")
|
||||||
carry_idx = cancel.index("S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);")
|
carry_idx = cancel.index("S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);")
|
||||||
render_idx = cancel.index("renderMessages({preserveScroll:true});")
|
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;")
|
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);")
|
fallback_apply_idx = cancel.index("if(data&&data.session) _applyCancelSessionPayload(data.session);")
|
||||||
assert embedded_idx < fallback_get_idx < fallback_apply_idx
|
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"
|
"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():
|
def test_mobile_enter_newline_only_overrides_enter_default():
|
||||||
"""Mobile newline override must only apply when _sendKey is the default 'enter'."""
|
"""Mobile newline override must only apply when _sendKey is the default 'enter'."""
|
||||||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
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;
|
let _visWithIdxCacheSrc = null;
|
||||||
|
|
||||||
const heightStart = src.indexOf('const MESSAGE_RENDER_WINDOW_DEFAULT');
|
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(heightStart !== -1 && heightEnd !== -1) eval(src.slice(heightStart, heightEnd));
|
||||||
if(src.indexOf('function _isProcessWakeupMessage') !== -1) eval(extractFunc('_isProcessWakeupMessage'));
|
if(src.indexOf('function _isProcessWakeupMessage') !== -1) eval(extractFunc('_isProcessWakeupMessage'));
|
||||||
eval(extractFunc('_stripWorkspaceDisplayPrefix'));
|
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 "_completionNotificationPreviewText(lastAsst," in MESSAGES_JS
|
||||||
assert "sendBrowserNotification('Response complete',_completionPreview||'Task finished',{forceHidden:_wasEverBackgrounded,sid:activeSid})" 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 "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():
|
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()
|
ui_src = (REPO_ROOT / "static/ui.js").read_text()
|
||||||
fn_start = ui_src.find("function renderMessages")
|
fn_start = ui_src.find("function renderMessages")
|
||||||
assert fn_start >= 0, "renderMessages() not found in ui.js"
|
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)
|
compact = re.sub(r"\s+", "", fn_body)
|
||||||
assert (
|
assert (
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Observed Agent runtime, durable replay and HTTP reconnect projection."""
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.run_journal import RunJournalWriter, latest_run_summary, read_run_events
|
||||||
|
|
||||||
|
|
||||||
|
def observation(model="backup", *, session_id="session-a", stream_id="run-a"):
|
||||||
|
return {"session_id": session_id, "stream_id": stream_id, "model": model,
|
||||||
|
"provider": "custom", "fallback_active": True, "phase": "observed_output"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_journal_runtime_is_owned_and_invalidated(tmp_path):
|
||||||
|
writer = RunJournalWriter("session-a", "run-a", session_dir=tmp_path)
|
||||||
|
writer.append_sse_event("token", {"text": "answer"})
|
||||||
|
first = writer.append_sse_event("runtime_model", observation())
|
||||||
|
assert read_run_events("session-a", "run-a", session_dir=tmp_path)["events"][-1] == first
|
||||||
|
summary = latest_run_summary("session-a", "run-a", session_dir=tmp_path)
|
||||||
|
assert summary["runtime_model"] == observation()
|
||||||
|
summary["runtime_model"]["model"] = "corrupted"
|
||||||
|
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] == observation()
|
||||||
|
writer.append_sse_event("warning", {"type": "fallback", "message": "Trying another route"})
|
||||||
|
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] is None
|
||||||
|
writer.append_sse_event("runtime_model", {**observation(), "stream_id": "foreign"})
|
||||||
|
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] is None
|
||||||
|
writer.append_sse_event("runtime_model", observation("recovered"))
|
||||||
|
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"]["model"] == "recovered"
|
||||||
|
writer.append_sse_event("runtime_model", "malformed observation")
|
||||||
|
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] is None
|
||||||
|
writer.append_sse_event("runtime_model", observation("recovered-again"))
|
||||||
|
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"]["model"] == "recovered-again"
|
||||||
|
assert latest_run_summary("session-a", "another-run", session_dir=tmp_path)["runtime_model"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("malformed", ["bad observation", ["bad observation"], None])
|
||||||
|
def test_non_object_observation_invalidates_summary_and_http_snapshot(tmp_path, monkeypatch, malformed):
|
||||||
|
from api import routes, run_journal
|
||||||
|
writer = RunJournalWriter("session-a", "run-a", session_dir=tmp_path)
|
||||||
|
writer.append_sse_event("runtime_model", observation("first"))
|
||||||
|
writer.append_sse_event("token", malformed)
|
||||||
|
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"]["model"] == "first"
|
||||||
|
writer.append_sse_event("runtime_model", malformed if malformed is not None else {"placeholder": True})
|
||||||
|
if malformed is None:
|
||||||
|
# The writer normalizes None to {}; exercise a legacy/raw null journal row.
|
||||||
|
path = run_journal._run_path("session-a", "run-a", session_dir=tmp_path)
|
||||||
|
rows = [json.loads(line) for line in path.read_text().splitlines()]
|
||||||
|
rows[-1]["payload"] = None
|
||||||
|
path.write_text("\n".join(json.dumps(row) for row in rows) + "\n")
|
||||||
|
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] is None
|
||||||
|
monkeypatch.setattr(routes, "find_run_summary", lambda rid: latest_run_summary("session-a", rid, session_dir=tmp_path))
|
||||||
|
monkeypatch.setattr(routes, "read_run_events", lambda sid, rid: read_run_events(sid, rid, session_dir=tmp_path))
|
||||||
|
snapshot = routes._run_journal_live_snapshot("run-a")
|
||||||
|
assert snapshot["runtime_model"] is None
|
||||||
|
assert routes._runtime_journal_snapshot_for_session_payload(snapshot)["runtime_model"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("suffix,expected", [("", "backup"), ("warning", None), ("foreign", None)])
|
||||||
|
def test_http_snapshot_projects_exact_journal_window(tmp_path, monkeypatch, suffix, expected):
|
||||||
|
import api.routes as routes
|
||||||
|
writer = RunJournalWriter("session-a", "run-a", session_dir=tmp_path)
|
||||||
|
writer.append_sse_event("runtime_model", observation())
|
||||||
|
writer.append_sse_event("token", {"text": "answer"})
|
||||||
|
if suffix == "warning":
|
||||||
|
writer.append_sse_event("warning", {"type": "fallback"})
|
||||||
|
elif suffix == "foreign":
|
||||||
|
writer.append_sse_event("runtime_model", {**observation(), "session_id": "foreign"})
|
||||||
|
monkeypatch.setattr(routes, "find_run_summary", lambda rid: latest_run_summary("session-a", rid, session_dir=tmp_path))
|
||||||
|
monkeypatch.setattr(routes, "read_run_events", lambda sid, rid: read_run_events(sid, rid, session_dir=tmp_path))
|
||||||
|
snapshot = routes._run_journal_live_snapshot("run-a")
|
||||||
|
assert snapshot["last_assistant_text"] == "answer"
|
||||||
|
value = snapshot["runtime_model"]
|
||||||
|
assert (value["model"] if value else None) == expected
|
||||||
|
assert routes._runtime_journal_snapshot_for_session_payload(snapshot)["runtime_model"] == value
|
||||||
|
|
||||||
|
|
||||||
|
def run_worker(steps, *, fail=False, ephemeral=False, tmp_path=None, monkeypatch=None):
|
||||||
|
"""Drive the production local worker; fake only external Agent/provider calls."""
|
||||||
|
from api import streaming, run_journal
|
||||||
|
from api.config import SESSION_AGENT_CACHE
|
||||||
|
SESSION_AGENT_CACHE.clear()
|
||||||
|
monkeypatch.setattr(run_journal, "_default_session_dir", lambda: tmp_path)
|
||||||
|
class Session:
|
||||||
|
session_id = "session-a"
|
||||||
|
title = "Session"
|
||||||
|
workspace = "/tmp"
|
||||||
|
model = "primary"
|
||||||
|
model_provider = "anthropic"
|
||||||
|
profile = None
|
||||||
|
personality = None
|
||||||
|
messages = [{"role": "user", "content": "old"}, {"role": "assistant", "content": "old answer"}]
|
||||||
|
context_messages = messages
|
||||||
|
input_tokens = output_tokens = cache_read_tokens = cache_write_tokens = 0
|
||||||
|
estimated_cost = 0.0
|
||||||
|
tool_calls = []
|
||||||
|
gateway_routing = None
|
||||||
|
gateway_routing_history = []
|
||||||
|
active_stream_id = "run-a"
|
||||||
|
pending_user_message = None
|
||||||
|
pending_attachments = []
|
||||||
|
pending_started_at = None
|
||||||
|
context_length = threshold_tokens = last_prompt_tokens = 0
|
||||||
|
llm_title_generated = True
|
||||||
|
def save(self, *args, **kwargs): pass
|
||||||
|
def compact(self): return {"session_id": self.session_id}
|
||||||
|
|
||||||
|
class Agent:
|
||||||
|
def __init__(self, model=None, provider=None, session_id=None,
|
||||||
|
stream_delta_callback=None, reasoning_callback=None,
|
||||||
|
status_callback=None, **kwargs):
|
||||||
|
self.model, self.provider, self.session_id = model, provider, session_id
|
||||||
|
self.stream_delta_callback = stream_delta_callback
|
||||||
|
self.reasoning_callback = reasoning_callback
|
||||||
|
self.status_callback = status_callback
|
||||||
|
self._provider_fallback_active = False
|
||||||
|
self.context_compressor = None
|
||||||
|
self.session_prompt_tokens = self.session_completion_tokens = 0
|
||||||
|
self.session_estimated_cost_usd = None
|
||||||
|
self.session_cache_read_tokens = self.session_cache_write_tokens = 0
|
||||||
|
self.reasoning_config = self.ephemeral_system_prompt = self._last_error = None
|
||||||
|
def run_conversation(self, **kwargs):
|
||||||
|
for model, provider, fallback, channel, text in steps:
|
||||||
|
self.model, self.provider, self._provider_fallback_active = model, provider, fallback
|
||||||
|
if channel == "token": self.stream_delta_callback(text)
|
||||||
|
elif channel == "reasoning": self.reasoning_callback(text)
|
||||||
|
elif channel == "status": self.status_callback("lifecycle", text)
|
||||||
|
if fail: return {"error": "provider unavailable", "messages": []}
|
||||||
|
return {"messages": kwargs.get("conversation_history", []) + [
|
||||||
|
{"role": "user", "content": kwargs["persist_user_message"]},
|
||||||
|
{"role": "assistant", "content": "served answer"}]}
|
||||||
|
def interrupt(self, message): pass
|
||||||
|
|
||||||
|
runtime_module = types.ModuleType("hermes_cli.runtime_provider")
|
||||||
|
runtime_module.resolve_runtime_provider = mock.Mock(return_value={
|
||||||
|
"provider": "anthropic", "base_url": None, "api_key": "sk-test",
|
||||||
|
"api_mode": "chat_completions", "command": None, "args": [], "credential_pool": None})
|
||||||
|
cli_module = types.ModuleType("hermes_cli")
|
||||||
|
cli_module.runtime_provider = runtime_module
|
||||||
|
state_module = types.ModuleType("hermes_state")
|
||||||
|
state_module.SessionDB = mock.Mock(return_value=None)
|
||||||
|
injected = {"hermes_cli": cli_module, "hermes_cli.runtime_provider": runtime_module,
|
||||||
|
"hermes_state": state_module}
|
||||||
|
sentinel = object()
|
||||||
|
saved = {name: sys.modules.get(name, sentinel) for name in injected}
|
||||||
|
sys.modules.update(injected)
|
||||||
|
q = queue.Queue()
|
||||||
|
s = Session()
|
||||||
|
try:
|
||||||
|
with mock.patch.object(streaming, "get_session", return_value=s), \
|
||||||
|
mock.patch.object(streaming, "_get_ai_agent", return_value=Agent), \
|
||||||
|
mock.patch.object(streaming, "resolve_model_provider", return_value=("primary", "anthropic", None)), \
|
||||||
|
mock.patch("api.config.get_config", return_value={}), \
|
||||||
|
mock.patch("api.config._resolve_cli_toolsets", return_value=[]):
|
||||||
|
streaming.STREAMS["run-a"] = q
|
||||||
|
streaming._run_agent_streaming("session-a", "new turn", "primary", "/tmp", "run-a", ephemeral=ephemeral)
|
||||||
|
finally:
|
||||||
|
for name, original in saved.items():
|
||||||
|
if original is sentinel: sys.modules.pop(name, None)
|
||||||
|
else: sys.modules[name] = original
|
||||||
|
streaming.STREAMS.pop("run-a", None)
|
||||||
|
SESSION_AGENT_CACHE.clear()
|
||||||
|
return s, list(q.queue)
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_observes_serving_runtime_not_requested(tmp_path, monkeypatch):
|
||||||
|
s, events = run_worker([
|
||||||
|
("primary", "anthropic", False, "reasoning", "thinking"),
|
||||||
|
("backup", "custom", True, "token", "answer"),
|
||||||
|
("backup", "custom", True, "status", "Switched to fallback: backup (custom)"),
|
||||||
|
], tmp_path=tmp_path, monkeypatch=monkeypatch)
|
||||||
|
names = [name for name, _ in events]
|
||||||
|
assert names.count("runtime_model") == 3 # primary, backup, post-warning correction
|
||||||
|
observed = [value for name, value in events if name == "runtime_model"]
|
||||||
|
assert [value["model"] for value in observed] == ["primary", "backup", "backup"]
|
||||||
|
assert observed[-1] == observation()
|
||||||
|
assert names.index("runtime_model") < names.index("reasoning")
|
||||||
|
assert names.index("warning") < len(names) - 1 - names[::-1].index("runtime_model")
|
||||||
|
assert latest_run_summary("session-a", "run-a", session_dir=tmp_path)["runtime_model"] == observation()
|
||||||
|
assert s.model == "primary" # selection is not overwritten
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_does_not_observe_attempt_or_missing_model(tmp_path, monkeypatch):
|
||||||
|
_, events = run_worker([
|
||||||
|
("backup", "custom", True, "status", "Trying fallback backup"),
|
||||||
|
(None, "custom", True, "token", "partial"),
|
||||||
|
], fail=True, tmp_path=tmp_path, monkeypatch=monkeypatch)
|
||||||
|
assert not any(name == "runtime_model" for name, _ in events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_agent_model_does_not_stamp_requested_as_served(tmp_path, monkeypatch):
|
||||||
|
s, events = run_worker([(None, "custom", True, "token", "partial")],
|
||||||
|
tmp_path=tmp_path, monkeypatch=monkeypatch)
|
||||||
|
assert not any(name == "runtime_model" for name, _ in events)
|
||||||
|
assert "_usedModel" not in s.messages[-1]
|
||||||
|
usage = next(value["usage"] for name, value in events if name == "done")
|
||||||
|
assert not usage.get("used_model")
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_nonstreaming_success_observes_fallback(tmp_path, monkeypatch):
|
||||||
|
_, events = run_worker([("backup", "custom", True, "route", "")],
|
||||||
|
tmp_path=tmp_path, monkeypatch=monkeypatch, ephemeral=True)
|
||||||
|
assert [value["model"] for name, value in events if name == "runtime_model"] == ["backup"]
|
||||||
@@ -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["parent_session_id"] == "parent-1"
|
||||||
assert data["source_tag"] == "webui"
|
assert data["source_tag"] == "webui"
|
||||||
assert data["session_source"] == "webui"
|
assert data["session_source"] == "webui"
|
||||||
|
assert data["message_count"] == 2
|
||||||
assert [m["content"] for m in data["messages"]] == ["message 1", "message 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):
|
def test_recover_missing_sidecars_from_state_db_skips_deleted_webui_tombstone(tmp_path, monkeypatch):
|
||||||
import api.models as _m
|
import api.models as _m
|
||||||
monkeypatch.setattr(_m, "SESSION_DIR", tmp_path)
|
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"
|
||||||
@@ -67,7 +67,22 @@ def _build_status_callback():
|
|||||||
for node in ast.walk(tree)
|
for node in ast.walk(tree)
|
||||||
if isinstance(node, ast.FunctionDef) and node.name == "_agent_status_callback"
|
if isinstance(node, ast.FunctionDef) and node.name == "_agent_status_callback"
|
||||||
)
|
)
|
||||||
module = ast.Module(body=[fn], type_ignores=[])
|
# The callback is a closure inside ``_run_agent_streaming`` and may declare
|
||||||
|
# ``nonlocal`` state (e.g. the runtime-model dedupe identity it resets on a
|
||||||
|
# fallback warning). Compiled alone at module level, ``nonlocal`` has no
|
||||||
|
# binding and raises SyntaxError, so wrap it in an enclosing function that
|
||||||
|
# binds every nonlocal name it declares, then return the inner callback.
|
||||||
|
nonlocals = sorted({
|
||||||
|
name
|
||||||
|
for node in ast.walk(fn)
|
||||||
|
if isinstance(node, ast.Nonlocal)
|
||||||
|
for name in node.names
|
||||||
|
})
|
||||||
|
binds = "".join(f" {name} = None\n" for name in nonlocals)
|
||||||
|
outer = ast.parse(f"def _status_callback_scope():\n{binds} pass\n").body[0]
|
||||||
|
assert isinstance(outer, ast.FunctionDef)
|
||||||
|
outer.body[-1:] = [fn, ast.Return(value=ast.Name(id=fn.name, ctx=ast.Load()))]
|
||||||
|
module = ast.fix_missing_locations(ast.Module(body=[outer], type_ignores=[]))
|
||||||
events = []
|
events = []
|
||||||
ns = {
|
ns = {
|
||||||
"put": lambda event, data: events.append((event, data)),
|
"put": lambda event, data: events.append((event, data)),
|
||||||
@@ -78,7 +93,7 @@ def _build_status_callback():
|
|||||||
"_is_session_lease_wait_message": streaming._is_session_lease_wait_message,
|
"_is_session_lease_wait_message": streaming._is_session_lease_wait_message,
|
||||||
}
|
}
|
||||||
exec(compile(module, "streaming_status_callback", "exec"), ns)
|
exec(compile(module, "streaming_status_callback", "exec"), ns)
|
||||||
return ns["_agent_status_callback"], events
|
return ns["_status_callback_scope"](), events
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -925,6 +925,9 @@ eval(extractFunc('_sessionLineageKey'));
|
|||||||
eval(extractFunc('_sidebarLineageKeyForRow'));
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
||||||
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
||||||
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
||||||
|
eval(extractFunc('_isDelegatedSubagentRow'));
|
||||||
|
eval(extractFunc('_sidebarProjectResolver'));
|
||||||
|
eval(extractFunc('_sidebarRowsById'));
|
||||||
eval(extractFunc('_scopedSidebarReferenceRows'));
|
eval(extractFunc('_scopedSidebarReferenceRows'));
|
||||||
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
|
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'}};
|
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('_sidebarLineageKeyForRow'));
|
||||||
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
||||||
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
||||||
|
eval(extractFunc('_isDelegatedSubagentRow'));
|
||||||
|
eval(extractFunc('_sidebarProjectResolver'));
|
||||||
|
eval(extractFunc('_sidebarRowsById'));
|
||||||
eval(extractFunc('_scopedSidebarReferenceRows'));
|
eval(extractFunc('_scopedSidebarReferenceRows'));
|
||||||
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
|
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'}};
|
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'}),
|
model_config=json.dumps({'_delegate_from': 'some-other-session'}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Unparsable model_config degrades to no markers (no crash, no match).
|
# Unparsable model_config is untrusted lineage identity: it fails closed
|
||||||
assert _is_continuation_session(
|
# as a boundary (no crash), even inside the tolerance window.
|
||||||
|
assert not _is_continuation_session(
|
||||||
make_parent(),
|
make_parent(),
|
||||||
make_child(started_at=999.95, model_config='not-json'),
|
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'))
|
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):
|
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
|
"""#7021 r2: the open/import transcript stitcher (get_state_db_session_messages
|
||||||
with stitch_continuations=True) must apply the same model_config branch-marker
|
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
|
assert "parent turn" in contents
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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"
|
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):
|
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.
|
"""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"
|
sid = "state_sync_save_fail_sid"
|
||||||
stream_id = "stream_save_fail_no_done"
|
stream_id = "stream_save_fail_no_done"
|
||||||
|
attachment = {"name": "sample.png", "mime": "image/png", "is_image": True}
|
||||||
s = Session(
|
s = Session(
|
||||||
session_id=sid,
|
session_id=sid,
|
||||||
title="Save failure",
|
title="Save failure",
|
||||||
@@ -893,7 +1057,10 @@ def test_sync_save_failure_does_not_mutate_session(monkeypatch):
|
|||||||
],
|
],
|
||||||
active_stream_id=stream_id,
|
active_stream_id=stream_id,
|
||||||
pending_user_message="new request",
|
pending_user_message="new request",
|
||||||
|
pending_attachments=[attachment],
|
||||||
pending_started_at=102.0,
|
pending_started_at=102.0,
|
||||||
|
pending_user_source="webui",
|
||||||
|
_webui_pending_user_timestamp_identity=(stream_id, 102.0),
|
||||||
)
|
)
|
||||||
s.save()
|
s.save()
|
||||||
|
|
||||||
@@ -926,7 +1093,13 @@ def test_sync_save_failure_does_not_mutate_session(monkeypatch):
|
|||||||
assert result is False
|
assert result is False
|
||||||
assert s.active_stream_id == stream_id
|
assert s.active_stream_id == stream_id
|
||||||
assert s.pending_user_message == "new request"
|
assert s.pending_user_message == "new request"
|
||||||
|
assert s.pending_attachments == [attachment]
|
||||||
assert [m["content"] for m in s.messages] == ["old question", "old answer"]
|
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):
|
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")
|
render_body = _function_block("renderSessionListFromCache")
|
||||||
|
|
||||||
assert "_partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in render_body
|
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 renderedWebuiSessionCount=_serverWebuiSessionCount===null" in render_body
|
||||||
assert "const renderedCliSessionCount=_serverCliSessionCount===null" in render_body
|
assert "const renderedCliSessionCount=_serverCliSessionCount===null" in render_body
|
||||||
assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length" in render_body
|
assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false, projectIdFor)]).length" in render_body
|
||||||
assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length" in render_body
|
assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true, projectIdFor)]).length" in render_body
|
||||||
assert ": null;" in render_body
|
assert ": null;" in render_body
|
||||||
assert "null is a deliberate \"not computed\" sentinel" in render_body
|
assert "null is a deliberate \"not computed\" sentinel" in render_body
|
||||||
assert "const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);" 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 "cliSessionsRaw," in _partition_block()
|
||||||
assert "const renderedWebuiSessionCount=_serverWebuiSessionCount===null" in render_body
|
assert "const renderedWebuiSessionCount=_serverWebuiSessionCount===null" in render_body
|
||||||
assert "const renderedCliSessionCount=_serverCliSessionCount===null" in render_body
|
assert "const renderedCliSessionCount=_serverCliSessionCount===null" in render_body
|
||||||
assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length" in render_body
|
assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false, projectIdFor)]).length" in render_body
|
||||||
assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length" in render_body
|
assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true, projectIdFor)]).length" in render_body
|
||||||
assert "function _countRenderedSidebarRowsFromRawSessions" not in SESSIONS_JS
|
assert "function _countRenderedSidebarRowsFromRawSessions" not in SESSIONS_JS
|
||||||
assert "function _renderSidebarRowsFromRawSessions(sessionsRaw, referenceSessionsRaw){" in SESSIONS_JS
|
assert "function _renderSidebarRowsFromRawSessions(sessionsRaw, referenceSessionsRaw){" in SESSIONS_JS
|
||||||
assert "_attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw, referenceRows)" 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, (
|
assert "if(_activeProject===NO_PROJECT_FILTER){" in js, (
|
||||||
"The Unassigned filter must select sessions without a project_id"
|
"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"
|
"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).
|
— though that's vanishingly rare).
|
||||||
"""
|
"""
|
||||||
js = _js()
|
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"
|
"The render function must compute whether unassigned sessions exist"
|
||||||
)
|
)
|
||||||
assert "if(_allProjects.length>0||hasUnprojected){" in js, (
|
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_chat_backend_mode,
|
||||||
webui_gateway_chat_enabled,
|
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():
|
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)
|
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):
|
def test_gateway_chat_worker_classifies_terminal_provider_error_without_text(tmp_path, monkeypatch):
|
||||||
"""Gateway terminal errors must survive an empty assistant stream."""
|
"""Gateway terminal errors must survive an empty assistant stream."""
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|||||||
Reference in new issue
Block a user