Merge current master while preserving recoverable hidden polling (#7299)
This commit is contained in:
commit
a0e5e32e46
55 files changed
+5389
-281
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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})
|
||||
""",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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' }};
|
||||
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
@@ -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() == []
|
||||
@@ -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']
|
||||
@@ -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()
|
||||
@@ -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, (
|
||||
|
||||
@@ -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
|
||||
@@ -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") == "/"
|
||||
|
||||
@@ -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);"
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user