Merge current master while preserving recoverable hidden polling (#7299)

This commit is contained in:
laitekin committed 2026-09-23 12:18:23 +08:00
commit a0e5e32e46
55 files changed
+5389 -281

No files matched your search

+7
View File
@@ -132,6 +132,13 @@
# Extra sources for the notes drawer (see docs for the expected format).
# HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=
# Sidebar recency window: how many recent sessions are listed (default: 20).
# Also caps how many delegated subagent children can nest at once; raise it
# for wide fan-outs. Invalid or non-positive values fall back to 20.
# Values above 200 are clamped. Resolved before profile init, so a profile
# .env cannot override this server-wide bound.
# HERMES_WEBUI_VISIBLE_SESSION_LIMIT=20
# Directory scanned for WebUI plugins (default: ~/.hermes/plugins).
# HERMES_WEBUI_PLUGINS_DIR=~/.hermes/plugins
+8 -1
View File
@@ -13,6 +13,7 @@ on:
- 'server.py'
- 'tests/browser_conversation_lifecycle.py'
- 'tests/browser_historical_transcript_hydration.py'
- 'tests/browser_reconnect_scene_redraw.py'
- 'requirements*.txt'
- 'pyproject.toml'
- '.github/workflows/conversation-lifecycle.yml'
@@ -24,6 +25,7 @@ on:
- 'server.py'
- 'tests/browser_conversation_lifecycle.py'
- 'tests/browser_historical_transcript_hydration.py'
- 'tests/browser_reconnect_scene_redraw.py'
- 'requirements*.txt'
- 'pyproject.toml'
- '.github/workflows/conversation-lifecycle.yml'
@@ -32,7 +34,8 @@ jobs:
live-to-final:
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
# Keep existing proof rows informational; reconnect regressions fail CI.
continue-on-error: ${{ matrix.name != 'reconnect-scene-redraw' }}
strategy:
fail-fast: false
matrix:
@@ -45,6 +48,9 @@ jobs:
scenario: terminal-error
- name: historical-transcript-hydration
script: tests/browser_historical_transcript_hydration.py
- name: reconnect-scene-redraw
script: tests/browser_reconnect_scene_redraw.py
browsers: chromium
steps:
- uses: actions/checkout@v4
@@ -69,6 +75,7 @@ jobs:
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts
HISTORICAL_HYDRATION_ARTIFACT_DIR: lifecycle-artifacts
LIFECYCLE_SCENARIO: ${{ matrix.scenario }}
BROWSERS: ${{ matrix.browsers }}
run: python ${{ matrix.script }}
- name: Run settlement frame proof
+23
View File
@@ -237,6 +237,29 @@ Session is a plain Python class (not a dataclass, not SQLAlchemy):
title_from(): takes messages list, finds first user message, returns first 64 chars.
Called after run_conversation() completes to set the session title retroactively.
#### Session transcript reconciliation with `state.db`
`reconciled_state_db_messages_for_session()` uses
`merge_session_messages_append_only()` to combine a WebUI sidecar or context
projection with active Agent `state.db` rows. Its explicit
`incoming_provenance="state_db"` fence permits a state-only row whose timestamp
predates the sidecar tail to use a safe chronological slot. Paginated
`msg_limit` consumers rely on this merged order directly rather than applying a
later timestamp sort.
The same merge helper also stitches ordered child sidecars onto archived
compression parents. Those calls leave incoming provenance unverified, so their
stable message sequence remains append-only even when parent rows carry later,
restamped timestamps. Timestamp alone is never authority to move a continuation
inside its parent transcript.
If an older row could only be placed before the first surviving sidecar/context
row, the insertion helper declines it to avoid resurrecting compacted history.
Reconciliation then appends that row instead of dropping it; rows without a
usable timestamp and rows at or after the sidecar tail also append normally.
The fallback therefore preserves an accepted state-only row when exact ordering
is ambiguous, while safely placeable recovery rows remain chronological.
#### Imported `state.db` sidebar projection
`api.models.get_cli_sessions()` projects conversations from the active Hermes
+83 -1
View File
@@ -3,9 +3,91 @@
## [Unreleased]
### Added
- **The sidebar's recent-session window is configurable.** `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` sets
how many recent sessions the sidebar lists (default 20). It also bounds how many delegated subagent
children can nest at once, so raise it for wide fan-outs. Invalid or non-positive values fall back to
20, values above 200 are clamped, and it is resolved before profile init so a profile `.env` cannot
override it. (#7631 by @carlotestor)
### Performance
- **Opening a session while its task is still running is much faster.** Rebuilding the live
snapshot from the run journal parsed the journal twice, walked every metering row and grew the
reasoning text with repeated string concatenation, which is quadratic on long runs. The journal
is now parsed once, metering rows are skipped (their timestamp watermark is kept) and reasoning
deltas are joined once per segment. On a 15 MB / 22.7k-event journal the author measured the
rebuild going from 4.34 s to 0.49 s with a byte-identical snapshot. The interim-echo check now matches a
compact-equivalent suffix without a fixed window, so an echo stretched by interior whitespace is
no longer shown twice. (#7310, #7569 by @happy5318)
### Fixed
- **A rejected request no longer poisons the next one on the same connection.** `server.py`
- **Reconnecting to a running session no longer redraws every tool card over and over.** After a
reconnect restored the live activity scene from the run journal, the client also replayed its older
cached in-flight tool list on top, so a turn with N tool cards redrew them N times. When the
journal-backed scene restores successfully the replay is now skipped (newer rows still arrive
through the reattached stream); the replay stays for legacy restores and for a failed or
unavailable scene. (#7436, @atchisonbrent)
- **A settled assistant answer is no longer shown twice (#2051).** Two client-side paths could put a
second copy of a finished answer on screen after a turn settled: a stale live-turn node that was
still treated as live, and a settled rebuild branch that appended a turn instead of replacing it.
The same fix stops an interrupted turn's notice from appearing twice, and stops the composer's
model picker from listing one configured custom-provider model twice. The stored transcript was
always correct; only the rendering duplicated it. (#7374, @Thireus)
- **A hidden browser tab stops polling a session that was deleted.** When a tab is in the
background, its stream poll kept asking for a session every few seconds after it was deleted or
archived away, forever. A `404`/`410` for the polled session now stops that poll; `503` and network
errors keep retrying, a late `404` for one session cannot stop another session's poll, and making
the tab visible again reopens the live stream. (#7299, #7716 by @happy5318)
- **The OpenRouter setup no longer offers a model OpenRouter doesn't serve.** Onboarding offered
`z-ai/glm-4.5-flash`, which is not in OpenRouter's catalog, so picking it failed on the first
message. That slot now offers `z-ai/glm-4.5-air`, Z.AI's nearest light model. The direct Z.AI
setup still offers GLM-4.5 Flash. (#7520, #7734 by @MuhammadUsamaMX)
- **Opening the sidebar can no longer stall the agent's writes to `state.db`.** Several WebUI
paths that only read the agent's `state.db` could quietly become writers. The read-only opener
fell back to a writable connection when `mode=ro` failed. The session listing self-healed a
missing `idx_messages_session` with `CREATE INDEX` through its own writable connection, which
holds the SQLite writer lock for minutes on a large `messages` table. The cron sidebar, insights
and deep-health checks opened the database with a bare `sqlite3.connect()`. Every one of these
readers now opens strictly read-only (`file:...?mode=ro`) and never creates an index. A missing
index falls back to the existing pre-aggregated listing. The gateway watcher also no longer polls
the database when no client is subscribed. Operators with an older agent can create the read
indexes in a drained maintenance window with `scripts/ensure_state_db_read_indexes.py`; see
`docs/troubleshooting.md`. (#7445 by @ruizanthony)
- **A failed chat launch no longer leaves the session stuck "running".** If the worker thread
couldn't start, its stream ownership and the session's pending-stream fields were never cleared,
so the session could look permanently active and the registries grew. Launch failure now clears
exactly that stream's records, after releasing the chat-start lock, and never evicts a successor
stream. (#6869, #6937 by @jbdrak)
- **A compressed conversation no longer shows up twice in the sidebar.** When context
compression started the continuation a few milliseconds before the parent was marked ended, the
continuation wasn't recognised. The sidebar then showed a duplicate same-title row plus a
spurious child-session entry, and opening the conversation didn't stitch the transcript. Both the
sidebar and the transcript stitcher now accept a bounded (2 s) early start when every other
lineage signal agrees: same source, direct parent link, a compression (or `cli_close`) end reason and not a fork.
(#6931, #7021 by @webtecnica)
- **The profile switcher no longer 500s in a two-container Docker setup.** With the agent
source not mounted (`HERMES_WEBUI_CHAT_BACKEND=gateway`), `GET /api/profiles` fell through to
a skills-stats fallback that imported `agent.skill_utils` unguarded, so the missing module
surfaced as an error on every profile-list load. The import is now guarded and the skill
stats report as unknown in that case, so the picker stays usable (the skills line is simply
omitted). Thanks @webtecnica. (#7305, #7312)
- **A title that mixes Chinese, Japanese or Korean with English terms is no longer rejected.**
The cross-script guard that stops a generated title from drifting into the wrong language
treated the borrowed Latin words in a CJK title as drift, so a valid title like
`WeChat Pay 回调失败排查` or `Python 代码修复` was thrown away and the session kept its
fallback title. Latin terms are now accepted when the title also contains CJK text, while
an all-Latin title for a CJK conversation is still rejected. Thanks @MuhammadUsamaMX.
(#7693, #7727)
- **A late-arriving prompt no longer renders below the reply it asked for.** When a message
reached the transcript from `state.db` after the sidecar had already been merged⟪HERMES-CONTEXT-COMPRESSION: 809 of 1,009 chars omitted here by Hermes's context compressor. This is NOT part of the original tool call and must never be reproduced in new output — always write full, untruncated content.⟫- **A rejected request no longer poisons the next one on the same connection.** `server.py`
is a raw HTTP/1.1 handler where `rfile` is the socket itself, so answering a request
before reading its body left those bytes queued. The next request on a keep-alive
connection was then parsed starting mid-body, and the client got
+1
View File
@@ -373,6 +373,7 @@ Full list of environment variables:
| `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) |
| `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file |
| `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable |
| `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` | `20` | Size of the sidebar's interactive recency window (how many recent non-cron/webhook sessions are listed). Also bounds how many delegated subagent children can nest at once, since a child only renders when its row wins a slot in the window — raise it for wide fan-outs. Non-integer or non-positive values fall back to the default. Values above 200 are clamped. Resolved before profile init, so a profile `.env` cannot override it |
| `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) |
| `HERMES_WEBUI_SESSIONS_MAX` | `100` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions; an evicted session lazily reloads from its JSON sidecar on next access |
+29 -6
View File
@@ -154,12 +154,35 @@ HISTORICAL_HYDRATION_TEST_BITE=break-tool-link \
python tests/browser_historical_transcript_hydration.py
```
The dedicated `Conversation lifecycle (informational)` workflow runs the current
proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`) and
stays non-blocking while the public
matrix expands to additional behavior rows. The maintainer's private QA harness
remains broader; later public slices will add session switching, reconnect/replay,
cancellation, compression, and recovery.
The dedicated `Conversation lifecycle (informational)` workflow keeps the existing
proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`)
non-blocking while the public matrix expands. The Chromium
`reconnect-scene-redraw` row does **not** allow failures: it exercises the real
`loadSession` reconnect path and fails the workflow on a regression. Required
merge checks remain a maintainer-controlled repository setting.
The maintainer's private QA harness remains broader; later public slices will
add cancellation, compression, and recovery coverage.
### Active-session reconnect redraw gate
`tests/browser_reconnect_scene_redraw.py` is an opt-in deterministic browser
gate for reconnecting to a running session with a large Anchor activity scene.
It uses an isolated temporary server/home and fixture SSE events—no agent,
provider credentials, or production state. By default it checks Chromium and
WebKit, desktop and 390px viewports, and all three activity display modes:
```bash
pip install playwright
python -m playwright install chromium webkit
python tests/browser_reconnect_scene_redraw.py
```
`TOOL_COUNT`, `BROWSERS`, and `MODES` narrow the matrix. `MEASURE_BASELINE=1`
records an unoptimized baseline without enforcing the redraw ceiling;
`METRICS_FILE` saves JSON metrics and `SCREENSHOT_DIR` saves generated-fixture
screenshots. This gate tests page reconstruction, journal-cursor resume, and
subsequent fixture SSE updates. It does not test a real provider/network or PWA
service-worker cache behavior.
### Streaming reader intent
+141 -59
View File
@@ -1,8 +1,12 @@
"""Shared helpers for reading Hermes Agent sessions from state.db."""
import json
import logging
import os
import sqlite3
import sys
from contextlib import closing
from pathlib import Path
from pathlib import Path, PurePosixPath, PureWindowsPath
from urllib.parse import quote, quote_from_bytes
logger = logging.getLogger(__name__)
@@ -18,36 +22,71 @@ logger = logging.getLogger(__name__)
_SOURCE_COLUMN_WARNED_DB_PATHS: set[str] = set()
def state_db_file_uri(db_path, platform: str | None = None) -> str:
"""Build the ``file:`` URI (no query string) for an absolute ``state.db`` path.
``Path.as_uri()`` is not usable here: for a UNC share it emits
``file://server/share/state.db`` and SQLite rejects any non-local URI
authority unless compiled with ``SQLITE_ALLOW_URI_AUTHORITY`` (the
CPython 3.11-3.13 Windows builds are not). SQLite instead accepts the
*empty*-authority spelling ``file:////server/share/state.db``, whose path
``//server/share/...`` the Windows VFS opens as the UNC name. Drive-letter
paths keep the documented ``file:///C:/...`` form and POSIX paths are
unchanged. Only ``/`` survives unescaped, so ``?`` and ``#`` in path
components cannot leak into the query string.
``platform`` defaults to the running interpreter; tests pass it explicitly
so the Windows shapes are checked from any host.
"""
platform = platform or sys.platform
if platform == "win32":
win = PureWindowsPath(str(db_path))
drive = win.drive
if drive.startswith("\\\\?\\"):
# Extended-length prefix: "\\?\C:" or "\\?\UNC\server\share".
drive = drive[4:]
if drive.upper().startswith("UNC\\"):
drive = "\\\\" + drive[4:]
parts = win.parts[1:] # drop the anchor, keep the path components
if drive.startswith("\\\\"):
host_share = drive[2:].replace("\\", "/")
posix_path = "//" + "/".join((host_share, *parts))
else:
posix_path = "/" + "/".join((drive, *parts))
# ``:`` stays literal so the drive letter keeps SQLite's documented
# ``file:///C:/...`` shape (``Path.as_uri()`` leaves it unescaped too).
return "file://" + quote(posix_path, safe="/:")
posix_path = PurePosixPath(str(db_path)).as_posix()
# Quote the filesystem BYTES, not the str: a POSIX path component that is
# not valid UTF-8 is carried in the str as ``surrogateescape`` code points,
# which ``quote(str)`` rejects with UnicodeEncodeError (the caller then
# treats the db as unreadable and every agent-backed session vanishes).
# ``Path.as_uri()`` — what master used — percent-encodes os.fsencode()
# bytes; this keeps that behavior.
return "file://" + quote_from_bytes(os.fsencode(posix_path), safe="/")
def state_db_readonly_uri(db_path, platform: str | None = None) -> str:
"""Strict read-only (``mode=ro``) form of :func:`state_db_file_uri`."""
return state_db_file_uri(db_path, platform=platform) + "?mode=ro"
def open_state_db_readonly(db_path: Path, log: logging.Logger | None = None) -> sqlite3.Connection:
"""Open the live agent ``state.db`` read-only for a pure-read projection.
Same rationale as the session-listing path (#5455): a write-capable handle
on the multi-GB, WAL ``state.db`` while the agent streams into it adds
needless checkpoint/lock surface. The read-only ``file:...?mode=ro`` URI
avoids that. Falls back to a writable connection (and warns) if the
read-only open fails, so callers never lose data on exotic filesystems.
avoids that. Read failures propagate; a reader never upgrades to a writer.
The caller must ensure ``db_path`` exists — this raises ``FileNotFoundError``
for a missing path rather than letting the writable fallback below create an
empty, writable ``state.db`` there (a ghost DB in the agent's HOME). The
fallback is only for an *existing* DB whose read-only open fails on an exotic
filesystem, so a real read never loses data.
for a missing path rather than creating a ghost database.
Callers own the returned connection (wrap it in ``contextlib.closing``).
"""
log = log or logger
if not db_path.exists():
raise FileNotFoundError(f"agent state.db not found: {db_path}")
read_only_uri = f"{db_path.resolve().as_uri()}?mode=ro"
try:
return sqlite3.connect(read_only_uri, uri=True)
except sqlite3.Error as exc:
log.warning(
"agent state.db read-only open failed for %s; falling back to writable connection: %s",
db_path,
exc,
)
return sqlite3.connect(str(db_path))
return sqlite3.connect(state_db_readonly_uri(db_path.resolve()), uri=True)
MESSAGING_SOURCES = {
@@ -65,6 +104,15 @@ MESSAGING_SOURCES = {
CLI_MIN_UNTITLED_MESSAGE_COUNT = 6
CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2
# Sub-second scheduling/write-order races during a compression/cli_close
# handoff can persist the continuation row with ``started_at`` a few
# milliseconds BEFORE the parent's ``ended_at`` lands (#6931). The tolerance
# below lets those rows still classify as the next segment; the fork /
# model_config branch-marker / cross-source / end_reason guards in
# ``_is_continuation_session`` already rule out unrelated rows, so a small
# window cannot collapse genuinely concurrently started children.
CONTINUATION_STARTED_AT_TOLERANCE_SECONDS = 2.0
SOURCE_LABELS = {
'acp': 'ACP',
'api_server': 'API',
@@ -318,6 +366,30 @@ def is_cli_session_row_visible(row: dict) -> bool:
return _count_user_turns(row) >= CLI_MIN_UNTITLED_USER_MESSAGE_COUNT
def _branch_markers(row: dict | None) -> tuple[str | None, str | None]:
"""Return ``(_branched_from, _delegate_from)`` from a row's ``model_config``.
Hermes Agent stamps explicit branches and delegate/subagent runs in the
``model_config`` JSON column (``_branched_from`` / ``_delegate_from``);
``session_source='fork'`` alone only covers WebUI-created forks. Missing,
empty, or unparsable ``model_config`` degrades to ``(None, None)``.
"""
if not row:
return None, None
raw = row.get('model_config')
if isinstance(raw, dict):
config = raw
elif isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
parsed = None
config = parsed if isinstance(parsed, dict) else {}
else:
config = {}
return config.get('_branched_from'), config.get('_delegate_from')
def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
"""Return True when ``child`` is the next segment of the same conversation.
@@ -325,17 +397,36 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
by ``hermes -c`` also records a new child session; for sidebar projection it
should continue the same visible conversation rather than becoming a
separate child-session row. Plain parent/child links that started before the
parent's ended boundary remain child sessions.
parent's ended boundary remain child sessions. A small scheduling/write-order
overlap between the handoff boundary timestamps is tolerated (#6931).
Do not collapse lineage across raw sources. A WebUI session that continues
from a Telegram/CLI/etc. parent must remain visible as its own surface-owned
conversation; otherwise the tip inherits the root's title/source metadata and
can disappear under messaging/sidebar policies.
Explicit branches/delegates never continue: a child whose ``model_config``
``_branched_from`` / ``_delegate_from`` points at this parent is a fork of
the conversation and must stay visible. Beyond the bounded tolerance window
there is no reliable continuation signal — titles are user-controlled and
non-unique, so they are never used to widen the window (#7021 re-gate).
"""
if not parent or not child:
return False
if str(child.get('session_source') or '').strip().lower() == 'fork':
return False
# Real Agent branches/delegates are marked in model_config (not
# session_source, which only WebUI-created forks carry). The marker must
# reference THIS parent: compression continuations inherit the rotated
# agent's model_config verbatim, so presence alone (a delegate's
# continuation still carries the delegate's own ``_delegate_from``) would
# misclassify real continuations.
child_branched_from, child_delegate_from = _branch_markers(child)
parent_id = parent.get('id')
if parent_id and (
child_branched_from == parent_id or child_delegate_from == parent_id
):
return False
parent_source = str(parent.get('source') or '').strip().lower()
child_source = str(child.get('source') or '').strip().lower()
if parent_source and child_source and parent_source != child_source:
@@ -349,9 +440,16 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
# continuations when no boundary timestamp is available.
return True
try:
return float(child.get('started_at') or 0) >= float(ended_at)
child_started = float(child.get('started_at') or 0)
parent_ended = float(ended_at)
except (TypeError, ValueError):
return False
if child_started >= parent_ended - CONTINUATION_STARTED_AT_TOLERANCE_SECONDS:
return True
# Beyond the bounded early-side window the child is a genuine concurrent
# session — an exact title match is not evidence (titles are user-controlled
# and non-unique, so it cannot extend the tolerance).
return False
def _continuation_root_id(rows_by_id: dict[str, dict], session_id: str | None) -> str | None:
@@ -549,21 +647,7 @@ def read_importable_agent_session_rows(
return []
log = log or logger
# Open read-only for this projection/listing path: it is a pure read, and
# holding a write-capable handle on the live (multi-GB, WAL) state.db while
# the agent streams into it adds needless checkpoint/lock surface (#5455).
# The defensive index self-heal below still runs, but through a separate
# short-lived writable connection on the rare missing-index path only.
read_only_uri = f"{db_path.resolve().as_uri()}?mode=ro"
try:
conn = sqlite3.connect(read_only_uri, uri=True)
except sqlite3.Error as exc:
log.warning(
"agent session listing read-only open failed for %s; falling back to writable connection: %s",
db_path,
exc,
)
conn = sqlite3.connect(str(db_path))
conn = open_state_db_readonly(db_path, log=log)
with closing(conn):
conn.row_factory = sqlite3.Row
cur = conn.cursor()
@@ -588,6 +672,7 @@ def read_importable_agent_session_rows(
parent_expr = _optional_col('parent_session_id', session_cols)
session_source_expr = _optional_col('session_source', session_cols)
model_config_expr = _optional_col('model_config', session_cols)
ended_expr = _optional_col('ended_at', session_cols)
end_reason_expr = _optional_col('end_reason', session_cols)
user_id_expr = _optional_col('user_id', session_cols)
@@ -613,11 +698,7 @@ def read_importable_agent_session_rows(
use_messages_join = messages_has_session_id
count_col = 'id' if 'id' in message_cols else 'session_id'
# Defensive index prime (#3887). The normal candidate-ordering shape uses
# the agent's standard ``idx_messages_session ON messages(session_id,
# timestamp)`` index; without it, large cron-only scans degrade badly.
# Writable dbs self-heal by recreating the index. Read-only or locked dbs
# fall back to the pre-aggregated cron-only path below instead of failing.
# Index creation belongs to explicit drained maintenance, never a read.
messages_index_present = False
if messages_has_session_id and messages_has_timestamp:
try:
@@ -625,21 +706,7 @@ def read_importable_agent_session_rows(
messages_index_present = any(str(row[1]) == "idx_messages_session" for row in cur.fetchall())
except sqlite3.Error:
messages_index_present = False
if not messages_index_present:
# Self-heal via a separate writable connection so the common
# (index-present) path keeps its read-only handle. On a truly
# read-only/locked db this fails and we degrade to the
# pre-aggregated cron-only path below, exactly as before.
try:
with closing(sqlite3.connect(str(db_path))) as _heal:
_heal.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_session "
"ON messages(session_id, timestamp)"
)
_heal.commit()
messages_index_present = True
except sqlite3.Error:
pass # read-only db / locked / older schema — degrade gracefully
if use_messages_join:
actual_count_expr = f"COUNT(m.{count_col})"
@@ -665,7 +732,6 @@ def read_importable_agent_session_rows(
where_clauses = ["s.source IS NOT NULL"]
params: list[object] = []
included = ()
if include_sources:
included = tuple(str(source) for source in include_sources if source)
if included:
@@ -679,10 +745,16 @@ def read_importable_agent_session_rows(
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(excluded)
# Without ``idx_messages_session`` the correlated ``MAX(mx.timestamp)``
# candidate ordering rescans ``messages`` once per session (seconds on
# a few thousand sessions). Every missing-index projection — default
# sidebar, gateway watcher, cron/webhook/kanban views — orders through
# one read-only pre-aggregation pass instead; the listing never creates
# the index itself (that is drained maintenance, see
# ``scripts/ensure_state_db_read_indexes.py``).
use_preaggregated_candidate_order = (
use_messages_join
and messages_has_timestamp
and included == ("cron",)
and not messages_index_present
)
if use_preaggregated_candidate_order:
@@ -709,6 +781,7 @@ def read_importable_agent_session_rows(
SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source,
{session_source_expr},
{model_config_expr},
{user_id_expr},
{chat_id_expr},
{chat_type_expr},
@@ -791,6 +864,11 @@ def read_importable_agent_session_rows(
params,
)
projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()])
# model_config is selected only so the continuation classifier can
# read the real _branched_from/_delegate_from markers; it is internal
# agent state and must not leak into the /api/sessions response.
for row in projected:
row.pop('model_config', None)
projected = [_with_normalized_source(row) for row in projected]
projected = [row for row in projected if is_cli_session_row_visible(row)]
if limit is None:
@@ -893,6 +971,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
source_expr = _optional_col('source', session_cols)
session_source_expr = _optional_col('session_source', session_cols)
model_config_expr = _optional_col('model_config', session_cols)
title_expr = _optional_col('title', session_cols)
started_expr = _optional_col('started_at', session_cols, '0')
ended_expr = _optional_col('ended_at', session_cols)
@@ -907,6 +986,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
SELECT s.id,
{source_expr},
{session_source_expr},
{model_config_expr},
{title_expr},
{started_expr},
{parent_expr},
@@ -953,6 +1033,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
SELECT s.id,
{source_expr},
{session_source_expr},
{model_config_expr},
{title_expr},
{started_expr},
{parent_expr},
@@ -1030,6 +1111,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
return {}
session_source_expr = _optional_col('session_source', session_cols)
source_expr = _optional_col('source', session_cols)
model_config_expr = _optional_col('model_config', session_cols)
message_count_expr = _optional_col('message_count', session_cols, '0')
# Scoped fetch via PRIMARY KEY + idx_sessions_parent rather than a
# full table scan. The sessions table grows unbounded over time
@@ -1066,7 +1148,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
placeholders = ','.join('?' * len(chunk))
cur.execute(
f"""
SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
SELECT s.id, {source_expr}, {session_source_expr}, {model_config_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
FROM sessions s
WHERE s.id IN ({placeholders})
""",
@@ -1095,7 +1177,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
placeholders = ','.join('?' * len(chunk))
cur.execute(
f"""
SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
SELECT s.id, {source_expr}, {session_source_expr}, {model_config_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
FROM sessions s
WHERE s.parent_session_id IN ({placeholders})
""",
+15
View File
@@ -68,6 +68,21 @@ def _env_int(name: str, default: int, *, minimum: int = 1) -> int:
return default
return value if value >= minimum else default
def _env_int_clamped(name: str, default: int, *, minimum: int = 1, maximum: int) -> int:
"""Like ``_env_int``, then clamp a valid override to ``maximum``."""
value = _env_int(name, default, minimum=minimum)
if not str(os.getenv(name) or "").strip():
return value
return min(value, maximum)
# Sidebar recency window. Resolved here, before profile init, so a profile
# .env cannot override a server-wide resource bound. Clamped at 200.
CLI_VISIBLE_SESSION_LIMIT = _env_int_clamped(
"HERMES_WEBUI_VISIBLE_SESSION_LIMIT", 20, maximum=200,
)
# ── TLS/HTTPS config (optional, env-overridable) ────────────────────────────
TLS_CERT = os.getenv("HERMES_WEBUI_TLS_CERT", "").strip() or None
TLS_KEY = os.getenv("HERMES_WEBUI_TLS_KEY", "").strip() or None
+6
View File
@@ -328,6 +328,12 @@ class GatewayWatcher:
protects projection fields (notably role-derived CLI visibility) that
the agent's existing index cannot see.
"""
with self._sub_lock:
has_subscribers = bool(self._subscribers)
if not has_subscribers:
# Force fresh projection on the next subscription, without touching DB.
self._last_full_projection_at = None
return False
db_path = self._state_db_path
# A watcher may start before the agent has created state.db. Publishing an
# empty first snapshot would make an already-rendered sidebar disappear;
+61 -16
View File
@@ -48,7 +48,11 @@ from api.agent_sessions import (
from api.process_event_utils import stamp_message_source
logger = logging.getLogger(__name__)
CLI_VISIBLE_SESSION_LIMIT = 20
# Size of the interactive sidebar recency window. Also bounds how many
# delegated subagent children can be rendered at once, since a child only
# nests when it wins a slot in this window. Resolved in api.config before
# profile init; override with HERMES_WEBUI_VISIBLE_SESSION_LIMIT (clamped 1–200).
CLI_VISIBLE_SESSION_LIMIT = _cfg.CLI_VISIBLE_SESSION_LIMIT
# How many messageful cron sessions to surface in the project-chip layer.
# Needs to exceed CLI_VISIBLE_SESSION_LIMIT so older cron runs stay
# addressable even when many newer non-cron sessions dominate the default
@@ -8581,9 +8585,26 @@ def get_state_db_session_messages(
cur.execute("PRAGMA table_info(sessions)")
session_cols = {str(row['name']) for row in cur.fetchall()}
if {'parent_session_id', 'end_reason', 'started_at', 'source'}.issubset(session_cols):
# session_source/model_config carry the fork/delegate boundary
# identity consumed by _is_continuation_session (#6931/#7021).
# Select them when present so the branch-marker guard applies
# to the stitch path too; older schemas degrade to NULL.
identity_cols = []
if 'session_source' in session_cols:
identity_cols.append('session_source')
else:
identity_cols.append('NULL AS session_source')
if 'model_config' in session_cols:
identity_cols.append('model_config')
else:
identity_cols.append('NULL AS model_config')
lineage_select = (
"id, source, started_at, parent_session_id, ended_at, end_reason"
+ ", " + ", ".join(identity_cols)
)
cur.execute(
"""
SELECT id, source, started_at, parent_session_id, ended_at, end_reason
f"""
SELECT {lineage_select}
FROM sessions
WHERE id = ?
""",
@@ -8601,8 +8622,8 @@ def get_state_db_session_messages(
if not parent_id or parent_id in seen:
break
cur.execute(
"""
SELECT id, source, started_at, parent_session_id, ended_at, end_reason
f"""
SELECT {lineage_select}
FROM sessions
WHERE id = ?
""",
@@ -10382,16 +10403,22 @@ def _insert_state_message_chronologically(messages: list, msg: dict) -> bool:
# preceding assistant's tool_calls, not just the first — a multi-tool
# turn has several adjacent tool results, and inserting between any of
# them splits the block (assistant, tool, <insert>, tool).
if (
idx < len(messages)
and messages[idx].get("role") == "tool"
and idx > 0
and messages[idx - 1].get("role") == "assistant"
and messages[idx - 1].get("tool_calls")
):
while idx < len(messages) and messages[idx].get("role") == "tool":
idx += 1
advanced = True
if idx < len(messages) and messages[idx].get("role") == "tool":
# Walk back over any contiguous tool rows already emitted for
# this block, so the owning assistant is found even when idx
# lands on the SECOND result of a multi-tool turn (where
# messages[idx - 1] is another tool row, not the assistant).
owner = idx - 1
while owner >= 0 and messages[owner].get("role") == "tool":
owner -= 1
if (
owner >= 0
and messages[owner].get("role") == "assistant"
and messages[owner].get("tool_calls")
):
while idx < len(messages) and messages[idx].get("role") == "tool":
idx += 1
advanced = True
# (b) Skip past an equal-timestamp run whose left neighbour shares
# this message's role — inserting there would re-order an
# already-matched same-role turn (user, <inserted user>, assistant).
@@ -10420,6 +10447,7 @@ def merge_session_messages_append_only(
*,
truncation_watermark=None,
truncation_boundary=None,
incoming_provenance: Literal["unverified", "state_db"] = "unverified",
) -> list:
"""Merge sidecar/context and state.db messages without deleting local rows.
@@ -10433,6 +10461,7 @@ def merge_session_messages_append_only(
state_messages,
truncation_watermark=truncation_watermark,
truncation_boundary=truncation_boundary,
incoming_provenance=incoming_provenance,
)
finally:
_STRUCTURED_IDENTITY_MEMO.reset(token)
@@ -10444,6 +10473,7 @@ def _merge_session_messages_append_only_impl(
*,
truncation_watermark=None,
truncation_boundary=None,
incoming_provenance=None,
) -> list:
"""Merge sidecar/context and state.db messages without deleting local rows.
@@ -11038,7 +11068,21 @@ def _merge_session_messages_append_only_impl(
seen_dedup_keys.add(dedup_key)
seen_content_keys.add(content_key)
seen_visible_keys.add(visible_key)
merged_messages.append(msg)
# This terminal path is shared by state.db reconciliation and by ordered
# sidecar stitching (notably compression continuations). Only the caller
# that read state.db may authorize timestamp-based recovery placement;
# stable child-sidecar sequence remains authoritative even when an
# archived parent was restamped later.
if (
incoming_provenance == "state_db"
and max_sidecar_timestamp is not None
and timestamp is not None
and timestamp < max_sidecar_timestamp
):
if not _insert_state_message_chronologically(merged_messages, msg):
merged_messages.append(msg)
else:
merged_messages.append(msg)
_remember_merged_message(msg, source="state")
return merged_messages
@@ -11158,6 +11202,7 @@ def reconciled_state_db_messages_for_session(
state_messages,
truncation_watermark=getattr(session, "truncation_watermark", None),
truncation_boundary=getattr(session, "truncation_boundary", None),
incoming_provenance="state_db",
)
return _state_db_session_messages_result(
reconciled_messages,
+3 -1
View File
@@ -71,7 +71,9 @@ _SUPPORTED_PROVIDER_SETUPS = {
"default_model": "anthropic/claude-sonnet-4.6",
"requires_base_url": False,
"models": [
{"id": _to_openrouter_namespace(model["id"]), "label": model["label"]}
{"id": "z-ai/glm-4.5-air", "label": "GLM-4.5 Air"}
if model["id"] == "zai/glm-4.5-flash"
else {"id": _to_openrouter_namespace(model["id"]), "label": model["label"]}
for model in _FALLBACK_MODELS
],
"category": "easy_start",
+19 -2
View File
@@ -198,7 +198,11 @@ def _unwrap_profile_home_to_base(home: Path) -> Path:
# are operator/deployment-level postures, not per-profile toggles. Letting a
# profile .env set HERMES_WEBUI_ISOLATED_PROFILE=0 would let a contained user
# escape isolation (#4589).
_PROTECTED_ENV_KEYS = frozenset({'HERMES_WEBUI_ISOLATED_PROFILE'})
_PROTECTED_ENV_KEYS = frozenset({
'HERMES_WEBUI_ISOLATED_PROFILE',
# Server-wide sidebar window; a profile .env must not widen the per-request query.
'HERMES_WEBUI_VISIBLE_SESSION_LIMIT',
})
def _isolated_profile_opt_in() -> bool:
@@ -934,6 +938,7 @@ _BLOCKED_RUNTIME_ENV_KEYS = {
# #4589: operator/deployment isolation posture — never overridable by a
# profile's own env on any runtime/gateway-parity path.
'HERMES_WEBUI_ISOLATED_PROFILE',
'HERMES_WEBUI_VISIBLE_SESSION_LIMIT',
}
@@ -1866,7 +1871,19 @@ def _compute_profile_skills_stats(profile_dir: Path) -> tuple[int, int]:
except Exception:
pass
from agent.skill_utils import iter_skill_index_files, parse_frontmatter, skill_matches_platform
try:
from agent.skill_utils import iter_skill_index_files, parse_frontmatter, skill_matches_platform
except ImportError as exc:
logger.debug("agent.skill_utils unavailable; reporting skill stats as unknown: %s", exc)
# agent source not mounted (two-container Docker,
# HERMES_WEBUI_CHAT_BACKEND=gateway): this must never 500 GET
# /api/profiles (#7305). Report the skill stats as unknown — a stable
# (0, 0) — instead of keeping a partial shadow of agent.skill_utils
# here: a local re-implementation cannot preserve the index walk's
# exclusions, frontmatter-name identity or platform filtering, so any
# count it produced would be inaccurate. The UI omits the skills line
# when the total is 0, so the profile picker stays fully usable.
return (0, 0)
seen_names = set()
enabled_count = 0
+145 -28
View File
@@ -46,6 +46,7 @@ from api.agent_sessions import (
_looks_like_default_cli_title,
is_cli_session_row,
is_cli_session_row_visible,
open_state_db_readonly,
read_session_lineage_report,
)
from api.compression_anchor import visible_messages_for_anchor
@@ -320,7 +321,7 @@ def _latest_cron_session_info_for_jobs(
if not db_path or not Path(db_path).exists():
return {jid: {"session_id": "", "message_count": None} for jid in requested}
try:
with closing(sqlite3.connect(str(db_path))) as conn:
with closing(open_state_db_readonly(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("PRAGMA table_info(sessions)")
@@ -2543,8 +2544,8 @@ def _build_session_list_cache_payload(
)
if show_cli_sessions:
diag_stage("cli_cap")
archived_scoped = _cap_recent_cli_sessions(archived_scoped, cli_cap=CLI_VISIBLE_SESSION_CAP)
visible_scoped = _cap_recent_cli_sessions(visible_scoped, cli_cap=CLI_VISIBLE_SESSION_CAP)
archived_scoped = _cap_recent_cli_sessions(archived_scoped)
visible_scoped = _cap_recent_cli_sessions(visible_scoped)
if visible_only:
archived_scoped = [
s for s in archived_scoped if _session_has_server_visible_messages(s)
@@ -3484,16 +3485,31 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
emit_error=False,
):
return None
summary = find_run_summary(stream_id)
if not summary:
return None
session_id = str(summary.get("session_id") or "")
if not session_id:
return None
# Locate the run journal WITHOUT parsing it first. find_run_summary reads
# and parses the whole file (tens of thousands of rows on a long live
# run), and read_run_events below then parsed it AGAIN — two full passes
# cost ~0.6s per rebuild. Derive the durable summary from the single
# parse below instead; its last_seq / last_event_id are rebuilt from the
# same rows the snapshot consumes.
located = find_run_file(stream_id)
if located:
session_id, _journal_path = located
else:
# No journal file on disk for this run id: fall back to the
# historical summary-based lookup seam (defensive — a real miss
# returns None exactly like before; this is also the seam that
# long-standing tests stub with a handler-side summary).
fallback_summary = find_run_summary(stream_id)
if not fallback_summary:
return None
session_id = str(fallback_summary.get("session_id") or "")
if not session_id:
return None
journal = read_run_events(session_id, stream_id)
events = [event for event in (journal.get("events") or []) if isinstance(event, dict)]
if not events:
return None
summary = _summary_from_events(session_id, stream_id, events)
event_run_ids: set[str] = set()
malformed_envelope_run_id = False
for event in events:
@@ -3513,6 +3529,18 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
assistant_text = ""
reasoning_text = ""
# Reasoning deltas arrive as thousands of tiny append events. Growing the
# transcript with ``reasoning_text += chunk`` copies the full (multi-
# hundred-KB) string on every append inside these closures — quadratic
# (~2.3s of a 4.6s rebuild on a 9.7k-chunk run). Collect chunks and
# materialize lazily; readers (echo probes, strip, final message) join at
# most once per interim segment.
reasoning_parts: list[str] = []
reasoning_dirty = False
# Incremental folded index over the reasoning transcript: the per-interim
# echo probe consults this instead of re-walking the raw text (see
# ``_CompactEchoIndex``).
reasoning_index = _CompactEchoIndex()
messages: list[dict] = []
tool_calls: list[dict] = []
activity_burst_anchors: list[dict] = []
@@ -3521,6 +3549,13 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
last_ts = None
reasoning_first_tool_count: int | None = None
def _materialize_reasoning_text() -> str:
nonlocal reasoning_text, reasoning_dirty
if reasoning_dirty:
reasoning_text = "".join(reasoning_parts)
reasoning_dirty = False
return reasoning_text
def mark_boundary() -> int:
nonlocal current_activity_burst_id
text_end = len(assistant_text)
@@ -3585,22 +3620,42 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
tool_calls.append(call)
def reasoning_echo_tail_matches(text: str) -> bool:
candidate = _compact_for_echo_compare(text)
if not candidate:
return False
return _compact_for_echo_compare(reasoning_text).endswith(candidate)
# Indexed tail match (no raw-text walk): the incremental index folds
# each reasoning chunk once as it is appended, so this probe costs
# O(len(text)) regardless of how much interior whitespace stretches
# the raw span. The previous raw backward walk re-walked that span
# once per interim event, which turned the replay quadratic on a
# whitespace-heavy transcript (#7569 review, ~55x slower than master
# on a production-shaped journal).
return reasoning_index.matches_tail(text)
def strip_reasoning_echo_tail(text: str) -> bool:
nonlocal reasoning_text, reasoning_first_tool_count
next_reasoning, did_remove = _strip_compact_echo_suffix(reasoning_text, text)
if did_remove:
reasoning_text = next_reasoning
if not _compact_for_echo_compare(reasoning_text):
reasoning_first_tool_count = None
return did_remove
nonlocal reasoning_text, reasoning_dirty, reasoning_first_tool_count
cut = reasoning_index.cut_to(text)
if cut is None:
return False
next_reasoning = _materialize_reasoning_text()[:cut].rstrip()
reasoning_text = next_reasoning
reasoning_parts.clear()
reasoning_parts.append(next_reasoning)
reasoning_dirty = False
# Re-index the truncated transcript so later probes match against it
# instead of the pre-strip tail (the index is the match authority).
reasoning_index.reset()
reasoning_index.append(next_reasoning)
if not _compact_for_echo_compare(reasoning_text):
reasoning_first_tool_count = None
return True
for event in events:
event_name = str(event.get("event") or event.get("type") or "")
if event_name == "metering":
# Metering rows are the bulk of a long live journal (~10k rows,
# ~60% of its bytes) and project nothing onto the snapshot; skip
# their per-row work while preserving the last_ts watermark they
# carry.
last_ts = event.get("created_at", last_ts)
continue
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
last_ts = event.get("created_at", last_ts)
if event_name == "token":
@@ -3611,9 +3666,12 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
continue
if event_name == "reasoning":
text = str(payload.get("text") or "")
if text and reasoning_first_tool_count is None:
reasoning_first_tool_count = len(tool_calls)
reasoning_text += text
if text:
if reasoning_first_tool_count is None:
reasoning_first_tool_count = len(tool_calls)
reasoning_parts.append(text)
reasoning_index.append(text)
reasoning_dirty = True
continue
if event_name == "interim_assistant":
visible = str(payload.get("text") or "").strip()
@@ -3658,6 +3716,8 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
update_completed_tool(payload)
fresh_segment = True
reasoning_text = _materialize_reasoning_text()
if assistant_text or reasoning_text:
message = {
"role": "assistant",
@@ -9296,6 +9356,7 @@ def _limited_webui_messages_for_display_with_sidecar(
state_db_messages,
truncation_watermark=getattr(session, "truncation_watermark", None),
truncation_boundary=getattr(session, "truncation_boundary", None),
incoming_provenance="state_db",
)
if cache_key is not None:
_state_key = cache_key[4]
@@ -10344,11 +10405,16 @@ def _dedupe_cli_sidebar_sessions_for_api(
return _include_project_hidden_background_sidebar_sessions(candidates, visible)
CLI_VISIBLE_SESSION_CAP = 20
def _cli_visible_session_cap() -> int:
"""Shared sidebar window, not a second hard-coded 20."""
from api.config import CLI_VISIBLE_SESSION_LIMIT
return CLI_VISIBLE_SESSION_LIMIT
def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int = CLI_VISIBLE_SESSION_CAP) -> list[dict]:
def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int | None = None) -> list[dict]:
"""Keep only the most recent CLI-visible sessions after filtering."""
if cli_cap is None:
cli_cap = _cli_visible_session_cap()
if cli_cap <= 0:
return sessions
kept = []
@@ -10769,12 +10835,14 @@ from api.streaming import (
_materialize_pending_user_turn_before_error,
generate_session_title_for_session,
_compact_for_echo_compare,
_strip_compact_echo_suffix,
_CompactEchoIndex,
)
from api.gateway_chat import _run_gateway_chat_streaming, webui_gateway_chat_enabled
from api.run_journal import (
_parse_run_journal_event_id as _shared_parse_run_journal_event_id,
_summary_from_events,
bound_run_journal_snapshot_args,
find_run_file,
find_run_summary,
journal_replay_visible,
read_run_events,
@@ -11969,7 +12037,7 @@ def _handle_insights(handler, parsed) -> bool:
from api.models import _active_state_db_path
db_path = _active_state_db_path()
if db_path and db_path.exists():
with closing(sqlite3.connect(str(db_path))) as conn:
with closing(open_state_db_readonly(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# cache_read_tokens may not exist on older agent state DBs;
@@ -12604,7 +12672,7 @@ def _deep_health_checks(stream_check: dict | None = None) -> tuple[dict, bool]:
"ms": round((time.time() - t0) * 1000, 1),
}
else:
with closing(sqlite3.connect(str(db_path))) as conn:
with closing(open_state_db_readonly(db_path)) as conn:
conn.execute("PRAGMA schema_version").fetchone()
checks["state_db"] = {
"status": "ok",
@@ -22962,6 +23030,54 @@ def _prepare_chat_start_session_for_stream(
s.save()
def _cleanup_chat_start_launch_failure(session, stream_id: str) -> None:
"""Release state registered before a worker thread successfully starts."""
clear_session_writeback_owner_if_owned(session.session_id, stream_id)
unregister_stream_owner(stream_id)
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
STREAM_GOAL_RELATED.pop(stream_id, None)
# The session-field reset needs the same concurrency discipline as the
# registry half: hold the per-session lock and re-resolve the canonical
# session before clearing anything. Mutating the passed-in stale object
# could wipe a concurrent successor turn's pending fields, and saving it
# could resurrect a session deleted while the launch was failing. Same
# pattern as the #1533 race fix (routes.py:3077) and the anchor-scene
# write guard (routes.py:5140).
#
# This runs while the original launch failure is being handled, so it must
# never raise. Lock acquisition and session resolution can fail on their own
# (I/O, deserialization), and an escaping error here would mask the launch
# failure the caller is about to report while leaving the reset half done.
try:
with _get_session_agent_lock(session.session_id):
try:
canonical = get_session(session.session_id)
except KeyError:
return # session deleted while the thread launch was failing
if getattr(canonical, "active_stream_id", None) != stream_id:
return # a successor turn already owns the session
canonical.active_stream_id = None
canonical.pending_user_message = None
canonical.pending_attachments = []
canonical.pending_started_at = None
canonical.pending_user_source = None
try:
canonical.save()
except Exception:
logger.debug(
"Failed to persist chat-start cleanup after worker launch failure for %s",
stream_id,
exc_info=True,
)
except Exception:
logger.debug(
"Failed to reset session state after worker launch failure for %s",
stream_id,
exc_info=True,
)
def _is_hidden_empty_session(s) -> bool:
return (
getattr(s, "title", "Untitled") == "Untitled"
@@ -23545,6 +23661,7 @@ def _start_chat_stream_for_session(
_clear_gateway_run_starting(stream_id)
except Exception:
logger.debug("Failed to record gateway run-start failure for stream %s", stream_id, exc_info=True)
_cleanup_chat_start_launch_failure(s, stream_id)
raise
response = {
"stream_id": stream_id,
+21
View File
@@ -620,6 +620,27 @@ def find_run_summary(run_id: str, *, session_dir: Path | None = None) -> dict |
return None
def find_run_file(run_id: str, *, session_dir: Path | None = None) -> tuple[str, Path] | None:
"""Locate a run journal file by run id WITHOUT parsing its body.
Hot callers that immediately read the full journal (the live-snapshot
rebuild) must not pay :func:`find_run_summary`'s full-file parse first:
on a long live run the file holds tens of thousands of rows and parsing
it twice per rebuild dominated the snapshot cost. Returns
``(session_id, path)`` for the first match, or ``None`` when the run id
is invalid or no journal exists.
"""
try:
rid = _validate_id(run_id, "run_id")
except ValueError:
return None
root = Path(session_dir) if session_dir is not None else _default_session_dir()
journal_root = root / RUN_JOURNAL_DIR_NAME
for path in journal_root.glob(f"*/{rid}.jsonl"):
return path.parent.name, path
return None
def read_session_run_events(
session_id: str,
*,
+206 -44
View File
@@ -197,41 +197,156 @@ def _compact_for_echo_compare(value: str) -> str:
return re.sub(r'\s+', '', str(value or ''))
def _strip_compact_echo_suffix(value: str, suffix: str, *, search_window: int = 4096) -> tuple[str, bool]:
class _CompactEchoIndex:
"""Incremental whitespace-folded index over a growing text buffer.
The journal-rebuild echo check runs once per interim event against the
whole accumulated reasoning transcript. Rescanning the raw buffer each
time walks every whitespace character between the tail and the first
non-whitespace character — on a whitespace-heavy transcript that span is
megabytes, and the per-interim walk turns the replay quadratic (the
``_find_compact_echo_suffix_start`` shape measured ~55x slower than
master on a production-shaped journal in the #7569 review).
This class keeps the folded view and the raw cut offsets *incrementally*:
* ``append`` folds each new chunk once and records, per folded character,
its raw index — so the cost of a chunk is proportional to the chunk,
never to the buffer.
* ``matches_tail`` compares only the candidate against the end of the
folded view: O(len(candidate)), with no raw-text walk at all.
* ``cut_to`` converts a folded length back to a raw index by bisecting
the recorded offsets, so one call returns the same cut point the raw
backward walk would have produced.
``str.isspace`` is used for folding instead of the ``\\s`` pattern used
by :func:`_compact_for_echo_compare`. The two agree on every Unicode code
point, so the indexed view and the regex-folded view stay consistent.
"""
__slots__ = ('_compact', '_offsets', '_raw_len')
def __init__(self) -> None:
self._compact: list[str] = []
self._offsets: list[int] = []
self._raw_len = 0
def append(self, text: str) -> None:
"""Fold ``text`` onto the end of the index (one pass, no rescan)."""
raw = str(text or '')
if not raw:
return
compact = self._compact
offsets = self._offsets
base = self._raw_len
for i, ch in enumerate(raw):
if not ch.isspace():
compact.append(ch)
offsets.append(base + i)
self._raw_len = base + len(raw)
def reset(self) -> None:
"""Drop all indexed state (used after the raw text is truncated)."""
self._compact.clear()
self._offsets.clear()
self._raw_len = 0
@property
def compact_length(self) -> int:
return len(self._compact)
def matches_tail(self, suffix: str) -> bool:
"""True when the folded ``suffix`` equals the folded tail."""
candidate = _compact_for_echo_compare(suffix)
if not candidate:
return False
n = len(candidate)
if n > len(self._compact):
return False
start = len(self._compact) - n
compact = self._compact
for i in range(n):
if compact[start + i] != candidate[i]:
return False
return True
def cut_to(self, suffix: str) -> int | None:
"""Raw index at which the echo of ``suffix`` starts, else ``None``.
The returned index is the first raw character of the echo, matching
the leftmost-cut semantics of the raw backward walk. Trailing
whitespace before the echo is left to the caller's ``rstrip``.
"""
candidate = _compact_for_echo_compare(suffix)
if not candidate:
return None
n = len(candidate)
if n > len(self._compact):
return None
start = len(self._compact) - n
compact = self._compact
for i in range(n):
if compact[start + i] != candidate[i]:
return None
return self._offsets[start]
def compact_view(self) -> str:
"""The folded text (materialized on demand — never kept as a string)."""
return ''.join(self._compact)
def _find_compact_echo_suffix_start(value: str, suffix: str) -> int | None:
"""Return the index where a whitespace-folded ``suffix`` starts at the
end of ``value``, or ``None`` when the tail does not echo it.
The match walks ``value`` and ``suffix`` from the end, skipping
whitespace in ``value``; only the echo span itself is inspected, so the
cost is linear in the echo length and allocation-free. Unlike a fixed
fold window, the walk cannot miss a compact-equivalent suffix whose raw
span is stretched by interior whitespace.
``str.isspace`` is used for the walk instead of the ``\\s`` pattern used
by :func:`_compact_for_echo_compare`. The two agree on every Unicode code
point, so the folded view and the walk stay consistent.
"""
candidate = _compact_for_echo_compare(suffix)
if not candidate:
return None
i = len(value) - 1
j = len(candidate) - 1
while j >= 0:
while i >= 0 and value[i].isspace():
i -= 1
if i < 0 or value[i] != candidate[j]:
return None
i -= 1
j -= 1
return i + 1
def _strip_compact_echo_suffix(value: str, suffix: str) -> tuple[str, bool]:
"""Remove ``suffix`` from ``value`` when they match after whitespace folding.
The search window is folded once and the cut point is then located by
walking backwards across the echo itself. The previous implementation
probed every candidate cut index and re-folded the whole remaining tail for
each probe, which is quadratic in the window size: a 6000-character final
message cost seconds of CPU, held under the GIL, stalling every other
stream in the process.
The cut point is located by the same backward walk as
:func:`_find_compact_echo_suffix_start`: no fixed search window is
involved, so a compact-equivalent suffix is removed no matter how much
interior whitespace stretches its raw span. The previous windowed
implementation folded a bounded tail on every call; its retired probing
variant re-folded the remaining tail per candidate cut index, which is
quadratic: a 6000-character final message cost seconds of CPU, held under
the GIL, stalling every other stream in the process.
``str.isspace`` is used for the backwards walk instead of the ``\\s``
pattern used by :func:`_compact_for_echo_compare`. The two agree on every
Unicode code point, so the folded view and the walk stay consistent.
Whitespace sitting between the kept text and the echo is removed by
``rstrip``, which lands on the same result as the leftmost cut index the
probing loop used to return.
"""
raw = str(value or '')
candidate = _compact_for_echo_compare(suffix)
if not raw or not candidate:
if not raw:
return raw, False
tail = raw[-max(len(str(suffix or '')) * 3, search_window):]
offset = len(raw) - len(tail)
compact_tail = _compact_for_echo_compare(tail)
if len(candidate) > len(compact_tail) or not compact_tail.endswith(candidate):
start = _find_compact_echo_suffix_start(raw, suffix)
if start is None:
return raw, False
# Consume exactly as many non-whitespace characters as the folded suffix
# holds; ``idx`` then sits on the first character of the echo. Whitespace
# sitting between the kept text and the echo is removed by ``rstrip``,
# which is why this lands on the same result as the leftmost cut index the
# probing loop used to return.
remaining = len(candidate)
idx = len(tail)
while remaining and idx:
idx -= 1
if not tail[idx].isspace():
remaining -= 1
return raw[: offset + idx].rstrip(), True
return raw[:start].rstrip(), True
def _redacted_session_payload_with_full_messages(session, *, tool_calls=None) -> dict | None:
@@ -4379,9 +4494,10 @@ def _title_language_mismatch(user_text: str, title: str) -> bool:
short and frequently embed a borrowed Latin technical term (e.g. a CJK
title containing the word "Python"), the title side uses a proportion
threshold (>=35% of the title's alphabetic characters in a non-start
script, min 2 chars) rather than a strict majority -- so a CJK title with
one English word still trips, while an English title with a single
foreign place-name does not.
script, min 2 chars) rather than a strict majority. CJK titles with
borrowed Latin terms are allowed when the title also contains CJK
characters (#7693), but pure-Latin titles for CJK conversations are
still rejected.
2. The legacy German-start → English-title heuristic, preserved verbatim so
the original behavior keeps working for same-script (latin) drift that
the script check can't see.
@@ -4391,13 +4507,26 @@ def _title_language_mismatch(user_text: str, title: str) -> bool:
return False
# (1) Cross-script mismatch — language-agnostic.
# CJK text routinely borrows Latin product/technical terms (e.g. "WeChat
# Pay", "Python", "ProRes RAW"), so when the user writes in CJK, Latin
# characters in the title are acceptable as long as the title also
# contains CJK — i.e. the title is genuinely mixed, not pure drift.
# A pure-Latin title for a CJK conversation is still rejected.
# Unrelated scripts (Cyrillic, Arabic, Greek …) are always flagged.
user_script = _dominant_script(user_text)
if user_script:
title_counts = _script_counts(candidate)
title_total = sum(title_counts.values())
if title_total >= 2:
for script, n in title_counts.items():
if script != user_script and n >= 2 and (n / title_total) >= 0.35:
if script == user_script:
continue
# When user writes in CJK, Latin in the title is a borrowed
# term as long as the title also contains CJK characters.
if user_script == 'cjk' and script == 'latin':
if title_counts.get('cjk', 0) >= 2:
continue
if n >= 2 and (n / title_total) >= 0.35:
return True
# (2) Legacy same-script German→English heuristic.
@@ -10006,6 +10135,14 @@ def _run_agent_streaming(
# (#3587) replaces the flat _reasoning_text string so each intermediate
# assistant turn (before tool calls) keeps its own reasoning segment.
_reasoning_segments: dict = {}
# Incremental folded indexes mirroring each reasoning buffer. The
# live echo strip consults them instead of re-walking the raw
# buffers: an unbounded raw scan of the growing transcript is
# quadratic on whitespace-heavy output (#7569 review). Each index
# is fed on every append and re-indexed after a strip.
_reasoning_segment_indexes: dict = {}
_stream_reasoning_index = _CompactEchoIndex()
_reasoning_buffer_index = _CompactEchoIndex()
_current_reasoning_idx = 0
_tool_boundary_advanced = False
_live_tool_calls = [] # tool progress fallback when final messages omit tool IDs
@@ -10028,6 +10165,10 @@ def _run_agent_streaming(
if _reasoning_buffer[0]:
put('reasoning', {'text': _reasoning_buffer[0]})
_reasoning_buffer[0] = ''
# The folded index mirrors this buffer 1:1 — dropping the
# text without dropping the index would leave it describing
# text that is no longer there.
_reasoning_buffer_index.reset()
def _emit_metering():
@@ -10067,30 +10208,37 @@ def _run_agent_streaming(
nonlocal _reasoning_segments
removed = False
if stream_id in STREAM_REASONING_TEXT:
next_text, did_remove = _strip_compact_echo_suffix(
STREAM_REASONING_TEXT.get(stream_id, ''),
text,
)
if did_remove:
cut = _stream_reasoning_index.cut_to(text)
if cut is not None:
next_text = STREAM_REASONING_TEXT.get(stream_id, '')[:cut].rstrip()
STREAM_REASONING_TEXT[stream_id] = next_text
_stream_reasoning_index.reset()
_stream_reasoning_index.append(next_text)
removed = True
next_buffer, did_remove_buffer = _strip_compact_echo_suffix(_reasoning_buffer[0], text)
if did_remove_buffer:
cut = _reasoning_buffer_index.cut_to(text)
if cut is not None:
next_buffer = _reasoning_buffer[0][:cut].rstrip()
_reasoning_buffer[0] = next_buffer
_reasoning_buffer_index.reset()
_reasoning_buffer_index.append(next_buffer)
removed = True
for idx in (_current_reasoning_idx, _current_reasoning_idx - 1):
if idx not in _reasoning_segments:
continue
next_segment, did_remove_segment = _strip_compact_echo_suffix(
_reasoning_segments.get(idx, ''),
text,
)
if not did_remove_segment:
segment_index = _reasoning_segment_indexes.get(idx)
if segment_index is None:
continue
cut = segment_index.cut_to(text)
if cut is None:
continue
next_segment = _reasoning_segments.get(idx, '')[:cut].rstrip()
if next_segment:
_reasoning_segments[idx] = next_segment
segment_index.reset()
segment_index.append(next_segment)
else:
_reasoning_segments.pop(idx, None)
_reasoning_segment_indexes.pop(idx, None)
removed = True
break
return removed
@@ -10154,15 +10302,21 @@ def _run_agent_streaming(
_reasoning_segments[_current_reasoning_idx] = (
_reasoning_segments.get(_current_reasoning_idx, '') + reasoning_delta
)
# Keep the folded index in step with the segment text.
_reasoning_segment_indexes.setdefault(
_current_reasoning_idx, _CompactEchoIndex()
).append(reasoning_delta)
# Mirror full concatenation to shared dict so cancel_stream() can persist
# it (#1361 §A). Cancel only creates one partial message, so the flat
# concatenation is correct there.
# Lock-free GIL-atomic mirror — see the STREAMS_LOCK contract in on_token.
if stream_id in STREAM_REASONING_TEXT:
STREAM_REASONING_TEXT[stream_id] += reasoning_delta
_stream_reasoning_index.append(reasoning_delta)
# Accumulate into a coalescing buffer so every delta reaches the
# browser — reasoning deltas are incremental, not idempotent.
_reasoning_buffer[0] += reasoning_delta
_reasoning_buffer_index.append(reasoning_delta)
# Throttle reasoning SSE events to ~10 Hz to avoid overwhelming the
# frontend renderer. Each event triggers _parseStreamState() which
# scans the full accumulated text — 10k+ reasoning tokens/second
@@ -10173,6 +10327,10 @@ def _run_agent_streaming(
_reasoning_last_put[0] = now
put('reasoning', {'text': _reasoning_buffer[0]})
_reasoning_buffer[0] = ''
# The folded index mirrors this buffer 1:1 — dropping the
# text without dropping the index would leave it describing
# text that is no longer there.
_reasoning_buffer_index.reset()
# Track reasoning deltas in the meter so live TPS reflects all AI output.
_metering_reasoning_deltas[0] += 1
meter().record_reasoning(stream_id, _metering_reasoning_deltas[0])
@@ -10281,10 +10439,14 @@ def _run_agent_streaming(
_reasoning_segments[_current_reasoning_idx] = (
_reasoning_segments.get(_current_reasoning_idx, '') + reason_delta
)
_reasoning_segment_indexes.setdefault(
_current_reasoning_idx, _CompactEchoIndex()
).append(reason_delta)
# Mirror full concatenation to shared dict (#1361 §A)
# Lock-free GIL-atomic mirror — see STREAMS_LOCK contract in on_token.
if stream_id in STREAM_REASONING_TEXT:
STREAM_REASONING_TEXT[stream_id] += reason_delta
_stream_reasoning_index.append(reason_delta)
put('reasoning', {'text': reason_delta})
_metering_reasoning_deltas[0] += 1
meter().record_reasoning(stream_id, _metering_reasoning_deltas[0])
@@ -574,6 +574,38 @@ early renderers can avoid chasing `S.messages` during every paint. If settlement
later rewrites the transcript message, the anchor must be refreshed from that
message instead of allowing the two copies to drift silently.
### Settled ownership of a restored live turn
A preserved or snapshotted live-turn node is not evidence of a running turn. The
settled assistant message is persisted a few milliseconds before the stream's
terminal event clears the active stream id, so a live node whose stream has
already ended can survive a transcript rebuild or a session switch and be
appended under the settled answer, putting the same answer on screen twice
(#6948 follow-up; upstream symptom report #2051).
A live-turn node is therefore discarded only where the settled transcript is
proved to own it: the transcript ends with a settled assistant message, no
message still carries a live-projection marker, that settled message's
persisted stream identity equals the id of the stream that built the live node
— or, only where no scene identity was persisted, the markdown the stream
produced equals the persisted message source — and the node carries nothing the
settled rebuild could not have produced, meaning no unpersisted tool card,
reasoning row or transparent-stream row and no second live segment.
Ownership is a state proof, never a comparison of two rendered DOM trees: the
live body comes from the streaming parser and the settled body from the
markdown renderer, and an answer that reads the same as the previous one must
not delete a genuinely live turn. An unrecognised node shape keeps the turn.
Only the two branches that ADD a turn are gated; a branch that replaces a node
cannot duplicate one.
A settled activity scene projects one answer as well. A `process_prose` row
whose text duplicates the scene's final answer — the same text, or a prefix of
it within ten percent of its length — is dropped where both settled renderers
take their rows, so the answer is not rebuilt as a second assistant segment
above itself. Live rendering is unchanged: while the turn streams, the inline
live segment is hidden and the prose row is the visible answer.
## Replay, Reload, And Reconstruction
Replay/reload should reconstruct the same Assistant Turn Anchor from durable
@@ -131,6 +131,23 @@ and 5; it does not mark every run-state boundary implemented.
Visible interim assistant progress must remain visible timeline content; a
compact Activity disclosure may summarize adjacent tool/debug detail, but it
must not be the only place where the user can see emitted progress text.
Interim assistant text that duplicates the tail of the accumulated reasoning
transcript is stripped from the reasoning copy so the restored snapshot
shows the content once. That echo match is whitespace-insensitive and
carries no fixed search window: a compact-equivalent suffix is recognized
however much interior whitespace stretches its raw span. Both consumers
(the live-stream echo path and the journal replay in `api/routes.py`)
match through an incremental folded index (`_CompactEchoIndex` in
`api/streaming.py`): the folded view and its raw cut offsets are built as
each chunk is appended, so a probe costs O(len(candidate)) and never
rescans the transcript's whitespace. The retired windowed variants could
drop the strip when the span exceeded the window, duplicating the interim
text; the retired raw backward walk was correct but re-walked the span per
interim event, quadratic on whitespace-heavy transcripts. Regressions:
`tests/test_live_snapshot_echo_dedup.py` pins the single-occurrence
result, `tests/test_live_snapshot_echo_scan_scaling.py` pins the scaling
property (a fixed-size fixture cannot catch a per-interim rescan), and
`tests/test_compact_echo_index.py` pins index/oracle equivalence.
6. **Compression is not current intent.** Automatic compression summaries and
reference cards are recovery/handoff material. They must not be treated as a
new user request, active-turn content, or the default visible explanation for
+30
View File
@@ -231,6 +231,36 @@ For a foreground `python3 bootstrap.py`, stop it with Ctrl-C and start it again.
---
## Agent sessions list slowly (or the `state.db` read index is missing)
**Symptom.** The sidebar's imported/CLI session list takes seconds per refresh on a large Hermes profile, or a log line says a `state.db` read failed. Sessions still appear; nothing is lost.
**Why.** Every WebUI reader of the agent's `state.db` (session listing, transcript reads, lineage, gateway watcher, cron sidebar, insights, health) opens it strictly read-only (`file:...?mode=ro`). A reader never upgrades to a write-capable handle and never creates an index: on a multi-GiB `messages` table `CREATE INDEX` holds the SQLite writer lock for minutes and stalls the agent streaming into the same WAL database. When the agent's standard `idx_messages_session` index is missing (older agent, hand-rebuilt or re-imported DB), listings degrade to a bounded one-pass pre-aggregation — slower than the indexed seek, but read-only. A read-only open failure propagates to the caller's existing error boundary (the listing returns empty for that profile) instead of silently reopening the file writable.
**Diagnostic.**
```bash
sqlite3 "file:$HOME/.hermes/state.db?mode=ro" "PRAGMA index_list(messages)"
```
`idx_messages_session` should be listed. If it is not, the agent has not created it and WebUI will not create it for you.
**Fix.** Create the covering read indexes in an explicit drained maintenance window: stop the agent (and any gateway/cron runner) writing to that `state.db`, then run:
```bash
python3 scripts/ensure_state_db_read_indexes.py --db ~/.hermes/state.db --confirm-drained
```
- `--confirm-drained` is mandatory: it is your assertion that no agent turn is running against the database. The tool does not verify it.
- `--lock-file PATH` (optional) additionally holds an exclusive non-blocking lock on `PATH` (`flock` on POSIX, `msvcrt.locking` on Windows) for deployments that serialise agent turns on a lock file; a held lock makes the tool exit without touching the database. Without `--lock-file` no lock primitive is required, so the script runs on native Windows as well. On Windows the lock covers byte 0 of `PATH`; a new or empty lock file is initialised with one byte first (an existing lock file is never rewritten).
- The tool opens the database `mode=rw` (never `rwc`): a mistyped path raises instead of creating an empty database. Indexes are created inside one `BEGIN IMMEDIATE` transaction and rolled back on any error.
- It is idempotent and prints a JSON status per index (`created` / `existing` / `skipped`). `skipped` means this database's schema lacks a column that index keys on (an older agent); the indexes the schema does support are still created. An existing index with a different table, key shape or collation is reported as `Incompatible index` and never replaced; an index that is not covering (`EXPLAIN QUERY PLAN`) is reported as `Index is not covering`.
- Windows UNC profiles (`HERMES_HOME=\\server\share\...`) are supported: readers and this tool build the empty-authority URI `file:////server/share/state.db` that the bundled SQLite accepts.
**When to file a bug.** File a WebUI bug if the listing stays slow after the tool reports `existing` for `idx_messages_session`, if the tool reports `Incompatible index` on an untouched agent-created database, or if a read-only open fails on a local path. Include the tool's JSON output, the `PRAGMA index_list(messages)` result, and the sanitized error text.
---
## 404 after login when password auth is enabled
**Symptom.** After enabling password authentication (`HERMES_WEBUI_PASSWORD`), logging in redirects to `/sessions` and the browser shows a `404 not found` error instead of the chat interface.
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Create the covering read indexes on the agent ``state.db`` in an explicit,
drained maintenance window.
The WebUI read paths (session listing, lineage reads, gateway watcher, cron
sidebar, insights, health) never create indexes themselves: ``CREATE INDEX``
on a multi-GiB ``messages`` table holds the SQLite writer lock for minutes,
which stalls the agent streaming into the same WAL database. Index
maintenance is therefore an operator action run while the agent is idle.
Usage::
python scripts/ensure_state_db_read_indexes.py --db ~/.hermes/state.db \\
--confirm-drained [--lock-file /path/to/agent-activity.lock]
``--confirm-drained`` is mandatory. When ``--lock-file`` is given, the tool
takes an exclusive non-blocking lock on that path (``flock`` on POSIX,
``msvcrt.locking`` on Windows) so that a deployment which serialises agent
turns on a lock file cannot start a turn mid-rebuild. Without ``--lock-file``
no lock primitive is needed, so the script runs on every supported platform.
Existing indexes are verified for table, key shape and collation and must be
covering (``EXPLAIN QUERY PLAN``); an incompatible index is reported, never
silently replaced.
"""
import argparse
from contextlib import closing, nullcontext
import json
import os
from pathlib import Path
import sqlite3
import sys
try: # POSIX
import fcntl
except ImportError: # native Windows
fcntl = None
try: # Windows
import msvcrt
except ImportError: # POSIX
msvcrt = None
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from api.agent_sessions import state_db_file_uri # same UNC-aware URI as the readers
INDEXES = {
"idx_messages_session": ("messages", (("session_id", "BINARY"), ("timestamp", "BINARY"))),
"idx_messages_session_role": ("messages", (("session_id", "BINARY"), ("role", "NOCASE"))),
"idx_sessions_webui_fingerprint": ("sessions", tuple((c, "BINARY") for c in
("source", "id", "message_count", "last_activity_at"))),
}
# The only schema gap an older Agent is known to have: sessions.last_activity_at
# arrived later than the other keyed columns. An index whose missing columns are
# all listed here is reported "skipped"; any other missing column means this is
# not an Agent state.db (or a damaged one), and the tool fails closed.
_LEGACY_OPTIONAL_COLUMNS = {
"idx_sessions_webui_fingerprint": frozenset({"last_activity_at"}),
}
def _require_agent_schema(db) -> None:
"""Fail closed unless this is an Agent ``state.db``.
Matching column names alone would accept a look-alike chat database. Every
Agent ``state.db`` since the first schema has carried a ``schema_version``
row and ``sessions.id`` as the primary key, so require both before any
``CREATE INDEX``.
"""
has_marker = db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_version'"
).fetchone()
version = None
if has_marker:
cols = {r[1] for r in db.execute("PRAGMA table_info(schema_version)")}
if "version" in cols:
row = db.execute("SELECT version FROM schema_version LIMIT 1").fetchone()
version = row[0] if row else None
if not isinstance(version, int) or version < 1:
raise RuntimeError(
"state.db has no Agent schema_version marker; not an Agent state.db, refusing to continue"
)
pk = [r[1] for r in db.execute("PRAGMA table_info(sessions)") if r[5]]
if pk != ["id"]:
raise RuntimeError(
"state.db sessions table is not keyed on id; not an Agent state.db, refusing to continue"
)
def _exclusive_lock(lock_file):
if lock_file is None:
return nullcontext()
if fcntl is None and msvcrt is None:
raise RuntimeError("--lock-file needs fcntl (POSIX) or msvcrt (Windows); "
"neither is available on this interpreter")
handle = open(lock_file, "a+")
try:
if fcntl is not None:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
else:
# Lock one byte at offset 0 without blocking; raises OSError when held.
# ``a+`` may have just created an empty file, and Windows cannot lock
# a range beyond EOF, so make sure byte 0 exists first. An existing
# (deployment-owned) lock file is left untouched. Write the byte on
# the descriptor: a text-mode ``handle.write("\n")`` becomes two
# bytes (``\r\n``) on native Windows.
if os.fstat(handle.fileno()).st_size == 0:
os.write(handle.fileno(), b"\0")
handle.flush()
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
except BaseException:
handle.close()
raise
return closing(handle)
def ensure_read_indexes(db_path, *, confirmed_drained=False, lock_file=None):
if not confirmed_drained:
raise RuntimeError("requires --confirm-drained")
db_path = Path(db_path).resolve(strict=True)
statuses = {}
with _exclusive_lock(lock_file):
# mode=rw (not rwc): never create a ghost database at a mistyped path.
with closing(sqlite3.connect(state_db_file_uri(db_path) + "?mode=rw", uri=True)) as db:
db.execute("BEGIN IMMEDIATE")
try:
# Validate the whole Agent schema before creating anything, so a
# wrong database fails closed without a partial index set.
_require_agent_schema(db)
missing_by_index = {}
for name, (table, keys) in INDEXES.items():
present = {r[1] for r in db.execute(f"PRAGMA table_info({table})")}
if not present:
raise RuntimeError(f"state.db has no {table!r} table; refusing to continue")
missing = {col for col, _ in keys if col not in present}
if missing - _LEGACY_OPTIONAL_COLUMNS.get(name, frozenset()):
raise RuntimeError(
f"state.db {table!r} table lacks Agent column(s) "
f"{sorted(missing)}; not an Agent state.db, refusing to continue"
)
missing_by_index[name] = missing
for name, (table, keys) in INDEXES.items():
existing = db.execute("SELECT tbl_name FROM sqlite_master WHERE name=?", (name,)).fetchone()
if missing_by_index[name]:
# Known legacy gap. A same-named index here cannot be the
# shape we expect (it keys on the absent column), so it is
# incompatible, not something to skip past.
if existing:
raise RuntimeError(f"Incompatible index: {name}")
statuses[name] = "skipped"
continue
if existing:
actual = tuple((r[2], r[4]) for r in db.execute(f"PRAGMA index_xinfo({name})") if r[5])
flags = next((r for r in db.execute(f"PRAGMA index_list({table})") if r[1] == name), None)
if existing[0] != table or actual != keys or not flags or flags[2] or flags[4]:
raise RuntimeError(f"Incompatible index: {name}")
statuses[name] = "existing"
else:
columns = ", ".join(f"{col} COLLATE {collation}" for col, collation in keys)
db.execute(f"CREATE INDEX {name} ON {table}({columns})")
statuses[name] = "created"
columns = ", ".join(col for col, _ in keys)
plan = db.execute(f"EXPLAIN QUERY PLAN SELECT {columns} FROM {table} INDEXED BY {name}").fetchall()
if not any(f"COVERING INDEX {name}" in row[3] for row in plan):
raise RuntimeError(f"Index is not covering: {name}")
db.commit()
except BaseException:
db.rollback()
raise
return statuses
def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--db", type=Path, required=True, help="path to the agent state.db")
parser.add_argument("--lock-file", type=Path, default=None,
help="optional lock file to hold exclusively (flock) while indexes are built")
parser.add_argument("--confirm-drained", action="store_true",
help="assert that no agent turn is running against this database")
args = parser.parse_args()
result = ensure_read_indexes(args.db, confirmed_drained=args.confirm_drained,
lock_file=args.lock_file)
print(json.dumps(result, sort_keys=True))
if __name__ == "__main__":
main()
+6
View File
@@ -2169,6 +2169,12 @@ async function loadSession(sid){
S.activeStreamId=activeStreamId;
const liveToolReplayId=(tc)=>String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim();
const replayPersistedLiveToolCards=(opts)=>{
// The journal-backed Anchor scene is authoritative through its resume
// cursor; newer rows arrive through the reattached SSE stream. Replaying
// the older INFLIGHT tool cache after a successful scene restore would
// redraw all N rows N times. Keep the #3707 replay only for legacy HTML
// restoration or a failed/unavailable scene render.
if(restoredAnchorScene) return;
const liveToolCalls=Array.isArray(S.toolCalls)
? S.toolCalls
: (Array.isArray(INFLIGHT[sid]&&INFLIGHT[sid].toolCalls)?INFLIGHT[sid].toolCalls:[]);
+221 -6
View File
@@ -3949,6 +3949,27 @@ function _isEquivalentConfiguredModelEntry(modelId,badge,entries){
// providers (@custom:name:model) without collapsing matching model IDs from
// different providers.
const rawId=String(modelId||'');
// `<provider>/<model>` is another routing spelling of `<model>`, so its badge
// key must not become a second picker row. configured_model_badges holds every
// spelling of a configured model and renderModelDropdown() synthesises a row
// for each key this predicate does not recognise. The provider-qualified
// spelling was missed because _normalizeConfiguredModelKey() strips only one
// leading slash segment (#3360 keeps `vendor_a/x` and `vendor_b/y/x` distinct),
// so `acme/example-model` and `custom/acme/example-model` normalise to
// different keys and the picker lists one model twice.
// Match it the way the `@provider:` rule below does: the badge declares a
// provider, the key starts with that provider's `<provider>/` prefix, and an
// existing row from the same provider normalises equal to the remainder. Two
// different models never satisfy the last clause, so this can only drop a
// duplicate of a row the catalog already produced.
const slashPrefix=provider?`${provider}/`:'';
if(slashPrefix&&rawId.toLowerCase().startsWith(slashPrefix)){
const slashRoutedId=rawId.slice(slashPrefix.length);
if(slashRoutedId&&(entries||[]).some(entry=>
String(entry.providerId||'').toLowerCase()===provider
&&_normalizeConfiguredModelKey(entry.value)===_normalizeConfiguredModelKey(slashRoutedId)
)) return true;
}
const prefix=provider?`@${provider}:`:'';
if(!prefix||!rawId.toLowerCase().startsWith(prefix)) return false;
const routedId=rawId.slice(prefix.length);
@@ -9928,6 +9949,99 @@ function _liveAssistantSegmentTextLength(seg){
return String(body.textContent||'').trim().length;
}
// ── #6948 follow-up: settled-transcript ownership of a live-turn node ──────
// (duplicate assistant answer; upstream symptom report #2051)
//
// Both live-turn insertion paths — the renderMessages() re-attach and
// restoreLiveTurnHtmlForSession() — have one branch that ADDS a turn the
// settled rebuild did not produce. The assistant row is persisted a few ms
// BEFORE the stream's terminal event clears S.activeStreamId, so a dead live
// node left behind by an INFLIGHT entry that outlived its stream is re-attached
// on top of the settled transcript: the same answer twice, self-sustaining
// across later renders (the node keeps id=liveAssistantTurn and is re-preserved
// by the #6948 guard), healed only by a reload.
//
// Dropping a live node must be an OWNERSHIP proof, never "the rendered text
// looks the same":
// * the live body is built by the streaming smd parser and the settled body
// by renderMd + post-processing, so their textContent differs for anything
// past single-paragraph plain text (lists, fenced code with its language
// label, tables, `_underscore_` emphasis, the injected copy button) — a
// text comparison would miss most real answers; and
// * an identical answer earlier in the history (a repeated "Done.", a
// greeting, a same-prompt resend) would delete a GENUINELY LIVE turn: while
// the current turn is unpersisted, the last settled assistant message IS the
// previous turn's answer.
// So ownership is proved from state: the transcript must END with a settled
// assistant message that THIS stream produced, and the node must carry nothing
// the settled rebuild could not have produced.
// Stream identity persisted on a settled assistant message. The server stamps
// _anchor_stream_id from the anchor-scene sidecar record (api/routes.py); the
// client stamps _anchor_stream_id and scene.identity.stream_id at stream end
// (_attachProjectedAnchorSceneToLastAssistant, static/messages.js).
function _settledAssistantStreamId(message){
if(!message) return '';
const scene=message._anchor_activity_scene||null;
const identity=(scene&&scene.identity)||null;
return String(message._anchor_stream_id||(scene&&scene.stream_id)||(identity&&identity.stream_id)||'');
}
// The live-projection markers the #6948 preserve guard already treats as proof
// of a live owner. A message the client still considers live is never evidence
// that the turn has settled.
function _messageHasLiveAssistantProjection(m){
return !!(m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined));
}
// True when the live turn holds something the settled rebuild cannot have
// produced from S.messages — an unpersisted tool card, reasoning/thinking row,
// transparent-stream row, or a second live segment. Such a node is never
// dropped: #3714's whole premise is that the live DOM can be AHEAD of
// S.messages, and a tail-segment match must not discard the tool card or the
// earlier segment above it. An unknown node shape fails closed (keep the turn).
function _liveTurnCarriesUnsettledContent(turn){
if(!turn||typeof turn.querySelectorAll!=='function') return true;
if(turn.querySelectorAll(
'.tool-card-row,.wl-reason,.agent-activity-thinking,.thinking-card-row,.transparent-event-row'
).length) return true;
return turn.querySelectorAll('[data-live-assistant="1"]').length>1;
}
function _settledTranscriptOwnsLiveTurn(sid, turn){
if(!turn) return false;
const msgs=(typeof S!=='undefined'&&S&&Array.isArray(S.messages))?S.messages:null;
if(!msgs||!msgs.length) return false;
// The transcript must END with a settled assistant answer. A trailing user
// turn means the live turn IS the current turn — nothing of it is persisted
// yet, so it can never be a leftover (this is the case a text comparison gets
// wrong when the previous answer happens to read the same), and a live
// projection anywhere means the rebuild still owns a live turn of its own.
const last=msgs[msgs.length-1];
if(!last||last.role!=='assistant') return false;
if(msgs.some(_messageHasLiveAssistantProjection)) return false;
const inflight=(typeof INFLIGHT!=='undefined'&&INFLIGHT)?INFLIGHT[sid]:null;
// Owner of the live DOM: INFLIGHT carries the id of the stream that built it
// (attachLiveStream), and S.activeStreamId is that same id until the terminal
// event clears it.
const liveStreamId=String((inflight&&inflight.streamId)||(typeof S!=='undefined'&&S&&S.activeStreamId)||'');
const settledStreamId=_settledAssistantStreamId(last);
if(settledStreamId){
// Identity recorded on both sides decides — renderer- and text-independent,
// so code blocks, lists and tables are handled like plain prose.
if(!liveStreamId||settledStreamId!==liveStreamId) return false;
}else{
// No persisted identity on the tail (a turn whose scene was not worklog
// worthy). Compare SOURCE with SOURCE — the markdown this stream produced
// (INFLIGHT.lastAssistantText) against the persisted message content —
// never two independently rendered DOM trees. Regeneration cannot reach
// this branch: startRegeneration() truncates S.messages at the user turn,
// so the tail is a user message while a regenerated answer streams.
const streamed=String((inflight&&inflight.lastAssistantText)||'').replace(/\s+/g,' ').trim();
if(!streamed) return false;
const settled=String((typeof msgContent==='function'?msgContent(last):last.content)||'').replace(/\s+/g,' ').trim();
if(!settled||streamed!==settled) return false;
}
return !_liveTurnCarriesUnsettledContent(turn);
}
function _mergeRestoredLiveAssistantSegment(restored, existing){
if(!restored||!existing) return;
const existingLive=existing.querySelector('[data-live-assistant="1"]');
@@ -9964,6 +10078,18 @@ function restoreLiveTurnHtmlForSession(sid){
const existing=$('liveAssistantTurn');
_mergeRestoredLiveAssistantSegment(restored, existing);
if(existing) existing.replaceWith(restored);
// #6948 follow-up (duplicate assistant answer; #2051): only this branch ADDS a
// turn — replacing an existing live turn cannot duplicate. When the settled
// transcript already owns the stream this snapshot belongs to, appending it
// pins a SECOND copy of the same answer under the settled one. Release the
// stale snapshot (no other consumer can use it — it would be re-refused on
// every later restore) and report "nothing restored", so loadSession takes the
// same path it already takes for an INFLIGHT entry that never carried one.
else if(typeof _settledTranscriptOwnsLiveTurn==='function'
&&_settledTranscriptOwnsLiveTurn(sid, restored)){
inflight.liveTurnHtml=null;
return false;
}
else inner.appendChild(restored);
// Transparent Stream: liveTurnHtml is restored via template.innerHTML, which
// drops the property-bound onclick/onkeydown handlers wired by
@@ -11469,6 +11595,15 @@ function _assistantMessageBelongsInWorklog(m, rawIdx, toolCallAssistantIdxs, vis
const isTurnFinalAssistant=!!(opts&&opts.isTurnFinalAssistant);
const visibleText=String(visibleContent!==undefined?visibleContent:msgContent(m)||'').trim();
const hasVisibleText=!!visibleText&&!_isAssistantEmptyPlaceholderContent(m, visibleText);
// The caller only consults this predicate once the turn has settled (it gates
// on `!S.busy`), so an `_live` marker seen here is a leftover of the
// live-snapshot projection, not an ongoing stream. Folding on it hid the turn's
// final answer inline and echoed it into the Worklog, leaving the turn with no
// visible content until the #3875 blank-turn fail-safe revealed both copies. A
// turn-final assistant message with visible text is the answer, so keep it out
// of the fold; every other `_live` message folds as before. Kept as a separate
// guard so the live rule below keeps its position.
if(m._live&&hasVisibleText&&isTurnFinalAssistant) return false;
if(m._live) return true;
if(hasVisibleText&&m._anchor_activity_scene) return false;
if(hasVisibleText&&isTurnFinalAssistant) return false;
@@ -13151,6 +13286,17 @@ function _anchorSceneRowsForRendering(scene, opts){
const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[];
const settled=!!(opts&&opts.settled);
const live=!settled;
// #6948 follow-up (duplicate assistant answer; #2051): on a SETTLED turn the
// assistant segment owns the final answer, so a process_prose row carrying
// that same text must not also be rebuilt into the activity scene. The
// transparent-stream renderer suppresses it per row
// (_anchorSceneTransparentNodeForRow), but the compact-worklog row builder
// (_anchorSceneNodeForRow) has no such guard and renders the row as a second
// .assistant-segment above the settled one. Drop it here — the single place
// BOTH settled renderers (and the #5839 deferred-rows path) get their rows.
// LIVE rendering is untouched: while streaming, the inline live segment is
// hidden and the prose row IS the visible answer.
const settledFinalAnswer=settled?String((scene&&scene.final_answer)||'').trim():'';
const out=[];
const byKey=new Map();
const liveProseTextKeys=new Map();
@@ -13173,6 +13319,9 @@ function _anchorSceneRowsForRendering(scene, opts){
if(_anchorSceneIsSettledSuccessfulCompression(row,settled)) continue;
const text=String(row.text||'').trim();
if((row.role==='prose'||row.role==='thinking')&&!text) continue;
if(settledFinalAnswer&&row.role==='prose'
&&typeof _anchorSceneProseDuplicatesFinalAnswer==='function'
&&_anchorSceneProseDuplicatesFinalAnswer(text,settledFinalAnswer)) continue; // #6948 follow-up
const key=keyFor(row);
if(byKey.has(key)){
const index=byKey.get(key);
@@ -13447,6 +13596,26 @@ function _anchorSceneProseMatchesFinalAnswer(proseText, finalAnswer){
const shorter=Math.min(a.length,b.length), longer=Math.max(a.length,b.length);
return shorter>=80 && (shorter/longer)>=0.9;
}
// #6948 follow-up (duplicate assistant answer; #2051): the same near-equality
// test as _anchorSceneProseMatchesFinalAnswer, minus its absolute `shorter>=80`
// floor. That floor makes the matcher a no-op for every SHORT final answer
// (greetings, "Done.", one-liners), so a prose row that is the answer minus its
// last streamed token — e.g. "Hey! What can I help you with today" vs
// "...today?" — was rebuilt as a SECOND assistant-segment beside the settled
// one. The >=0.9 length ratio is what actually keeps a distinct short
// intermediate sentence from being swallowed by a long final answer (Codex
// #4568), so it alone is kept. Deliberately a separate helper: the per-row
// render-time matcher above, and the tests pinning its exact predicate, stay
// untouched.
function _anchorSceneProseDuplicatesFinalAnswer(proseText, finalAnswer){
if(_anchorSceneProseMatchesFinalAnswer(proseText,finalAnswer)) return true;
const norm=(s)=>String(s||'').replace(/\s+/g,' ').trim();
const a=norm(proseText), b=norm(finalAnswer);
if(!a||!b) return false;
if(!(a.startsWith(b)||b.startsWith(a))) return false;
const shorter=Math.min(a.length,b.length), longer=Math.max(a.length,b.length);
return (shorter/longer)>=0.9;
}
function _anchorSceneWorklogGroup(blocks, opts){
if(!blocks) return null;
const live=!!(opts&&opts.live);
@@ -16922,6 +17091,23 @@ function _processWakeupCardHtml(info, rawText, extras){
return `<details class="process-wakeup-card"><summary class="process-wakeup-summary"><span class="process-wakeup-toggle">${li('chevron-right',12)}</span><span class="process-wakeup-label">${li('terminal',13)}<span>${esc(t('process_wakeup_label'))}</span></span>${cmdHtml}${chip}${extras.timeHtml||''}</summary><div class="process-wakeup-detail">${extras.filesHtml||''}${patternRow}${cmdRow}<div class="msg-body process-wakeup-body">${outHtml}</div>${extras.footHtml||''}</div></details>`;
}
// #2051: parse into a <template> and move the nodes instead of insertAdjacentHTML —
// every step is idempotent, so a DOM-API wrapper (e.g. an anti-fingerprinting
// extension) that executes the call twice cannot duplicate the block.
function _insertSegmentBlock(seg, html){
if(!seg) return;
if(typeof document!=='undefined'&&typeof document.createElement==='function'){
try{
const tpl=document.createElement('template');
if('content' in tpl){
tpl.innerHTML=html;
seg.appendChild(tpl.content);
return;
}
}catch(_){ /* fall through to the string path below */ }
}
seg.insertAdjacentHTML('beforeend', html);
}
function renderMessages(options){
_lastMessageRenderAt=performance.now();
const preserveScroll=!!(options&&options.preserveScroll);
@@ -17686,7 +17872,7 @@ function renderMessages(options){
if(isLastTextPart&&statusHtml){
orderedSeg.insertAdjacentHTML('beforeend', statusHtml);
}
orderedSeg.insertAdjacentHTML('beforeend', `${isLastTextPart?filesHtml:''}<div class="msg-body">${(typeof m!=='undefined'&&m&&m._media_snapshots&&typeof m._media_snapshots==='object')?_stampMediaSnapshots(partBodyHtml,m._media_snapshots):partBodyHtml}</div>${isLastTextPart?footHtml:''}`);
_insertSegmentBlock(orderedSeg, `${isLastTextPart?filesHtml:''}<div class="msg-body">${(typeof m!=='undefined'&&m&&m._media_snapshots&&typeof m._media_snapshots==='object')?_stampMediaSnapshots(partBodyHtml,m._media_snapshots):partBodyHtml}</div>${isLastTextPart?footHtml:''}`);
blocks.appendChild(orderedSeg);
if(!firstSeg) firstSeg=orderedSeg;
});
@@ -17746,9 +17932,9 @@ function renderMessages(options){
const hasVisibleBody=!!(String(content||'').trim()||filesHtml||recoveryHtml);
if(statusHtml){
seg.insertAdjacentHTML('beforeend', statusHtml);
if(hasVisibleBody) seg.insertAdjacentHTML('beforeend', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
if(hasVisibleBody) _insertSegmentBlock(seg, `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
}else if(hasVisibleBody){
seg.insertAdjacentHTML('beforeend', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
_insertSegmentBlock(seg, `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
}else if(!(thinkingText&&window._showThinking!==false&&!isSimplifiedToolCalling())){
seg.classList.add('assistant-segment-anchor');
}
@@ -18171,12 +18357,15 @@ function renderMessages(options){
if(!cards.length&&!anchorReasonHtml&&!thinkingText) continue;
const anchorTurn=anchorRow.closest('.assistant-turn');
if(!anchorTurn) continue;
// Hoisted out of the `if(!state)` block below (same expression, same
// value) so the append path can use the ownership fact the group
// construction already uses.
const anchorIsWorklogSource=anchorRow.classList&&anchorRow.classList.contains('assistant-segment-worklog-source');
let state=activityByTurn.get(anchorTurn);
if(!state){
const includeTurnDuration=!durationAssignedTurns.has(anchorTurn);
if(includeTurnDuration) durationAssignedTurns.add(anchorTurn);
const activityKey=`assistant:${aIdx}`;
const anchorIsWorklogSource=anchorRow.classList&&anchorRow.classList.contains('assistant-segment-worklog-source');
const group=ensureActivityGroup(anchorParent,{
collapsed:true,
anchor:anchorRow,
@@ -18196,7 +18385,14 @@ function renderMessages(options){
state.cards.push(...cards);
_appendWorklogStep(state.group, anchorRow, cards, thinkingText, {
live:false,
includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml,
// Echo an anchor's prose as a `.wl-reason` row only when that anchor was
// folded into this Worklog. `assistant-segment-worklog-source` is the
// proof, and its `display:none` is the only reason the echo is not a
// second visible copy. The group construction above already reasons
// that way (`syncAnchorReason`); the append path did not, so an anchor
// that escapes the fold (the turn-final answer, an `_error` message)
// had its text rendered both inline and inside the Worklog.
includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml&&!!anchorIsWorklogSource,
thinkingKey:thinkingText?`thinking:${_normalizeThinkingEchoCompare(thinkingText)}`:'',
thinkingDisclosureKey:thinkingText?`thinking:${entry.key}`:'',
seenReasons:state.seenReasons,
@@ -18485,6 +18681,15 @@ function renderMessages(options){
const groups=turn.querySelectorAll('.tool-worklog-group,.tool-call-group');
let revealed=false;
for(const group of groups){
// A settled Worklog whose rows are still deferred (#5839) has an empty
// textContent but is not empty in substance. Judging it empty here drops
// through to the last-resort un-hide below, and the deferred rows then
// materialize the same prose beside the segments it just un-hid.
// Materialize first, then judge.
if(group.getAttribute&&group.getAttribute('data-worklog-rows-deferred')==='1'
&&typeof _materializeDeferredWorklogRows==='function'){
_materializeDeferredWorklogRows(group);
}
if(!(group.textContent||'').trim()) continue; // empty group can't help
if(group.classList.contains('tool-call-group-collapsed')){
group.classList.remove('tool-call-group-collapsed');
@@ -18603,7 +18808,17 @@ function renderMessages(options){
// restore the whole preserved turn so nothing the user saw vanishes.
if(S.session) _preservedLiveTurn.dataset.sessionId=S.session.session_id;
_rebuilt.replaceWith(_preservedLiveTurn);
}else{
}else if(!(typeof _settledTranscriptOwnsLiveTurn==='function'
&&_settledTranscriptOwnsLiveTurn(sid,_preservedLiveTurn))){
// #6948 follow-up (duplicate assistant answer; #2051): this is the
// only branch that ADDS a turn — the rebuild produced no live turn of
// its own. When the settled transcript already ends with THIS stream's
// own answer and the preserved node carries nothing unpersisted, the
// node is a dead leftover (the row persisted and the turn settled while
// INFLIGHT[sid] was not yet cleaned) and appending pins a SECOND copy
// that re-preserves itself on every later render until a reload.
// Mid-stream the transcript ends with the user turn (or still carries a
// live projection), so #3877 preservation is untouched.
if(S.session) _preservedLiveTurn.dataset.sessionId=S.session.session_id;
inner.appendChild(_preservedLiveTurn);
}
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Real page/loadSession/renderers with deterministic session and SSE fixtures.
No agent, provider, production state, or credentials. Tests DOM reconstruction,
not a real provider/network reconnect.
"""
import json
import os
import tempfile
import time
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
from playwright.sync_api import sync_playwright
from browser_conversation_lifecycle import _start_webui_server, _terminate_process
ROOT = Path(os.environ.get('WEBUI_TEST_ROOT', Path(__file__).resolve().parent.parent))
def fixture(count):
tools = [dict(name='terminal', tid=f'tool-{i}', args={'command': f'printf result-{i}'},
preview=f'result-{i}', snippet=f'result-{i}', done=True) for i in range(count)]
rows = [dict(row_id=f'tool:{t["tid"]}', local_id=t['tid'], role='tool', kind='tool_call',
source_event_type='tool_complete', status='completed', order_index=i,
tool=t, payload=t) for i, t in enumerate(tools)]
return dict(stream_id='run-fixture', last_seq=1000, last_event_id='run-fixture:1000',
messages=[], tool_calls=tools, last_assistant_text='', last_reasoning_text='',
anchor_activity_scene=dict(version='activity_scene_v1',
identity=dict(session_id='fixture', stream_id='run-fixture', run_id='run-fixture'),
activity_rows=rows))
INIT = """
// A controlling service worker bypasses Playwright's page route fixtures in
// WebKit. This test targets reconnect rendering, not PWA cache behavior.
if(window===window.top && 'serviceWorker' in navigator){
navigator.serviceWorker.register=()=>Promise.reject(new Error('Disabled in reconnect harness'));
}
window.fixtureSources=[];
class FixtureEventSource {
static OPEN=1; static CONNECTING=0; static CLOSED=2;
constructor(url){this.url=String(url);this.readyState=1;this.listeners={};window.fixtureSources.push(this);}
addEventListener(name,fn){(this.listeners[name]||=[]).push(fn);}
removeEventListener(){}
close(){this.readyState=2;}
emit(name,data,id){for(const fn of this.listeners[name]||[])fn({data:JSON.stringify(data),lastEventId:id||''});}
}
window.EventSource=FixtureEventSource;
"""
def session_route(session, sid, workspace):
def handle(route):
query = parse_qs(urlsplit(route.request.url).query)
requested = query.get('session_id', [''])[0]
data = session if requested == sid else dict(
session_id='idle-fixture', messages=[], message_count=0,
tool_calls=[], workspace=workspace)
route.fulfill(json={'session': data})
return handle
def main():
tool_count = int(os.environ.get('TOOL_COUNT', '470'))
snapshot = fixture(tool_count)
stream_id = snapshot['stream_id']
sid = 'fixture'
with tempfile.TemporaryDirectory(prefix='webui-reconnect-') as temp:
state = Path(temp)
env = {k:os.environ[k] for k in ('PATH','SYSTEMROOT','TMPDIR') if k in os.environ}
env.update(HOME=temp, HERMES_HOME=temp, HERMES_BASE_HOME=temp,
HERMES_WEBUI_STATE_DIR=str(state/'webui'), HERMES_CONFIG_PATH=str(state/'config.yaml'),
HERMES_WEBUI_HOST='127.0.0.1', HERMES_WEBUI_SKIP_ONBOARDING='1',
HERMES_WEBUI_AGENT_DIR=str(state/'no-agent'))
proc, log, _, base = _start_webui_server(ROOT, env, state)
results = []
try:
with sync_playwright() as pw:
for engine in os.environ.get('BROWSERS','chromium,webkit').split(','):
browser = getattr(pw,engine).launch(headless=True)
try:
for mode in os.environ.get('MODES','compact_worklog,transparent_stream,hide_all_activity').split(','):
for width in [1280,390]:
context = browser.new_context(viewport={'width':width,'height':844},bypass_csp=True)
context.add_init_script(INIT)
page = context.new_page()
errors=[]
page.on('pageerror',lambda e, errors=errors:errors.append(str(e)))
session = dict(session_id=sid,title='Reconnect performance fixture',model='',
workspace=temp,messages=[],message_count=0,tool_calls=[],
active_stream_id=stream_id,pending_user_message='Inspect the fixture',
pending_started_at=1,runtime_journal_snapshot=snapshot)
page.route('**/api/session?*',session_route(session,sid,temp))
page.route('**/api/chat/stream/status?*',lambda r:r.fulfill(json={'active':True}))
page.goto(base,wait_until='load')
# WebKit's wait_for_function uses eval, blocked by
# the app CSP even with bypass_csp. Inspector
# evaluation still works; wait on actual boot state.
deadline=time.monotonic()+30
while not page.evaluate("typeof loadSession==='function' && S._bootReady === true"):
assert time.monotonic()<deadline, ('boot timeout', errors)
page.wait_for_timeout(50)
page.evaluate("""mode=>{
window._chatActivityDisplayMode=mode;window._showThinking=true;
window._simplifiedToolCalling=true;
window.redrawCount=0;
const render=window._renderLiveAnchorActivitySceneForStream;
window._renderLiveAnchorActivitySceneForStream=function(...args){window.redrawCount++;return render(...args);};
}""",mode)
measured=page.evaluate("""async sid=>{
const t=performance.now();await loadSession(sid);
const loadMs=performance.now()-t;
const deadline=performance.now()+5000;
while(!fixtureSources.some(s=>s.url.includes('api/chat/stream?'))&&performance.now()<deadline){
await new Promise(r=>setTimeout(r,10));
}
return {ms:loadMs,redraws:window.redrawCount,
tools:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
busy:S.busy,
sources:fixtureSources.filter(s=>s.url.includes('api/chat/stream?')).map(s=>s.url)};
}""",sid)
result=dict(engine=engine,mode=mode,width=width,
**{k:v for k,v in measured.items() if k!='sources'})
print(json.dumps(result),flush=True)
results.append(result)
if not os.environ.get('MEASURE_BASELINE'):
assert measured['redraws']<=2,result
expected=0 if mode=='hide_all_activity' else tool_count
assert measured['tools']==expected,result
assert measured['busy'],result
assert measured['sources'], (result, errors)
cursor=parse_qs(urlsplit(measured['sources'][-1]).query)
assert cursor['after_seq']==[str(snapshot['last_seq'])], (result,errors)
if mode!='hide_all_activity':
# Subsequent real SSE handler updates still paint the existing owner.
updated=page.evaluate("""({sid,stream})=>{
const source=fixtureSources.findLast(s=>s.url.includes('api/chat/stream?')&&s.readyState===1);
if(!source)throw new Error('missing chat stream');
source.emit('tool',{name:'terminal',tid:'after-reconnect',args:{command:'printf later'},preview:'later'},stream+':1001');
source.emit('tool_complete',{name:'terminal',tid:'after-reconnect',preview:'LATER RESULT',duration:1},stream+':1002');
return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')};
}""",{'sid':sid,'stream':stream_id})
assert updated=={'rows':expected+1,'result':True},updated
switched=page.evaluate("""async sid=>{
await loadSession('idle-fixture');
await loadSession(sid);
return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')};
}""",sid)
assert switched==updated,switched
artifact=os.environ.get('SCREENSHOT_DIR')
if artifact:
Path(artifact).mkdir(parents=True,exist_ok=True)
page.screenshot(path=str(Path(artifact)/f'{engine}-{mode}-{width}.png'))
assert not errors,errors
context.close()
finally:
browser.close()
output=os.environ.get('METRICS_FILE')
if output:
Path(output).write_text(json.dumps(results,indent=2))
finally:
_terminate_process(proc)
log.close()
if __name__=='__main__':
main()
@@ -0,0 +1,81 @@
"""#7569 review fix: timed-flush must not leave the folded index stale.
The ~10 Hz reasoning throttle in the stream callback clears the coalescing
buffer before emitting:
put('reasoning', {'text': _reasoning_buffer[0]})
_reasoning_buffer[0] = ''
# ← _reasoning_buffer_index.reset() was missing here
``_CompactEchoIndex`` records raw offsets *relative to the buffer's accumulated
length*. Clearing the buffer without resetting the index leaves it describing
text that is no longer there, so the next ``cut_to`` returns a raw offset that
no longer corresponds to the buffer — the live-echo strip then truncates the
buffer at a phantom position and falsely marks the interim as
``reasoning_echo: true`` (leaks a truncated copy into both the SSE stream and
the durable journal).
This file pins the invariant at the *call site*, not just on the index class:
revert the fix commit and the first test fails.
"""
import pathlib
import re
REPO = pathlib.Path(__file__).resolve().parent.parent
STREAMING = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
def _timed_flush_block() -> str:
"""The ``if now - _reasoning_last_put[0] >= 0.1:`` throttle branch body."""
match = re.search(
r"if now - _reasoning_last_put\[0\] >= 0\.1:\n(.*?)(?=\n\s{16}(?:# Track reasoning deltas|_metering_reasoning_deltas))",
STREAMING,
re.DOTALL,
)
assert match, "timed-flush throttle branch not found in api/streaming.py"
return match.group(1)
def test_timed_flush_clears_index_together_with_the_buffer():
"""Clearing the reasoning buffer must also reset the folded index, in the
same throttle branch, after the clear (ordering matters: the reset cannot
sit in an unrelated branch or before the clear)."""
block = _timed_flush_block()
assert "_reasoning_buffer[0] = ''" in block, (
"timed-flush branch must clear the coalesced reasoning buffer"
)
assert "_reasoning_buffer_index.reset()" in block, (
"timed-flush branch must reset the folded index when it clears the "
"buffer — leaving the index stale makes later cut_to() offsets "
"describe text that is no longer in the buffer (#7569 re-gate)"
)
# reset must come AFTER the clear, and be at the same indent depth (8
# spaces inside the throttle branch), not in a sibling branch.
clear_pos = block.index("_reasoning_buffer[0] = ''")
reset_pos = block.index("_reasoning_buffer_index.reset()")
assert reset_pos > clear_pos, (
"reset must follow the buffer clear, so the next append starts from "
"an empty raw-length base"
)
assert re.search(r"^\s{20}_reasoning_buffer_index\.reset\(\)", block, re.M), (
"reset must be inside the timed-flush branch (deeper than the clear), "
"not in an outer/sibling scope"
)
def test_all_buffer_clears_are_index_safe():
"""Every site that zeroes ``_reasoning_buffer[0]`` must reset the folded
index immediately after — grep the codebase for clear sites and require
each to be paired with the index reset."""
for clear_match in re.finditer(r"_reasoning_buffer\[0\] = ''", STREAMING):
window = STREAMING[clear_match.start(): clear_match.start() + 400]
reset_present = re.search(
r"_reasoning_buffer_index\.reset\(\)", window
)
assert reset_present, (
"a buffer clear at api/streaming.py has no adjacent "
"_reasoning_buffer_index.reset() — stale offsets would corrupt "
"the next cut_to() call"
)
+5
View File
@@ -416,6 +416,10 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
legacy_metadata_source = _function_source(
_ui_js(), "_legacySettledFallbackHasToolMetadata"
)
# #2051: renderMessages() inserts a message block through this helper, so it is
# evaluated with it. The shim's createElement() returns no template `content`, so the
# helper takes its insertAdjacentHTML fallback here, exactly as before.
insert_block_source = _function_source(_ui_js(), "_insertSegmentBlock")
script = textwrap.dedent(
f"""
class FakeClassList {{
@@ -702,6 +706,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
eval({json.dumps(transparent_source)});
eval({json.dumps(legacy_metadata_source)});
eval({json.dumps(insert_block_source)});
eval({json.dumps(render_source)});
const toolResult = {{ role: 'tool', tool_call_id: 'toolu_1', content: 'tool result' }};
+218
View File
@@ -0,0 +1,218 @@
"""``_CompactEchoIndex`` must agree with the raw backward walk, cheaply.
The index is the echo-match authority for both the journal rebuild
(``api/routes.py::_run_journal_live_snapshot``) and the live stream
(``api/streaming.py``). It replaces a per-interim raw backward walk that
rescanned the whole transcript's whitespace — quadratic on whitespace-heavy
text (#7569). Two contracts must hold for the swap to be safe:
1. **Equivalence** — incremental appends must reach the same cut point as a
raw walk over the concatenated text, for every shape including chunked
appends, whitespace-only chunks, and exotic Unicode whitespace.
2. **Cost** — the index must never rescan the buffer it has already folded:
a probe after a wide whitespace span must not walk that span.
"""
import random
import re
import string
import pytest
def _compact(value):
return re.sub(r'\s+', '', str(value or ''))
def _raw_reference(value, suffix):
"""Deliberately naive oracle: probe every cut index, fold the tail."""
raw = str(value or '')
candidate = _compact(suffix)
if not raw or not candidate:
return raw, False
for idx in range(len(raw) + 1):
if _compact(raw[idx:]) == candidate:
return raw[:idx].rstrip(), True
return raw, False
def _cases():
cases = [
# Exact echo at the end.
("bla bla bla une conclusion", "une conclusion"),
# Echo differing only by whitespace shape.
("bla bla une conclusion", "une conclusion"),
("bla bla\nune\tconclusion", "une conclusion"),
# No echo.
("texte quelconque sans rapport", "une conclusion"),
# Partial echo must NOT match.
("bla une conclu", "une conclusion"),
# The buffer is exactly the echo.
("une conclusion", "une conclusion"),
# Empty / None inputs.
("", "une conclusion"),
("bla bla", ""),
("", ""),
(None, "x"),
("x", None),
# Whitespace around the cut point.
("bla bla une conclusion ", "une conclusion"),
("bla bla \n une conclusion", "une conclusion"),
# Unicode, accents, emoji, exotic whitespace.
("préambule ✅ conclusion émise", "conclusion émise"),
("texte 🟢 Réponse", "🟢 Réponse"),
("bla\u00a0bla une conclusion", "une\u00a0conclusion"),
("bla\u2028une conclusion", "une conclusion"),
# Whitespace-only buffer.
(" ", "une conclusion"),
# Repetitions: the leftmost cut point is contractual.
("abc abc abc", "abc"),
("abc abc abc", "abc abc"),
("aaaa", "aa"),
]
rnd = random.Random(20260920)
alphabet = string.ascii_letters + " \n\t" + "éà✅\u00a0"
for _ in range(2000):
base = "".join(rnd.choice(alphabet) for _ in range(rnd.randint(0, 120)))
if rnd.random() < 0.5 and len(base) > 4:
k = rnd.randint(1, max(1, len(base) // 2))
suffix = base[-k:]
else:
suffix = "".join(rnd.choice(alphabet) for _ in range(rnd.randint(0, 30)))
cases.append((base, suffix))
return cases
def test_index_matches_raw_reference_oracle():
"""Incremental index == naive raw probe, for one-shot and chunked appends."""
import api.streaming as streaming
divergences = []
for value, suffix in _cases():
expected = _raw_reference(value, suffix)
index = streaming._CompactEchoIndex()
index.append(value)
actual = index.cut_to(suffix)
if actual is None:
got = (str(value or ''), False)
else:
got = (str(value or '')[:actual].rstrip(), True)
if got != expected:
divergences.append((value, suffix, expected, got))
assert not divergences[:5], (
f"{len(divergences)} divergence(s) from the reference implementation; "
f"first: {divergences[:1]}"
)
def test_chunked_appends_reach_the_same_cut_point():
"""Appending in pieces must equal appending the whole string."""
import api.streaming as streaming
rnd = random.Random(99)
for _ in range(500):
parts = [
"".join(
rnd.choice(string.ascii_letters + " \n\té")
for _ in range(rnd.randint(1, 20))
)
for _ in range(rnd.randint(1, 8))
]
value = "".join(parts)
if rnd.random() < 0.6 and len(value) > 4:
suffix = value[-rnd.randint(1, len(value) // 2):]
else:
suffix = "".join(rnd.choice("abc \t") for _ in range(rnd.randint(0, 12)))
chunked = streaming._CompactEchoIndex()
for part in parts:
chunked.append(part)
whole = streaming._CompactEchoIndex()
whole.append(value)
assert chunked.cut_to(suffix) == whole.cut_to(suffix), (
f"chunked append diverged for value={value!r} suffix={suffix!r}"
)
def test_matches_tail_and_cut_to_agree():
"""The boolean probe and the cut probe must never disagree."""
import api.streaming as streaming
for value, suffix in _cases():
index = streaming._CompactEchoIndex()
index.append(value)
assert index.matches_tail(suffix) == (index.cut_to(suffix) is not None), (
f"matches_tail/cut_to disagree for value={value!r} suffix={suffix!r}"
)
def test_reset_clears_state_after_truncation():
"""After reset + re-append the index must describe only the new text."""
import api.streaming as streaming
index = streaming._CompactEchoIndex()
index.append("old reasoning tail with a wide span " + " " * 5000)
index.reset()
index.append("fresh start only")
assert index.compact_length == len("freshstartonly")
assert index.cut_to("start only") == len("fresh ")
assert index.cut_to("old reasoning") is None
def test_index_probe_does_not_rescan_a_wide_whitespace_span(monkeypatch):
"""The probe must not walk raw characters it already folded.
Pins the cost property the raw backward walk violated: after a 1 MB
whitespace span is appended, a probe must cost O(candidate), not
O(span). Counting ``str.isspace``-adjacent raw access is impractical, so
this counts how many characters ``append`` has to fold: exactly the
appended length, once — and the probe itself must not fold anything.
"""
import api.streaming as streaming
fold_calls = {'n': 0}
original = streaming._compact_for_echo_compare
def counting(value):
fold_calls['n'] += 1
return original(value)
monkeypatch.setattr(streaming, '_compact_for_echo_compare', counting)
index = streaming._CompactEchoIndex()
index.append("alpha" + " " * 100_000)
fold_calls['n'] = 0
# 20 probes of a short candidate: the raw walk would fold/walk the span
# each time; the index folds only the candidate.
for i in range(20):
assert index.matches_tail(f"progress {i}") is False
assert fold_calls['n'] <= 20, (
f"echo probe folded {fold_calls['n']} times — it is rescanning the "
"raw transcript instead of using the indexed tail"
)
@pytest.mark.timeout(60)
def test_index_stays_cheap_on_a_wide_span_with_many_probes():
"""Wide whitespace span + many probes must not cost seconds of CPU."""
import time
import api.streaming as streaming
index = streaming._CompactEchoIndex()
index.append("alpha" + " " * 1_000_000)
probes = [f"progress {i}" for i in range(200)]
start = time.perf_counter()
for probe in probes:
index.matches_tail(probe)
index.cut_to(probe)
elapsed = time.perf_counter() - start
# The raw backward walk needs ~0.05s per probe here (2M+ character walks
# total). A generous budget still fails it by an order of magnitude.
assert elapsed < 0.5, f"200 probes on a 1MB span took {elapsed:.3f}s"
+29 -41
View File
@@ -7,11 +7,16 @@ with ``window x suffix`` instead of with the data actually inspected. On a
6000-character conclusion block that is seconds of pure CPU, held under the
GIL, which stalls every other stream in the process.
The current implementation walks ``value`` and ``suffix`` backwards from the
end (skipping whitespace in ``value``) and has no fixed search window, so a
compact-equivalent echo is found regardless of how much interior whitespace
stretches its raw span.
These tests pin both halves of the contract:
* the fast path must return byte-identical results to a straightforward
reference implementation, including the leftmost-cut tie-break; and
* it must not re-fold the search window once per candidate cut index.
* it must not fold the whole buffer once per candidate cut index.
"""
import random
import re
@@ -20,11 +25,12 @@ import string
import pytest
def _reference_strip_compact_echo_suffix(value, suffix, *, search_window: int = 4096):
"""Deliberately naive oracle: probe every cut index, fold the whole tail.
def _reference_strip_compact_echo_suffix(value, suffix):
"""Deliberately naive oracle: probe every cut index, fold the tail.
This mirrors the original behaviour exactly and exists only so the
optimised implementation can be proven equivalent to it.
This mirrors the pre-window semantics (the original probing loop) with no
search window, and exists only so the optimised backward walk can be
proven equivalent to it.
"""
def compact(v):
return re.sub(r'\s+', '', str(v or ''))
@@ -33,11 +39,9 @@ def _reference_strip_compact_echo_suffix(value, suffix, *, search_window: int =
candidate = compact(suffix)
if not raw or not candidate:
return raw, False
tail = raw[-max(len(str(suffix or '')) * 3, search_window):]
offset = len(raw) - len(tail)
for idx in range(len(tail) + 1):
if compact(tail[idx:]) == candidate:
return raw[: offset + idx].rstrip(), True
for idx in range(len(raw) + 1):
if compact(raw[idx:]) == candidate:
return raw[:idx].rstrip(), True
return raw, False
@@ -74,8 +78,8 @@ def _cases():
("aaaa", "aa"),
# Long buffer with the echo at the very end.
("remplissage " * 500 + "la vraie conclusion", "la vraie conclusion"),
# Echo whose whitespace-padded span is wider than the search window:
# the window only exposes ``conclusion``, so nothing may be stripped.
# Echo whose whitespace-padded span is wider than any fixed fold
# window: the backward walk must still strip it.
("bla une" + " " * 5000 + "conclusion", "une conclusion"),
# Exotic whitespace that ``\s`` and ``str.isspace`` must treat alike.
("bla\u00a0bla une conclusion", "une\u00a0conclusion"),
@@ -111,45 +115,29 @@ def test_strip_compact_echo_suffix_matches_reference_oracle():
)
def test_strip_compact_echo_suffix_honours_a_custom_search_window():
"""The bounded window must keep its meaning on the fast path."""
import api.streaming as streaming
buffer_text = "z" * 400 + " la conclusion"
for window in (16, 64, 512, 4096):
assert streaming._strip_compact_echo_suffix(
buffer_text, "la conclusion", search_window=window
) == _reference_strip_compact_echo_suffix(
buffer_text, "la conclusion", search_window=window
), f"window={window}"
def test_strip_compact_echo_suffix_rejects_an_echo_wider_than_the_window():
"""An echo that only fits inside a wider window must not be found.
def test_strip_compact_echo_suffix_finds_an_echo_wider_than_any_fixed_window():
"""An echo stretched past any fold window must still be stripped.
The buffer ends with the echo, but interior whitespace stretches its raw
span past the default window, so the folded window only exposes the last
word. Widening the window to cover the whole span makes the same echo
visible again, which proves the non-match is caused by the window and
not by the text.
span far beyond a fixed fold window (the retired windowed implementation
exposed only the last word and left the buffer untouched). The backward
walk has no window, so the cut point is found and the echo is removed.
"""
import api.streaming as streaming
buffer_text = "bla une" + " " * 5000 + "conclusion"
suffix = "une conclusion"
for impl in (streaming._strip_compact_echo_suffix, _reference_strip_compact_echo_suffix):
assert impl(buffer_text, suffix) == (buffer_text, False), impl.__name__
assert impl(buffer_text, suffix, search_window=len(buffer_text)) == ("bla", True), impl.__name__
assert streaming._strip_compact_echo_suffix(buffer_text, suffix) == ("bla", True)
assert _reference_strip_compact_echo_suffix(buffer_text, suffix) == ("bla", True)
def test_strip_compact_echo_suffix_does_not_refold_the_window_per_cut(monkeypatch):
def test_strip_compact_echo_suffix_does_not_refold_per_cut(monkeypatch):
"""One pass, not one fold per candidate cut index.
The original implementation called the whitespace-folding helper once per
possible cut position — roughly 4097 times for a default window — and each
of those calls re-scanned the remaining tail. Counting the calls pins the
algorithmic property directly, without depending on wall-clock timing.
Counting the folding-helper calls pins the algorithmic property directly,
without depending on wall-clock timing: the backward walk folds the
suffix once and then compares characters.
"""
import api.streaming as streaming
@@ -168,7 +156,7 @@ def test_strip_compact_echo_suffix_does_not_refold_the_window_per_cut(monkeypatc
assert calls['n'] <= 8, (
f"whitespace folding ran {calls['n']} times for a single call; the scan "
"is re-folding the window once per candidate cut index"
"is re-folding once per candidate cut index"
)
@@ -193,4 +181,4 @@ def test_strip_compact_echo_suffix_stays_cheap_on_a_long_conclusion():
# The original implementation needs ~7s here. A very loose budget keeps the
# test meaningful on a loaded CI box while still failing the quadratic scan
# by three orders of magnitude.
assert elapsed < 0.5, f"single call took {elapsed:.3f}s"
assert elapsed < 0.5, f"single call took {elapsed:.3f}s"
@@ -50,6 +50,10 @@ _REAL_FUNCTIONS = (
"_insertPreservedCompressionTaskFallback",
"_pinCompactionCardAtTop",
"_pinSettledCompressionReferenceAtTop",
# #2051: renderMessages() inserts a message block through this helper, so it has to be
# evaluated with it. The shim's createElement() returns no template `content`, so the
# helper takes its insertAdjacentHTML fallback here, exactly as before.
"_insertSegmentBlock",
"renderMessages",
)
+3 -3
View File
@@ -163,9 +163,9 @@ def test_read_importable_agent_session_rows_closes_connection(tmp_path, tracking
read_importable_agent_session_rows(db)
_assert_all_closed(tracking_sqlite, "read_importable_agent_session_rows")
# #5455: missing-index self-heal uses one separate short-lived RW connection
# in addition to the five read-only listing connections.
assert len(tracking_sqlite.instances) == 6
# #5455: the listing is strictly read-only, including when indexes are
# missing — five calls, five read-only connections, no self-heal writer.
assert len(tracking_sqlite.instances) == 5
def test_read_session_lineage_metadata_closes_connection(tmp_path, tracking_sqlite):
@@ -0,0 +1,370 @@
"""Regression: the assistant answer is displayed TWICE (#6948 follow-up;
upstream symptom report #2051).
Two independent renderers can put a second copy of the same answer on screen
after a turn settles. Both are covered here.
1. The live-turn node (static/ui.js)
------------------------------------
`renderMessages()` re-attaches the preserved `#liveAssistantTurn` node after the
transcript wipe, and `restoreLiveTurnHtmlForSession()` re-inserts the
`INFLIGHT[sid].liveTurnHtml` snapshot on a session switch / reconnect. Each has
ONE branch that *appends* — i.e. adds a turn the settled rebuild did not
produce. The assistant row is persisted a few ms BEFORE the stream's terminal
event clears `S.activeStreamId`, so a dead live node left behind by an INFLIGHT
entry that outlived its stream is appended under the settled copy: the same
answer twice, self-sustaining across later renders (the node keeps
id=liveAssistantTurn and is re-preserved by the #6948 guard), healed only by a
reload.
The contract (`_settledTranscriptOwnsLiveTurn`) is an OWNERSHIP proof, never a
rendered-text comparison:
* the live body comes from the streaming smd parser and the settled body from
renderMd + post-processing, so comparing textContent would only ever match
single-paragraph plain text — lists, fenced code, tables and `_emphasis_`
render differently in the two pipelines;
* comparing text would DELETE a genuinely live turn whenever the previous
answer reads the same ("Done.", a greeting, a same-prompt resend), because
while the current turn is unpersisted the last settled assistant message is
the PREVIOUS turn's answer.
So the append is skipped only when the transcript ENDS with a settled assistant
message that the SAME stream produced (persisted anchor identity, or a
source-vs-source text match when the turn has no persisted scene), and the
preserved node carries nothing the settled rebuild could not have produced (no
unpersisted tool card / reasoning row, no second live segment — #3714).
2. The settled activity scene (static/ui.js)
--------------------------------------------
`_anchorSceneRowsForRendering(scene,{settled:true})` feeds both settled
renderers. A `process_prose` row whose text IS the final answer is suppressed
per-row by the transparent-stream renderer but NOT by the compact-worklog row
builder, which rebuilds it as a second `.assistant-segment` above the settled
answer. The row is now dropped in the shared filter, using a near-equality test
without the absolute `shorter>=80` floor that made the existing matcher a no-op
for short answers (the reported case: the row is the answer minus its last
streamed token, 35 vs 36 chars). LIVE rendering is untouched — while streaming,
the inline live segment is hidden and the prose row IS the visible answer.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.js_source_extract import extract_function
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
def js(*names):
"""Real source of the named ui.js functions — never a re-implementation."""
return "\n".join(extract_function(UI_JS, name) for name in names)
def run_node(script):
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}"
assert "OK" in out.stdout, out.stdout
return out.stdout
# Minimal DOM stand-in: class/attribute selector matching over a node tree.
DOM_STUB = """
const assert=require('assert');
class El{
constructor(spec){
spec=spec||{};
this.classes=new Set(spec.classes||[]);
this.attrs=Object.assign({},spec.attrs||{});
this.children=spec.children||[];
}
_matchesToken(tok){
tok=String(tok).trim();
if(tok.startsWith('.')) return this.classes.has(tok.slice(1));
const m=/^\\[([a-zA-Z0-9-]+)="([^"]*)"\\]$/.exec(tok);
if(m) return String(this.attrs[m[1]]===undefined?'':this.attrs[m[1]])===m[2];
return false;
}
querySelectorAll(sel){
const toks=String(sel).split(',');
const out=[];
const walk=(node)=>{
for(const child of node.children){
if(toks.some(t=>child._matchesToken(t))) out.push(child);
walk(child);
}
};
walk(this);
return out;
}
}
const seg=(text)=>new El({attrs:{'data-live-assistant':'1'},classes:['assistant-segment'],
children:[new El({classes:['msg-body'],children:[]})]});
const turnWith=(...kids)=>new El({classes:['assistant-turn'],children:kids});
const toolCard=()=>new El({classes:['tool-card-row']});
const emptyWorklogShell=()=>new El({classes:['live-worklog'],attrs:{'data-live-worklog-shell':'1'}});
var S={messages:[],activeStreamId:null,session:{session_id:'s1'}};
var INFLIGHT={};
function setState(messages,inflight,activeStreamId){
S.messages=messages;
S.activeStreamId=activeStreamId===undefined?null:activeStreamId;
for(const k of Object.keys(INFLIGHT)) delete INFLIGHT[k];
if(inflight) INFLIGHT['s1']=inflight;
}
const user=(content)=>({role:'user',content});
const settledAnswer=(content,streamId)=>{
const m={role:'assistant',content};
if(streamId) m._anchor_stream_id=streamId;
return m;
};
const sceneIdentityAnswer=(content,streamId)=>({
role:'assistant',content,
_anchor_activity_scene:{identity:{stream_id:streamId},final_answer:content},
});
"""
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_dead_live_turn_is_dropped_only_when_the_stream_is_provably_settled():
"""The #2051 duplicate is suppressed; a live turn is never deleted."""
script = DOM_STUB + js(
"msgContent",
"_settledAssistantStreamId",
"_messageHasLiveAssistantProjection",
"_liveTurnCarriesUnsettledContent",
"_settledTranscriptOwnsLiveTurn",
) + """
const ANSWER='Hey! What can I help you with today?';
// ── the bug: stream settled, INFLIGHT not yet cleaned → dead leftover ──
setState([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1');
assert.strictEqual(
_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER),emptyWorklogShell())),true,
'dead live node whose stream the settled transcript owns must be dropped (#2051)');
// Identity may live only on the persisted scene.
setState([user('Hi'),sceneIdentityAnswer(ANSWER,'st1')],{streamId:'st1'},null);
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),true,
'scene identity.stream_id is accepted as ownership proof');
// Shape independence: the decision must not depend on rendered text at all, so
// a multi-block answer (fenced code / list / table), which the smd parser and
// renderMd render to DIFFERENT textContent, is handled like plain prose.
setState([user('Hi'),settledAnswer('Here you go:\\n\\n```python\\nprint(1)\\n```','st1')],
{streamId:'st1'},'st1');
assert.strictEqual(
_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Here you go:print(1)'))),true,
'a code-block answer must be de-duplicated too (renderer-independent)');
// ── false positives that a text comparison would cause ──
// The current turn is unpersisted: the transcript ends with the USER turn while
// the live turn streams text identical to the PREVIOUS answer.
setState([user('Hi'),settledAnswer('Done.','st1'),user('Hi')],{streamId:'st2'},'st2');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
'a genuinely live turn must never be dropped because an earlier answer matches');
// markInflight() shape at send time (no snapshot yet, stream just started).
setState([user('Hi'),settledAnswer('Done.','st1'),user('Hi')],{sid:'s1',streamId:'st2',ts:1},'st2');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Do'))),false,
'partial live text under a fresh stream must never be dropped');
// A settled tail from a DIFFERENT stream never owns this live turn.
setState([user('Hi'),settledAnswer('Done.','st1')],{streamId:'st2'},'st2');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
'a tail from another stream must not authorize dropping the live turn');
// Reconnect/terminal projection: S.messages still carries a live marker.
setState([user('Hi'),Object.assign(settledAnswer('Done.','st1'),{_live:true})],
{streamId:'st1'},null);
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
'a live projection in S.messages means the turn has not settled');
// Ownership unknown (no INFLIGHT stream id, no active stream) → fail closed.
setState([user('Hi'),settledAnswer('Done.','st1')],{},null);
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
'unknown live owner must fail closed');
setState([],{streamId:'st1'},'st1');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
'an empty transcript owns nothing');
// ── #3714: live-only structure is never discarded ──
setState([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1');
assert.strictEqual(
_settledTranscriptOwnsLiveTurn('s1',turnWith(toolCard(),seg(ANSWER))),false,
'a preserved turn carrying an unpersisted tool card must be kept');
assert.strictEqual(
_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('first'),seg(ANSWER))),false,
'a multi-segment live turn must not be dropped on a tail-segment match');
assert.strictEqual(
_settledTranscriptOwnsLiveTurn('s1',turnWith(new El({classes:['wl-reason']}),seg(ANSWER))),false,
'a preserved turn carrying an unpersisted reasoning row must be kept');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',{}),false,
'an unknown node shape must fail closed');
// ── no persisted scene identity: SOURCE text vs SOURCE text ──
setState([user('Hi'),settledAnswer(ANSWER,null)],{streamId:'st1',lastAssistantText:ANSWER},'st1');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),true,
'streamed markdown identical to the persisted answer proves ownership');
setState([user('Hi'),settledAnswer(ANSWER,null)],
{streamId:'st1',lastAssistantText:'Hey! What can I help'},'st1');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Hey! What can I help'))),false,
'a still-growing stream must not be dropped against a longer settled answer');
setState([user('Hi'),settledAnswer(ANSWER,null)],{streamId:'st1'},'st1');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),false,
'no identity and no streamed source text → fail closed');
console.log('OK');
"""
run_node(script)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_append_decision_renders_the_answer_exactly_once():
"""Observable outcome of the two append branches, driven by the real
predicate: one copy for a settled leftover, and the live turn is still
attached in every case where the turn may still be running."""
script = DOM_STUB + js(
"msgContent",
"_settledAssistantStreamId",
"_messageHasLiveAssistantProjection",
"_liveTurnCarriesUnsettledContent",
"_settledTranscriptOwnsLiveTurn",
) + """
// Mirrors the two call sites: the branch appends unless the settled transcript
// provably owns the node (static/ui.js renderMessages re-attach and
// restoreLiveTurnHtmlForSession).
function visibleAnswers(messages,inflight,activeStreamId,turn){
setState(messages,inflight,activeStreamId);
const settledCopies=messages.filter(m=>m.role==='assistant'&&!m._live).length;
const appended=!_settledTranscriptOwnsLiveTurn('s1',turn)?1:0;
return {settledCopies,appended,total:settledCopies+appended};
}
const ANSWER='Hey! What can I help you with today?';
// #2051: exactly one copy of the answer.
assert.deepStrictEqual(
visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1',
turnWith(seg(ANSWER),emptyWorklogShell())),
{settledCopies:1,appended:0,total:1},
'the settled answer must render exactly once');
// A live turn is still attached while the turn is running (#3877).
assert.deepStrictEqual(
visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1'),user('Hi again')],
{streamId:'st2'},'st2',turnWith(seg(ANSWER))),
{settledCopies:1,appended:1,total:2},
'a running turn must stay on screen even when it repeats an earlier answer');
// A leftover carrying unpersisted structure is kept whole (#3714).
assert.deepStrictEqual(
visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1',
turnWith(toolCard(),seg(ANSWER))).appended,1,
'never discard live-only blocks the rebuild did not produce');
console.log('OK');
"""
run_node(script)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_settled_scene_drops_the_prose_row_that_is_the_final_answer():
script = DOM_STUB + js(
"_anchorSceneIsSettledSuccessfulCompression",
"_anchorSceneProseMatchesFinalAnswer",
"_anchorSceneProseDuplicatesFinalAnswer",
"_anchorSceneRowsForRendering",
) + """
function _anchorSceneToolRowLogicalKey(row){ return (row&&row.row_id)||''; }
function _anchorSceneMergeToolRows(_prev,row){ return row; }
const FINAL='Hey! What can I help you with today?';
const scene={
final_answer:FINAL,
activity_rows:[
{role:'thinking',row_id:'t1',text:'thinking out loud'},
{role:'prose',row_id:'p1',text:'Let me check the config first.'},
// the reported shape: the answer minus its last streamed token (35 vs 36)
{role:'prose',row_id:'p2',text:'Hey! What can I help you with today'},
{role:'terminal',row_id:'d1',source_event_type:'done'},
],
};
const texts=(opts)=>_anchorSceneRowsForRendering(scene,opts).map(r=>r.text);
assert.deepStrictEqual(texts({settled:true}),['thinking out loud','Let me check the config first.'],
'the settled scene must not rebuild the final answer as a second segment (#2051)');
assert.deepStrictEqual(texts({settled:false}),
['thinking out loud','Let me check the config first.','Hey! What can I help you with today'],
'LIVE rendering must keep the prose row — it IS the visible answer while streaming');
// Exact repeats and multi-block answers are dropped too; source text is compared,
// so the markdown pipeline never matters here.
const code={final_answer:'Here you go:\\n\\n```python\\nprint(1)\\n```',activity_rows:[
{role:'prose',row_id:'p1',text:'Here you go:\\n\\n```python\\nprint(1)\\n```'},
{role:'prose',row_id:'p2',text:'Working on it.'}]};
assert.deepStrictEqual(_anchorSceneRowsForRendering(code,{settled:true}).map(r=>r.text),
['Working on it.'],'an exact fenced-code duplicate must be dropped, intermediates kept');
// Codex #4568: a SHORT distinct intermediate that merely prefixes a LONG answer
// must survive — the >=0.9 length ratio, not the removed absolute floor, is what
// protects it.
const long_={final_answer:'Hey! What can I help you with today? I can read files, run commands and edit code.',
activity_rows:[{role:'prose',row_id:'p1',text:'Hey! What can'}]};
assert.deepStrictEqual(_anchorSceneRowsForRendering(long_,{settled:true}).map(r=>r.text),
['Hey! What can'],'a distinct short intermediate must not be swallowed by a long answer');
// The pinned per-row matcher is untouched: its absolute floor still rejects the
// short near-miss that the new helper accepts.
assert.strictEqual(_anchorSceneProseMatchesFinalAnswer('Hey! What can I help you with today',FINAL),false,
'_anchorSceneProseMatchesFinalAnswer must keep its >=80 char floor');
assert.strictEqual(_anchorSceneProseDuplicatesFinalAnswer('Hey! What can I help you with today',FINAL),true,
'the new helper must catch the short near-miss');
assert.strictEqual(_anchorSceneProseDuplicatesFinalAnswer('Hey! What can',FINAL),false,
'the new helper must keep the >=0.9 ratio guard');
console.log('OK');
"""
run_node(script)
def test_both_append_branches_are_gated_by_the_ownership_check():
"""Wiring: the predicate only helps if it guards the two branches that ADD a
turn (the replace branches cannot duplicate and must stay unguarded)."""
restore = UI_JS[UI_JS.index("function restoreLiveTurnHtmlForSession"):]
restore = restore[:restore.index("\nfunction markInflight")]
assert "if(existing) existing.replaceWith(restored);" in restore
assert "_settledTranscriptOwnsLiveTurn(sid, restored)" in restore, (
"the restore append branch must be gated by the ownership check"
)
assert "inflight.liveTurnHtml=null;" in restore, (
"a refused snapshot must be released, or it is re-refused on every restore"
)
reattach = UI_JS[UI_JS.index(" const _rebuilt=document.getElementById('liveAssistantTurn');"):]
reattach = reattach[:reattach.index(" // Only force-scroll when not actively streaming")]
assert "_settledTranscriptOwnsLiveTurn(sid,_preservedLiveTurn)" in reattach, (
"the renderMessages append branch must be gated by the ownership check"
)
assert reattach.count("_settledTranscriptOwnsLiveTurn(") == 1, (
"the segment-swap and whole-turn replace branches must stay unguarded — "
"they replace a node instead of adding one and cannot duplicate"
)
def test_segment_block_insertion_moves_nodes_instead_of_reparsing_html():
"""Wiring: every message-block insertion in renderMessages() goes through the
helper, and the helper MOVES parsed nodes instead of re-parsing a string.
Appending a DocumentFragment moves its children and empties it, so a DOM-API
wrapper that executes the call twice appends nothing the second time; an
insertAdjacentHTML wrapper that does the same parses and appends a second copy.
That difference is the whole fix, so it is pinned here rather than left to the
three call sites.
"""
helper = UI_JS[UI_JS.index("function _insertSegmentBlock("):]
helper = helper[:helper.index("\nfunction renderMessages")]
assert "createElement('template')" in helper, (
"the block must be parsed into a <template>, not inserted as a string"
)
assert "seg.appendChild(tpl.content)" in helper, (
"the template's content must be MOVED into the segment — that is what makes "
"a repeated call a no-op"
)
assert helper.count("insertAdjacentHTML") == 1, (
"insertAdjacentHTML may survive only as the fallback for an environment "
"without <template>"
)
render = UI_JS[UI_JS.index("function renderMessages(options){"):]
assert render.count("_insertSegmentBlock(") == 3, (
"all three assistant-segment block insertions must go through the helper"
)
assert "seg.insertAdjacentHTML('beforeend', `${filesHtml}" not in render, (
"an assistant segment's block must not be built from an HTML string again"
)
@@ -0,0 +1,234 @@
"""Regression: an interrupted turn's notice — and any turn-final answer — is
displayed twice (#6948 / #2051).
A ``hermes chat`` run killed mid tool-call persists a single assistant message
``Operation interrupted.``; the WebUI renders that notice twice. The same
doubling was reported for a long tool run's final answer and for an image reply
carrying a ``MEDIA:`` line. The stored data is clean in every case — one message
in ``state.db``, one in the session sidecar, one in ``/api/session`` — so both
copies are produced client-side.
``renderMessages()`` folds intermediate assistant segments into the collapsed
Worklog card (#3401): the inline segment is classed
``assistant-segment-worklog-source`` (``display:none``) and its prose is
re-rendered as a ``.wl-reason`` row inside the Worklog. Two copies, one hidden —
correct, as long as the fold decision is correct.
``_assistantMessageBelongsInWorklog()`` folded on ``m._live`` before it could
reach the rule that protects the answer (``hasVisibleText && isTurnFinalAssistant
-> false``). Its caller only consults the predicate once the turn has settled (it
gates on ``!S.busy``), so an ``_live`` marker seen there is a leftover of the
live-snapshot projection rather than an ongoing stream. Folding on it hides the
answer inline and echoes it into the Worklog: the turn then holds two copies and
no visible content, which the #3875 blank-turn fail-safe reveals — by expanding
the Worklog, or by un-hiding the source segments — so the user sees the notice or
answer twice.
Three contracts are pinned here:
1. ``_assistantMessageBelongsInWorklog``: a live marker may no longer outrank the
turn-final visible answer. Every other ``_live`` message folds as before, and
the change can only keep a segment visible, never hide or drop one.
2. ``renderMessages``: the Worklog may echo an anchor's prose as a ``.wl-reason``
row only when that anchor was folded into it —
``assistant-segment-worklog-source`` is that proof, and its ``display:none``
is the only reason the echo is not a second visible copy. This also covers
anchors that escape the fold for other reasons, such as ``_error`` messages,
which are never folded.
3. The #3875 fail-safe must materialize a deferred settled Worklog (#5839) before
judging it empty; otherwise it falls through to the last-resort un-hide,
strips the fold off every source segment, and the deferred rows then render
the same prose beside them.
Each contract is a structural/ownership check rather than a rendered-text
comparison, and each fails towards keeping content: the first only un-hides, the
second only suppresses an echo whose original is provably visible, the third only
builds rows earlier.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.js_source_extract import extract_function
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
def js(*names):
"""Real source of the named ui.js functions — never a re-implementation."""
return "\n".join(extract_function(UI_JS, name) for name in names)
def _function_body(src: str, name: str) -> str:
marker = f"function {name}("
start = src.find(marker)
assert start != -1, f"{name} not found"
brace = src.find("{", start)
assert brace != -1, f"{name} body not found"
depth = 0
for idx in range(brace, len(src)):
ch = src[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[brace + 1 : idx]
raise AssertionError(f"{name} body not closed")
def run_node(script):
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}"
assert "OK" in out.stdout, out.stdout
return out.stdout
HARNESS = """
const assert=require('assert');
var window={};
function _messageHasReasoningPayload(m){ return !!(m&&(m.reasoning||m.reasoning_content)); }
const assistant=(content,extra)=>Object.assign({role:'assistant',content},extra||{});
"""
def test_live_marker_guard_precedes_the_live_fold_rule():
"""Contract 1, structurally: the guard sits before the live fold rule.
Written as an added guard rather than an edit to `if(m._live) return true;`
so the live rule keeps the exact shape and ordering pinned by
tests/test_live_to_final_anchor_visible_order.py.
"""
belongs = _function_body(UI_JS, "_assistantMessageBelongsInWorklog")
guard = "if(m._live&&hasVisibleText&&isTurnFinalAssistant) return false;"
assert guard in belongs, (
"a settled-render live remnant must not fold the turn's final visible answer"
)
assert belongs.index(guard) < belongs.index("if(m._live) return true;"), (
"the guard must be evaluated before the unconditional live fold"
)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_stale_live_marker_never_folds_the_turn_final_answer():
"""The fold must not swallow the answer on a settled-render live remnant."""
script = HARNESS + js(
"msgContent",
"_isAssistantEmptyPlaceholderContent",
"_assistantMessageBelongsInWorklog",
) + """
const FINAL={isTurnFinalAssistant:true};
const MID={isTurnFinalAssistant:false};
const owners=new Set([0]);
// The bug: the settled turn-final answer folds on a stale live marker.
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('Operation interrupted.',{_live:true}),0,owners,undefined,FINAL),
false,
'a settled turn-final notice must never be folded into the Worklog (#2051)');
assert.strictEqual(
_assistantMessageBelongsInWorklog(
assistant('Here is the image you asked for.\\n\\nMEDIA:/tmp/example.png',{_live:true}),0,owners,undefined,FINAL),
false,
'a MEDIA-bearing turn-final answer must never be folded either');
// Renderer-independent: the decision must not depend on what the text looks like.
assert.strictEqual(
_assistantMessageBelongsInWorklog(
assistant('Here you go:\\n\\n```python\\nprint(1)\\n```',{_live:true}),0,owners,undefined,FINAL),
false,
'a fenced-code turn-final answer must never be folded');
// Everything else about the fold is unchanged.
// A live intermediate segment still folds — that is the Worklog's content.
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('Reading the files now.',{_live:true}),0,owners,undefined,MID),
true,
'a live intermediate segment must still fold into the Worklog');
// A live turn-final message with no visible text still folds (an empty tool-call
// anchor is Worklog material, not an answer).
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('',{_live:true}),0,owners,undefined,FINAL),
true,
'an empty live anchor must still fold');
// `(empty)` placeholder + reasoning payload is not visible text either.
assert.strictEqual(
_assistantMessageBelongsInWorklog(
assistant('(empty)',{_live:true,reasoning:'thought'}),0,owners,undefined,FINAL),
true,
'an (empty) placeholder anchor must still fold');
// Settled (no marker at all) is untouched in both directions.
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('Operation interrupted.'),0,owners,undefined,FINAL),
false, 'settled turn-final answer: unchanged');
// (A settled message with visible text and no live/burst marker never folds —
// `if(hasVisibleText) return false;` precedes the tool-metadata rule. Pinned so a
// future change to the live-marker line cannot quietly alter this ordering.)
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('Reading the files now.'),0,owners,undefined,MID),
false, 'settled visible intermediate without a live/burst marker: unchanged');
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('',{}),0,owners,undefined,MID),
true, 'settled empty tool-call anchor still folds: unchanged');
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('Done.'),0,new Set(),undefined,MID),
false, 'settled intermediate with no tool metadata: unchanged');
// An error message is still never folded.
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('**Error:** boom',{_error:true,_live:true}),0,owners,undefined,MID),
false, 'an error message is never folded');
// Live burst/segment markers on a non-final message still fold.
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('mid',{_activityBurstId:2}),0,new Set(),undefined,MID),
true, 'activity-burst anchors still fold');
console.log('OK');
"""
run_node(script)
def test_worklog_reason_echo_requires_a_folded_anchor():
"""Contract 2: only a folded anchor may be echoed as a .wl-reason row."""
body = _function_body(UI_JS, "renderMessages")
assert "includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml&&!!anchorIsWorklogSource," in body, (
"the settled Worklog step may only re-render an anchor's prose when that "
"anchor was folded into it (assistant-segment-worklog-source); without "
"that proof the row is a second visible copy of a visible segment"
)
# The ownership fact must be computed for every entry, not only for the first
# entry of a turn (where the group is constructed) — otherwise the append path
# would read a stale/undefined value for later anchors in the same turn.
hoisted = body.find(
"const anchorIsWorklogSource=anchorRow.classList"
"&&anchorRow.classList.contains('assistant-segment-worklog-source');"
)
state_lookup = body.find("let state=activityByTurn.get(anchorTurn);")
assert hoisted != -1 and state_lookup != -1, "settled worklog append path not found"
assert hoisted < state_lookup, (
"anchorIsWorklogSource must be computed per activity entry (before the "
"per-turn `state` lookup), not only inside the group-construction branch"
)
def test_blank_turn_failsafe_materializes_deferred_rows_before_judging_empty():
"""Contract 3: a deferred (#5839) Worklog is not empty."""
body = _function_body(UI_JS, "renderMessages")
failsafe = body[body.find("Fail-safe invariant (#3875)"):]
assert failsafe, "the #3875 fail-safe is missing"
materialize = failsafe.find("data-worklog-rows-deferred")
empty_check = failsafe.find("continue; // empty group can't help")
assert materialize != -1, (
"the fail-safe must materialize a deferred settled Worklog before judging "
"it empty — otherwise it un-hides every folded segment and the deferred "
"rows later render the same prose beside them"
)
assert materialize < empty_check, (
"deferred rows must be materialized before the empty-group check"
)
# The last-resort un-hide must still exist: content is never lost.
assert "seg.classList.remove('assistant-segment-worklog-source');" in failsafe, (
"the last-resort un-hide must remain — a duplicate is preferable to a "
"blank turn"
)
@@ -0,0 +1,156 @@
"""Regression: the composer model picker lists one configured model twice (#2051).
The picker showed two rows for a single configured model, ``acme/example-model``
and ``custom/acme/example-model``, while ``GET /api/models`` returned a ``Custom``
group holding exactly one model and ``#modelSelect`` ended up with exactly one
matching ``<option>``. Both rows are produced client-side.
``configured_model_badges`` carries every routing spelling of a configured model.
For one custom-provider model that is three keys::
acme/example-model (bare/canonical)
custom/acme/example-model (provider-qualified)
@custom:acme/example-model (routable)
``renderModelDropdown()`` walks that map and pushes a synthetic ``_modelData``
row for every key that ``_isEquivalentConfiguredModelEntry()`` does not recognise
as an alias of a row the catalog already produced. That predicate recognised two
of the three spellings; it missed ``<provider>/<model>`` because
``_normalizeConfiguredModelKey()`` strips only one leading slash segment —
deliberately, since #3360 requires ``vendor_a/x`` and ``vendor_b/y/x`` to stay
distinct. So::
acme/example-model -> example.model
custom/acme/example-model -> acme/example.model (different key)
No match, so the alias was pushed as a second row: one model, two picker entries.
``_deduplicateModelPickerOptions()`` cannot catch this — it dedupes ``<option>``
elements inside ``<select>``, and this row never becomes an ``<option>``.
The contract pinned here: ``<provider>/<model>`` is recognised as an alias of
``<model>`` under the same proof the ``@<provider>:<model>`` rule already uses.
The badge declares a provider, the key begins with that provider's prefix, and an
existing picker row from that same provider normalises equal to the remainder.
Two genuinely different models never satisfy the last clause, so the change can
only drop a duplicate — never hide a distinct model.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.js_source_extract import extract_function
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
def js(*names):
"""Real source of the named ui.js functions — never a re-implementation."""
return "\n".join(extract_function(UI_JS, name) for name in names)
def run_node(script):
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}"
assert "OK" in out.stdout, out.stdout
return out.stdout
HARNESS = """
const assert=require('assert');
var window={};
"""
# One Custom group holding one model, with three badge spellings of it.
CATALOG_ROW = "{value:'acme/example-model',providerId:'custom'}"
BADGE = "{role:'primary',label:'Primary',provider:'custom'}"
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_provider_qualified_badge_alias_is_not_a_second_picker_row():
"""All three spellings of one configured model collapse to one row (#2051)."""
script = HARNESS + js(
"_normalizeConfiguredModelKey",
"_isEquivalentConfiguredModelEntry",
) + f"""
const entries=[{CATALOG_ROW}];
const badge={BADGE};
// All three routing spellings name the same model the catalog already rendered.
for(const alias of ['acme/example-model',
'custom/acme/example-model',
'@custom:acme/example-model']){{
assert.strictEqual(_isEquivalentConfiguredModelEntry(alias,badge,entries), true,
'badge alias '+alias+' must not become a second picker row (#2051)');
}}
console.log('OK');
"""
run_node(script)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_a_genuinely_different_model_is_never_suppressed():
"""The new rule must not hide a model the catalog does not have."""
script = HARNESS + js(
"_normalizeConfiguredModelKey",
"_isEquivalentConfiguredModelEntry",
) + f"""
const entries=[{CATALOG_ROW}];
const badge={BADGE};
// Same provider prefix, different model -> must still render its own row.
assert.strictEqual(
_isEquivalentConfiguredModelEntry('custom/acme/other-model',badge,entries), false,
'a different model under the same provider must keep its own row');
// Right model name, but no catalog row owned by that provider -> not proven
// equivalent, so keep it.
assert.strictEqual(
_isEquivalentConfiguredModelEntry(
'custom/acme/example-model',
{{role:'primary',label:'Primary',provider:'otherprovider'}},
[{{value:'acme/example-model',providerId:'custom'}}]), false,
'a same-named model owned by a different provider must keep its own row');
// A badge with no provider cannot use the prefix proof at all.
assert.strictEqual(
_isEquivalentConfiguredModelEntry('custom/acme/example-model',
{{role:'primary',label:'Primary'}},entries), false,
'a provider-less badge must not be collapsed by the prefix rule');
// The prefix must be a real path segment, not a substring of the first segment.
assert.strictEqual(
_isEquivalentConfiguredModelEntry('customer/acme/example-model',badge,entries), false,
'only a literal `<provider>/` prefix counts');
console.log('OK');
"""
run_node(script)
def test_prefix_rule_mirrors_the_at_provider_rule_structurally():
"""The `<provider>/` proof must require the same three clauses as `@<provider>:`.
Structural, not behavioural: it pins that the new branch is gated on the
badge-declared provider, on a literal prefix match, and on an existing entry
of that same provider — so it cannot grow into a loose label comparison.
"""
src = extract_function(UI_JS, "_isEquivalentConfiguredModelEntry")
assert "const slashPrefix=provider?`${provider}/`:'';" in src, (
"the provider-qualified alias rule must derive its prefix from the "
"badge-declared provider"
)
assert "rawId.toLowerCase().startsWith(slashPrefix)" in src, (
"the rule must require a literal `<provider>/` prefix on the badge key"
)
assert "String(entry.providerId||'').toLowerCase()===provider" in src, (
"the rule must require an existing picker row owned by the same provider"
)
# It must run before the `@provider:` early return, which would otherwise
# short-circuit every non-`@` alias to false.
assert src.index("slashPrefix") < src.index(
"if(!prefix||!rawId.toLowerCase().startsWith(prefix)) return false;"
), "the `<provider>/` rule must be reached before the `@<provider>:` early return"
+67 -15
View File
@@ -1,15 +1,14 @@
"""Regression coverage for capped CLI/agent session sidebar scans (#2628)."""
import pathlib
import sqlite3
import time
from contextlib import contextmanager
import pytest
import api.agent_sessions as agent_sessions
_REAL_SQLITE_CONNECT = sqlite3.connect
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
def _make_state_db(path, *, sessions=80, messages_per_session=3, create_messages_index=True, source="cli", session_source="cli"):
@@ -169,6 +168,24 @@ def _make_connect_with_progress_budget(*, budget_ops, interval=1):
return _connect
@contextmanager
def _capture_listing_sql():
"""Record the SQL statements the listing runs (trace callback, read-only)."""
statements = []
def _tracing_connect(*args, **kwargs):
conn = _REAL_SQLITE_CONNECT(*args, **kwargs)
conn.set_trace_callback(statements.append)
return conn
original_connect = agent_sessions.sqlite3.connect
agent_sessions.sqlite3.connect = _tracing_connect
try:
yield statements
finally:
agent_sessions.sqlite3.connect = original_connect
def _make_connect_with_progress_counter(*, interval=1):
steps = {"count": 0}
@@ -198,24 +215,59 @@ def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path):
db = tmp_path / "state.db"
_make_state_db(db, sessions=120, messages_per_session=5)
rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",))
with _capture_listing_sql() as statements:
rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",))
assert len(rows) == 20
assert [row["id"] for row in rows][:3] == ["cli_perf_0119", "cli_perf_0118", "cli_perf_0117"]
assert {row["actual_message_count"] for row in rows} == {5}
src = (REPO_ROOT / "api" / "agent_sessions.py").read_text()
assert "WITH candidates AS" in src
assert "JOIN candidates c ON c.id = s.id" in src
assert "latest_messages AS" in src
assert "LEFT JOIN latest_messages lm ON lm.session_id = s.id" in src
assert 'included == ("cron",)' in src
assert "not messages_index_present" in src
assert "PRAGMA index_list(messages)" in src
assert "CREATE INDEX IF NOT EXISTS idx_messages_session" in src
assert "_CRON_PREAGGREGATE_CANDIDATE_ORDER_MIN_MESSAGES" not in src
assert "MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id" in src
assert "candidate_limit = max(result_limit * 8, result_limit)" in src
candidates_sql = next(
statement
for statement in statements
if "LIMIT" in statement and "FROM sessions s" in statement
)
# The cap is applied inside the candidate subquery, not in Python after an
# unbounded aggregate: the oversampled window (limit * 8 = 160 here) is
# part of the statement SQLite actually runs, and it bounds the candidate
# subquery that the outer projection joins.
assert candidates_sql.count("LIMIT") == 1, candidates_sql
window_end = candidates_sql.index("LIMIT")
window = candidates_sql[:window_end]
assert "JOIN candidates" not in window, candidates_sql
cap = int(candidates_sql[window_end:].split(None, 2)[1])
assert cap == 160, candidates_sql
def test_importable_agent_rows_candidate_window_overfilters_then_fills(tmp_path):
"""Rows dropped after projection must not shrink the returned page.
The SQL candidate window is oversampled (limit * 8) precisely so sessions
filtered client-side of the SQL (invisible CLI rows here) only consume
candidates, not result slots: a page that would be short if the cap were
the plain ``limit`` still fills to ``limit`` visible rows.
"""
db = tmp_path / "state.db"
_make_state_db(db, sessions=120, messages_per_session=5)
# 30 newest sessions are CLI rows that the projection hides (ended, default
# title, no user turn visible): they must fall out of the window without
# starving the visible page below them.
conn = sqlite3.connect(str(db))
for i in range(90, 120):
conn.execute(
"UPDATE sessions SET title = 'cli session', ended_at = 1.0, end_reason = 'timeout' WHERE id = ?",
(f"cli_perf_{i:04d}",),
)
conn.commit()
conn.close()
rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",))
assert len(rows) == 20
assert rows[0]["id"] == "cli_perf_0089"
assert rows[-1]["id"] == "cli_perf_0070"
assert {row["actual_message_count"] for row in rows} == {5}
def test_importable_agent_rows_candidate_ordering_stays_under_progress_budget(tmp_path, monkeypatch):
@@ -71,6 +71,35 @@ def test_cjk_start_english_title_is_rejected():
assert _title_language_mismatch("如何修复这个错误问题", "Fixing the Bug") is True
def test_cjk_start_mixed_cjk_latin_title_is_allowed():
"""CJK conversations frequently embed English product/technical terms in
titles (e.g. 'WeChat Pay 回调失败排查'). As long as the title also
contains CJK characters, Latin borrowed terms should not trigger rejection.
Regression test for #7693."""
from api.streaming import _title_language_mismatch
# Pure CJK user, CJK title with English product name
assert _title_language_mismatch(
"微信支付回调一直失败怎么办", "WeChat Pay 回调失败排查"
) is False
# CJK user, CJK title with English tech term
assert _title_language_mismatch(
"如何修复这个错误问题", "Python 代码修复"
) is False
# Mixed CJK+Latin user, mixed title
assert _title_language_mismatch(
"prores raw是否能选择压缩?", "ProRes RAW 压缩选项与 BRAW 对比"
) is False
def test_cjk_start_pure_latin_title_still_rejected():
"""A purely Latin title for a CJK conversation is still a genuine drift."""
from api.streaming import _title_language_mismatch
assert _title_language_mismatch("如何修复这个错误问题", "Fixing the Bug") is True
assert _title_language_mismatch("如何修复这个错误问题", "Python Error Guide") is True
# ── regression guards: legitimate same-script titles must NOT be rejected ───
def test_english_start_english_title_allowed():
@@ -534,6 +534,7 @@ def test_poll_loop_skips_projection_when_unchanged(tmp_path, monkeypatch):
monkeypatch.setattr(gw, "_get_agent_sessions_from_db", fake_projection)
w = gw.GatewayWatcher(state_db_path=db)
w.subscribe() # Idle watchers deliberately do not poll without observers.
assert w._poll_once(now=1.0) is True
assert projected == [True]
+22 -12
View File
@@ -74,28 +74,38 @@ def _messages_indexes(path):
return {r[0] for r in rows}
def test_prime_creates_missing_index(tmp_path):
"""A db missing idx_messages_session gets it primed on the first scan."""
def test_listing_does_not_create_missing_index(tmp_path, monkeypatch):
"""A missing index must never turn a sidebar read into a schema write.
CREATE INDEX on a multi-GiB ``messages`` table holds the SQLite writer lock
for minutes; index maintenance belongs to the explicit drained tool
(``scripts/ensure_state_db_read_indexes.py``), not to a listing.
"""
db = tmp_path / "state.db"
_full_schema_db(db)
assert "idx_messages_session" not in _messages_indexes(db)
connect_calls = []
real_connect = agent_sessions.sqlite3.connect
def recording_connect(*args, **kwargs):
connect_calls.append((args, kwargs))
return real_connect(*args, **kwargs)
monkeypatch.setattr(agent_sessions.sqlite3, "connect", recording_connect)
rows = agent_sessions.read_importable_agent_session_rows(
db, limit=20, exclude_sources=None
)
# Snapshot before the verifier below opens its own (non-listing) handle.
listing_connect_calls = list(connect_calls)
# Listing still returns the sessions ...
assert {r["id"] for r in rows} == {"sess0", "sess1", "sess2"}
# ... and the index now exists on (session_id, timestamp).
assert "idx_messages_session" in _messages_indexes(db)
conn = sqlite3.connect(str(db))
try:
sql = conn.execute(
"SELECT sql FROM sqlite_master WHERE name='idx_messages_session'"
).fetchone()[0]
finally:
conn.close()
assert "session_id" in sql and "timestamp" in sql
# ... without mutating the schema or opening a write-capable connection.
assert "idx_messages_session" not in _messages_indexes(db)
assert len(listing_connect_calls) == 1
assert "mode=ro" in str(listing_connect_calls[0][0][0])
assert listing_connect_calls[0][1].get("uri") is True
def test_prime_is_noop_when_index_exists(tmp_path):
+13 -14
View File
@@ -9,7 +9,6 @@ lineage-metadata reads, and the gateway-watcher fingerprint projection (a 5s
poll), were still opening a read-WRITE connection. This shared them onto the
same ``open_state_db_readonly`` helper.
"""
import logging
import sqlite3
from contextlib import closing
@@ -98,25 +97,25 @@ def test_helper_encodes_special_path_chars(tmp_path, monkeypatch):
assert calls[0]["target"].endswith("?mode=ro")
def test_helper_falls_back_to_writable_and_logs(tmp_path, monkeypatch, caplog):
def test_helper_never_falls_back_to_writable(tmp_path, monkeypatch):
"""A read-only open failure propagates; the helper never retries with a
write-capable handle on the live WAL database."""
db = tmp_path / "state.db"
_make_lineage_db(db)
real_connect = sqlite3.connect
calls = []
def fail_read_only(target, *args, **kwargs):
if kwargs.get("uri"):
raise sqlite3.OperationalError("synthetic read-only URI failure")
return real_connect(target, *args, **kwargs)
calls.append({"target": str(target), "uri": bool(kwargs.get("uri"))})
raise sqlite3.OperationalError("synthetic read-only URI failure")
monkeypatch.setattr(agent_sessions.sqlite3, "connect", fail_read_only)
with caplog.at_level(logging.WARNING, logger="api.agent_sessions"):
conn = open_state_db_readonly(db)
try:
assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 2
finally:
conn.close()
assert "read-only open failed" in caplog.text
assert "synthetic read-only URI failure" in caplog.text
with pytest.raises(sqlite3.OperationalError, match="synthetic read-only URI failure"):
open_state_db_readonly(db)
# Exactly one attempt, and it was the read-only URI form.
assert len(calls) == 1
assert calls[0]["uri"] is True
assert calls[0]["target"].endswith("?mode=ro")
def test_helper_raises_on_missing_db_instead_of_creating_a_ghost(tmp_path):
@@ -6,13 +6,12 @@ re-run a defensive ``CREATE INDEX`` self-heal on every sidebar build. Holding a
write-capable handle while the agent streams into the same DB adds needless
checkpoint/lock surface.
The listing path now opens the DB read-only (``file:...?mode=ro``) and only
self-heals a missing ``idx_messages_session`` through a SEPARATE short-lived
writable connection. With the index present (the normal case) the read path
performs zero writes; when the index is missing the self-heal still runs and
rows still come back.
The listing path opens the DB read-only (``file:...?mode=ro``) and never
upgrades to a writable handle: a read-only open failure propagates, and a
missing ``idx_messages_session`` degrades to the pre-aggregated path instead
of being self-healed. Index maintenance is an explicit drained operation
(``scripts/ensure_state_db_read_indexes.py``).
"""
import logging
import sqlite3
import api.agent_sessions as agent_sessions
@@ -74,6 +73,77 @@ def test_listing_opens_read_only_and_returns_rows(tmp_path, monkeypatch):
assert "mode=ro" in calls[0]["target"]
def test_listing_keeps_model_config_branch_visible_and_never_leaks_it(tmp_path, monkeypatch):
"""#7021 re-gate: a CLI/agent branch whose _branched_from marker lives in
model_config must stay visible even when it starts inside the 2s
compression tolerance window, and model_config must not leak into the
returned rows."""
db = tmp_path / "state.db"
conn = sqlite3.connect(str(db))
conn.execute(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER,
started_at REAL, source TEXT, session_source TEXT,
parent_session_id TEXT, ended_at REAL, end_reason TEXT,
model_config TEXT
)
"""
)
conn.execute(
"""
INSERT INTO sessions
(id, title, model, message_count, started_at, source, session_source,
parent_session_id, ended_at, end_reason, model_config)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"cli-parent", "Parent conversation", "gpt", 0, 1000.0, "cli", "cli",
None, 1100.0, "compression", None,
),
)
# An explicit Agent branch starting 1.5s BEFORE the parent's compression
# ended_at. No session_source — the fork identity lives in model_config.
conn.execute(
"""
INSERT INTO sessions
(id, title, model, message_count, started_at, source, session_source,
parent_session_id, ended_at, end_reason, model_config)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"cli-branch", "Branch conversation", "gpt", 0, 1098.5, "cli", "cli",
"cli-parent", None, None, '{"_branched_from": "cli-parent"}',
),
)
conn.execute(
"CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, timestamp REAL)"
)
conn.executemany(
"INSERT INTO messages (session_id, role, timestamp) VALUES (?,?,?)",
[
("cli-parent", "user", 1001.0),
("cli-parent", "assistant", 1002.0),
("cli-branch", "user", 1099.0),
("cli-branch", "assistant", 1099.5),
],
)
conn.commit()
conn.close()
_record_connects(monkeypatch)
out = read_importable_agent_session_rows(db, exclude_sources=None)
out_ids = {r["id"] for r in out}
# The branch survives the tolerance window: it is NOT collapsed into the
# parent lineage.
assert "cli-branch" in out_ids
assert "cli-parent" in out_ids
# model_config is internal agent state — never exposed on returned rows.
for row in out:
assert "model_config" not in row
def test_listing_read_only_uri_encodes_special_path_chars(tmp_path, monkeypatch):
db_dir = tmp_path / "state dir #1"
db_dir.mkdir()
@@ -92,24 +162,27 @@ def test_listing_read_only_uri_encodes_special_path_chars(tmp_path, monkeypatch)
assert calls[0]["target"].endswith("?mode=ro")
def test_read_only_open_fallback_is_logged(tmp_path, monkeypatch, caplog):
def test_read_only_open_failure_never_retries_with_writable_connection(tmp_path, monkeypatch):
db = tmp_path / "state.db"
_make_db(db, with_index=True)
real_connect = sqlite3.connect
calls = []
def fail_read_only(target, *args, **kwargs):
if kwargs.get("uri"):
raise sqlite3.OperationalError("synthetic read-only URI failure")
return real_connect(target, *args, **kwargs)
calls.append({"target": str(target), "uri": bool(kwargs.get("uri"))})
raise sqlite3.OperationalError("synthetic read-only URI failure")
monkeypatch.setattr(agent_sessions.sqlite3, "connect", fail_read_only)
with caplog.at_level(logging.WARNING, logger="api.agent_sessions"):
out = read_importable_agent_session_rows(db, exclude_sources=None)
try:
read_importable_agent_session_rows(db, exclude_sources=None)
raise AssertionError("read-only open failure unexpectedly recovered")
except sqlite3.OperationalError as exc:
assert "synthetic read-only URI failure" in str(exc)
assert "cli-1" in {r["id"] for r in out}
assert "read-only open failed" in caplog.text
assert "synthetic read-only URI failure" in caplog.text
# Exactly one attempt, and it was the read-only URI form.
assert len(calls) == 1
assert calls[0]["uri"] is True
assert "mode=ro" in calls[0]["target"]
def test_index_present_performs_no_writable_connection(tmp_path, monkeypatch):
@@ -124,7 +197,7 @@ def test_index_present_performs_no_writable_connection(tmp_path, monkeypatch):
assert all(c["uri"] and "mode=ro" in c["target"] for c in calls), calls
def test_missing_index_self_heals_via_separate_connection(tmp_path, monkeypatch):
def test_missing_index_degrades_without_writable_connection(tmp_path, monkeypatch):
db = tmp_path / "state.db"
_make_db(db, with_index=False)
calls = _record_connects(monkeypatch)
@@ -133,12 +206,13 @@ def test_missing_index_self_heals_via_separate_connection(tmp_path, monkeypatch)
# Rows still come back...
assert "cli-1" in {r["id"] for r in out}
# ...and the self-heal ran through a separate writable (non-ro) connection.
assert any((not c["uri"]) and "mode=ro" not in c["target"] for c in calls), calls
# The index now exists on disk.
# ...without any implicit schema-maintenance writer.
assert calls
assert all(c["uri"] and "mode=ro" in c["target"] for c in calls), calls
# A missing index stays a maintenance concern, not a listing side effect.
verify = sqlite3.connect(str(db))
try:
names = {row[1] for row in verify.execute("PRAGMA index_list(messages)")}
finally:
verify.close()
assert "idx_messages_session" in names
assert "idx_messages_session" not in names
@@ -0,0 +1,217 @@
"""Regression tests for #6869: failed Gateway worker launch cleanup."""
from types import SimpleNamespace
import api.config as config
import api.routes as routes
from api import turn_journal
def _make_session(session_id, *, active_stream_id=None, pending_user_message=None, save=None):
return SimpleNamespace(
session_id=session_id,
title="Existing",
active_stream_id=active_stream_id,
pending_user_message=pending_user_message,
pending_attachments=[],
pending_started_at=None,
pending_user_source=None,
messages=[{"role": "user", "content": "old"}],
workspace="/tmp",
model="old-model",
model_provider=None,
worktree_path=None,
profile=None,
save=save or (lambda: None),
)
def _register_failed_stream(session_id, stream_id):
"""Mirror the registry state _prepare_chat_start_session_for_stream installs."""
config.register_session_writeback_owner(session_id, stream_id)
config.register_stream_owner(stream_id, session_id)
config.STREAMS[stream_id] = object()
def test_gateway_thread_start_failure_releases_writeback_owner_and_stream_state(monkeypatch):
"""A thread-start exception must not leave one owner per failed Gateway launch."""
class FailingThread:
def __init__(self, *args, **kwargs):
pass
def start(self):
raise RuntimeError("thread launch failed")
canonical_by_id = {}
registered_stream_ids = []
def fake_prepare(session, *, stream_id, **kwargs):
session.active_stream_id = stream_id
session.pending_user_message = kwargs["msg"]
session.pending_started_at = 1.0
_register_failed_stream(session.session_id, stream_id)
canonical_by_id[session.session_id] = session
registered_stream_ids.append(stream_id)
monkeypatch.setattr(routes.threading, "Thread", FailingThread)
monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", fake_prepare)
monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace, **_kw: None)
monkeypatch.setattr(routes, "_is_hidden_empty_session", lambda _session: False)
monkeypatch.setattr(
routes,
"get_session",
lambda sid, metadata_only=False: canonical_by_id[sid],
)
monkeypatch.setattr(
turn_journal,
"append_turn_journal_event",
lambda *_args, **_kwargs: {},
)
for index in range(3):
session = _make_session(f"session-launch-failure-{index}")
try:
routes._start_chat_stream_for_session(
session,
msg="start gateway turn",
attachments=[],
workspace="/tmp",
model="test-model",
external_runtime_owned=True,
)
except RuntimeError as exc:
assert str(exc) == "thread launch failed"
else:
raise AssertionError("thread-start failure must propagate")
assert session.active_stream_id is None
assert session.pending_user_message is None
assert session.pending_started_at is None
# Scope these to the streams this test registered. scripts/test.sh runs the
# whole selection in one process, so asserting the global registries are
# empty makes this test fail on unrelated files that leave entries behind.
assert registered_stream_ids, "the launch path must register state before failing"
for stream_id in registered_stream_ids:
assert stream_id not in config.STREAM_SESSION_OWNERS
assert stream_id not in config.STREAMS
for session_id in canonical_by_id:
assert config.session_writeback_owner(session_id) is None
def test_gateway_launch_failure_cleanup_does_not_clear_successor_owner(monkeypatch):
"""A successor fully installed before cleanup keeps its registry ownership."""
canonical = _make_session(
"session-launch-successor",
active_stream_id="new-stream",
pending_user_message="new prompt",
save=lambda: None,
)
monkeypatch.setattr(
routes,
"get_session",
lambda sid, metadata_only=False: canonical,
)
stale = _make_session("session-launch-successor")
_register_failed_stream(stale.session_id, "old-stream")
config.register_session_writeback_owner(stale.session_id, "new-stream")
routes._cleanup_chat_start_launch_failure(stale, "old-stream")
assert config.session_writeback_owner(stale.session_id) == "new-stream"
assert canonical.active_stream_id == "new-stream"
assert canonical.pending_user_message == "new prompt"
assert "old-stream" not in config.STREAMS
def test_gateway_launch_failure_cleanup_does_not_wipe_successor_admitted_during_cleanup(monkeypatch):
"""A successor admitted mid-cleanup survives: the guard runs on the re-resolved
canonical session, not on the stale passed-in object that still shows the
failed stream."""
saved = []
canonical = _make_session(
"session-launch-race",
active_stream_id="new-stream",
pending_user_message="successor prompt",
save=lambda: saved.append("canonical"),
)
monkeypatch.setattr(
routes,
"get_session",
lambda sid, metadata_only=False: canonical,
)
stale = _make_session(
"session-launch-race",
active_stream_id="old-stream",
pending_user_message="failed-stream prompt",
save=lambda: saved.append("stale"),
)
_register_failed_stream(stale.session_id, "old-stream")
config.register_session_writeback_owner(stale.session_id, "new-stream")
routes._cleanup_chat_start_launch_failure(stale, "old-stream")
assert config.session_writeback_owner(stale.session_id) == "new-stream"
assert canonical.active_stream_id == "new-stream"
assert canonical.pending_user_message == "successor prompt"
assert "old-stream" not in config.STREAMS
assert saved == [], "cleanup must not save a session it early-returns on"
def test_gateway_launch_failure_cleanup_does_not_resurrect_deleted_session(monkeypatch):
"""A session deleted while the launch was failing must not be recreated:
the resolver raises KeyError and cleanup returns without saving anything."""
saved = []
def deleted_resolver(sid, metadata_only=False):
raise KeyError(sid)
monkeypatch.setattr(routes, "get_session", deleted_resolver)
stale = _make_session(
"session-launch-deleted",
active_stream_id="old-stream",
pending_user_message="pending prompt",
save=lambda: saved.append("stale"),
)
_register_failed_stream(stale.session_id, "old-stream")
routes._cleanup_chat_start_launch_failure(stale, "old-stream")
assert config.session_writeback_owner(stale.session_id) is None
assert "old-stream" not in config.STREAM_SESSION_OWNERS
assert "old-stream" not in config.STREAMS
assert stale.active_stream_id == "old-stream"
assert stale.pending_user_message == "pending prompt"
assert saved == [], "cleanup must not save a deleted session"
def test_gateway_launch_failure_cleanup_never_masks_the_launch_error(monkeypatch):
"""A resolution failure during cleanup must not replace the launch error.
Cleanup runs inside the launch-failure handler, so an error it fails to
contain is reported instead of the real launch failure. Resolution can fail
with something other than KeyError when the session store is unreadable or
cannot be deserialized.
"""
saved = []
def failing_resolver(sid, metadata_only=False):
raise OSError("session store unreadable")
monkeypatch.setattr(routes, "get_session", failing_resolver)
stale = _make_session(
"session-launch-resolution-error",
active_stream_id="old-stream",
pending_user_message="pending prompt",
save=lambda: saved.append("stale"),
)
_register_failed_stream(stale.session_id, "old-stream")
# Must not raise: the caller is already handling the launch failure.
routes._cleanup_chat_start_launch_failure(stale, "old-stream")
# The registry half still completed, and nothing was saved.
assert config.session_writeback_owner(stale.session_id) is None
assert "old-stream" not in config.STREAM_SESSION_OWNERS
assert "old-stream" not in config.STREAMS
assert saved == []
@@ -0,0 +1,152 @@
"""Regression tests for issue #7299 — hidden-tab active-stream poll
keeps retrying deleted sessions forever, generating an invisible stream
of permanent 404 requests from stale background tabs.
When a tab is hidden, Hermes WebUI replaces the persistent per-session
SSE connection with a lightweight ``GET /api/session/status`` poll
every six seconds. If the tracked session has been deleted or no
longer exists in the active state directory, the endpoint returns
``404``, but the client previously converted every non-2xx response to
``null`` and kept the interval alive forever — one tab generates up to
ten requests per minute whenever browser timer throttling allows it.
The fix: in ``_startHiddenActiveStreamPoll``'s fetch chain, treat
``404 Not Found`` and ``410 Gone`` as **terminal** for the
session-owned poll — call ``_stopHiddenActiveStreamPoll()`` so the
interval and the bound ``_sessionStreamHiddenPollSid`` are cleared
together. Transient failures (5xx, rate-limit, network error) keep
polling because they do not necessarily mean the session is gone; a
later tick may catch a server-initiated turn starting while the tab
remains hidden.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# Source-shape: the fetch chain in ``_startHiddenActiveStreamPoll`` must
# gate the terminal 404/410 path through ``_stopHiddenActiveStreamPoll``
# and the bound sid check. A silent reversion to the pre-fix
# "convert all non-2xx to null and keep polling" form fails the suite.
# ---------------------------------------------------------------------------
def test_hidden_poll_stops_on_404_response() -> None:
"""The fetch chain must call ``_stopHiddenActiveStreamPoll`` on a 404.
A 404 from ``/api/session/status`` means the session has been
deleted or no longer exists in the active state directory. The
pre-fix code converted every non-2xx to ``null`` and never
stopped the interval — so the same 404 fired every six seconds
for the lifetime of the hidden tab. Pin the contract that a 404
now tears down the poll.
"""
# The check is anchored on the hidden-poll function so a future
# refactor cannot satisfy it with an unrelated status-404
# handler in a different code path.
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
assert start != -1
# 3.6 KB is enough to cover the fetch → first .then → second .then
# block, even after the #7299 fix added the 404/410 terminal
# branch. The hidden poll is one of the larger functions in the
# file, so we take a wide window to be resilient to a small
# reformat.
body = MESSAGES_JS[start:start + 8000]
assert "r.status === 404" in body, (
"the hidden poll must check r.status === 404 in the fetch "
"response so a deleted session no longer triggers an "
"infinite 404 loop on a stale background tab"
)
assert "_stopHiddenActiveStreamPoll" in body, (
"the hidden poll must call _stopHiddenActiveStreamPoll when "
"the response is terminal (404/410) so the interval is "
"cleared instead of left running"
)
def test_hidden_poll_stops_on_410_response() -> None:
"""The fetch chain must also call ``_stopHiddenActiveStreamPoll`` on 410.
Some session-state endpoints use ``410 Gone`` (rather than
``404 Not Found``) for "this session id is permanently gone".
Both are terminal for the hidden poll — the pre-fix code treated
them identically, so the fix must handle both.
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
body = MESSAGES_JS[start:start + 8000]
assert "r.status === 410" in body, (
"the hidden poll must check r.status === 410 in the fetch "
"response so a 'gone' session also tears down the poll, "
"matching the 404 contract"
)
def test_hidden_poll_404_stop_guards_on_bound_sid() -> None:
"""The 404/410 stop path must check the bound ``_sessionStreamHiddenPollSid``.
The same fetch chain is invoked on every tick of a 6-second
interval. A race can let a stale fetch's response land after
the user switched sessions — in that case ``_sessionStreamHiddenPollSid``
no longer matches the fetched ``sid``, and the stop must NOT
tear down the *new* session's poll. Pin the contract.
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
body = MESSAGES_JS[start:start + 8000]
# The 404/410 branch must check the bound sid before stopping
# — pinned as a string match because the JS module is large
# and we want a regression to show up at a glance in CI.
assert "_sessionStreamHiddenPollSid === sid" in body, (
"the 404/410 stop path must guard on "
"_sessionStreamHiddenPollSid === sid so a stale fetch's "
"response cannot tear down a different session's poll"
)
def test_hidden_poll_preserves_transient_failure_retry() -> None:
"""5xx / rate-limit / network errors MUST keep polling (not stop).
The fix only narrows the stop path to terminal 404/410. Transient
failures (5xx, 429, network error) still produce ``null`` and
the next tick re-fires. A regression that broadens the stop
path to all non-2xx would re-introduce the original bug in
the opposite direction — the poll would die on a single
503 and never recover.
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
body = MESSAGES_JS[start:start + 8000]
# The pre-fix ``r.ok ? r.json() : null`` fallback must still be
# present for any non-ok / non-terminal response.
assert "r.ok ? r.json() : null" in body, (
"the 5xx / rate-limit / network-error path must still "
"produce null and let the next tick re-fire; only 404/410 "
"should tear down the poll"
)
def test_hidden_poll_starts_interval_unchanged() -> None:
"""The interval is unchanged (still 6000 ms).
The fix is purely about the response handling, not the
cadence. A regression that doubles the interval (or removes
the immediate-fire) breaks the original contract — a turn
already running when the tab goes hidden must be caught
without waiting a full interval.
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
body = MESSAGES_JS[start:start + 8000]
assert "setInterval(tick, 6000)" in body, (
"the hidden poll must keep the 6000ms interval; the fix is "
"in the response handling, not the cadence"
)
# The immediate-fire call (no full interval wait) must also
# still be present so a turn already running when the tab goes
# hidden is caught without waiting a full interval.
assert "tick();" in body, (
"the immediate-fire tick() call must be preserved so a "
"turn already running when the tab goes hidden is caught "
"without waiting a full interval"
)
+236
View File
@@ -0,0 +1,236 @@
"""#7305: GET /api/profiles must never 500 when the hermes-agent source is not
importable (two-container Docker / HERMES_WEBUI_CHAT_BACKEND=gateway).
Contract pinned here: when ``agent.skill_utils`` is missing, the WebUI reports
the skill stats as *unknown* — a stable ``(0, 0)`` — instead of counting skills
with a local partial re-implementation of the index walk. The profile picker
still renders, and it never claims a count it cannot verify (the UI omits the
skills line when the total is 0).
The fixture tree deliberately reproduces the shapes that a partial local scan
gets wrong: a SKILL.md whose declared ``name`` differs from its directory, two
directories declaring the same name, a declared name disabled in the profile
config, a platform-incompatible skill, and the paths the real index walk prunes
(support dirs of a skill package, dependency/VCS/metadata dirs, an inactive
org-mirror). ``test_agent_backed_path_counts_these_shapes_accurately`` is the
positive control: it runs the same tree through the real agent-backed path, so
the fixture cannot silently degrade into "a tree that never had a count to
lose".
"""
from __future__ import annotations
import builtins
import pathlib
import pytest
import api.profiles as profiles
# How a fixture entry relates to the reported counts.
COUNTED = "counted" # yields a logical skill; contributes to compatible/enabled
WALK_PRUNED = "walk-pruned" # never yielded by agent.skill_utils' index walk
PLATFORM_FILTERED = "platform-filtered" # walked, but incompatible with this host
# path -> (declared frontmatter name, platform restriction or None, outcome)
SKILLS_TREE = {
# Declared name differs from the directory name; that declared name is
# disabled for the webui platform in config.yaml below.
"alpha-dir/SKILL.md": ("my-skill", None, COUNTED),
# A second directory declaring the same name — one logical skill.
"beta-dir/SKILL.md": ("my-skill", None, COUNTED),
# Platform-incompatible: must not be offered (or counted) on this host.
"gamma-dir/SKILL.md": ("gamma-skill", "some-other-platform", PLATFORM_FILTERED),
# A plain, enabled skill.
"delta-dir/SKILL.md": ("delta-skill", None, COUNTED),
# Support dir of a skill package: disclosure-only, not a standalone skill.
"delta-dir/references/notes/SKILL.md": ("support-note", None, WALK_PRUNED),
# Dependency / virtualenv / VCS / metadata dirs are excluded wholesale, and
# an org mirror without its .active_org marker is token-gated shut.
"node_modules/dep/SKILL.md": ("vendored-skill", None, WALK_PRUNED),
".venv/lib/pkg/SKILL.md": ("venv-skill", None, WALK_PRUNED),
".git/hooks/SKILL.md": ("hook-skill", None, WALK_PRUNED),
"_org/other-org/org-skill/SKILL.md": ("org-skill", None, WALK_PRUNED),
}
# The one declared name the profile config disables for the webui platform.
DISABLED_NAMES = {"my-skill"}
CONFIG_YAML = (
"profile:\n"
" name: default\n"
"skills:\n"
" platform_disabled:\n"
" webui:\n"
" - my-skill\n"
)
CONFIG_WITHOUT_DISABLE = "profile:\n name: default\n"
def _expected_walked() -> set[str]:
return {
rel
for rel, (_, _, outcome) in SKILLS_TREE.items()
if outcome != WALK_PRUNED
}
@pytest.fixture()
def agent_absent(monkeypatch):
"""Simulate the agent-less deployment: importing anything under ``agent.``
raises ``ImportError``, even when the source tree is present locally."""
real_import = builtins.__import__
def _no_agent(name, *args, **kwargs):
if name == "agent" or str(name).startswith("agent."):
raise ImportError(f"{name} not mounted (simulated two-container Docker)")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _no_agent)
@pytest.fixture()
def hermes_cli_absent(monkeypatch):
"""Simulate the same deployment's other half: no ``hermes_cli`` either."""
real_import = builtins.__import__
def _no_cli(name, *args, **kwargs):
if name == "hermes_cli" or str(name).startswith("hermes_cli."):
raise ImportError(f"{name} not mounted (simulated two-container Docker)")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _no_cli)
def _write_profile(tmp_path: pathlib.Path, dir_name: str = "home") -> pathlib.Path:
"""Materialise SKILLS_TREE + config.yaml as a profile home."""
home = tmp_path / dir_name
skills = home / "skills"
for rel, (name, platform, _outcome) in SKILLS_TREE.items():
skill_md = skills / rel
skill_md.parent.mkdir(parents=True, exist_ok=True)
lines = ["---", f"name: {name}", "description: issue 7305 fixture"]
if platform:
lines += ["platforms:", f" - {platform}"]
lines += ["---", "body", ""]
skill_md.write_text("\n".join(lines), encoding="utf-8")
(home / "config.yaml").write_text(CONFIG_YAML, encoding="utf-8")
return home
def test_skills_stats_are_unknown_without_agent_skill_utils(tmp_path, agent_absent):
"""Without agent.skill_utils the stats are (0, 0) — never a guessed count,
no matter which fixtures a partial local scan would have counted."""
home = _write_profile(tmp_path)
assert profiles._get_profile_skills_stats(home) == (0, 0)
def test_default_profile_dict_never_raises_without_agent(tmp_path, agent_absent, monkeypatch):
"""_default_profile_dict() (the fallback row for GET /api/profiles) must
return a usable row with unknown skill stats instead of raising."""
home = _write_profile(tmp_path)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", home)
row = profiles._default_profile_dict()
assert row["name"] == "default"
assert row["path"] == str(home)
assert row["is_default"] is True
assert row["skill_count"] == 0
assert row["enabled_skills"] == 0
assert row["total_skills"] == 0
def test_list_profiles_api_isolated_mode_without_agent_or_cli(
tmp_path, agent_absent, hermes_cli_absent, monkeypatch
):
"""The #7305 path end to end: isolated profile mode with neither
agent.skill_utils nor hermes_cli importable must still return the
default-only row instead of propagating ImportError out of the route."""
home = _write_profile(tmp_path, dir_name="default")
monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: True)
monkeypatch.setattr(profiles, "_INITIAL_HERMES_HOME", str(home))
rows = profiles.list_profiles_api()
assert [r["name"] for r in rows] == ["default"]
assert rows[0]["is_active"] is True
assert rows[0]["skill_count"] == 0
assert rows[0]["enabled_skills"] == 0
assert rows[0]["total_skills"] == 0
def test_agent_backed_path_counts_these_shapes_accurately(tmp_path):
"""Positive control (agent source present): the fixture above is a real
semantic trap, not a tree with nothing to count.
The fixture's expectations are derived from the paths the agent's index
walk actually yields, because that walk (and its exclusion list) belongs to
the agent package and differs between deployments. What this asserts:
- the compatible total stays strictly below the walked file count — the
support/dependency/VCS/org-mirror files a naive local ``os.walk`` picks
up are either pruned by the walk or, when the walk keeps them, still not
enough to reach the walk's file count;
- the platform-incompatible and duplicate-declared-name files do not add to
the compatible total;
- identity comes from frontmatter ``name``: the disabled ``my-skill`` (a
declared name, not a directory) is compatible but not enabled;
- dropping that config disable flips it to enabled without changing the
compatible total.
"""
skill_utils = pytest.importorskip("agent.skill_utils")
# Other test files install a bare ``types.ModuleType`` stub for
# ``agent.skill_utils`` in ``sys.modules`` (no ``__file__``, rglob walk,
# MagicMock frontmatter). This positive control is only meaningful against
# the real agent package, so a stub must skip it, not satisfy it.
if not getattr(skill_utils, "__file__", None):
pytest.skip("agent.skill_utils is a test stub, not the real agent package")
from agent.skill_utils import iter_skill_index_files
home = _write_profile(tmp_path)
skills = home / "skills"
walked = {
p.relative_to(skills).as_posix() for p in iter_skill_index_files(skills, "SKILL.md")
}
on_disk = {p.relative_to(skills).as_posix() for p in skills.rglob("SKILL.md")}
# The index walk itself belongs to the agent package and its exclusion list
# is version-dependent, so this test does not freeze it: every expectation
# below is derived from the paths the walk actually yielded. (Removing the
# local mirror of that walk is the point of #7305 — the fallback reported
# (0, 0) instead of guessing.)
assert _expected_walked() <= walked, "the walk must keep the non-pruned skills"
assert walked <= on_disk
expected_compatible = {
name
for rel, (name, platform, _outcome) in SKILLS_TREE.items()
if rel in walked and platform is None
}
expected_enabled = expected_compatible - DISABLED_NAMES
# The fixture is a real semantic trap: the compatible total must stay below
# the walked file count. If the walk pruned the support/dependency/VCS/org
# files the count drops because those files are gone; if it did not, the
# count still drops because the platform-incompatible file is filtered out
# and the two directories declaring `my-skill` collapse to one logical
# skill.
assert len(expected_compatible) < len(walked), (
"fixture must make the compatible total fall below the walked file count"
)
enabled, compatible = profiles._compute_profile_skills_stats(home)
assert compatible == len(expected_compatible), (compatible, expected_compatible)
assert compatible < len(on_disk)
assert enabled == len(expected_enabled)
(home / "config.yaml").write_text(CONFIG_WITHOUT_DISABLE, encoding="utf-8")
enabled_all, compatible_all = profiles._compute_profile_skills_stats(home)
assert compatible_all == compatible
assert enabled_all == len(expected_compatible)
@@ -34,12 +34,24 @@ def _ids(provider_id: str) -> list[str]:
def _expected_openrouter_id(model_id: str) -> str:
"""Independent reference implementation of the namespace translation."""
"""Independent reference implementation of the namespace translation.
One Z.AI model has no OpenRouter counterpart: ``glm-4.5-flash`` is not in
OpenRouter's catalog, so #7520 serves Z.AI's nearest light model
``z-ai/glm-4.5-air`` in its place (same slot, so order is unchanged).
"""
if model_id in _OPENROUTER_REMAPS:
return _OPENROUTER_REMAPS[model_id]
if model_id.startswith("zai/"):
return "z-ai/" + model_id[len("zai/") :]
return model_id
# #7520: fallback ids that OpenRouter does not serve, mapped to the id the
# wizard offers instead. Keep this list tiny and explicit.
_OPENROUTER_REMAPS = {"zai/glm-4.5-flash": "z-ai/glm-4.5-air"}
def test_openrouter_setup_never_serves_direct_zai_namespace():
served = [model_id for model_id in _ids("openrouter") if model_id.startswith("zai/")]
assert served == [], (
@@ -66,14 +78,19 @@ def test_openrouter_setup_exposes_z_ai_models():
def test_openrouter_projection_is_namespace_only():
"""The OpenRouter list stays a 1:1, order-preserving projection."""
"""The OpenRouter list stays a 1:1, order-preserving projection.
Labels are preserved for every entry except the #7520 remap slots, whose
label names the model actually served.
"""
fallback = config._FALLBACK_MODELS
openrouter = _models("openrouter")
assert len(openrouter) == len(fallback)
for source, projected in zip(fallback, openrouter, strict=True):
assert projected["label"] == source["label"]
assert projected["id"] == _expected_openrouter_id(source["id"])
if source["id"] not in _OPENROUTER_REMAPS:
assert projected["label"] == source["label"]
def test_direct_zai_setup_keeps_direct_provider_ids():
@@ -0,0 +1,174 @@
"""Regression coverage for source-aware transcript merge ordering."""
import sqlite3
from types import SimpleNamespace
import api.models as models
import api.routes as routes
def _user(content, ts, **extra):
return {"role": "user", "content": content, "timestamp": ts, **extra}
def _assistant(content, ts, **extra):
return {"role": "assistant", "content": content, "timestamp": ts, **extra}
def _read_idless_state_rows(monkeypatch, tmp_path, rows):
"""Project the production shape from a schema with no durable row id."""
db_path = tmp_path / "state.db"
with sqlite3.connect(db_path) as conn:
conn.execute(
"CREATE TABLE messages "
"(session_id TEXT, role TEXT, content TEXT, timestamp REAL)"
)
conn.executemany(
"INSERT INTO messages VALUES (?, ?, ?, ?)",
[
("ordering-session", row["role"], row["content"], row["timestamp"])
for row in rows
],
)
monkeypatch.setattr(models, "_active_state_db_path", lambda: db_path)
return models.get_state_db_session_messages("ordering-session")
def test_idless_state_db_projection_keeps_older_row_in_chronological_slot(
monkeypatch, tmp_path
):
"""The SQLite reader must not turn its row identity into a WebUI stable id."""
sidecar = [
_user("question one", 1000.0, id="parent-user"),
_assistant("answer one", 1010.0, id="parent-answer"),
_assistant("final conclusion", 1200.0, id="parent-final"),
]
state = _read_idless_state_rows(
monkeypatch,
tmp_path,
[_user("question two", 1100.0)],
)
assert state == [_user("question two", 1100.0)]
session = SimpleNamespace(
session_id="ordering-session",
messages=sidecar,
truncation_watermark=None,
truncation_boundary=None,
)
merged = models.reconciled_state_db_messages_for_session(
session,
state_messages=state,
)
assert [message["content"] for message in merged] == [
"question one",
"answer one",
"question two",
"final conclusion",
]
def test_idless_state_db_projection_still_appends_genuinely_newest_row(
monkeypatch, tmp_path
):
sidecar = [
_user("question one", 1000.0, id="parent-user"),
_assistant("answer one", 1010.0, id="parent-answer"),
]
state = _read_idless_state_rows(
monkeypatch,
tmp_path,
[_user("late question", 2000.0)],
)
session = SimpleNamespace(
session_id="ordering-session",
messages=sidecar,
truncation_watermark=None,
truncation_boundary=None,
)
merged = models.reconciled_state_db_messages_for_session(
session,
state_messages=state,
)
assert [message["content"] for message in merged][-1] == "late question"
def test_private_state_db_provenance_can_reorder_terminal_conflict_row():
"""Only a private state.db row identity can authorize terminal reordering."""
sidecar = [
_user("question one", 1000.0, id="sidecar-user"),
_user(
"recovered question",
1100.0,
_state_db_row_id=848467,
api_content="wire-sidecar",
),
_assistant("final conclusion", 1200.0, id="sidecar-final"),
]
state = [
_user(
"recovered question",
1100.0,
_state_db_row_id=848467,
api_content="wire-state-db-conflict",
)
]
merged = models.merge_session_messages_append_only(
sidecar,
state,
incoming_provenance="state_db",
)
assert [message["content"] for message in merged] == [
"question one",
"recovered question",
"recovered question",
"final conclusion",
]
def test_compression_child_stable_ids_remain_after_restamped_parent(monkeypatch):
"""Child-sidecar sequence is authoritative even when parent timestamps are later."""
parent = SimpleNamespace(
session_id="compression-parent",
parent_session_id=None,
session_source="webui",
pre_compression_snapshot=True,
truncation_watermark=None,
truncation_boundary=None,
messages=[
_user("parent question", 1000.0, id="parent-user"),
_assistant("parent answer", 1400.0, id="parent-answer"),
],
)
child = SimpleNamespace(
session_id="compression-child",
parent_session_id="compression-parent",
session_source="webui",
pre_compression_snapshot=False,
truncation_watermark=None,
truncation_boundary=None,
messages=[
_user("child continuation", 1100.0, id="child-user"),
_assistant("child answer", 1200.0, id="child-answer"),
],
)
monkeypatch.setattr(
routes.Session,
"load",
lambda session_id: parent if session_id == parent.session_id else None,
)
merged = routes._webui_sidecar_lineage_messages_for_display(child)
assert [message["id"] for message in merged] == [
"parent-user",
"parent-answer",
"child-user",
"child-answer",
]
+104
View File
@@ -0,0 +1,104 @@
"""Journal-reconstruction regression: a whitespace-stretched reasoning echo
must be de-duplicated exactly once.
``_run_journal_live_snapshot`` strips reasoning echoes of interim assistant
text so the restored transcript does not show the same content in both the
thinking block and the assistant output (run-state consistency invariant:
replay must not duplicate interim assistant text). The previous bounded echo
probe could miss a compact-equivalent suffix whose raw span exceeded its fold
window, leaving the duplicate in the snapshot; the backward walk has no
window.
Both directions are pinned: a stretched echo is stripped (appears once), and
genuine interim text that is NOT a reasoning echo is left alone.
"""
import json
import re
import api.routes as routes
from api import run_journal as RJ
def _compact(value):
return re.sub(r"\s+", "", str(value or ""))
def _write_journal(root, session_id, run_id, events):
session_root = root / RJ.RUN_JOURNAL_DIR_NAME / session_id
session_root.mkdir(parents=True, exist_ok=True)
path = session_root / f"{run_id}.jsonl"
rows = []
for seq, (event, payload) in enumerate(events, start=1):
rows.append(
json.dumps(
{
"version": 1,
"event_id": f"{run_id}:{seq}",
"seq": seq,
"run_id": run_id,
"session_id": session_id,
"event": event,
"type": event,
"created_at": 1000 + seq,
"payload": payload,
}
)
)
path.write_text("\n".join(rows) + "\n", encoding="utf-8")
return path
def test_whitespace_stretched_reasoning_echo_appears_once(tmp_path, monkeypatch):
gap = " " * 5000 # raw echo span far beyond any fixed fold window
_write_journal(
tmp_path,
"session_1",
"run_1",
[
("reasoning", {"text": "prefix " + "alpha" + gap + "beta"}),
("interim_assistant", {"text": "alpha beta"}),
],
)
monkeypatch.setattr(RJ, "_default_session_dir", lambda: tmp_path)
snapshot = routes._run_journal_live_snapshot("run_1")
message = snapshot["messages"][-1]
reasoning_text = message.get("reasoning") or ""
content_text = message.get("content") or ""
occurrences = _compact(reasoning_text).count("alphabeta") + _compact(
content_text
).count("alphabeta")
assert occurrences == 1, (
f"the interim echo must appear once after reconstruction; "
f"reasoning={reasoning_text[-60:]!r} content={content_text[-60:]!r}"
)
# The echo span is stripped from the reasoning copy; the prefix stays.
assert "alphabeta" not in _compact(reasoning_text)
assert "alpha beta" in content_text
def test_genuine_interim_text_is_not_treated_as_echo(tmp_path, monkeypatch):
gap = " " * 5000
_write_journal(
tmp_path,
"session_1",
"run_1",
[
("reasoning", {"text": "prefix " + "alpha" + gap + "beta"}),
("interim_assistant", {"text": "gamma delta"}),
],
)
monkeypatch.setattr(RJ, "_default_session_dir", lambda: tmp_path)
snapshot = routes._run_journal_live_snapshot("run_1")
message = snapshot["messages"][-1]
reasoning_text = message.get("reasoning") or ""
content_text = message.get("content") or ""
# The genuine interim answer is preserved in full...
assert "gamma delta" in content_text
# ...and the reasoning transcript is not suppressed by the matcher.
assert _compact(reasoning_text).count("alphabeta") == 1
assert "prefix" in reasoning_text
@@ -0,0 +1,127 @@
"""Scaling regression for the journal-rebuild echo path (#7569).
A fixed-size fixture cannot catch a per-interim quadratic rescan: the shape
that exposes it is a whitespace span that grows in proportion to the interim
event count, so a quadratic implementation's cost grows with the square of
the row count while a linear one stays flat per row.
The retired implementations both rescan the raw reasoning transcript per
interim event (the folded-tail probe, then the window-free backward walk):
with a 1 MB whitespace span and 20 interim events that is 20M raw-character
walks. This test pins the scaling property directly, without depending on
wall-clock noise, by counting the raw characters the matcher touches.
"""
import json
import time
import pytest
def _write_journal(root, session_id, run_id, events):
from api import run_journal as RJ
session_root = root / RJ.RUN_JOURNAL_DIR_NAME / session_id
session_root.mkdir(parents=True, exist_ok=True)
path = session_root / f"{run_id}.jsonl"
rows = []
for seq, (event, payload) in enumerate(events, start=1):
rows.append(
json.dumps(
{
"version": 1,
"event_id": f"{run_id}:{seq}",
"seq": seq,
"run_id": run_id,
"session_id": session_id,
"event": event,
"type": event,
"created_at": 1000 + seq,
"payload": payload,
}
)
)
path.write_text("\n".join(rows) + "\n", encoding="utf-8")
return path
def _whitespace_heavy_journal(interim_count: int, span: int):
"""Reasoning ends in a wide whitespace span; every interim probes it.
``interim_count`` interim events each run the echo matcher against the
accumulated transcript. Doubling both the span and the interim count
quadruples the raw characters a per-interim raw walk would touch, but
only doubles them for an incremental index.
"""
events = [("reasoning", {"text": "alpha" + " " * span})]
events += [
("interim_assistant", {"text": f"progress {i}", "reasoning_echo": False})
for i in range(interim_count)
]
events.append(("token", {"text": "final answer"}))
return events
def _rebuild(root, run_id):
import api.routes as routes
from api import run_journal as RJ
original = RJ._default_session_dir
RJ._default_session_dir = lambda: root
try:
return routes._run_journal_live_snapshot(run_id)
finally:
RJ._default_session_dir = original
@pytest.mark.parametrize("scale", [1, 2, 4])
def test_rebuild_echo_scan_is_not_per_interim_quadratic(tmp_path, scale):
"""Doubling span AND interim count must stay far under quadratic cost.
The budget is a per-row constant: a quadratic implementation spends
``span * interim_count`` raw-character walks, a linear one spends
``span`` (folded once) plus ``O(interim_count * echo_len)``. The
assertion below fails the quadratic shape by roughly an order of
magnitude while leaving the linear one with generous headroom.
"""
interim_count = 20 * scale
span = 100_000 * scale
_write_journal(
tmp_path, "sess", f"run_{scale}", _whitespace_heavy_journal(interim_count, span)
)
start = time.perf_counter()
snapshot = _rebuild(tmp_path, f"run_{scale}")
elapsed = time.perf_counter() - start
# Quadratic reference: 20*1 interims x 100k span took ~0.05s on the
# window-free walk, and ~1s at 1MB/20. Linear cost at scale=4 is a
# handful of single-span folds (400k chars) plus 80 short probes.
assert elapsed < 0.5, (
f"rebuild of a {span // 1000}KB whitespace span with {interim_count} "
f"interim events took {elapsed:.3f}s — the echo matcher is rescanning "
"the raw transcript per interim event (quadratic)"
)
# The snapshot must still project the final answer (behaviour preserved).
messages = (snapshot or {}).get("messages") or []
assert any("final answer" in (m.get("content") or "") for m in messages)
def test_rebuild_scaling_is_subquadratic(tmp_path):
"""Ratio check: 4x the rows must cost far less than 16x the time."""
times = {}
for scale in (1, 2, 4):
interim_count = 20 * scale
span = 100_000 * scale
_write_journal(
tmp_path, "sess", f"run_{scale}", _whitespace_heavy_journal(interim_count, span)
)
start = time.perf_counter()
_rebuild(tmp_path, f"run_{scale}")
times[scale] = time.perf_counter() - start
ratio = times[4] / times[1]
assert ratio < 8.0, (
f"4x the interim events and span cost {ratio:.1f}x the time "
f"({times}); a linear implementation stays near 4x, a quadratic one "
"reaches 16x"
)
+114
View File
@@ -0,0 +1,114 @@
"""Regression: live-snapshot rebuild locates the journal with a single parse.
``_run_journal_live_snapshot`` used ``find_run_summary`` (full parse) and
then ``read_run_events`` (second full parse) - two passes over the whole
journal per rebuild (~0.6s+ of a 4.4s rebuild on a 22.7k-row live run).
``find_run_file`` now locates the file without parsing and the durable
summary is derived from the single events parse; the historical
``find_run_summary`` lookup remains as the fallback seam when no journal
file exists on disk.
"""
import json
import api.routes as routes
from api import run_journal as RJ
def _write_journal(root, session_id, run_id, count=5):
session_root = root / RJ.RUN_JOURNAL_DIR_NAME / session_id
session_root.mkdir(parents=True, exist_ok=True)
path = session_root / f"{run_id}.jsonl"
rows = []
for seq in range(1, count + 1):
rows.append(
json.dumps(
{
"version": 1,
"event_id": f"{run_id}:{seq}",
"seq": seq,
"run_id": run_id,
"session_id": session_id,
"event": "token",
"type": "token",
"created_at": 1000 + seq,
"payload": {"text": "x"},
}
)
)
path.write_text("\n".join(rows) + "\n", encoding="utf-8")
return path
def test_find_run_file_locates_without_parsing(tmp_path):
path = _write_journal(tmp_path, "session_1", "run_1")
located = RJ.find_run_file("run_1", session_dir=tmp_path)
assert located is not None
session_id, located_path = located
assert session_id == "session_1"
assert located_path == path
assert RJ.find_run_file("missing_run", session_dir=tmp_path) is None
assert RJ.find_run_file("../evil", session_dir=tmp_path) is None
def _forbid_summary_parse(*_a, **_k):
raise AssertionError(
"find_run_summary must not be parsed when the journal file exists on "
"disk - the single-parse locate path regressed"
)
def test_live_snapshot_rebuilds_from_single_parse(tmp_path, monkeypatch):
_write_journal(tmp_path, "session_1", "run_1", count=3)
monkeypatch.setattr(RJ, "_default_session_dir", lambda: tmp_path)
# The double-parse fix: when a journal file exists, the rebuild must NOT
# route through find_run_summary (that would parse the file a second time).
monkeypatch.setattr(routes, "find_run_summary", _forbid_summary_parse)
snapshot = routes._run_journal_live_snapshot("run_1")
assert snapshot is not None
assert snapshot["session_id"] == "session_1"
assert snapshot["stream_id"] == "run_1"
assert snapshot["last_seq"] == 3
assert snapshot["event_count"] == 3
assert snapshot["last_assistant_text"] == "xxx"
def test_live_snapshot_summary_fallback_seam(tmp_path, monkeypatch):
"""No journal file on disk -> the historical find_run_summary seam is used."""
monkeypatch.setattr(RJ, "_default_session_dir", lambda: tmp_path)
monkeypatch.setattr(
routes,
"find_run_summary",
lambda stream_id: {
"session_id": "session_1",
"run_id": stream_id,
"last_seq": 1,
"last_event_id": f"{stream_id}:1",
},
)
monkeypatch.setattr(
routes,
"read_run_events",
lambda sid, rid: {
"events": [
{
"version": 1,
"event_id": f"{rid}:1",
"seq": 1,
"run_id": rid,
"session_id": sid,
"event": "token",
"type": "token",
"created_at": 1000,
"payload": {"text": "hello"},
}
],
},
)
snapshot = routes._run_journal_live_snapshot("run_1")
assert snapshot is not None
assert snapshot["last_assistant_text"] == "hello"
+118
View File
@@ -0,0 +1,118 @@
"""A chronological insert must never land inside a multi-result tool block.
``_insert_state_message_chronologically`` skips past an
``assistant(tool_calls)`` -> tool-result block so a late state.db row cannot be
placed between a tool call and its result — splitting that pair makes the next
send fail provider validation.
The original guard only recognised the block when ``messages[idx - 1]`` was the
owning assistant. That holds for a single-result turn, but a multi-tool turn
emits several adjacent tool rows, so when the insertion point landed on the
SECOND result its left neighbour was another ``tool`` row and the guard did not
fire. The row was inserted mid-block:
assistant(tool_calls), tool(t1), <inserted user>, tool(t2)
These tests pin the backward scan that finds the owning assistant across any
number of preceding contiguous tool rows.
"""
import pytest
from api.models import _insert_state_message_chronologically
def _msg(role, ts, content="x", **extra):
row = {"role": role, "timestamp": ts, "content": content}
row.update(extra)
return row
def _tool_result(call_id, ts):
return {
"role": "tool",
"timestamp": ts,
"tool_call_id": call_id,
"content": f"result-{call_id}",
}
def _block(n_results, first_result_ts=210, step=10):
"""An assistant turn with ``n_results`` tool calls and their results."""
assistant = _msg(
"assistant",
200,
"",
tool_calls=[
{"id": f"t{i + 1}", "function": {"name": f"fn{i + 1}"}}
for i in range(n_results)
],
)
results = [
_tool_result(f"t{i + 1}", first_result_ts + i * step) for i in range(n_results)
]
return assistant, results
def _assert_block_intact(messages, assistant, results):
start = messages.index(assistant)
positions = [messages.index(r) for r in results]
expected = [start + 1 + i for i in range(len(results))]
assert positions == expected, (
"tool block was split: "
f"{[m['role'] for m in messages]} (assistant at {start}, results at {positions})"
)
@pytest.mark.parametrize("late_ts", [205, 210, 215, 220, 225, 235])
@pytest.mark.parametrize("n_results", [2, 3, 4])
def test_late_row_never_splits_a_multi_result_tool_block(n_results, late_ts):
"""No insertion point may land between an assistant's tool results."""
assistant, results = _block(n_results)
messages = [_msg("user", 100), assistant, *results, _msg("assistant", 500)]
late = _msg("user", late_ts, "late-prompt")
_insert_state_message_chronologically(messages, late)
_assert_block_intact(messages, assistant, results)
assert late in messages, "the late row should still be placed somewhere"
def test_single_result_block_still_protected():
"""The original single-result case must keep working."""
assistant, results = _block(1)
messages = [_msg("user", 100), assistant, *results, _msg("assistant", 500)]
late = _msg("user", 215, "late-prompt")
_insert_state_message_chronologically(messages, late)
_assert_block_intact(messages, assistant, results)
def test_orphan_tool_rows_do_not_trap_the_backward_scan():
"""Tool rows with no owning assistant must not make the scan run away.
If the backward walk finds no ``assistant(tool_calls)`` owner it must leave
the insertion point alone rather than skipping the whole run.
"""
orphans = [_tool_result("t1", 210), _tool_result("t2", 220)]
messages = [_msg("user", 100), *orphans, _msg("assistant", 500)]
late = _msg("user", 215, "late-prompt")
_insert_state_message_chronologically(messages, late)
assert late in messages
# The orphan rows are still present and still in their original order.
assert [m for m in messages if m.get("role") == "tool"] == orphans
def test_late_row_after_the_block_is_unaffected():
"""A row that belongs after the whole block lands after it, not inside."""
assistant, results = _block(3)
messages = [_msg("user", 100), assistant, *results, _msg("assistant", 500)]
late = _msg("user", 260, "late-prompt")
_insert_state_message_chronologically(messages, late)
_assert_block_intact(messages, assistant, results)
assert messages.index(late) > messages.index(results[-1])
+39
View File
@@ -249,3 +249,42 @@ def test_onboarding_setup_rejects_api_key_with_newline():
)
assert status == 400
assert "newline" in data["error"].lower()
# ── Regression: #7520 ────────────────────────────────────────────────────────
def test_openrouter_wizard_no_dead_zai_ids():
"""OpenRouter wizard must not offer zai/glm-4.5-flash — OpenRouter does not
serve it. The free-tier equivalent is z-ai/glm-4.5-air. (#7520)"""
from api.onboarding import _SUPPORTED_PROVIDER_SETUPS
or_setup = _SUPPORTED_PROVIDER_SETUPS["openrouter"]
or_ids = {m["id"] for m in or_setup["models"]}
# Dead id must not appear in the OpenRouter projection. Since #7514 the
# projection maps zai/ -> z-ai/, so the id actually offered (and dead on
# OpenRouter) is z-ai/glm-4.5-flash; check both spellings so this test
# fails on the pre-fix code rather than passing vacuously.
assert "z-ai/glm-4.5-flash" not in or_ids, (
"OpenRouter wizard still offers dead id z-ai/glm-4.5-flash"
)
assert "zai/glm-4.5-flash" not in or_ids
# The remapped replacement must be present, and must not be labelled
# "free": OpenRouter bills z-ai/glm-4.5-air as a paid model.
air = [m for m in or_setup["models"] if m["id"] == "z-ai/glm-4.5-air"]
assert air, "OpenRouter wizard missing remapped z-ai/glm-4.5-air"
assert "free" not in air[0]["label"].lower()
def test_zai_native_still_has_glm_4_5_flash():
"""The native Z.AI onboarding setup must still offer glm-4.5-flash —
the remapping is OpenRouter-only."""
from api.onboarding import _SUPPORTED_PROVIDER_SETUPS
zai_setup = _SUPPORTED_PROVIDER_SETUPS["zai"]
zai_ids = {m["id"] for m in zai_setup["models"]}
assert "glm-4.5-flash" in zai_ids, (
"Native Z.AI setup lost glm-4.5-flash — the remapping must be "
"OpenRouter-only, not touching _FALLBACK_MODELS"
)
@@ -0,0 +1,522 @@
"""Review follow-ups for the strictly read-only ``state.db`` readers (PR #7445).
Contract under test:
* every missing-index projection (default sidebar, webhook, kanban, …), not
only the cron view, orders its candidate window through the read-only
pre-aggregated ``latest_messages`` CTE — never through the correlated
``MAX(mx.timestamp)`` scalar subquery that rescans ``messages`` per session;
the selected rows and their order stay identical to the indexed listing;
* ``state_db_readonly_uri`` builds a ``file:`` URI SQLite accepts on every
supported platform: POSIX unchanged, Windows drive letters as
``file:///C:/...``, UNC shares with an *empty* authority
(``file:////server/share/...``) because CPython's bundled SQLite rejects any
non-local authority, extended-length ``\\\\?\\`` prefixes stripped; the
query string is always the strict ``mode=ro``;
* ``scripts/ensure_state_db_read_indexes.py`` imports and runs its no-lock path
on a platform without ``fcntl`` (native Windows), and refuses ``--lock-file``
there with a clear error instead of an ``ImportError`` at import time;
* on the ``msvcrt`` branch the tool locks byte 0 of the lock file only after
making sure that byte exists, so a fresh (empty) ``--lock-file`` is acquired
without relying on the CRT's beyond-EOF locking behaviour; that byte is
written as exactly one byte even under the ``\\n`` → ``\\r\\n`` translation
native Windows applies to text-mode handles.
"""
import importlib
import os
import sqlite3
import sys
from contextlib import closing
import pytest
import api.agent_sessions as agent_sessions
# --------------------------------------------------------------------------
# 1. Missing-index projections use the pre-aggregated candidate ordering.
# --------------------------------------------------------------------------
def _make_state_db(path, *, sessions, source, with_index):
with closing(sqlite3.connect(str(path))) as conn:
conn.executescript(
"""
CREATE TABLE sessions(
id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER,
started_at REAL, source TEXT, session_source TEXT,
parent_session_id TEXT, ended_at REAL, end_reason TEXT);
CREATE TABLE messages(
id INTEGER PRIMARY KEY, session_id TEXT, role TEXT,
content TEXT, timestamp REAL);
"""
)
for i in range(sessions):
sid = f"{source}-{i:04d}"
conn.execute(
"INSERT INTO sessions VALUES (?,?,?,?,?,?,?,NULL,NULL,NULL)",
(sid, f"Session {i}", "m", 3, 1000.0 + i, source, source),
)
for j in range(3):
conn.execute(
"INSERT INTO messages(session_id, role, content, timestamp) VALUES (?,?,?,?)",
(sid, "user" if j == 0 else "assistant", "x", 1000.0 + i + j / 10),
)
# A resumed old session: started first, but its last message is newest.
conn.execute(
"INSERT INTO sessions VALUES (?,?,?,?,?,?,?,NULL,NULL,NULL)",
(f"{source}-resumed", "Resumed", "m", 2, 1.0, source, source),
)
conn.execute(
"INSERT INTO messages(session_id, role, content, timestamp) VALUES (?,?,?,?)",
(f"{source}-resumed", "user", "old", 1.0),
)
conn.execute(
"INSERT INTO messages(session_id, role, content, timestamp) VALUES (?,?,?,?)",
(f"{source}-resumed", "assistant", "recent", 5000.0),
)
if with_index:
conn.execute("CREATE INDEX idx_messages_session ON messages(session_id, timestamp)")
conn.commit()
def _capture_listing_sql(monkeypatch):
"""Record the SQL statements the listing runs (trace callback, read-only)."""
statements = []
real_connect = sqlite3.connect
def tracing_connect(*args, **kwargs):
conn = real_connect(*args, **kwargs)
conn.set_trace_callback(statements.append)
return conn
monkeypatch.setattr(agent_sessions.sqlite3, "connect", tracing_connect)
return statements
def _candidate_query_plan(db_path, statements):
listing_sql = [s for s in statements if "FROM sessions s" in s]
assert len(listing_sql) == 1, listing_sql
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
return [row[3] for row in conn.execute("EXPLAIN QUERY PLAN " + listing_sql[0])]
@pytest.mark.parametrize(
("source", "kwargs"),
[
("cli", {}), # default /api/sessions projection
("webhook", {"exclude_sources": None, "include_sources": ("webhook",)}),
("kanban", {"exclude_sources": None, "include_sources": ("kanban",)}),
("cli", {"exclude_sources": None}), # gateway watcher / diagnostic view
],
)
def test_missing_index_projection_uses_preaggregated_ordering(tmp_path, monkeypatch, source, kwargs):
indexed = tmp_path / "indexed.db"
unindexed = tmp_path / "unindexed.db"
_make_state_db(indexed, sessions=60, source=source, with_index=True)
_make_state_db(unindexed, sessions=60, source=source, with_index=False)
expected = agent_sessions.read_importable_agent_session_rows(indexed, limit=20, **kwargs)
assert expected[0]["id"] == f"{source}-resumed"
assert len(expected) == 20
statements = _capture_listing_sql(monkeypatch)
actual = agent_sessions.read_importable_agent_session_rows(unindexed, limit=20, **kwargs)
# Same rows, same order as the indexed listing.
assert [r["id"] for r in actual] == [r["id"] for r in expected]
assert [r["actual_message_count"] for r in actual] == [r["actual_message_count"] for r in expected]
assert [r["last_activity"] for r in actual] == [r["last_activity"] for r in expected]
plan = _candidate_query_plan(unindexed, statements)
# One pre-aggregation pass over messages, never a per-candidate rescan.
assert not any("CORRELATED SCALAR SUBQUERY" in step for step in plan), plan
assert any("latest_messages" in step for step in plan), plan
# The listing stayed a pure read: no index appeared.
with closing(sqlite3.connect(str(unindexed))) as conn:
assert conn.execute("PRAGMA index_list(messages)").fetchall() == []
def test_indexed_projection_keeps_the_correlated_index_seek(tmp_path, monkeypatch):
"""With ``idx_messages_session`` present the indexed seek stays in place."""
db = tmp_path / "state.db"
_make_state_db(db, sessions=10, source="cli", with_index=True)
statements = _capture_listing_sql(monkeypatch)
rows = agent_sessions.read_importable_agent_session_rows(db, limit=20)
assert rows[0]["id"] == "cli-resumed"
plan = _candidate_query_plan(db, statements)
assert not any("latest_messages" in step for step in plan), plan
assert any("idx_messages_session" in step for step in plan), plan
# --------------------------------------------------------------------------
# 2. Read-only URI construction is platform-aware and strictly mode=ro.
# --------------------------------------------------------------------------
@pytest.mark.parametrize(
("platform", "path", "expected"),
[
("linux", "/home/me/.hermes/state.db", "file:///home/me/.hermes/state.db?mode=ro"),
("darwin", "/Users/me/state dir #1/state?.db",
"file:///Users/me/state%20dir%20%231/state%3F.db?mode=ro"),
("win32", r"C:\Users\me\.hermes\state.db", "file:///C:/Users/me/.hermes/state.db?mode=ro"),
("win32", r"C:\a b\state#?.db", "file:///C:/a%20b/state%23%3F.db?mode=ro"),
("win32", r"\\server\share\hermes\state.db", "file:////server/share/hermes/state.db?mode=ro"),
("win32", r"\\server\share\a b\state#.db", "file:////server/share/a%20b/state%23.db?mode=ro"),
("win32", r"\\?\C:\x\state.db", "file:///C:/x/state.db?mode=ro"),
("win32", r"\\?\UNC\server\share\state.db", "file:////server/share/state.db?mode=ro"),
],
)
def test_state_db_readonly_uri_by_platform(platform, path, expected):
assert agent_sessions.state_db_readonly_uri(path, platform=platform) == expected
def test_state_db_readonly_uri_never_carries_a_host_authority():
uri = agent_sessions.state_db_readonly_uri(r"\\server\share\state.db", platform="win32")
assert uri.startswith("file:////")
assert "file://server" not in uri
assert uri.endswith("?mode=ro")
def test_open_state_db_readonly_uses_platform_uri_builder(tmp_path, monkeypatch):
path = tmp_path / "state.db"
with closing(sqlite3.connect(str(path))) as conn:
conn.execute("CREATE TABLE t(x)")
calls = []
real_connect = sqlite3.connect
def recording(target, *args, **kwargs):
calls.append((str(target), kwargs))
return real_connect(target, *args, **kwargs)
monkeypatch.setattr(agent_sessions.sqlite3, "connect", recording)
with closing(agent_sessions.open_state_db_readonly(path)) as conn:
assert conn.execute("SELECT COUNT(*) FROM t").fetchone() == (0,)
assert calls == [(agent_sessions.state_db_readonly_uri(path.resolve()), {"uri": True})]
assert calls[0][0].endswith("?mode=ro")
def test_empty_authority_uri_opens_on_this_platform(tmp_path):
"""SQLite accepts the empty-authority form the UNC path relies on."""
path = tmp_path / "state.db"
with closing(sqlite3.connect(str(path))) as conn:
conn.execute("CREATE TABLE t(x)")
uri = agent_sessions.state_db_readonly_uri(path.resolve())
with closing(sqlite3.connect(uri, uri=True)) as conn:
assert conn.execute("SELECT COUNT(*) FROM t").fetchone() == (0,)
with pytest.raises(sqlite3.OperationalError):
conn.execute("INSERT INTO t VALUES (1)")
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX filesystem-bytes path")
def test_non_utf8_posix_path_still_opens_readonly(tmp_path):
"""A non-UTF-8 path component must not make every agent session vanish.
Python carries undecodable POSIX filename bytes as ``surrogateescape``
code points; ``quote(str)`` rejects those with UnicodeEncodeError, which
the listing path swallows as "no sessions". ``Path.as_uri()`` (the pre-PR
shape) percent-encoded the fsencode() bytes, so the URI builder must too.
"""
try:
odd_dir = tmp_path / os.fsdecode(b"hermes-\xff-home")
odd_dir.mkdir()
except (OSError, UnicodeError):
pytest.skip("filesystem rejects non-UTF-8 names")
path = odd_dir / "state.db"
with closing(sqlite3.connect(str(path))) as conn:
conn.execute("CREATE TABLE t(x)")
conn.execute("INSERT INTO t VALUES (1)")
conn.commit()
uri = agent_sessions.state_db_readonly_uri(path.resolve())
assert "%FF" in uri
with closing(agent_sessions.open_state_db_readonly(path)) as conn:
assert conn.execute("SELECT COUNT(*) FROM t").fetchone() == (1,)
with pytest.raises(sqlite3.OperationalError):
conn.execute("INSERT INTO t VALUES (2)")
# --------------------------------------------------------------------------
# 3. The index maintenance script imports and runs without fcntl.
# --------------------------------------------------------------------------
_AGENT_MARKER_SQL = """
CREATE TABLE schema_version(version INTEGER NOT NULL);
INSERT INTO schema_version(version) VALUES (30);
"""
def _tool_indexes(path):
"""Names of the indexes this tool manages that exist in ``path``.
SQLite auto-creates ``sqlite_autoindex_*`` for a ``TEXT PRIMARY KEY``, so a
raw index count is the wrong oracle for "the tool created nothing".
"""
with closing(sqlite3.connect(str(path))) as conn:
return {
r[0]
for r in conn.execute("SELECT name FROM sqlite_master WHERE type='index'")
if not r[0].startswith("sqlite_autoindex_")
}
def _maintenance_schema(path):
with closing(sqlite3.connect(str(path))) as conn:
conn.executescript(
_AGENT_MARKER_SQL
+ """
CREATE TABLE sessions(id TEXT PRIMARY KEY, source TEXT, message_count INTEGER, last_activity_at REAL);
CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT, timestamp REAL, role TEXT);
"""
)
def test_maintenance_skips_indexes_an_older_schema_cannot_hold(tmp_path, maintenance_without_fcntl):
"""A legacy ``sessions`` table without ``last_activity_at`` must not roll back
the message indexes the schema does support."""
module = maintenance_without_fcntl
path = tmp_path / "state.db"
with closing(sqlite3.connect(str(path))) as conn:
conn.executescript(
_AGENT_MARKER_SQL
+ """
CREATE TABLE sessions(id TEXT PRIMARY KEY, source TEXT, message_count INTEGER);
CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT, timestamp REAL, role TEXT);
"""
)
result = module.ensure_read_indexes(path, confirmed_drained=True)
assert result == {
"idx_messages_session": "created",
"idx_messages_session_role": "created",
"idx_sessions_webui_fingerprint": "skipped",
}
with closing(sqlite3.connect(str(path))) as conn:
names = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='index'")}
assert {"idx_messages_session", "idx_messages_session_role"} <= names
assert "idx_sessions_webui_fingerprint" not in names
def test_maintenance_fails_loud_on_a_database_without_agent_tables(tmp_path, maintenance_without_fcntl):
"""``skipped`` is for a missing column only. A database with no ``messages``
table is not an agent state.db, so the tool must raise, not report success."""
module = maintenance_without_fcntl
path = tmp_path / "state.db"
with closing(sqlite3.connect(str(path))) as conn:
conn.executescript(_AGENT_MARKER_SQL + "CREATE TABLE sessions(id TEXT PRIMARY KEY); CREATE TABLE unrelated(x);")
with pytest.raises(RuntimeError, match="no 'messages' table"):
module.ensure_read_indexes(path, confirmed_drained=True)
assert _tool_indexes(path) == set()
def test_maintenance_fails_closed_on_same_named_tables_without_agent_columns(tmp_path, maintenance_without_fcntl):
"""Tables named ``messages``/``sessions`` with foreign columns are not an Agent
state.db: raise, create nothing, never report every index ``skipped``."""
module = maintenance_without_fcntl
path = tmp_path / "state.db"
with closing(sqlite3.connect(str(path))) as conn:
conn.executescript(
_AGENT_MARKER_SQL
+ "CREATE TABLE messages(body TEXT); CREATE TABLE sessions(id TEXT PRIMARY KEY, title TEXT);"
)
with pytest.raises(RuntimeError, match="not an Agent state.db"):
module.ensure_read_indexes(path, confirmed_drained=True)
assert _tool_indexes(path) == set()
def test_maintenance_rejects_a_look_alike_chat_database_without_the_agent_marker(tmp_path, maintenance_without_fcntl):
"""Codex round-3 repro: a non-Agent chat schema that happens to carry all seven
indexed column names must not be indexed. The Agent ``schema_version`` marker
is the identity check; column names alone are not."""
module = maintenance_without_fcntl
path = tmp_path / "chat.db"
with closing(sqlite3.connect(str(path))) as conn:
conn.executescript(
"""
CREATE TABLE sessions(id TEXT PRIMARY KEY, source TEXT, message_count INTEGER, last_activity_at REAL);
CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT, timestamp REAL, role TEXT);
"""
)
with pytest.raises(RuntimeError, match="no Agent schema_version marker"):
module.ensure_read_indexes(path, confirmed_drained=True)
assert _tool_indexes(path) == set()
def test_maintenance_rejects_a_sessions_table_not_keyed_on_id(tmp_path, maintenance_without_fcntl):
"""Marker present but ``sessions`` isn't keyed on ``id``: not the Agent shape."""
module = maintenance_without_fcntl
path = tmp_path / "state.db"
with closing(sqlite3.connect(str(path))) as conn:
conn.executescript(
_AGENT_MARKER_SQL
+ """
CREATE TABLE sessions(source TEXT, id TEXT, message_count INTEGER, last_activity_at REAL);
CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT, timestamp REAL, role TEXT);
"""
)
with pytest.raises(RuntimeError, match="not keyed on id"):
module.ensure_read_indexes(path, confirmed_drained=True)
assert _tool_indexes(path) == set()
def test_maintenance_rejects_a_same_named_index_on_the_legacy_gap(tmp_path, maintenance_without_fcntl):
"""On the legacy schema a pre-existing ``idx_sessions_webui_fingerprint`` can't
be the expected shape (it would key on the absent column): report it as
incompatible instead of skipping past it, and roll back the message indexes."""
module = maintenance_without_fcntl
path = tmp_path / "state.db"
with closing(sqlite3.connect(str(path))) as conn:
conn.executescript(
_AGENT_MARKER_SQL
+ """
CREATE TABLE sessions(id TEXT PRIMARY KEY, source TEXT, message_count INTEGER);
CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT, timestamp REAL, role TEXT);
CREATE INDEX idx_sessions_webui_fingerprint ON messages(role);
"""
)
with pytest.raises(RuntimeError, match="Incompatible index: idx_sessions_webui_fingerprint"):
module.ensure_read_indexes(path, confirmed_drained=True)
assert _tool_indexes(path) == {"idx_sessions_webui_fingerprint"}
@pytest.fixture
def maintenance_without_fcntl(monkeypatch):
"""Import the maintenance module as a platform without ``fcntl`` sees it."""
name = "scripts.ensure_state_db_read_indexes"
monkeypatch.setitem(sys.modules, "fcntl", None)
monkeypatch.delitem(sys.modules, name, raising=False) # restores a prior real import
module = importlib.import_module(name)
try:
yield module
finally:
# Drop the fcntl-less copy before monkeypatch restores the original entry.
if sys.modules.get(name) is module:
del sys.modules[name]
def test_maintenance_imports_and_runs_without_fcntl(tmp_path, maintenance_without_fcntl):
module = maintenance_without_fcntl
path = tmp_path / "state.db"
_maintenance_schema(path)
result = module.ensure_read_indexes(path, confirmed_drained=True)
assert set(result.values()) == {"created"}
with closing(sqlite3.connect(str(path))) as conn:
names = {row[1] for row in conn.execute("PRAGMA index_list(messages)")}
assert "idx_messages_session" in names
def test_maintenance_refuses_lock_file_without_a_lock_primitive(tmp_path, maintenance_without_fcntl, monkeypatch):
module = maintenance_without_fcntl
monkeypatch.setitem(sys.modules, "msvcrt", None)
path = tmp_path / "state.db"
_maintenance_schema(path)
with pytest.raises(RuntimeError, match="--lock-file"):
module.ensure_read_indexes(path, confirmed_drained=True, lock_file=tmp_path / "turns.lock")
with closing(sqlite3.connect(str(path))) as conn:
assert conn.execute("PRAGMA index_list(messages)").fetchall() == []
def test_maintenance_cli_without_lock_file_works_without_fcntl(tmp_path, maintenance_without_fcntl, monkeypatch, capsys):
module = maintenance_without_fcntl
path = tmp_path / "state.db"
_maintenance_schema(path)
monkeypatch.setattr(sys, "argv", ["ensure_state_db_read_indexes.py", "--db", str(path), "--confirm-drained"])
module.main()
out = capsys.readouterr().out
assert '"idx_messages_session": "created"' in out
# --------------------------------------------------------------------------
# 4. The Windows lock branch acquires a fresh (empty) lock file.
# --------------------------------------------------------------------------
class _StrictMsvcrt:
"""Stand-in for ``msvcrt`` that, like Windows, refuses to lock past EOF.
Records every ``locking`` call with the file size at the current offset so
the test can prove byte 0 existed when the lock was taken.
"""
LK_NBLCK = 2
LK_UNLCK = 0
def __init__(self):
self.calls = []
def locking(self, fd, mode, nbytes):
offset = os.lseek(fd, 0, os.SEEK_CUR)
size = os.fstat(fd).st_size
self.calls.append((mode, offset, nbytes, size))
if mode == self.LK_NBLCK and offset + nbytes > size:
raise OSError(36, "Resource deadlock avoided: lock region beyond end of file")
@pytest.fixture
def windows_text_mode(maintenance_without_fcntl, monkeypatch):
"""Make the tool's text-mode ``open`` translate ``\\n`` to ``\\r\\n`` as on native Windows."""
real_open = open
def crlf_text_open(file, mode="r", *args, **kwargs):
if "b" not in mode:
kwargs.setdefault("newline", "\r\n")
return real_open(file, mode, *args, **kwargs)
monkeypatch.setattr(maintenance_without_fcntl, "open", crlf_text_open, raising=False)
def test_windows_lock_branch_acquires_a_fresh_empty_lock_file(tmp_path, maintenance_without_fcntl, windows_text_mode, monkeypatch):
module = maintenance_without_fcntl
assert module.fcntl is None # the fixture already routes the tool to the msvcrt branch
fake = _StrictMsvcrt()
monkeypatch.setattr(module, "msvcrt", fake)
path = tmp_path / "state.db"
_maintenance_schema(path)
lock = tmp_path / "turns.lock"
assert not lock.exists()
result = module.ensure_read_indexes(path, confirmed_drained=True, lock_file=lock)
assert set(result.values()) == {"created"}
# Exactly one non-blocking lock on byte 0, taken while that byte existed
# and was the only one: no newline translation may inflate the file.
assert fake.calls == [(fake.LK_NBLCK, 0, 1, 1)]
assert lock.stat().st_size == 1
with closing(sqlite3.connect(str(path))) as conn:
names = {row[1] for row in conn.execute("PRAGMA index_list(messages)")}
assert "idx_messages_session" in names
def test_windows_lock_branch_keeps_an_existing_lock_file_intact(tmp_path, maintenance_without_fcntl, monkeypatch):
module = maintenance_without_fcntl
fake = _StrictMsvcrt()
monkeypatch.setattr(module, "msvcrt", fake)
path = tmp_path / "state.db"
_maintenance_schema(path)
lock = tmp_path / "turns.lock"
lock.write_bytes(b"deployment-owned\n")
module.ensure_read_indexes(path, confirmed_drained=True, lock_file=lock)
assert fake.calls == [(fake.LK_NBLCK, 0, 1, len(b"deployment-owned\n"))]
assert lock.read_bytes() == b"deployment-owned\n"
def test_windows_lock_branch_reports_a_held_lock_without_touching_the_db(tmp_path, maintenance_without_fcntl, windows_text_mode, monkeypatch):
module = maintenance_without_fcntl
class _HeldMsvcrt(_StrictMsvcrt):
def locking(self, fd, mode, nbytes):
super().locking(fd, mode, nbytes)
raise PermissionError(13, "Permission denied: lock held")
fake = _HeldMsvcrt()
monkeypatch.setattr(module, "msvcrt", fake)
path = tmp_path / "state.db"
_maintenance_schema(path)
lock = tmp_path / "turns.lock"
with pytest.raises(PermissionError):
module.ensure_read_indexes(path, confirmed_drained=True, lock_file=lock)
assert fake.calls == [(fake.LK_NBLCK, 0, 1, 1)]
with closing(sqlite3.connect(str(path))) as conn:
assert conn.execute("PRAGMA index_list(messages)").fetchall() == []
+82
View File
@@ -0,0 +1,82 @@
"""Execute the production reconnect replay seam, not a source-string assertion.
Browser coverage in browser_reconnect_scene_redraw.py exercises loadSession and
real DOM rendering; these cheap Node checks pin fallback and redraw counts.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
NODE = shutil.which('node')
pytestmark = pytest.mark.skipif(NODE is None, reason='Node.js is required for the reconnect replay probe')
def source_slice(text, start_marker, end_marker, path):
start = text.find(start_marker)
assert start >= 0, f'{path}: missing start marker {start_marker!r}'
end = text.find(end_marker, start)
assert end >= 0, f'{path}: missing end marker {end_marker!r}'
return text[start:end]
def replay_probe(restored_scene, count, skip_unkeyed=True, has_rows=True):
sessions = (ROOT / 'static/sessions.js').read_text()
ui = (ROOT / 'static/ui.js').read_text()
replay = source_slice(
sessions, 'const liveToolReplayId=(tc)=>', ' let didReconnect=false;',
'static/sessions.js',
)
append = source_slice(
ui, 'function appendLiveToolCard(', '\nfunction _findLatestLiveAssistantByBurst',
'static/ui.js',
)
script = r"""
const vm=require('node:vm');
const opts=JSON.parse(process.argv[1]);
const tools=Array.from({length:opts.count},(_,i)=>({name:'terminal',tid:'tool-'+i,done:true}));
tools.push({name:'read_file',done:true});
let redraws=0, appended=[];
const sandbox={
S:{session:{session_id:'session'},activeStreamId:'run',toolCalls:tools},
sid:'session',activeStreamId:'run',INFLIGHT:{},restoredAnchorScene:opts.restored_scene,
document:{getElementById:()=>({querySelector:()=>opts.has_rows?{}:null})},
isFinalAnswerOnlyMode:()=>false,
isLiveAnchorActivitySceneOwner:()=>true,
_renderLiveAnchorActivitySceneForStream:()=>{redraws++;return true;},
};
vm.createContext(sandbox);
vm.runInContext(APPEND,sandbox);
// The actual append function proves the amplification when a scene owns DOM.
// For legacy fallback, observe arguments at the append boundary instead.
if(!opts.restored_scene) sandbox.appendLiveToolCard=(tc)=>appended.push(tc.tid||'unkeyed');
vm.runInContext(REPLAY+'\nreplayPersistedLiveToolCards('+JSON.stringify({skipUnkeyedRestoredDuplicates:opts.skip_unkeyed})+');',sandbox);
console.log(JSON.stringify({redraws,appended}));
""".replace('APPEND', json.dumps(append)).replace('REPLAY', json.dumps(replay))
result = subprocess.run(
[NODE or 'node', '-e', script, json.dumps({
'restored_scene': restored_scene, 'count': count,
'skip_unkeyed': skip_unkeyed, 'has_rows': has_rows,
})], capture_output=True, text=True, check=True,
)
return json.loads(result.stdout)
@pytest.mark.parametrize('count', [0, 1, 470])
def test_restored_complete_scene_is_not_redrawn_per_saved_tool(count):
result = replay_probe(True, count)
assert result['redraws'] == 0, result
def test_non_anchor_restore_still_replays_keyed_tools():
result = replay_probe(False, 3)
assert result['appended'] == ['tool-0', 'tool-1', 'tool-2']
@pytest.mark.parametrize('skip_unkeyed,has_rows', [(False, True), (True, False)])
def test_failed_scene_or_empty_html_keeps_unkeyed_fallback(skip_unkeyed, has_rows):
result = replay_probe(False, 3, skip_unkeyed, has_rows)
assert result['appended'] == ['tool-0', 'tool-1', 'tool-2', 'unkeyed']
+460 -5
View File
@@ -1,5 +1,6 @@
"""Regression tests for /api/sessions lineage metadata used by sidebar collapse."""
import json
import sqlite3
import time
@@ -51,7 +52,8 @@ def _ensure_state_db(path):
message_count INTEGER DEFAULT 0,
parent_session_id TEXT,
ended_at REAL,
end_reason TEXT
end_reason TEXT,
model_config TEXT
);
"""
)
@@ -62,6 +64,7 @@ def _ensure_messages_table(conn):
conn.execute(
"""
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
session_id TEXT,
role TEXT,
content TEXT,
@@ -79,14 +82,14 @@ def _insert_state_message(conn, sid, *, role, content, timestamp):
conn.commit()
def _insert_state_row(conn, sid, *, title=None, parent=None, ended_at=None, end_reason=None, started_at=None, source='webui', session_source=None):
def _insert_state_row(conn, sid, *, title=None, parent=None, ended_at=None, end_reason=None, started_at=None, source='webui', session_source=None, model_config=None):
conn.execute(
"""
INSERT INTO sessions
(id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason)
VALUES (?, ?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?)
(id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason, model_config)
VALUES (?, ?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?, ?)
""",
(sid, source, session_source, title or sid, started_at or time.time(), parent, ended_at, end_reason),
(sid, source, session_source, title or sid, started_at or time.time(), parent, ended_at, end_reason, model_config),
)
conn.commit()
@@ -693,3 +696,455 @@ def test_sessions_route_preserves_visible_child_lineage_when_archived_parent_fil
]
finally:
conn.close()
def test_compression_continuation_tolerates_sub_second_timestamp_overlap(_isolate):
"""#6931: a compression continuation recorded a few ms BEFORE the parent's
ended_at (write-order race) is still one lineage in /api/sessions."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_race_root", title="Shared conversation", updated_at=t0)
_save_webui_session("lineage_race_tip", title="Shared conversation", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_race_root",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
# child.started_at lands 0.1s BEFORE parent.ended_at — the #6931 race.
_insert_state_row(
conn,
"lineage_race_tip",
parent="lineage_race_root",
started_at=t0 + 5 - 0.1,
)
rows = {row["session_id"]: row for row in all_sessions()}
tip = rows["lineage_race_tip"]
assert tip.get("parent_session_id") == "lineage_race_root"
assert tip.get("_lineage_root_id") == "lineage_race_root"
assert tip.get("_lineage_tip_id") == "lineage_race_tip"
assert tip.get("_compression_segment_count") == 2
assert tip.get("relationship_type") != "child_session"
finally:
conn.close()
def test_materially_overlapping_compression_child_stays_child_session(_isolate):
"""#6931: a child that started long before the compression parent ended
(beyond tolerance, no matching title) must remain a separate child."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_overlap_root", title="Root title", updated_at=t0)
_save_webui_session("lineage_overlap_child", title="Child title", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_overlap_root",
started_at=t0,
ended_at=t0 + 60,
end_reason="compression",
)
# child started 30s before the parent ended — a genuine concurrent child.
_insert_state_row(
conn,
"lineage_overlap_child",
parent="lineage_overlap_root",
started_at=t0 + 30,
)
rows = {row["session_id"]: row for row in all_sessions()}
child = rows["lineage_overlap_child"]
assert child.get("relationship_type") == "child_session"
assert child.get("parent_session_id") == "lineage_overlap_root"
assert "_lineage_root_id" not in child
assert "_compression_segment_count" not in child
finally:
conn.close()
def test_same_title_independent_child_outside_tolerance_stays_visible(_isolate):
"""#7021 re-gate: a same-title independent child started well outside the
tolerance window must NOT collapse on the title match — titles are
user-controlled and non-unique, so the only continuation evidence is the
bounded early-side timestamp window."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_title_root", title="Shared conversation", updated_at=t0)
_save_webui_session("lineage_title_tip", title="Shared conversation", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_title_root",
title="Shared conversation",
started_at=t0,
ended_at=t0 + 60,
end_reason="compression",
)
# Same title, but started 50s before the parent ended — far outside any
# handoff race window. Must remain a visible child session.
_insert_state_row(
conn,
"lineage_title_tip",
title="Shared conversation",
parent="lineage_title_root",
started_at=t0 + 10,
)
rows = {row["session_id"]: row for row in all_sessions()}
tip = rows["lineage_title_tip"]
assert tip.get("relationship_type") == "child_session"
assert tip.get("parent_session_id") == "lineage_title_root"
assert "_lineage_root_id" not in tip
assert "_compression_segment_count" not in tip
finally:
conn.close()
def test_model_config_branched_from_child_stays_visible_within_tolerance(_isolate):
"""#7021 re-gate: an explicit Agent branch is marked in
model_config._branched_from (not session_source). Even when it starts
inside the 2s tolerance window of the parent's compression, the real
marker must keep it visible instead of collapsing it into the lineage."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_branch_root", title="Shared conversation", updated_at=t0)
_save_webui_session("lineage_branch_tip", title="Shared conversation", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_branch_root",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
# The reviewer's adversarial probe: an Agent branch starting 1.5s
# BEFORE the parent's compression ended_at. No session_source — the
# fork identity lives in model_config._branched_from.
_insert_state_row(
conn,
"lineage_branch_tip",
parent="lineage_branch_root",
started_at=t0 + 5 - 1.5,
model_config=json.dumps({"_branched_from": "lineage_branch_root"}),
)
rows = {row["session_id"]: row for row in all_sessions()}
tip = rows["lineage_branch_tip"]
assert tip.get("relationship_type") == "child_session"
assert tip.get("parent_session_id") == "lineage_branch_root"
assert "_lineage_root_id" not in tip
assert "_compression_segment_count" not in tip
finally:
conn.close()
def test_model_config_delegate_from_child_stays_visible_within_tolerance(_isolate):
"""#7021 re-gate: delegate/subagent runs are marked in
model_config._delegate_from. A delegate child starting inside the
tolerance window of the parent's compression must stay visible."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_delegate_root", title="Shared conversation", updated_at=t0)
_save_webui_session("lineage_delegate_tip", title="Shared conversation", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_delegate_root",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_delegate_tip",
parent="lineage_delegate_root",
started_at=t0 + 5 - 1.0,
model_config=json.dumps({"_delegate_from": "lineage_delegate_root"}),
)
rows = {row["session_id"]: row for row in all_sessions()}
tip = rows["lineage_delegate_tip"]
assert tip.get("relationship_type") == "child_session"
assert tip.get("parent_session_id") == "lineage_delegate_root"
assert "_lineage_root_id" not in tip
assert "_compression_segment_count" not in tip
finally:
conn.close()
def test_continuation_classification_timestamp_tolerance_and_guards():
"""#6931/#7021 focused unit coverage of _is_continuation_session: bounded
tolerance, model_config branch markers, and preserved fork/cross-source/
end_reason guards."""
from api.agent_sessions import _is_continuation_session
def make_parent(**over):
row = {
'id': 'parent-1',
'source': 'webui',
'end_reason': 'compression',
'ended_at': 1000.0,
'title': 'Shared conversation',
}
row.update(over)
return row
def make_child(**over):
row = {
'id': 'child-1',
'source': 'webui',
'started_at': 1000.05,
'title': 'Shared conversation',
}
row.update(over)
return row
# Normal non-overlapping ordering: continuation.
assert _is_continuation_session(make_parent(), make_child())
# Sub-second overlap (observed -0.06..-0.07s in #6931): continuation.
assert _is_continuation_session(make_parent(), make_child(started_at=999.95))
# Overlap inside the 2s tolerance: continuation.
assert _is_continuation_session(make_parent(), make_child(started_at=998.5))
# Overlap beyond tolerance: separate child, even with an exact title match
# (titles are user-controlled and non-unique — no title fallback).
assert not _is_continuation_session(make_parent(), make_child(started_at=950.0))
assert not _is_continuation_session(
make_parent(), make_child(started_at=950.0, title='Another conversation')
)
# model_config._branched_from pointing at the parent: never a
# continuation, regardless of timing.
assert not _is_continuation_session(
make_parent(),
make_child(
started_at=999.95,
model_config=json.dumps({'_branched_from': 'parent-1'}),
),
)
assert not _is_continuation_session(
make_parent(),
make_child(
started_at=998.5,
model_config={'_branched_from': 'parent-1'},
),
)
# model_config._delegate_from pointing at the parent: never a
# continuation, regardless of timing.
assert not _is_continuation_session(
make_parent(),
make_child(
started_at=999.95,
model_config=json.dumps({'_delegate_from': 'parent-1'}),
),
)
# A marker pointing at a DIFFERENT session does not disqualify: compression
# continuations inherit the rotated agent's model_config verbatim, so a
# delegate's continuation still carries the delegate's own marker.
assert _is_continuation_session(
make_parent(),
make_child(
started_at=999.95,
model_config=json.dumps({'_delegate_from': 'some-other-session'}),
),
)
# Unparsable model_config degrades to no markers (no crash, no match).
assert _is_continuation_session(
make_parent(),
make_child(started_at=999.95, model_config='not-json'),
)
assert not _is_continuation_session(
make_parent(),
make_child(started_at=950.0, model_config='not-json'),
)
# Fork guard holds regardless of timing.
assert not _is_continuation_session(
make_parent(), make_child(started_at=999.95, session_source='fork')
)
# Cross-source guard holds regardless of timing.
assert not _is_continuation_session(
make_parent(), make_child(started_at=999.95, source='telegram')
)
# Non-compression/cli_close parents never continue.
assert not _is_continuation_session(
make_parent(end_reason='user_stop'), make_child(started_at=999.95)
)
# Missing/unparsable boundary timestamps degrade to False (no crash).
assert not _is_continuation_session(make_parent(ended_at='not-a-number'), make_child())
assert not _is_continuation_session(make_parent(), make_child(started_at='not-a-number'))
def test_state_db_stitch_keeps_branched_child_out_of_parent_transcript(_isolate):
"""#7021 r2: the open/import transcript stitcher (get_state_db_session_messages
with stitch_continuations=True) must apply the same model_config branch-marker
guard as the listing classifier. A child whose model_config._branched_from /
_delegate_from points at the parent must NOT have its messages stitched into
the parent transcript even when it starts inside the tolerance window.
"""
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
try:
# Compression parent whose child starts INSIDE the 2s tolerance window.
_insert_state_row(
conn,
"stitch_branch_parent",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_message(
conn, "stitch_branch_parent", role="user", content="parent turn", timestamp=t0 + 1
)
# Explicit Agent branch: fork identity lives in model_config, not
# session_source. Starting 1.5s before the parent's ended_at — inside
# the tolerance — it must still stay out of the parent's transcript.
_insert_state_row(
conn,
"stitch_branch_child",
parent="stitch_branch_parent",
started_at=t0 + 5 - 1.5,
model_config=json.dumps({"_branched_from": "stitch_branch_parent"}),
)
_insert_state_message(
conn, "stitch_branch_child", role="user", content="branch turn", timestamp=t0 + 6
)
msgs = models.get_state_db_session_messages(
"stitch_branch_child", stitch_continuations=True
)
contents = [m["content"] for m in msgs]
assert "branch turn" in contents
assert "parent turn" not in contents
# Control: a genuine compression continuation (no branch marker) starting
# inside the same window IS stitched into the parent transcript.
_insert_state_row(
conn,
"stitch_continuation_child",
parent="stitch_branch_parent",
started_at=t0 + 5 - 0.5,
)
_insert_state_message(
conn,
"stitch_continuation_child",
role="user",
content="continuation turn",
timestamp=t0 + 7,
)
msgs = models.get_state_db_session_messages(
"stitch_continuation_child", stitch_continuations=True
)
contents = [m["content"] for m in msgs]
assert "continuation turn" in contents
assert "parent turn" in contents
finally:
conn.close()
def test_state_db_stitch_keeps_delegate_child_out_of_parent_transcript(_isolate):
"""#7021 r2: same guard for model_config._delegate_from (subagent runs) in
the open/import transcript stitcher."""
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
try:
_insert_state_row(
conn,
"stitch_delegate_parent",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_message(
conn, "stitch_delegate_parent", role="user", content="parent turn", timestamp=t0 + 1
)
_insert_state_row(
conn,
"stitch_delegate_child",
parent="stitch_delegate_parent",
started_at=t0 + 5 - 1.0,
model_config=json.dumps({"_delegate_from": "stitch_delegate_parent"}),
)
_insert_state_message(
conn, "stitch_delegate_child", role="user", content="delegate turn", timestamp=t0 + 6
)
msgs = models.get_state_db_session_messages(
"stitch_delegate_child", stitch_continuations=True
)
contents = [m["content"] for m in msgs]
assert "delegate turn" in contents
assert "parent turn" not in contents
finally:
conn.close()
def test_state_db_stitch_old_schema_without_identity_columns_still_stitches(_isolate):
"""#7021 r2: older state.db schemas lacking session_source/model_config degrade
to NULL and keep the pre-fix behavior — a genuine compression continuation
within the tolerance is still stitched, without crashing."""
conn = sqlite3.connect(str(_isolate))
conn.executescript(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
title TEXT,
started_at REAL NOT NULL,
message_count INTEGER DEFAULT 0,
parent_session_id TEXT,
ended_at REAL,
end_reason TEXT
);
"""
)
_ensure_messages_table(conn)
t0 = time.time() - 100
try:
conn.execute(
"""
INSERT INTO sessions
(id, source, title, started_at, message_count, parent_session_id, ended_at, end_reason)
VALUES (?, ?, ?, ?, 2, ?, ?, ?)
""",
(
"stitch_old_parent",
"webui",
"stitch_old_parent",
t0,
None,
t0 + 5,
"compression",
),
)
conn.execute(
"""
INSERT INTO sessions
(id, source, title, started_at, message_count, parent_session_id, ended_at, end_reason)
VALUES (?, ?, ?, ?, 2, ?, NULL, NULL)
""",
("stitch_old_child", "webui", "stitch_old_child", t0 + 4.5, "stitch_old_parent"),
)
conn.commit()
_insert_state_message(
conn, "stitch_old_parent", role="user", content="parent turn", timestamp=t0 + 1
)
_insert_state_message(
conn, "stitch_old_child", role="user", content="old child turn", timestamp=t0 + 6
)
msgs = models.get_state_db_session_messages(
"stitch_old_child", stitch_continuations=True
)
contents = [m["content"] for m in msgs]
assert "old child turn" in contents
assert "parent turn" in contents
finally:
conn.close()
+4 -1
View File
@@ -38,7 +38,10 @@ def test_footer_timestamp_uses_richer_format_for_older_messages():
def test_timestamp_footer_stays_on_visible_response_segments():
assert "if(hasVisibleBody){" in UI_JS
assert 'seg.insertAdjacentHTML(\'beforeend\', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);' in UI_JS, (
# #2051: the block goes in through _insertSegmentBlock() rather than
# seg.insertAdjacentHTML(), so a wrapped DOM API cannot render it twice. The footer
# still rides in the same block, which is what this test is about.
assert '_insertSegmentBlock(seg, `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);' in UI_JS, (
"Footer timestamp should stay attached to visible response segments"
)
assert "assistantThinking.set(rawIdx, thinkingText);" in UI_JS, (
+126
View File
@@ -0,0 +1,126 @@
"""state.db readers stay strictly read-only and idle watchers do not poll.
Contract under test:
* ``open_state_db_readonly`` opens ``file:...?mode=ro`` once and propagates a
failure — it never retries with a write-capable handle;
* the gateway watcher does not touch the database while nobody is subscribed;
* the session listing never runs ``CREATE INDEX`` (a writer lock held for
minutes on a large ``messages`` table) — a missing index degrades instead;
* index maintenance is an explicit drained operation
(``scripts/ensure_state_db_read_indexes.py``);
* the cron sidebar read in ``api/routes.py`` uses the read-only opener.
"""
import sqlite3
from contextlib import closing
import pytest
def test_readonly_open_failure_never_retries_writable(tmp_path, monkeypatch):
from api import agent_sessions
path = tmp_path / "state.db"
path.touch()
calls = []
def connect(*args, **kwargs):
calls.append((args, kwargs))
raise sqlite3.OperationalError("readonly unavailable")
monkeypatch.setattr(agent_sessions.sqlite3, "connect", connect)
with pytest.raises(sqlite3.OperationalError):
agent_sessions.open_state_db_readonly(path)
assert len(calls) == 1
assert calls[0][1] == {"uri": True}
def test_no_subscribers_means_no_database_poll(tmp_path, monkeypatch):
from api import gateway_watcher as watcher
path = tmp_path / "state.db"
path.touch()
instance = watcher.GatewayWatcher(hermes_home=tmp_path)
monkeypatch.setattr(watcher, "_cheap_change_fingerprint", lambda *a: pytest.fail("polled DB"))
monkeypatch.setattr(watcher, "_get_agent_sessions_from_db", lambda *a: pytest.fail("projected payload"))
assert instance._poll_once(now=500) is False
def test_listing_does_not_create_indexes(tmp_path):
from api.agent_sessions import read_importable_agent_session_rows
path = tmp_path / "state.db"
with closing(sqlite3.connect(path)) as db:
db.executescript("""
CREATE TABLE sessions(id TEXT PRIMARY KEY, title TEXT, model TEXT,
message_count INTEGER, started_at REAL, source TEXT);
CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT, timestamp REAL, role TEXT);
INSERT INTO sessions VALUES ('one', 'Test', 'test', 2, 1, 'cli');
INSERT INTO messages VALUES (1, 'one', 2, 'user');
INSERT INTO messages VALUES (2, 'one', 3, 'assistant');
""")
rows = read_importable_agent_session_rows(path)
assert rows[0]["id"] == "one"
with closing(sqlite3.connect(path)) as db:
assert db.execute("PRAGMA index_list(messages)").fetchall() == []
def _maintenance_schema(path):
with closing(sqlite3.connect(path)) as db:
db.executescript("""
CREATE TABLE schema_version(version INTEGER NOT NULL);
INSERT INTO schema_version(version) VALUES (30);
CREATE TABLE sessions(id TEXT PRIMARY KEY, source TEXT, message_count INTEGER, last_activity_at REAL);
CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT, timestamp REAL, role TEXT);
""")
def test_index_maintenance_requires_drain_and_is_idempotent(tmp_path):
from scripts.ensure_state_db_read_indexes import ensure_read_indexes
path = tmp_path / "state.db"
_maintenance_schema(path)
with pytest.raises(RuntimeError, match="confirm-drained"):
ensure_read_indexes(path)
lock = tmp_path / "turns.lock"
first = ensure_read_indexes(path, confirmed_drained=True, lock_file=lock)
second = ensure_read_indexes(path, confirmed_drained=True, lock_file=lock)
assert set(first.values()) == {"created"}
assert set(second.values()) == {"existing"}
assert "idx_messages_session_role" in first
def test_index_maintenance_without_lock_file_still_requires_drain(tmp_path):
from scripts.ensure_state_db_read_indexes import ensure_read_indexes
path = tmp_path / "state.db"
_maintenance_schema(path)
result = ensure_read_indexes(path, confirmed_drained=True)
assert set(result.values()) == {"created"}
def test_index_maintenance_refuses_when_lock_is_held(tmp_path):
fcntl = pytest.importorskip("fcntl", reason="flock-held scenario needs a POSIX interpreter")
from scripts.ensure_state_db_read_indexes import ensure_read_indexes
path = tmp_path / "state.db"
_maintenance_schema(path)
lock = tmp_path / "turns.lock"
with open(lock, "a+") as holder:
fcntl.flock(holder, fcntl.LOCK_EX | fcntl.LOCK_NB)
with pytest.raises(BlockingIOError):
ensure_read_indexes(path, confirmed_drained=True, lock_file=lock)
with closing(sqlite3.connect(path)) as db:
assert db.execute("PRAGMA index_list(messages)").fetchall() == []
def test_cron_sidebar_read_uses_readonly_connection(tmp_path, monkeypatch):
from api import routes
path = tmp_path / 'state.db'
with closing(sqlite3.connect(path)) as db:
db.execute('CREATE TABLE sessions(id TEXT, source TEXT)')
monkeypatch.setattr(routes, '_active_state_db_path', lambda: path)
connect = sqlite3.connect
calls = []
def recording(*args, **kwargs):
calls.append((args, kwargs))
return connect(*args, **kwargs)
monkeypatch.setattr(sqlite3, 'connect', recording)
routes._latest_cron_session_info_for_jobs(['test'])
assert len(calls) == 1
assert 'mode=ro' in calls[0][0][0]
assert calls[0][1].get('uri') is True
+7 -2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import importlib
import json
from pathlib import Path
from types import SimpleNamespace
@@ -7,7 +8,7 @@ from urllib.parse import quote
import api.config as api_config
import api.routes as routes
from api.updates import WEBUI_VERSION
ROOT = Path(__file__).resolve().parent.parent
@@ -81,8 +82,12 @@ def test_service_worker_and_favicon_follow_selected_static_root(tmp_path, monkey
monkeypatch.setattr(api_config, "get_static_root", lambda: static_root)
sw_handler = _get("/sw.js")
# The route resolves api.updates dynamically on each request. Other update
# tests deliberately evict and re-import that module, so a collection-time
# alias can become stale even though the checkout and runtime module agree.
webui_version = importlib.import_module("api.updates").WEBUI_VERSION
expected = sw_path.read_text(encoding="utf-8").replace(
"__WEBUI_VERSION__", quote(WEBUI_VERSION, safe="")
"__WEBUI_VERSION__", quote(webui_version, safe="")
).encode("utf-8")
assert sw_handler.status == 200
assert sw_handler.header("Service-Worker-Allowed") == "/"
+7
View File
@@ -87,6 +87,13 @@ function makeSeg() {{
}};
}}
// #2051: renderMessages() inserts a message block through this helper so a wrapped
// DOM API cannot render it twice. Outside a browser the helper's own fallback is
// insertAdjacentHTML, which is exactly what it does here.
function _insertSegmentBlock(seg, html) {{
seg.insertAdjacentHTML('beforeend', html);
}}
const ordinaryBlock = extractBlock(
"const hasVisibleBody=!!(String(content||'').trim()||filesHtml||recoveryHtml);",
"_assistantTurnBlocks(currentAssistantTurn).appendChild(seg);"
+117
View File
@@ -0,0 +1,117 @@
"""CLI_VISIBLE_SESSION_LIMIT is overridable via HERMES_WEBUI_VISIBLE_SESSION_LIMIT.
The sidebar recency window also bounds how many delegated subagent children can
render at once, since a child only nests when its row wins a slot in the same
payload. Operators running wide fan-outs need to raise it without editing code.
The constant is bound once at import time, so each case imports ``api.models``
in a fresh subprocess (same pattern as test_issue3283_profiles_config_import_order)
rather than ``importlib.reload``-ing it in the shared test process — reloading
would recreate the module's locks, caches, and classes while other modules keep
references to the old objects.
The route cap must come from that same value. A profile ``.env`` must not
override it: the constant is resolved in ``api.config`` before profile init,
and the key is in ``api.profiles._PROTECTED_ENV_KEYS``.
"""
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
_PROBE = """
import api.models
import api.routes
print(api.models.CLI_VISIBLE_SESSION_LIMIT)
print(api.routes._cli_visible_session_cap())
rows = [{"source": "cli", "title": "cli", "updated_at": i} for i in range(30)]
print(len(api.routes._cap_recent_cli_sessions(rows)))
"""
_PROFILE_PROBE = """
import os
from pathlib import Path
import api.config
import api.profiles
home = Path(os.environ["HERMES_HOME"])
(home / ".env").write_text("HERMES_WEBUI_VISIBLE_SESSION_LIMIT=7\\n", encoding="utf-8")
api.profiles._reload_dotenv(home)
print("PROTECTED", "HERMES_WEBUI_VISIBLE_SESSION_LIMIT" in api.profiles._PROTECTED_ENV_KEYS)
print("ENV", os.environ.get("HERMES_WEBUI_VISIBLE_SESSION_LIMIT"))
print("LIMIT", api.config.CLI_VISIBLE_SESSION_LIMIT)
"""
def _run_probe(tmp_path, probe, value):
home = tmp_path / "home"
home.mkdir()
env = os.environ.copy()
for key in list(env):
if key.startswith("HERMES_WEBUI_") or key in ("HERMES_HOME", "HERMES_BASE_HOME"):
env.pop(key)
env["HOME"] = str(home)
env["HERMES_HOME"] = str(home)
env["PYTHONPATH"] = str(REPO_ROOT)
if value is not None:
env["HERMES_WEBUI_VISIBLE_SESSION_LIMIT"] = value
proc = subprocess.run(
[sys.executable, "-c", probe],
cwd=REPO_ROOT,
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=90,
check=False,
)
assert proc.returncode == 0, proc.stderr
return proc.stdout.strip().splitlines()
def _limit_in_fresh_interpreter(tmp_path, value):
return int(_run_probe(tmp_path, _PROBE, value)[0])
def test_defaults_to_20_when_unset(tmp_path):
assert _limit_in_fresh_interpreter(tmp_path, None) == 20
def test_env_override_raises_the_window(tmp_path):
assert _limit_in_fresh_interpreter(tmp_path, "64") == 64
@pytest.mark.parametrize("value", ["bogus", "0", "-5"])
def test_invalid_or_nonpositive_falls_back_to_default(tmp_path, value):
assert _limit_in_fresh_interpreter(tmp_path, value) == 20
def test_values_above_200_are_clamped(tmp_path):
assert _limit_in_fresh_interpreter(tmp_path, "500") == 200
def test_route_cap_follows_the_shared_value(tmp_path):
lines = _run_probe(tmp_path, _PROBE, "8")
assert lines[0] == "8"
assert lines[1] == "8"
assert lines[2] == "8"
def test_profile_env_cannot_override_visible_session_limit(tmp_path):
lines = _run_probe(tmp_path, _PROFILE_PROBE, "40")
assert lines[0] == "PROTECTED True"
assert lines[1] == "ENV 40"
assert lines[2] == "LIMIT 40"
def test_setting_is_documented():
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
env_example = (REPO_ROOT / ".env.example").read_text(encoding="utf-8")
assert "| `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` | `20` |" in readme
assert "# HERMES_WEBUI_VISIBLE_SESSION_LIMIT=20" in env_example
assert "200" in readme
assert "clamped" in env_example