feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
"""
|
|
|
|
|
Tests for Phase 1: Real-time Gateway Session Sync.
|
|
|
|
|
|
|
|
|
|
Tests are ordered TDD-style:
|
|
|
|
|
1. Gateway sessions appear in /api/sessions when setting enabled
|
|
|
|
|
2. Gateway sessions excluded when setting disabled
|
|
|
|
|
3. Gateway sessions have correct metadata (source_tag, is_cli_session)
|
|
|
|
|
4. SSE stream endpoint opens and receives events
|
|
|
|
|
5. Watcher detects new sessions inserted into state.db
|
|
|
|
|
6. Settings UI has renamed label
|
|
|
|
|
"""
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import pathlib
|
2026-06-06 01:56:43 +00:00
|
|
|
import subprocess
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
import sqlite3
|
|
|
|
|
import time
|
|
|
|
|
import urllib.error
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
2026-04-14 19:04:48 +00:00
|
|
|
from tests._pytest_port import BASE
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
|
|
|
|
|
|
2026-06-16 04:55:31 +00:00
|
|
|
def get(path, *, profile=None):
|
|
|
|
|
headers = {}
|
|
|
|
|
if profile:
|
|
|
|
|
headers["Cookie"] = f"hermes_profile={profile}"
|
|
|
|
|
req = urllib.request.Request(BASE + path, headers=headers)
|
|
|
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
return json.loads(r.read()), r.status
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 04:55:31 +00:00
|
|
|
def post(path, body=None, *, profile=None):
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
data = json.dumps(body or {}).encode()
|
2026-06-16 04:55:31 +00:00
|
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
|
if profile:
|
|
|
|
|
headers["Cookie"] = f"hermes_profile={profile}"
|
|
|
|
|
req = urllib.request.Request(BASE + path, data=data, headers=headers)
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
try:
|
|
|
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
|
|
|
return json.loads(r.read()), r.status
|
|
|
|
|
except urllib.error.HTTPError as e:
|
|
|
|
|
try:
|
|
|
|
|
return json.loads(e.read()), e.code
|
|
|
|
|
except Exception:
|
|
|
|
|
return {}, e.code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_test_state_dir():
|
|
|
|
|
"""Return the test state directory (matches conftest.py TEST_STATE_DIR).
|
|
|
|
|
|
|
|
|
|
conftest.py sets HERMES_WEBUI_TEST_STATE_DIR in the test-process environment
|
|
|
|
|
(via os.environ.setdefault) so that tests writing directly to state.db always
|
|
|
|
|
use the same path the test server was started with. If the env var is not
|
|
|
|
|
set (e.g. when running this file standalone), fall back to the conftest
|
2026-06-16 02:04:16 +00:00
|
|
|
formula via _pytest_port (temp-rooted, never ~/.hermes).
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
"""
|
2026-04-14 19:04:48 +00:00
|
|
|
# Use _pytest_port which applies the same auto-derivation as conftest.py
|
|
|
|
|
from tests._pytest_port import TEST_STATE_DIR as _ptsd
|
|
|
|
|
return _ptsd
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
|
|
|
|
|
|
2026-06-16 04:55:31 +00:00
|
|
|
def _get_profile_state_dir(profile=None):
|
|
|
|
|
state_dir = _get_test_state_dir()
|
|
|
|
|
if isinstance(profile, str) and profile.strip():
|
|
|
|
|
return state_dir / 'profiles' / profile.strip()
|
|
|
|
|
return state_dir
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_state_db_path(profile=None):
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
"""Return path to the test state.db."""
|
2026-06-16 04:55:31 +00:00
|
|
|
return _get_profile_state_dir(profile) / 'state.db'
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
|
|
|
|
|
|
2026-06-16 04:55:31 +00:00
|
|
|
def _ensure_state_db(profile=None):
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
"""Create state.db with sessions and messages tables if it doesn't exist.
|
|
|
|
|
Returns a connection. Does NOT delete existing data (safe for parallel tests).
|
|
|
|
|
"""
|
2026-06-16 04:55:31 +00:00
|
|
|
db_path = _get_state_db_path(profile)
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
conn = sqlite3.connect(str(db_path))
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
|
|
|
conn.executescript("""
|
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
source TEXT NOT NULL,
|
|
|
|
|
user_id TEXT,
|
|
|
|
|
model TEXT,
|
|
|
|
|
started_at REAL NOT NULL,
|
|
|
|
|
message_count INTEGER DEFAULT 0,
|
|
|
|
|
title TEXT
|
|
|
|
|
);
|
|
|
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
session_id TEXT NOT NULL,
|
|
|
|
|
role TEXT NOT NULL,
|
|
|
|
|
content TEXT,
|
|
|
|
|
timestamp REAL NOT NULL
|
|
|
|
|
);
|
|
|
|
|
""")
|
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
|
|
|
for column, ddl in (
|
2026-05-03 04:59:07 +00:00
|
|
|
('user_id', 'ALTER TABLE sessions ADD COLUMN user_id TEXT'),
|
|
|
|
|
('chat_id', 'ALTER TABLE sessions ADD COLUMN chat_id TEXT'),
|
|
|
|
|
('chat_type', 'ALTER TABLE sessions ADD COLUMN chat_type TEXT'),
|
|
|
|
|
('thread_id', 'ALTER TABLE sessions ADD COLUMN thread_id TEXT'),
|
|
|
|
|
('session_key', 'ALTER TABLE sessions ADD COLUMN session_key TEXT'),
|
|
|
|
|
('origin_chat_id', 'ALTER TABLE sessions ADD COLUMN origin_chat_id TEXT'),
|
|
|
|
|
('origin_user_id', 'ALTER TABLE sessions ADD COLUMN origin_user_id TEXT'),
|
|
|
|
|
('platform', 'ALTER TABLE sessions ADD COLUMN platform TEXT'),
|
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
|
|
|
('parent_session_id', 'ALTER TABLE sessions ADD COLUMN parent_session_id TEXT'),
|
|
|
|
|
('ended_at', 'ALTER TABLE sessions ADD COLUMN ended_at REAL'),
|
|
|
|
|
('end_reason', 'ALTER TABLE sessions ADD COLUMN end_reason TEXT'),
|
|
|
|
|
):
|
|
|
|
|
existing = {row[1] for row in conn.execute("PRAGMA table_info(sessions)").fetchall()}
|
|
|
|
|
if column not in existing:
|
|
|
|
|
conn.execute(ddl)
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
conn.commit()
|
|
|
|
|
return conn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _insert_gateway_session(conn, session_id='20260401_120000_abcdefgh', source='telegram',
|
|
|
|
|
title='Telegram Chat', model='anthropic/claude-sonnet-4-5',
|
2026-05-03 04:59:07 +00:00
|
|
|
started_at=None, message_count=2, user_id=None, chat_id=None,
|
|
|
|
|
chat_type=None, thread_id=None, session_key=None, origin_chat_id=None,
|
|
|
|
|
origin_user_id=None, platform=None):
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
"""Insert a gateway session into state.db."""
|
|
|
|
|
conn.execute(
|
2026-05-03 04:59:07 +00:00
|
|
|
"INSERT OR REPLACE INTO sessions (id, source, user_id, title, model, started_at, message_count) "
|
|
|
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
|
|
|
(session_id, source, user_id, title, model, started_at or time.time(), message_count)
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
)
|
2026-05-03 04:59:07 +00:00
|
|
|
updates = []
|
|
|
|
|
params = []
|
|
|
|
|
for key, value in (
|
|
|
|
|
("chat_id", chat_id),
|
|
|
|
|
("chat_type", chat_type),
|
|
|
|
|
("thread_id", thread_id),
|
|
|
|
|
("session_key", session_key),
|
|
|
|
|
("origin_chat_id", origin_chat_id),
|
|
|
|
|
("origin_user_id", origin_user_id),
|
|
|
|
|
("platform", platform),
|
|
|
|
|
):
|
|
|
|
|
if value is not None:
|
|
|
|
|
updates.append(f"{key} = ?")
|
|
|
|
|
params.append(value)
|
|
|
|
|
if updates:
|
|
|
|
|
conn.execute(
|
|
|
|
|
f"UPDATE sessions SET {', '.join(updates)} WHERE id = ?",
|
|
|
|
|
[*params, session_id]
|
|
|
|
|
)
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
# Delete any existing messages for this session (idempotent re-insert)
|
|
|
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
|
|
|
|
# Insert some messages
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'user', ?, ?)",
|
|
|
|
|
(session_id, 'Hello from Telegram', started_at or time.time())
|
|
|
|
|
)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'assistant', ?, ?)",
|
|
|
|
|
(session_id, 'Hi there!', (started_at or time.time()) + 1)
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
|
|
|
def _insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
session_id,
|
|
|
|
|
source='weixin',
|
|
|
|
|
title='Agent Session',
|
|
|
|
|
model='openai/gpt-5',
|
|
|
|
|
started_at=None,
|
|
|
|
|
parent_session_id=None,
|
|
|
|
|
ended_at=None,
|
|
|
|
|
end_reason=None,
|
|
|
|
|
messages=1,
|
|
|
|
|
):
|
|
|
|
|
"""Insert an agent session row with optional compression lineage."""
|
|
|
|
|
started_at = started_at or time.time()
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT OR REPLACE INTO sessions "
|
|
|
|
|
"(id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) "
|
|
|
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
|
|
|
(
|
|
|
|
|
session_id,
|
|
|
|
|
source,
|
|
|
|
|
title,
|
|
|
|
|
model,
|
|
|
|
|
started_at,
|
|
|
|
|
messages,
|
|
|
|
|
parent_session_id,
|
|
|
|
|
ended_at,
|
|
|
|
|
end_reason,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
|
|
|
|
for i in range(messages):
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
|
|
|
|
(
|
|
|
|
|
session_id,
|
|
|
|
|
'user' if i % 2 == 0 else 'assistant',
|
|
|
|
|
f'{title} message {i + 1}',
|
|
|
|
|
started_at + i,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
def _remove_test_sessions(conn, *session_ids):
|
|
|
|
|
"""Remove specific test sessions from state.db (parallel-safe cleanup)."""
|
|
|
|
|
for sid in session_ids:
|
|
|
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
|
|
|
|
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cleanup_state_db():
|
|
|
|
|
"""Remove state.db if it exists (only used for tests that need a blank slate)."""
|
|
|
|
|
db_path = _get_state_db_path()
|
|
|
|
|
for p in [db_path, db_path.parent / 'state.db-wal', db_path.parent / 'state.db-shm']:
|
|
|
|
|
try:
|
|
|
|
|
p.unlink(missing_ok=True)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 04:59:07 +00:00
|
|
|
def _insert_message(conn, sid, role, content, timestamp):
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
|
|
|
|
(sid, role, content, timestamp),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
# ── Tests ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_gateway_sessions_appear_when_enabled():
|
|
|
|
|
"""Gateway sessions from state.db appear in /api/sessions when show_cli_sessions is on."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id='gw_test_tg_001', source='telegram', title='TG Test Chat')
|
|
|
|
|
|
|
|
|
|
# Enable the setting
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
sessions = data.get('sessions', [])
|
|
|
|
|
gw_ids = [s['session_id'] for s in sessions if s.get('session_id') == 'gw_test_tg_001']
|
|
|
|
|
assert len(gw_ids) == 1, f"Expected gateway session gw_test_tg_001, got {[s['session_id'] for s in sessions]}"
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, 'gw_test_tg_001')
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 13:41:56 +00:00
|
|
|
def test_webui_state_db_session_without_sidecar_appears_when_agent_sessions_enabled():
|
2026-06-05 07:11:48 +00:00
|
|
|
"""Regression: state.db rows without JSON sidecars can be recovered via agent bridge."""
|
2026-05-03 13:41:56 +00:00
|
|
|
conn = _ensure_state_db()
|
2026-06-05 07:11:48 +00:00
|
|
|
sid = 'cli_state_only_001'
|
2026-05-03 13:41:56 +00:00
|
|
|
try:
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid,
|
2026-06-05 07:11:48 +00:00
|
|
|
source='cli',
|
|
|
|
|
title='Recovered CLI Session',
|
2026-05-03 13:41:56 +00:00
|
|
|
model='openai/gpt-5',
|
|
|
|
|
messages=2,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
sessions = data.get('sessions', [])
|
|
|
|
|
recovered = [s for s in sessions if s.get('session_id') == sid]
|
|
|
|
|
assert len(recovered) == 1, (
|
2026-06-05 07:11:48 +00:00
|
|
|
"State.db sessions without a JSON sidecar "
|
2026-05-03 13:41:56 +00:00
|
|
|
"should be surfaced through the agent-session bridge for recovery."
|
|
|
|
|
)
|
2026-06-05 07:11:48 +00:00
|
|
|
assert recovered[0].get('source_tag') == 'cli'
|
2026-05-03 13:41:56 +00:00
|
|
|
assert recovered[0].get('is_cli_session') is True
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
|
|
|
def test_gateway_sessions_without_messages_are_hidden_from_sidebar():
|
|
|
|
|
"""Regression: empty agent session rows must not appear as broken sidebar entries."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
empty_sid = 'gw_empty_no_messages_001'
|
|
|
|
|
try:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
|
|
|
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
|
|
|
(empty_sid, 'cron', 'Cron Session', 'openai/gpt-5', time.time(), 0),
|
|
|
|
|
)
|
|
|
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (empty_sid,))
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
sessions = data.get('sessions', [])
|
|
|
|
|
assert empty_sid not in {s.get('session_id') for s in sessions}, (
|
|
|
|
|
"Agent sessions with no readable message rows should be filtered before "
|
|
|
|
|
"they reach the sidebar; otherwise clicking them fails during import."
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, empty_sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gateway_watcher_hides_sessions_without_messages(monkeypatch):
|
|
|
|
|
"""Regression: SSE watcher must use the same importable-agent filter."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
empty_sid = 'gw_empty_watcher_001'
|
|
|
|
|
live_sid = 'gw_live_watcher_001'
|
|
|
|
|
try:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
|
|
|
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.
Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)
Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 20:34:59 +00:00
|
|
|
(empty_sid, 'telegram', 'Empty Telegram Session', 'openai/gpt-5', time.time(), 0),
|
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
|
|
|
)
|
|
|
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (empty_sid,))
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=live_sid,
|
fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.
Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)
Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 20:34:59 +00:00
|
|
|
source='telegram',
|
|
|
|
|
title='Live Telegram Session',
|
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
|
|
|
message_count=0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
import api.gateway_watcher as gateway_watcher
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(gateway_watcher, '_get_state_db_path', _get_state_db_path)
|
|
|
|
|
|
|
|
|
|
sessions = gateway_watcher._get_agent_sessions_from_db()
|
|
|
|
|
ids = {s.get('session_id') for s in sessions}
|
|
|
|
|
live = next((s for s in sessions if s.get('session_id') == live_sid), None)
|
|
|
|
|
|
|
|
|
|
assert empty_sid not in ids
|
|
|
|
|
assert live is not None
|
|
|
|
|
assert live.get('message_count') == 2, (
|
|
|
|
|
"Watcher should fall back to actual message rows when stored "
|
|
|
|
|
"message_count is zero, matching the sidebar route."
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, empty_sid, live_sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
|
|
|
def test_compression_chain_collapses_to_latest_tip_in_sidebar():
|
|
|
|
|
"""Show one logical agent conversation for a compression continuation chain."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
ids_to_remove = ('chain_root_001', 'chain_empty_mid_001', 'chain_tip_001')
|
|
|
|
|
t0 = time.time() - 600
|
|
|
|
|
try:
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'chain_root_001',
|
|
|
|
|
title='Magazine Style PPT Skill',
|
|
|
|
|
started_at=t0,
|
|
|
|
|
ended_at=t0 + 100,
|
|
|
|
|
end_reason='compression',
|
|
|
|
|
messages=3,
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'chain_empty_mid_001',
|
|
|
|
|
title='Magazine Style PPT Skill #2',
|
|
|
|
|
started_at=t0 + 101,
|
|
|
|
|
parent_session_id='chain_root_001',
|
|
|
|
|
ended_at=t0 + 200,
|
|
|
|
|
end_reason='compression',
|
|
|
|
|
messages=0,
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'chain_tip_001',
|
|
|
|
|
title='Magazine Style PPT Skill #3',
|
|
|
|
|
started_at=t0 + 201,
|
|
|
|
|
parent_session_id='chain_empty_mid_001',
|
|
|
|
|
messages=2,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
ids = {s.get('session_id') for s in data.get('sessions', [])}
|
|
|
|
|
tip = next((s for s in data.get('sessions', []) if s.get('session_id') == 'chain_tip_001'), None)
|
|
|
|
|
|
|
|
|
|
assert 'chain_tip_001' in ids
|
|
|
|
|
assert 'chain_root_001' not in ids
|
|
|
|
|
assert 'chain_empty_mid_001' not in ids
|
|
|
|
|
assert tip is not None
|
|
|
|
|
assert tip.get('title') == 'Magazine Style PPT Skill'
|
|
|
|
|
assert tip.get('message_count') == 2
|
|
|
|
|
# created_at = the chain head's started_at (preserves original conversation date)
|
|
|
|
|
assert abs(tip.get('created_at') - t0) < 0.01
|
|
|
|
|
# updated_at = the tip's last message timestamp so the sidebar entry
|
|
|
|
|
# bubbles to the top by true recency, not by the root's stale activity.
|
|
|
|
|
# tip messages are at t0+201 and t0+202, so last_activity = t0 + 202.
|
|
|
|
|
assert abs(tip.get('updated_at') - (t0 + 202)) < 0.01
|
2026-04-30 23:04:49 +00:00
|
|
|
assert tip.get('_lineage_root_id') == 'chain_root_001'
|
|
|
|
|
assert tip.get('_lineage_tip_id') == 'chain_tip_001'
|
|
|
|
|
assert tip.get('_compression_segment_count') == 3
|
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
|
|
|
|
|
|
|
|
from api.agent_sessions import read_importable_agent_session_rows
|
|
|
|
|
|
|
|
|
|
rows = read_importable_agent_session_rows(_get_state_db_path(), limit=None)
|
|
|
|
|
projected_tip = next((row for row in rows if row.get('id') == 'chain_tip_001'), None)
|
|
|
|
|
assert projected_tip is not None
|
|
|
|
|
assert projected_tip.get('title') == 'Magazine Style PPT Skill'
|
|
|
|
|
assert projected_tip.get('_lineage_root_id') == 'chain_root_001'
|
|
|
|
|
assert projected_tip.get('_lineage_tip_id') == 'chain_tip_001'
|
|
|
|
|
assert projected_tip.get('_compression_segment_count') == 3
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, *ids_to_remove)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
Release v0.51.306 — Release JV (stage-a2 — branchy compression lineage freshest-tip) (#3761)
* fix(sessions): resolve branchy compression lineage to the freshest tip (#3751)
Compression parents can have multiple continuation-looking children when a stale
segment is resumed after a newer compressed branch already exists. The previous
projection followed the newest DIRECT child only, so it could hide the deeper
branch with the latest real activity and make a conversation look missing/stale
after compaction or session rotation.
- compression_tip() now walks ALL reachable continuation descendants (DFS) and
selects the freshest importable (messageful) tip by (last_activity, depth).
- read_session_lineage_metadata() expands descendants from the materialized
ancestors via the parent index (scoped, 20-hop cap), pulls per-session message
stats from the messages table, and exposes a canonical _lineage_tip_id so the
WebUI sidebar collapse picks the same tip as the projection.
Backward-compat hardening (two release-gate Codex findings, both fixed + tested):
The new message-stats / tip-scoring code must not raise on older/minimal or
non-standard state.db schemas, which previously collapsed the whole projection:
- messages table with NO `timestamp` column → MAX(timestamp) raised in SQL →
read_session_lineage_metadata returned {} (lost all lineage metadata).
- ISO-8601 TEXT messages.timestamp → float()/raw comparison raised TypeError;
in read_importable_agent_session_rows that propagated through compression_tip
and get_cli_sessions() swallowed it, hiding ALL imported agent rows.
Fixes:
- PRAGMA table_info(messages) detection: require session_id, only SELECT
MAX(timestamp) when present (else NULL + COUNT only); fall back to message_count.
- new _as_score() helper (first numerically-coercible value, else next
candidate e.g. started_at) used at EVERY tip-scoring / sort site in both
compression_tip() and freshest_continuation_tip(), plus the projection sort.
- regression tests: lineage metadata survives REAL/absent/TEXT messages.timestamp,
and read_importable_agent_session_rows survives a TEXT timestamp (no empty hide).
Co-authored-by: ai-ag2026 <[email protected]>
* docs(changelog): stamp v0.51.306 — Release JV (stage-a2 #3751)
---------
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-07 02:10:04 +00:00
|
|
|
def test_compression_lineage_prefers_freshest_descendant_over_newer_direct_sibling():
|
|
|
|
|
"""A later-started stale sibling must not hide a deeper active branch."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
ids_to_remove = (
|
|
|
|
|
'branch_root_001',
|
|
|
|
|
'branch_old_mid_001',
|
|
|
|
|
'branch_fresh_tip_001',
|
|
|
|
|
'branch_empty_stale_tip_001',
|
|
|
|
|
'branch_newer_direct_sibling_001',
|
|
|
|
|
)
|
|
|
|
|
t0 = time.time() - 800
|
|
|
|
|
try:
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'branch_root_001',
|
|
|
|
|
title='Qwen Routing Audit',
|
|
|
|
|
started_at=t0,
|
|
|
|
|
ended_at=t0 + 100,
|
|
|
|
|
end_reason='compression',
|
|
|
|
|
messages=2,
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'branch_old_mid_001',
|
|
|
|
|
title='Qwen Routing Audit #2',
|
|
|
|
|
started_at=t0 + 101,
|
|
|
|
|
parent_session_id='branch_root_001',
|
|
|
|
|
ended_at=t0 + 200,
|
|
|
|
|
end_reason='compression',
|
|
|
|
|
messages=2,
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'branch_newer_direct_sibling_001',
|
|
|
|
|
title='Qwen Routing Audit #3 stale sibling',
|
|
|
|
|
started_at=t0 + 150,
|
|
|
|
|
parent_session_id='branch_root_001',
|
|
|
|
|
messages=2,
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'branch_fresh_tip_001',
|
|
|
|
|
title='Qwen Routing Audit #4 freshest tip',
|
|
|
|
|
started_at=t0 + 500,
|
|
|
|
|
parent_session_id='branch_old_mid_001',
|
|
|
|
|
messages=2,
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'branch_empty_stale_tip_001',
|
|
|
|
|
title='Qwen Routing Audit #5 empty stale tip',
|
|
|
|
|
started_at=t0 + 700,
|
|
|
|
|
parent_session_id='branch_old_mid_001',
|
|
|
|
|
messages=0,
|
|
|
|
|
)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE sessions SET message_count = 3 WHERE id = ?",
|
|
|
|
|
('branch_empty_stale_tip_001',),
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
ids = {s.get('session_id') for s in data.get('sessions', [])}
|
|
|
|
|
tip = next((s for s in data.get('sessions', []) if s.get('session_id') == 'branch_fresh_tip_001'), None)
|
|
|
|
|
|
|
|
|
|
assert 'branch_fresh_tip_001' in ids
|
|
|
|
|
assert 'branch_newer_direct_sibling_001' not in ids
|
|
|
|
|
assert 'branch_empty_stale_tip_001' not in ids
|
|
|
|
|
assert tip is not None
|
|
|
|
|
assert tip.get('title') == 'Qwen Routing Audit'
|
|
|
|
|
assert abs(tip.get('updated_at') - (t0 + 501)) < 0.01
|
|
|
|
|
assert tip.get('_lineage_root_id') == 'branch_root_001'
|
|
|
|
|
assert tip.get('_lineage_tip_id') == 'branch_fresh_tip_001'
|
|
|
|
|
assert tip.get('_compression_segment_count') == 5
|
|
|
|
|
|
|
|
|
|
from api.agent_sessions import read_importable_agent_session_rows
|
|
|
|
|
|
|
|
|
|
rows = read_importable_agent_session_rows(_get_state_db_path(), limit=None)
|
|
|
|
|
projected_tip = next((row for row in rows if row.get('id') == 'branch_fresh_tip_001'), None)
|
|
|
|
|
assert projected_tip is not None
|
|
|
|
|
assert projected_tip.get('_lineage_root_id') == 'branch_root_001'
|
|
|
|
|
assert projected_tip.get('_lineage_tip_id') == 'branch_fresh_tip_001'
|
|
|
|
|
assert projected_tip.get('_compression_segment_count') == 5
|
|
|
|
|
|
|
|
|
|
from api.agent_sessions import read_session_lineage_metadata
|
|
|
|
|
|
|
|
|
|
metadata = read_session_lineage_metadata(
|
|
|
|
|
_get_state_db_path(),
|
|
|
|
|
{'branch_old_mid_001', 'branch_fresh_tip_001', 'branch_empty_stale_tip_001', 'branch_newer_direct_sibling_001'},
|
|
|
|
|
)
|
|
|
|
|
assert metadata['branch_old_mid_001'].get('_lineage_tip_id') == 'branch_fresh_tip_001'
|
|
|
|
|
assert metadata['branch_empty_stale_tip_001'].get('_lineage_tip_id') == 'branch_fresh_tip_001'
|
|
|
|
|
assert metadata['branch_newer_direct_sibling_001'].get('_lineage_tip_id') == 'branch_fresh_tip_001'
|
|
|
|
|
assert metadata['branch_newer_direct_sibling_001'].get('_compression_segment_count') == 5
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, *ids_to_remove)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_compression_projection_handles_deep_lineage_iteratively():
|
|
|
|
|
"""Very deep compression chains should not depend on Python recursion depth."""
|
|
|
|
|
from api.agent_sessions import _project_agent_session_rows
|
|
|
|
|
|
|
|
|
|
rows = []
|
|
|
|
|
previous = None
|
|
|
|
|
for idx in range(1100):
|
|
|
|
|
sid = f'deep_chain_{idx:04d}'
|
|
|
|
|
started_at = float(idx * 2)
|
|
|
|
|
rows.append({
|
|
|
|
|
'id': sid,
|
|
|
|
|
'title': 'Deep Chain' if idx == 0 else f'Deep Chain #{idx + 1}',
|
|
|
|
|
'source': 'cli',
|
|
|
|
|
'started_at': started_at,
|
|
|
|
|
'parent_session_id': previous,
|
|
|
|
|
'ended_at': started_at + 1 if idx < 1099 else None,
|
|
|
|
|
'end_reason': 'compression' if idx < 1099 else None,
|
|
|
|
|
'actual_message_count': 1,
|
|
|
|
|
'actual_user_message_count': 1,
|
|
|
|
|
'message_count': 1,
|
|
|
|
|
'last_activity': started_at + 0.5,
|
|
|
|
|
})
|
|
|
|
|
previous = sid
|
|
|
|
|
|
|
|
|
|
projected = _project_agent_session_rows(rows)
|
|
|
|
|
|
|
|
|
|
assert len(projected) == 1
|
|
|
|
|
assert projected[0]['id'] == 'deep_chain_1099'
|
|
|
|
|
assert projected[0]['_lineage_root_id'] == 'deep_chain_0000'
|
|
|
|
|
assert projected[0]['_lineage_tip_id'] == 'deep_chain_1099'
|
|
|
|
|
assert projected[0]['_compression_segment_count'] == 1100
|
|
|
|
|
|
|
|
|
|
|
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
|
|
|
def test_compression_chain_with_empty_latest_tip_falls_back_to_latest_importable_segment():
|
|
|
|
|
"""Empty latest tips should not make the whole conversation disappear."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
ids_to_remove = ('empty_tip_root_001', 'empty_tip_001')
|
|
|
|
|
t0 = time.time() - 500
|
|
|
|
|
try:
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'empty_tip_root_001',
|
|
|
|
|
title='Long Conversation',
|
|
|
|
|
started_at=t0,
|
|
|
|
|
ended_at=t0 + 100,
|
|
|
|
|
end_reason='compression',
|
|
|
|
|
messages=2,
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'empty_tip_001',
|
|
|
|
|
title='Long Conversation #2',
|
|
|
|
|
started_at=t0 + 101,
|
|
|
|
|
parent_session_id='empty_tip_root_001',
|
|
|
|
|
messages=0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
ids = {s.get('session_id') for s in data.get('sessions', [])}
|
|
|
|
|
|
|
|
|
|
assert 'empty_tip_root_001' in ids
|
|
|
|
|
assert 'empty_tip_001' not in ids
|
|
|
|
|
root = next((s for s in data.get('sessions', []) if s.get('session_id') == 'empty_tip_root_001'), None)
|
|
|
|
|
assert root and root.get('title') == 'Long Conversation'
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, *ids_to_remove)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_compression_chain_with_all_empty_segments_is_hidden():
|
|
|
|
|
"""A compression chain with no importable segment should not appear."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
ids_to_remove = ('all_empty_root_001', 'all_empty_tip_001')
|
|
|
|
|
t0 = time.time() - 450
|
|
|
|
|
try:
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'all_empty_root_001',
|
|
|
|
|
title='Empty Long Conversation',
|
|
|
|
|
started_at=t0,
|
|
|
|
|
ended_at=t0 + 100,
|
|
|
|
|
end_reason='compression',
|
|
|
|
|
messages=0,
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'all_empty_tip_001',
|
|
|
|
|
title='Empty Long Conversation #2',
|
|
|
|
|
started_at=t0 + 101,
|
|
|
|
|
parent_session_id='all_empty_root_001',
|
|
|
|
|
messages=0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
ids = {s.get('session_id') for s in data.get('sessions', [])}
|
|
|
|
|
|
|
|
|
|
assert 'all_empty_root_001' not in ids
|
|
|
|
|
assert 'all_empty_tip_001' not in ids
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, *ids_to_remove)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 03:25:32 +00:00
|
|
|
def test_default_title_cli_compression_chain_is_kept_by_lineage():
|
|
|
|
|
"""Default-titled CLI compression chains are meaningful even with a short tip."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
ids_to_remove = ('cli_default_compress_root_001', 'cli_default_compress_tip_001')
|
|
|
|
|
t0 = time.time() - 430
|
|
|
|
|
try:
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'cli_default_compress_root_001',
|
|
|
|
|
source='cli',
|
|
|
|
|
title='Cli Session',
|
|
|
|
|
started_at=t0,
|
|
|
|
|
ended_at=t0 + 100,
|
|
|
|
|
end_reason='compression',
|
|
|
|
|
messages=1,
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'cli_default_compress_tip_001',
|
|
|
|
|
source='cli',
|
|
|
|
|
title='Cli Session',
|
|
|
|
|
started_at=t0 + 101,
|
|
|
|
|
parent_session_id='cli_default_compress_root_001',
|
|
|
|
|
messages=1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
ids = {s.get('session_id') for s in data.get('sessions', [])}
|
|
|
|
|
|
|
|
|
|
assert 'cli_default_compress_tip_001' in ids
|
|
|
|
|
assert 'cli_default_compress_root_001' not in ids
|
2026-06-18 01:19:20 +00:00
|
|
|
tip = next((s for s in data.get('sessions', []) if s.get('session_id') == 'cli_default_compress_tip_001'), None)
|
|
|
|
|
assert tip is not None, "compression tip session missing from /api/sessions (test-isolation? check show_cli_sessions + session presence)"
|
2026-05-04 03:25:32 +00:00
|
|
|
assert tip.get('_compression_segment_count') == 2
|
|
|
|
|
assert tip.get('_lineage_root_id') == 'cli_default_compress_root_001'
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, *ids_to_remove)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
|
|
|
def test_non_compression_child_is_not_collapsed_into_parent():
|
|
|
|
|
"""Parent/child relationships that are not compression continuations stay flat."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
ids_to_remove = ('branch_parent_001', 'branch_child_001')
|
|
|
|
|
t0 = time.time() - 400
|
|
|
|
|
try:
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'branch_parent_001',
|
|
|
|
|
title='Branch Parent',
|
|
|
|
|
started_at=t0,
|
|
|
|
|
ended_at=t0 + 100,
|
|
|
|
|
end_reason='branched',
|
|
|
|
|
messages=2,
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'branch_child_001',
|
|
|
|
|
title='Branch Child',
|
|
|
|
|
started_at=t0 + 101,
|
|
|
|
|
parent_session_id='branch_parent_001',
|
|
|
|
|
messages=2,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
from api.agent_sessions import read_importable_agent_session_rows
|
|
|
|
|
|
|
|
|
|
rows = read_importable_agent_session_rows(_get_state_db_path(), limit=None)
|
|
|
|
|
ids = {row.get('id') for row in rows}
|
|
|
|
|
|
|
|
|
|
assert 'branch_parent_001' in ids
|
|
|
|
|
assert 'branch_child_001' in ids
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, *ids_to_remove)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_agent_session_limit_applies_after_compression_projection():
|
|
|
|
|
"""A long raw chain should count as one logical sidebar row before limiting."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
chain_ids = [f'limit_chain_{i:03d}' for i in range(8)]
|
|
|
|
|
standalone_id = 'limit_standalone_001'
|
|
|
|
|
t0 = time.time() - 300
|
|
|
|
|
try:
|
|
|
|
|
for i, sid in enumerate(chain_ids):
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
sid,
|
|
|
|
|
title=f'Limit Chain #{i + 1}',
|
|
|
|
|
started_at=t0 + i,
|
|
|
|
|
parent_session_id=chain_ids[i - 1] if i else None,
|
|
|
|
|
ended_at=t0 + i + 0.5 if i < len(chain_ids) - 1 else None,
|
|
|
|
|
end_reason='compression' if i < len(chain_ids) - 1 else None,
|
|
|
|
|
messages=1,
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
standalone_id,
|
|
|
|
|
title='Limit Standalone',
|
|
|
|
|
started_at=t0 + 20,
|
|
|
|
|
messages=1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
from api.agent_sessions import read_importable_agent_session_rows
|
|
|
|
|
|
|
|
|
|
rows = read_importable_agent_session_rows(_get_state_db_path(), limit=2)
|
|
|
|
|
ids = [row.get('id') for row in rows]
|
|
|
|
|
|
|
|
|
|
assert len(rows) == 2
|
|
|
|
|
assert chain_ids[-1] in ids
|
|
|
|
|
assert standalone_id in ids
|
|
|
|
|
assert not any(sid in ids for sid in chain_ids[:-1])
|
2026-06-18 01:19:20 +00:00
|
|
|
chain = next((row for row in rows if row.get('id') == chain_ids[-1]), None)
|
|
|
|
|
assert chain is not None, "limit-chain tip row missing from projected rows (test-isolation?)"
|
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
|
|
|
assert chain.get('title') == 'Limit Chain #1'
|
|
|
|
|
assert chain.get('_lineage_root_id') == chain_ids[0]
|
|
|
|
|
assert chain.get('_compression_segment_count') == len(chain_ids)
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, *(chain_ids + [standalone_id]))
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_compression_chain_bubbles_to_top_by_tip_activity():
|
|
|
|
|
"""An actively-used compression chain must surface in the sidebar by its
|
|
|
|
|
TIP's last activity, not by the (stale) root's last activity.
|
|
|
|
|
|
|
|
|
|
Without overriding ``last_activity`` from the tip, a long-running chain
|
|
|
|
|
whose tip is being actively edited NOW would sort by the root's old
|
|
|
|
|
timestamp and fall below recently touched standalone sessions — the
|
|
|
|
|
inverse of what users expect from "Show agent sessions" sorted by
|
|
|
|
|
recency. This regression test pins the override.
|
|
|
|
|
"""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
ids_to_remove = ('bubble_root_001', 'bubble_tip_001', 'bubble_standalone_001')
|
|
|
|
|
now = time.time()
|
|
|
|
|
# Root started long ago; tip is being edited "now" (very recent message)
|
|
|
|
|
root_started = now - 30 * 86400
|
|
|
|
|
root_ended = now - 28 * 86400
|
|
|
|
|
tip_started = root_ended + 1
|
|
|
|
|
tip_latest_msg = now - 5 # 5 seconds ago — most recent activity in the DB
|
|
|
|
|
# A standalone session active 2 days ago — older than tip, much newer
|
|
|
|
|
# than the root. Without the fix, the chain row sorts by ROOT's age and
|
|
|
|
|
# standalone wins; with the fix, the chain wins.
|
|
|
|
|
standalone_msg = now - 2 * 86400
|
|
|
|
|
try:
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'bubble_root_001',
|
|
|
|
|
title='Bubble Root',
|
|
|
|
|
started_at=root_started,
|
|
|
|
|
ended_at=root_ended,
|
|
|
|
|
end_reason='compression',
|
|
|
|
|
messages=2,
|
|
|
|
|
)
|
|
|
|
|
# Override message timestamps so root's last_activity is genuinely old.
|
|
|
|
|
conn.execute("DELETE FROM messages WHERE session_id = 'bubble_root_001'")
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
|
|
|
|
('bubble_root_001', 'user', 'old root msg', root_started + 60),
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'bubble_tip_001',
|
|
|
|
|
title='Bubble Tip',
|
|
|
|
|
started_at=tip_started,
|
|
|
|
|
parent_session_id='bubble_root_001',
|
|
|
|
|
messages=1,
|
|
|
|
|
)
|
|
|
|
|
conn.execute("DELETE FROM messages WHERE session_id = 'bubble_tip_001'")
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
|
|
|
|
('bubble_tip_001', 'user', 'fresh tip msg', tip_latest_msg),
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
'bubble_standalone_001',
|
|
|
|
|
title='Bubble Standalone',
|
|
|
|
|
started_at=now - 2 * 86400 - 60,
|
|
|
|
|
messages=1,
|
|
|
|
|
)
|
|
|
|
|
conn.execute("DELETE FROM messages WHERE session_id = 'bubble_standalone_001'")
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
|
|
|
|
('bubble_standalone_001', 'user', 'standalone msg', standalone_msg),
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
from api.agent_sessions import read_importable_agent_session_rows
|
|
|
|
|
|
|
|
|
|
rows = read_importable_agent_session_rows(_get_state_db_path(), limit=200)
|
|
|
|
|
ids = [row.get('id') for row in rows]
|
|
|
|
|
# Filter out unrelated rows from the shared DB
|
|
|
|
|
ids = [i for i in ids if i in ('bubble_root_001', 'bubble_tip_001', 'bubble_standalone_001')]
|
|
|
|
|
|
|
|
|
|
assert 'bubble_tip_001' in ids, (
|
|
|
|
|
f"Compression tip must appear in projected output. ids={ids}"
|
|
|
|
|
)
|
|
|
|
|
assert 'bubble_root_001' not in ids, (
|
|
|
|
|
"Compression root row must be hidden once the tip is the active row."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
tip_pos = ids.index('bubble_tip_001')
|
|
|
|
|
standalone_pos = ids.index('bubble_standalone_001') if 'bubble_standalone_001' in ids else -1
|
|
|
|
|
assert standalone_pos == -1 or tip_pos < standalone_pos, (
|
|
|
|
|
f"Active compression tip (last msg 5s ago) must sort BEFORE standalone "
|
|
|
|
|
f"session (last msg 2d ago). Got order: {ids}. "
|
|
|
|
|
f"This indicates merged.last_activity is the root's stale value, "
|
|
|
|
|
f"not the tip's recent value."
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-18 01:19:20 +00:00
|
|
|
tip_row = next((r for r in rows if r['id'] == 'bubble_tip_001'), None)
|
|
|
|
|
assert tip_row is not None, "bubble tip row missing from projected rows (test-isolation?)"
|
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
|
|
|
assert abs(tip_row['last_activity'] - tip_latest_msg) < 0.01, (
|
|
|
|
|
f"Projected tip's last_activity must equal the tip's most recent "
|
|
|
|
|
f"message timestamp ({tip_latest_msg}), not the root's "
|
|
|
|
|
f"({root_started + 60}). Got: {tip_row['last_activity']}"
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, *ids_to_remove)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
def test_gateway_sessions_excluded_when_disabled():
|
|
|
|
|
"""Gateway sessions are NOT returned when show_cli_sessions is off."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id='gw_test_dc_001', source='discord', title='DC Test Chat')
|
|
|
|
|
|
|
|
|
|
# Ensure setting is off
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
sessions = data.get('sessions', [])
|
|
|
|
|
gw_ids = [s['session_id'] for s in sessions if s.get('session_id') == 'gw_test_dc_001']
|
|
|
|
|
assert len(gw_ids) == 0, "Gateway session should not appear when setting is off"
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, 'gw_test_dc_001')
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gateway_session_has_correct_metadata():
|
2026-04-30 02:54:07 +00:00
|
|
|
"""Gateway sessions include legacy source fields and normalized source metadata."""
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id='gw_meta_001', source='telegram', title='Meta Test')
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
sessions = data.get('sessions', [])
|
|
|
|
|
gw = next((s for s in sessions if s['session_id'] == 'gw_meta_001'), None)
|
|
|
|
|
assert gw is not None, "Gateway session not found"
|
|
|
|
|
assert gw.get('source_tag') == 'telegram', f"Expected source_tag=telegram, got {gw.get('source_tag')}"
|
2026-04-30 02:54:07 +00:00
|
|
|
assert gw.get('raw_source') == 'telegram'
|
|
|
|
|
assert gw.get('session_source') == 'messaging'
|
|
|
|
|
assert gw.get('source_label') == 'Telegram'
|
2026-06-05 07:11:48 +00:00
|
|
|
assert gw.get('is_cli_session') is False, "is_cli_session should be False for messaging (telegram) sessions"
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
assert gw.get('title') == 'Meta Test'
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, 'gw_meta_001')
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
2026-04-30 02:54:07 +00:00
|
|
|
def test_agent_session_source_normalization_contract():
|
|
|
|
|
"""Raw Hermes Agent sources map to stable WebUI source categories."""
|
|
|
|
|
from api.agent_sessions import normalize_agent_session_source
|
|
|
|
|
|
|
|
|
|
cases = {
|
|
|
|
|
'cli': ('cli', 'CLI'),
|
2026-05-17 09:48:40 +00:00
|
|
|
'email': ('messaging', 'Email'),
|
2026-06-06 01:56:43 +00:00
|
|
|
'wecom': ('messaging', 'WeCom'),
|
|
|
|
|
'wecom_callback': ('messaging', 'WeCom Callback'),
|
2026-04-30 02:54:07 +00:00
|
|
|
'weixin': ('messaging', 'Weixin'),
|
|
|
|
|
'telegram': ('messaging', 'Telegram'),
|
|
|
|
|
'discord': ('messaging', 'Discord'),
|
|
|
|
|
'slack': ('messaging', 'Slack'),
|
|
|
|
|
'cron': ('cron', 'Cron'),
|
2026-06-29 22:40:52 +00:00
|
|
|
'webhook': ('webhook', 'Webhook'),
|
2026-04-30 02:54:07 +00:00
|
|
|
'tool': ('tool', 'Tool'),
|
|
|
|
|
'api_server': ('api', 'API'),
|
|
|
|
|
'something_new': ('other', 'Something New'),
|
|
|
|
|
None: ('other', 'Agent'),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for raw_source, (session_source, source_label) in cases.items():
|
|
|
|
|
normalized = normalize_agent_session_source(raw_source)
|
|
|
|
|
assert normalized['session_source'] == session_source
|
|
|
|
|
assert normalized['source_label'] == source_label
|
|
|
|
|
if raw_source:
|
|
|
|
|
assert normalized['raw_source'] == raw_source
|
|
|
|
|
else:
|
|
|
|
|
assert normalized['raw_source'] is None
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 09:48:40 +00:00
|
|
|
def test_sessions_js_treats_email_as_messaging_source():
|
|
|
|
|
"""Email gateway sessions should receive the same sidebar metadata as other messaging channels."""
|
|
|
|
|
src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
|
|
|
|
2026-06-06 01:56:43 +00:00
|
|
|
raw_section = src[src.find("_MESSAGING_RAW_SOURCES"):src.find("function _isMessagingSession")]
|
|
|
|
|
label_section = src[src.find("_MESSAGING_SOURCE_LABELS"):src.find("function _isMessagingSession")]
|
|
|
|
|
|
|
|
|
|
for raw_source in ("email", "wecom", "wecom_callback"):
|
|
|
|
|
assert f"'{raw_source}'" in raw_section, f"Missing raw source {raw_source!r} in _MESSAGING_RAW_SOURCES"
|
|
|
|
|
|
|
|
|
|
assert "email: 'Email'" in label_section
|
|
|
|
|
assert "wecom: 'WeCom'" in label_section
|
|
|
|
|
assert "wecom_callback: 'WeCom Callback'" in label_section
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sessions_js_treats_wecom_sidecars_as_messaging_behaviorally():
|
|
|
|
|
"""Stale WeCom sidecars with session_source=other should still route as messaging."""
|
|
|
|
|
src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
|
|
|
start = src.index("const _MESSAGING_RAW_SOURCES")
|
|
|
|
|
end = src.index("/**", start)
|
|
|
|
|
block = src[start:end]
|
|
|
|
|
script = f"""
|
|
|
|
|
{block}
|
|
|
|
|
const cases = [
|
|
|
|
|
{{ session_source: 'other', source: 'wecom' }},
|
|
|
|
|
{{ session_source: 'other', raw_source: 'wecom_callback' }},
|
|
|
|
|
{{ session_source: 'other', source_tag: 'wecom' }},
|
|
|
|
|
{{ session_source: 'messaging', source: 'anything' }},
|
|
|
|
|
];
|
|
|
|
|
for (const c of cases) {{
|
|
|
|
|
if (!_isMessagingSession(c)) throw new Error('expected messaging for '+JSON.stringify(c));
|
|
|
|
|
}}
|
|
|
|
|
if (_isMessagingSession({{ session_source: 'other', source: 'cli' }})) {{
|
|
|
|
|
throw new Error('cli should not be treated as messaging');
|
|
|
|
|
}}
|
|
|
|
|
"""
|
|
|
|
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
2026-05-17 09:48:40 +00:00
|
|
|
|
|
|
|
|
|
2026-05-15 03:23:08 +00:00
|
|
|
def test_empty_active_gateway_session_does_not_hide_messaging_history(monkeypatch):
|
|
|
|
|
"""A zero-message active Gateway row must not hide older Discord history."""
|
|
|
|
|
import api.routes as routes
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
routes,
|
|
|
|
|
"_load_gateway_session_identity_map",
|
|
|
|
|
lambda: {
|
|
|
|
|
"discord_empty_active": {
|
|
|
|
|
"raw_source": "discord",
|
|
|
|
|
"platform": "discord",
|
|
|
|
|
"user_id": "user-1",
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
rows = [
|
|
|
|
|
{
|
|
|
|
|
"session_id": "discord_previous_history",
|
|
|
|
|
"title": "Previous Discord chat",
|
|
|
|
|
"source_tag": "discord",
|
|
|
|
|
"raw_source": "discord",
|
|
|
|
|
"session_source": "messaging",
|
|
|
|
|
"source_label": "Discord",
|
|
|
|
|
"user_id": "user-1",
|
|
|
|
|
"message_count": 7,
|
|
|
|
|
"updated_at": 100.0,
|
|
|
|
|
"end_reason": "session_reset",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
kept = routes._keep_latest_messaging_session_per_source(rows)
|
|
|
|
|
|
|
|
|
|
assert [row["session_id"] for row in kept] == ["discord_previous_history"]
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 06:17:40 +00:00
|
|
|
def test_previous_messaging_setting_keeps_reset_history(monkeypatch):
|
|
|
|
|
"""The previous-messaging toggle exposes older reset segments."""
|
|
|
|
|
import api.routes as routes
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
routes,
|
|
|
|
|
"_load_gateway_session_identity_map",
|
|
|
|
|
lambda: {
|
|
|
|
|
"discord_active": {
|
|
|
|
|
"raw_source": "discord",
|
|
|
|
|
"platform": "discord",
|
|
|
|
|
"user_id": "user-1",
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
rows = [
|
|
|
|
|
{
|
|
|
|
|
"session_id": "discord_active",
|
|
|
|
|
"title": "Current Discord chat",
|
|
|
|
|
"source_tag": "discord",
|
|
|
|
|
"raw_source": "discord",
|
|
|
|
|
"session_source": "messaging",
|
|
|
|
|
"source_label": "Discord",
|
|
|
|
|
"user_id": "user-1",
|
|
|
|
|
"message_count": 3,
|
|
|
|
|
"updated_at": 200.0,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"session_id": "discord_previous_history",
|
|
|
|
|
"title": "Previous Discord chat",
|
|
|
|
|
"source_tag": "discord",
|
|
|
|
|
"raw_source": "discord",
|
|
|
|
|
"session_source": "messaging",
|
|
|
|
|
"source_label": "Discord",
|
|
|
|
|
"user_id": "user-1",
|
|
|
|
|
"message_count": 7,
|
|
|
|
|
"updated_at": 100.0,
|
|
|
|
|
"end_reason": "session_reset",
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
hidden = routes._keep_latest_messaging_session_per_source(rows)
|
|
|
|
|
visible = routes._keep_latest_messaging_session_per_source(
|
|
|
|
|
rows,
|
|
|
|
|
show_previous_messaging_sessions=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert [row["session_id"] for row in hidden] == ["discord_active"]
|
|
|
|
|
assert [row["session_id"] for row in visible] == [
|
|
|
|
|
"discord_active",
|
|
|
|
|
"discord_previous_history",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 07:28:32 +00:00
|
|
|
def test_cross_source_parent_child_is_not_collapsed_into_root_metadata(cleanup_test_sessions):
|
|
|
|
|
"""A WebUI continuation from a messaging parent must keep WebUI metadata.
|
|
|
|
|
|
|
|
|
|
Regression for a production case where a WebUI session continued from a
|
|
|
|
|
Telegram compression chain and was projected as the old Telegram root,
|
|
|
|
|
inheriting the wrong title/source and hiding from the expected sidebar view.
|
|
|
|
|
"""
|
|
|
|
|
from api.agent_sessions import read_importable_agent_session_rows
|
|
|
|
|
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
root_sid = 'gw_tg_cross_source_root_001'
|
|
|
|
|
webui_sid = 'webui_cross_source_tip_001'
|
|
|
|
|
now = time.time()
|
|
|
|
|
cleanup_test_sessions.extend([root_sid, webui_sid])
|
|
|
|
|
try:
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=root_sid,
|
|
|
|
|
source='telegram',
|
|
|
|
|
title='Old Telegram Root',
|
|
|
|
|
started_at=now - 20,
|
|
|
|
|
ended_at=now - 10,
|
|
|
|
|
end_reason='compression',
|
|
|
|
|
messages=2,
|
|
|
|
|
)
|
|
|
|
|
_insert_agent_session_row(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=webui_sid,
|
|
|
|
|
source='webui',
|
|
|
|
|
title='Current WebUI Work',
|
|
|
|
|
started_at=now - 9,
|
|
|
|
|
parent_session_id=root_sid,
|
|
|
|
|
messages=2,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
rows = read_importable_agent_session_rows(_get_state_db_path(), exclude_sources=None)
|
|
|
|
|
by_id = {row['id']: row for row in rows}
|
|
|
|
|
|
|
|
|
|
assert webui_sid in by_id
|
|
|
|
|
assert root_sid in by_id
|
|
|
|
|
webui = by_id[webui_sid]
|
|
|
|
|
assert webui.get('title') == 'Current WebUI Work'
|
|
|
|
|
assert webui.get('source') == 'webui'
|
|
|
|
|
assert webui.get('session_source') == 'webui'
|
|
|
|
|
assert webui.get('source_label') == 'WebUI'
|
|
|
|
|
assert webui.get('relationship_type') == 'child_session'
|
|
|
|
|
assert webui.get('parent_title') == 'Old Telegram Root'
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, root_sid, webui_sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-04-30 02:54:07 +00:00
|
|
|
def test_gateway_watcher_uses_normalized_source_metadata(monkeypatch):
|
|
|
|
|
"""SSE snapshots use the same normalized source contract as /api/sessions."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id='gw_watcher_source_001', source='weixin', title='Weixin Chat')
|
|
|
|
|
|
|
|
|
|
import api.gateway_watcher as gateway_watcher
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(gateway_watcher, '_get_state_db_path', _get_state_db_path)
|
|
|
|
|
sessions = gateway_watcher._get_agent_sessions_from_db()
|
|
|
|
|
gw = next((s for s in sessions if s['session_id'] == 'gw_watcher_source_001'), None)
|
|
|
|
|
|
|
|
|
|
assert gw is not None
|
|
|
|
|
assert gw.get('source') == 'weixin'
|
|
|
|
|
assert gw.get('raw_source') == 'weixin'
|
|
|
|
|
assert gw.get('session_source') == 'messaging'
|
|
|
|
|
assert gw.get('source_label') == 'Weixin'
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, 'gw_watcher_source_001')
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-04-30 21:32:46 +00:00
|
|
|
def test_imported_cli_session_metadata_survives_compact(cleanup_test_sessions):
|
|
|
|
|
"""Imported agent sessions should remain distinguishable in compact sidebar payloads."""
|
|
|
|
|
from api.models import Session
|
|
|
|
|
|
|
|
|
|
sid = 'gw_imported_metadata_001'
|
|
|
|
|
cleanup_test_sessions.append(sid)
|
|
|
|
|
s = Session(
|
|
|
|
|
session_id=sid,
|
|
|
|
|
title='Imported Telegram Chat',
|
|
|
|
|
messages=[{'role': 'user', 'content': 'hello from telegram', 'timestamp': time.time()}],
|
|
|
|
|
model='openai/gpt-5',
|
|
|
|
|
)
|
|
|
|
|
s.is_cli_session = True
|
|
|
|
|
s.source_tag = 'telegram'
|
|
|
|
|
s.session_source = 'messaging'
|
|
|
|
|
s.source_label = 'Telegram'
|
|
|
|
|
s.save(touch_updated_at=False)
|
|
|
|
|
|
|
|
|
|
loaded = Session.load_metadata_only(sid)
|
|
|
|
|
compact = loaded.compact()
|
|
|
|
|
|
|
|
|
|
assert compact['is_cli_session'] is True
|
|
|
|
|
assert compact['source_tag'] == 'telegram'
|
|
|
|
|
assert compact['session_source'] == 'messaging'
|
|
|
|
|
assert compact['source_label'] == 'Telegram'
|
|
|
|
|
|
|
|
|
|
|
2026-05-01 13:29:10 +00:00
|
|
|
def test_import_cli_preserves_messaging_source_metadata(cleanup_test_sessions):
|
|
|
|
|
"""Importing a messaging agent session should keep source metadata for WebUI policy."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
sid = 'gw_import_weixin_meta_001'
|
|
|
|
|
cleanup_test_sessions.append(sid)
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id=sid, source='weixin', title='Weixin Session')
|
|
|
|
|
|
|
|
|
|
data, status = post('/api/session/import_cli', {'session_id': sid})
|
|
|
|
|
assert status == 200
|
|
|
|
|
session = data.get('session', {})
|
|
|
|
|
assert session.get('is_cli_session') is True
|
|
|
|
|
assert session.get('source_tag') == 'weixin'
|
|
|
|
|
assert session.get('raw_source') == 'weixin'
|
|
|
|
|
assert session.get('session_source') == 'messaging'
|
|
|
|
|
assert session.get('source_label') == 'Weixin'
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 04:55:31 +00:00
|
|
|
def test_import_cli_named_profile_requires_explicit_all_profiles_opt_in(cleanup_test_sessions):
|
|
|
|
|
"""Unqualified import_cli should not read a named profile's state.db."""
|
|
|
|
|
named_profile = 'issue1611-import'
|
|
|
|
|
conn = _ensure_state_db(profile=named_profile)
|
|
|
|
|
sid = 'gw_named_profile_import_001'
|
|
|
|
|
cleanup_test_sessions.append(sid)
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid,
|
|
|
|
|
source='telegram',
|
|
|
|
|
title='Named Profile Telegram Session',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
data, status = post('/api/session/import_cli', {'session_id': sid})
|
|
|
|
|
assert status == 404, data
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_import_cli_all_profiles_opt_in_reads_named_profile_state_db(cleanup_test_sessions):
|
|
|
|
|
"""Explicit all-profiles imports should resolve messages from the named profile store."""
|
|
|
|
|
named_profile = 'issue1611-import'
|
|
|
|
|
conn = _ensure_state_db(profile=named_profile)
|
|
|
|
|
sid = 'gw_named_profile_import_001'
|
|
|
|
|
cleanup_test_sessions.append(sid)
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid,
|
|
|
|
|
source='telegram',
|
|
|
|
|
title='Named Profile Telegram Session',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
data, status = post('/api/session/import_cli', {
|
|
|
|
|
'session_id': sid,
|
|
|
|
|
'all_profiles': True,
|
|
|
|
|
'profile': named_profile,
|
|
|
|
|
})
|
|
|
|
|
assert status == 200, data
|
|
|
|
|
session = data.get('session', {})
|
|
|
|
|
messages = session.get('messages', [])
|
|
|
|
|
|
|
|
|
|
assert session.get('session_id') == sid
|
|
|
|
|
assert session.get('profile') == named_profile
|
|
|
|
|
assert session.get('source_tag') == 'telegram'
|
|
|
|
|
assert session.get('session_source') == 'messaging'
|
|
|
|
|
assert [m.get('content') for m in messages] == ['Hello from Telegram', 'Hi there!']
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_all_profiles_cli_contexts_normalizes_default_profile_name(tmp_path, monkeypatch):
|
|
|
|
|
from api import models, profiles
|
|
|
|
|
|
|
|
|
|
profiles_root = tmp_path / "profiles"
|
|
|
|
|
profiles_root.mkdir()
|
|
|
|
|
(profiles_root / "research").mkdir()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(profiles, "_profiles_root", lambda: profiles_root)
|
|
|
|
|
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
profiles,
|
|
|
|
|
"get_hermes_home_for_profile",
|
|
|
|
|
lambda profile_name: tmp_path / (profile_name or "default-home"),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
profiles,
|
|
|
|
|
"list_profiles_api",
|
|
|
|
|
lambda: [{"name": None}, {"name": "research"}],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
contexts, _cache_key = models._all_profiles_cli_contexts()
|
|
|
|
|
|
|
|
|
|
assert ("default" in [profile for _home, _db_path, profile in contexts])
|
|
|
|
|
assert ("research" in [profile for _home, _db_path, profile in contexts])
|
|
|
|
|
|
|
|
|
|
|
2026-05-01 13:29:10 +00:00
|
|
|
def test_sessions_response_backfills_imported_messaging_source_metadata(cleanup_test_sessions):
|
|
|
|
|
"""Old imported messaging sessions should still expose source metadata in /api/sessions."""
|
|
|
|
|
from api.models import Session
|
|
|
|
|
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
sid = 'gw_legacy_import_weixin_001'
|
|
|
|
|
cleanup_test_sessions.append(sid)
|
|
|
|
|
try:
|
2026-06-21 06:46:47 +00:00
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid,
|
|
|
|
|
source='weixin',
|
|
|
|
|
title='Weixin Session',
|
|
|
|
|
session_key='agent:test:weixin:legacy-import-backfill',
|
|
|
|
|
)
|
2026-05-01 13:29:10 +00:00
|
|
|
s = Session(
|
|
|
|
|
session_id=sid,
|
|
|
|
|
title='Legacy Imported Weixin',
|
|
|
|
|
messages=[{'role': 'user', 'content': 'hello', 'timestamp': time.time()}],
|
|
|
|
|
model='openai/gpt-5',
|
|
|
|
|
)
|
|
|
|
|
s.is_cli_session = True
|
|
|
|
|
s.save(touch_updated_at=False)
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
2026-06-18 01:19:20 +00:00
|
|
|
session = next((item for item in data.get('sessions', []) if item.get('session_id') == sid), None)
|
|
|
|
|
assert session is not None, f"{sid} missing from /api/sessions — show_cli_sessions not applied or session hidden (test-isolation)"
|
2026-05-01 13:29:10 +00:00
|
|
|
assert session.get('source_tag') == 'weixin'
|
|
|
|
|
assert session.get('raw_source') == 'weixin'
|
|
|
|
|
assert session.get('session_source') == 'messaging'
|
|
|
|
|
assert session.get('source_label') == 'Weixin'
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
_remove_test_sessions(conn, sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sessions_response_keeps_only_latest_messaging_session_per_source(cleanup_test_sessions):
|
2026-05-03 04:59:07 +00:00
|
|
|
"""Sidebar should keep messaging sessions by stable identity, not source-wide."""
|
2026-05-01 13:29:10 +00:00
|
|
|
from api.models import Session
|
|
|
|
|
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
old_sid = 'gw_old_weixin_visible_001'
|
|
|
|
|
new_sid = 'gw_new_weixin_visible_001'
|
|
|
|
|
cleanup_test_sessions.extend([old_sid, new_sid])
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id=old_sid, source='weixin', title='Old Weixin', started_at=time.time() - 100)
|
|
|
|
|
_insert_gateway_session(conn, session_id=new_sid, source='weixin', title='New Weixin', started_at=time.time())
|
|
|
|
|
|
|
|
|
|
old = Session(
|
|
|
|
|
session_id=old_sid,
|
|
|
|
|
title='Old Imported Weixin',
|
|
|
|
|
messages=[{'role': 'user', 'content': 'old', 'timestamp': time.time() - 100}],
|
|
|
|
|
model='openai/gpt-5',
|
|
|
|
|
)
|
|
|
|
|
old.is_cli_session = True
|
|
|
|
|
old.save(touch_updated_at=False)
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
ids = {item.get('session_id') for item in data.get('sessions', [])}
|
|
|
|
|
assert new_sid in ids
|
|
|
|
|
assert old_sid not in ids
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
_remove_test_sessions(conn, old_sid, new_sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 04:59:07 +00:00
|
|
|
def test_sessions_response_keeps_distinct_messaging_sessions_for_distinct_users(cleanup_test_sessions):
|
|
|
|
|
"""Messaging collapse should survive for different users on the same platform."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
sid_a = 'gw_tg_distinct_user_a'
|
|
|
|
|
sid_b = 'gw_tg_distinct_user_b'
|
|
|
|
|
cleanup_test_sessions.extend([sid_a, sid_b])
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid_a,
|
|
|
|
|
source='telegram',
|
|
|
|
|
title='TG User A',
|
|
|
|
|
user_id='1143399746',
|
|
|
|
|
started_at=time.time() - 20,
|
|
|
|
|
)
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid_b,
|
|
|
|
|
source='telegram',
|
|
|
|
|
title='TG User B',
|
|
|
|
|
user_id='9988776655',
|
|
|
|
|
started_at=time.time(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
ids = {s['session_id'] for s in data.get('sessions', []) if s.get('session_id') in {sid_a, sid_b}}
|
|
|
|
|
assert ids == {sid_a, sid_b}, f"Expected both Telegram sessions to remain, got {ids}"
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
_remove_test_sessions(conn, sid_a, sid_b)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sessions_response_distinguishes_same_user_different_chat_identity_from_gateway_metadata(cleanup_test_sessions):
|
|
|
|
|
"""Same user_id sessions should stay separate when gateway metadata exposes chat identity."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
sid_dm = 'gw_tg_same_user_dm'
|
|
|
|
|
sid_group = 'gw_tg_same_user_group'
|
|
|
|
|
cleanup_test_sessions.extend([sid_dm, sid_group])
|
|
|
|
|
sessions_file = _get_test_state_dir() / 'sessions' / 'sessions.json'
|
|
|
|
|
original_sessions_json = None
|
|
|
|
|
if sessions_file.exists():
|
|
|
|
|
original_sessions_json = sessions_file.read_text()
|
|
|
|
|
sessions_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
sessions_payload = {
|
|
|
|
|
"agent:main:telegram:dm:1143399746": {
|
|
|
|
|
"session_key": "agent:main:telegram:dm:1143399746",
|
|
|
|
|
"session_id": sid_dm,
|
|
|
|
|
"origin": {
|
|
|
|
|
"platform": "telegram",
|
|
|
|
|
"chat_type": "dm",
|
|
|
|
|
"chat_id": "1143399746",
|
|
|
|
|
"user_id": "1143399746",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"agent:main:telegram:group:chat_42:1143399746": {
|
|
|
|
|
"session_key": "agent:main:telegram:group:chat_42:1143399746",
|
|
|
|
|
"session_id": sid_group,
|
|
|
|
|
"origin": {
|
|
|
|
|
"platform": "telegram",
|
|
|
|
|
"chat_type": "group",
|
|
|
|
|
"chat_id": "chat_42",
|
|
|
|
|
"user_id": "1143399746",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
try:
|
|
|
|
|
sessions_file.write_text(json.dumps(sessions_payload), encoding='utf-8')
|
|
|
|
|
_insert_gateway_session(conn, session_id=sid_dm, source='telegram', title='DM Same User', user_id='1143399746', started_at=time.time() - 40)
|
|
|
|
|
_insert_gateway_session(conn, session_id=sid_group, source='telegram', title='Group Same User', user_id='1143399746', started_at=time.time())
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
ids = {s['session_id'] for s in data.get('sessions', []) if s.get('session_id') in {sid_dm, sid_group}}
|
|
|
|
|
assert ids == {sid_dm, sid_group}, f"Expected both DM/group Telegram sessions, got {ids}"
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
_remove_test_sessions(conn, sid_dm, sid_group)
|
|
|
|
|
if original_sessions_json is None:
|
|
|
|
|
sessions_file.unlink(missing_ok=True)
|
|
|
|
|
else:
|
|
|
|
|
sessions_file.write_text(original_sessions_json, encoding='utf-8')
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 04:35:23 +00:00
|
|
|
def test_messaging_projection_keeps_no_identity_continuation_when_gateway_source_active(monkeypatch, cleanup_test_sessions):
|
|
|
|
|
"""A WebUI/mobile recovery continuation must not disappear behind source-wide Gateway hiding.
|
|
|
|
|
|
|
|
|
|
Long WebUI turns can rotate to a child session during compression. If the
|
|
|
|
|
browser is backgrounded before the final SSE handoff, recovery depends on
|
|
|
|
|
the continuation still being visible in the sidebar. This is source-generic:
|
|
|
|
|
Telegram, Discord, Slack, and Weixin all use the same messaging projection.
|
|
|
|
|
"""
|
|
|
|
|
from api import routes
|
|
|
|
|
from api.models import Session
|
|
|
|
|
|
|
|
|
|
parent_sid = "webui_pre_compression_snapshot"
|
|
|
|
|
cleanup_test_sessions.append(parent_sid)
|
|
|
|
|
Session(
|
|
|
|
|
session_id=parent_sid,
|
|
|
|
|
title="Archived pre-compression snapshot",
|
|
|
|
|
model="openai/gpt-5",
|
|
|
|
|
messages=[{"role": "user", "content": "before compression", "timestamp": time.time() - 10}],
|
|
|
|
|
pre_compression_snapshot=True,
|
|
|
|
|
).save(touch_updated_at=False)
|
|
|
|
|
|
|
|
|
|
for source in ("telegram", "discord", "slack", "weixin"):
|
|
|
|
|
active_sid = f"{source}_active_sid"
|
|
|
|
|
continuation_sid = f"webui_{source}_continuation_no_identity"
|
|
|
|
|
cleanup_test_sessions.append(continuation_sid)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
routes,
|
|
|
|
|
"_load_gateway_session_identity_map",
|
|
|
|
|
lambda source=source, active_sid=active_sid: {
|
|
|
|
|
active_sid: {
|
|
|
|
|
"session_key": f"agent:main:{source}:dm:user_1",
|
|
|
|
|
"raw_source": source,
|
|
|
|
|
"platform": source,
|
|
|
|
|
"chat_type": "dm",
|
|
|
|
|
"chat_id": "user_1",
|
|
|
|
|
"user_id": "user_1",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
sessions = [
|
|
|
|
|
{
|
|
|
|
|
"session_id": active_sid,
|
|
|
|
|
"raw_source": source,
|
|
|
|
|
"session_source": "messaging",
|
|
|
|
|
"title": f"Current {source} DM",
|
|
|
|
|
"updated_at": 100,
|
|
|
|
|
"message_count": 3,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"session_id": continuation_sid,
|
|
|
|
|
"raw_source": source,
|
|
|
|
|
"session_source": "messaging",
|
|
|
|
|
"chat_id": "webui_recovery_chat",
|
|
|
|
|
"chat_type": "dm",
|
|
|
|
|
"title": f"Recovered {source} WebUI continuation",
|
|
|
|
|
"parent_session_id": parent_sid,
|
|
|
|
|
"updated_at": 200,
|
|
|
|
|
"message_count": 1003,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
kept = routes._keep_latest_messaging_session_per_source(sessions)
|
|
|
|
|
ids = {session.get("session_id") for session in kept}
|
|
|
|
|
|
|
|
|
|
assert ids == {active_sid, continuation_sid}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_session_load_exposes_continuation_for_pre_compression_snapshot(cleanup_test_sessions):
|
|
|
|
|
"""Reloading an old hidden compression snapshot should give the browser a recovery target."""
|
|
|
|
|
from api.models import Session
|
|
|
|
|
|
|
|
|
|
parent_sid = "webui_mobile_snapshot_parent"
|
|
|
|
|
child_sid = "webui_mobile_snapshot_child"
|
|
|
|
|
now = time.time()
|
|
|
|
|
cleanup_test_sessions.extend([parent_sid, child_sid])
|
|
|
|
|
|
|
|
|
|
parent = Session(
|
|
|
|
|
session_id=parent_sid,
|
|
|
|
|
title="Mobile reload parent",
|
|
|
|
|
model="openai/gpt-5",
|
|
|
|
|
messages=[{"role": "user", "content": "before compression", "timestamp": now - 10}],
|
|
|
|
|
created_at=now - 20,
|
|
|
|
|
updated_at=now - 10,
|
|
|
|
|
pre_compression_snapshot=True,
|
|
|
|
|
)
|
|
|
|
|
child = Session(
|
|
|
|
|
session_id=child_sid,
|
|
|
|
|
title="Mobile reload child",
|
|
|
|
|
model="openai/gpt-5",
|
|
|
|
|
parent_session_id=parent_sid,
|
|
|
|
|
messages=[{"role": "assistant", "content": "finished after reload", "timestamp": now}],
|
|
|
|
|
created_at=now - 5,
|
|
|
|
|
updated_at=now,
|
|
|
|
|
)
|
|
|
|
|
parent.save(touch_updated_at=False)
|
|
|
|
|
child.save(touch_updated_at=False)
|
|
|
|
|
|
|
|
|
|
data, status = get(f"/api/session?session_id={parent_sid}&messages=0&resolve_model=0")
|
|
|
|
|
|
|
|
|
|
assert status == 200
|
|
|
|
|
assert data["session"].get("session_id") == parent_sid
|
|
|
|
|
assert data["session"].get("continuation_session_id") == child_sid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_session_load_exposes_multihop_continuation_for_repeated_compression(cleanup_test_sessions):
|
|
|
|
|
"""A stale older snapshot should recover to the newest visible descendant."""
|
|
|
|
|
from api.models import Session
|
|
|
|
|
|
|
|
|
|
root_sid = "webui_mobile_snapshot_root"
|
|
|
|
|
middle_sid = "webui_mobile_snapshot_middle"
|
|
|
|
|
child_sid = "webui_mobile_snapshot_grandchild"
|
|
|
|
|
now = time.time()
|
|
|
|
|
cleanup_test_sessions.extend([root_sid, middle_sid, child_sid])
|
|
|
|
|
|
|
|
|
|
root = Session(
|
|
|
|
|
session_id=root_sid,
|
|
|
|
|
title="Mobile reload root snapshot",
|
|
|
|
|
model="openai/gpt-5",
|
|
|
|
|
messages=[{"role": "user", "content": "before first compression", "timestamp": now - 30}],
|
|
|
|
|
created_at=now - 40,
|
|
|
|
|
updated_at=now - 30,
|
|
|
|
|
pre_compression_snapshot=True,
|
|
|
|
|
)
|
|
|
|
|
middle = Session(
|
|
|
|
|
session_id=middle_sid,
|
|
|
|
|
title="Mobile reload middle snapshot",
|
|
|
|
|
model="openai/gpt-5",
|
|
|
|
|
parent_session_id=root_sid,
|
|
|
|
|
messages=[{"role": "assistant", "content": "before second compression", "timestamp": now - 20}],
|
|
|
|
|
created_at=now - 25,
|
|
|
|
|
updated_at=now - 20,
|
|
|
|
|
pre_compression_snapshot=True,
|
|
|
|
|
)
|
|
|
|
|
child = Session(
|
|
|
|
|
session_id=child_sid,
|
|
|
|
|
title="Mobile reload final child",
|
|
|
|
|
model="openai/gpt-5",
|
|
|
|
|
parent_session_id=middle_sid,
|
|
|
|
|
messages=[{"role": "assistant", "content": "finished after repeated compression", "timestamp": now}],
|
|
|
|
|
created_at=now - 5,
|
|
|
|
|
updated_at=now,
|
|
|
|
|
)
|
|
|
|
|
root.save(touch_updated_at=False)
|
|
|
|
|
middle.save(touch_updated_at=False)
|
|
|
|
|
child.save(touch_updated_at=False)
|
|
|
|
|
|
|
|
|
|
data, status = get(f"/api/session?session_id={root_sid}&messages=0&resolve_model=0")
|
|
|
|
|
|
|
|
|
|
assert status == 200
|
|
|
|
|
assert data["session"].get("session_id") == root_sid
|
|
|
|
|
assert data["session"].get("continuation_session_id") == child_sid
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 04:59:07 +00:00
|
|
|
def test_messaging_projection_hides_stale_gateway_internal_segments(monkeypatch):
|
|
|
|
|
"""Active Gateway identity should hide old reset rows and internal child segments."""
|
|
|
|
|
from api import routes
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
routes,
|
|
|
|
|
"_load_gateway_session_identity_map",
|
|
|
|
|
lambda: {
|
|
|
|
|
"weixin_current_sid": {
|
|
|
|
|
"session_key": "agent:main:weixin:dm:user_1",
|
|
|
|
|
"raw_source": "weixin",
|
|
|
|
|
"platform": "weixin",
|
|
|
|
|
"chat_type": "dm",
|
|
|
|
|
"chat_id": "user_1",
|
|
|
|
|
"user_id": "user_1",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
sessions = [
|
|
|
|
|
{
|
|
|
|
|
"session_id": "weixin_current_sid",
|
|
|
|
|
"raw_source": "weixin",
|
|
|
|
|
"title": "Current Weixin",
|
|
|
|
|
"updated_at": 100,
|
|
|
|
|
"message_count": 8,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"session_id": "weixin_internal_child_sid",
|
|
|
|
|
"raw_source": "weixin",
|
|
|
|
|
"title": "Internal Weixin Segment",
|
|
|
|
|
"parent_session_id": "weixin_current_sid",
|
|
|
|
|
"updated_at": 120,
|
|
|
|
|
"message_count": 4,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"session_id": "weixin_reset_sid",
|
|
|
|
|
"raw_source": "weixin",
|
|
|
|
|
"title": "Old Weixin Reset",
|
|
|
|
|
"end_reason": "session_reset",
|
|
|
|
|
"updated_at": 90,
|
|
|
|
|
"message_count": 6,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"session_id": "weixin_legacy_fallback_sid",
|
|
|
|
|
"raw_source": "weixin",
|
|
|
|
|
"title": "Legacy Weixin Fallback",
|
|
|
|
|
"updated_at": 95,
|
|
|
|
|
"message_count": 3,
|
|
|
|
|
"user_id": "user_1",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"session_id": "webui_sid",
|
|
|
|
|
"title": "Regular WebUI",
|
|
|
|
|
"updated_at": 80,
|
|
|
|
|
"message_count": 2,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
kept = routes._keep_latest_messaging_session_per_source(sessions)
|
|
|
|
|
ids = {session.get("session_id") for session in kept}
|
|
|
|
|
|
|
|
|
|
assert "weixin_current_sid" in ids
|
|
|
|
|
assert "webui_sid" in ids
|
|
|
|
|
assert "weixin_internal_child_sid" not in ids
|
|
|
|
|
assert "weixin_reset_sid" not in ids
|
|
|
|
|
assert "weixin_legacy_fallback_sid" not in ids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messaging_projection_keeps_distinct_active_gateway_conversations(monkeypatch):
|
|
|
|
|
"""Telegram DM and group chats must not collapse just because source matches."""
|
|
|
|
|
from api import routes
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
routes,
|
|
|
|
|
"_load_gateway_session_identity_map",
|
|
|
|
|
lambda: {
|
|
|
|
|
"telegram_dm_sid": {
|
|
|
|
|
"session_key": "agent:main:telegram:dm:user_1",
|
|
|
|
|
"raw_source": "telegram",
|
|
|
|
|
"platform": "telegram",
|
|
|
|
|
"chat_type": "dm",
|
|
|
|
|
"chat_id": "user_1",
|
|
|
|
|
"user_id": "user_1",
|
|
|
|
|
},
|
|
|
|
|
"telegram_group_sid": {
|
|
|
|
|
"session_key": "agent:main:telegram:group:group_1:user_1",
|
|
|
|
|
"raw_source": "telegram",
|
|
|
|
|
"platform": "telegram",
|
|
|
|
|
"chat_type": "group",
|
|
|
|
|
"chat_id": "group_1",
|
|
|
|
|
"user_id": "user_1",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
sessions = [
|
|
|
|
|
{
|
|
|
|
|
"session_id": "telegram_dm_sid",
|
|
|
|
|
"raw_source": "telegram",
|
|
|
|
|
"title": "Telegram DM",
|
|
|
|
|
"updated_at": 100,
|
|
|
|
|
"message_count": 4,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"session_id": "telegram_group_sid",
|
|
|
|
|
"raw_source": "telegram",
|
|
|
|
|
"title": "Telegram Group",
|
|
|
|
|
"updated_at": 110,
|
|
|
|
|
"message_count": 4,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
kept = routes._keep_latest_messaging_session_per_source(sessions)
|
|
|
|
|
ids = {session.get("session_id") for session in kept}
|
|
|
|
|
|
|
|
|
|
assert ids == {"telegram_dm_sid", "telegram_group_sid"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messaging_projection_does_not_aggressively_hide_without_gateway_metadata(monkeypatch):
|
|
|
|
|
"""Without sessions.json as source of truth, keep fallback behavior."""
|
|
|
|
|
from api import routes
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(routes, "_load_gateway_session_identity_map", lambda: {})
|
|
|
|
|
sessions = [
|
|
|
|
|
{
|
|
|
|
|
"session_id": "weixin_reset_sid",
|
|
|
|
|
"raw_source": "weixin",
|
|
|
|
|
"title": "Old Weixin Reset",
|
|
|
|
|
"end_reason": "session_reset",
|
|
|
|
|
"updated_at": 90,
|
|
|
|
|
"message_count": 6,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
kept = routes._keep_latest_messaging_session_per_source(sessions)
|
|
|
|
|
|
|
|
|
|
assert [session.get("session_id") for session in kept] == ["weixin_reset_sid"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sessions_response_distinguishes_same_platform_same_group_chat_different_users_without_session_key(cleanup_test_sessions):
|
|
|
|
|
"""Group sessions with same chat_id but different users should not collapse without session_key."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
sid_u1 = 'gw_tg_group_chat_001'
|
|
|
|
|
sid_u2 = 'gw_tg_group_chat_002'
|
|
|
|
|
cleanup_test_sessions.extend([sid_u1, sid_u2])
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid_u1,
|
|
|
|
|
source='telegram',
|
|
|
|
|
title='TG Group Same Chat User1',
|
|
|
|
|
user_id='2001001',
|
|
|
|
|
chat_id='tg_group_42',
|
|
|
|
|
chat_type='group',
|
|
|
|
|
started_at=time.time() - 20,
|
|
|
|
|
)
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid_u2,
|
|
|
|
|
source='telegram',
|
|
|
|
|
title='TG Group Same Chat User2',
|
|
|
|
|
user_id='2001002',
|
|
|
|
|
chat_id='tg_group_42',
|
|
|
|
|
chat_type='group',
|
|
|
|
|
started_at=time.time(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
ids = {s['session_id'] for s in data.get('sessions', []) if s.get('session_id') in {sid_u1, sid_u2}}
|
|
|
|
|
assert ids == {sid_u1, sid_u2}, (
|
|
|
|
|
f"Expected both group sessions in same chat to stay visible without session_key, got {ids}"
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
_remove_test_sessions(conn, sid_u1, sid_u2)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sessions_response_distinguishes_same_user_different_thread_without_session_key(cleanup_test_sessions):
|
|
|
|
|
"""Same user_id but different thread context should remain separate without session_key."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
sid_t1 = 'gw_tg_thread_001'
|
|
|
|
|
sid_t2 = 'gw_tg_thread_002'
|
|
|
|
|
cleanup_test_sessions.extend([sid_t1, sid_t2])
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid_t1,
|
|
|
|
|
source='telegram',
|
|
|
|
|
title='TG Thread A',
|
|
|
|
|
user_id='5550007',
|
|
|
|
|
chat_id='tg_group_42',
|
|
|
|
|
chat_type='thread',
|
|
|
|
|
thread_id='thread_a',
|
|
|
|
|
started_at=time.time() - 20,
|
|
|
|
|
)
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid_t2,
|
|
|
|
|
source='telegram',
|
|
|
|
|
title='TG Thread B',
|
|
|
|
|
user_id='5550007',
|
|
|
|
|
chat_id='tg_group_42',
|
|
|
|
|
chat_type='thread',
|
|
|
|
|
thread_id='thread_b',
|
|
|
|
|
started_at=time.time(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
ids = {s['session_id'] for s in data.get('sessions', []) if s.get('session_id') in {sid_t1, sid_t2}}
|
|
|
|
|
assert ids == {sid_t1, sid_t2}, (
|
|
|
|
|
f"Expected both thread-scoped Telegram sessions to stay visible without session_key, got {ids}"
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
_remove_test_sessions(conn, sid_t1, sid_t2)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-05-01 13:29:10 +00:00
|
|
|
def test_archiving_raw_messaging_session_imports_without_erasing_agent_memory(cleanup_test_sessions):
|
|
|
|
|
"""Archive should be the safe hide path for raw messaging sessions."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
sid = 'gw_archive_weixin_001'
|
|
|
|
|
cleanup_test_sessions.append(sid)
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id=sid, source='weixin', title='Weixin Session')
|
|
|
|
|
|
|
|
|
|
data, status = post('/api/session/archive', {'session_id': sid, 'archived': True})
|
|
|
|
|
assert status == 200
|
|
|
|
|
session = data.get('session', {})
|
|
|
|
|
assert session.get('archived') is True
|
|
|
|
|
assert session.get('session_source') == 'messaging'
|
|
|
|
|
|
|
|
|
|
remaining = conn.execute(
|
|
|
|
|
"SELECT COUNT(*) FROM messages WHERE session_id = ?",
|
|
|
|
|
(sid,),
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
assert remaining == 2
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_delete_imported_messaging_session_preserves_agent_memory(cleanup_test_sessions):
|
|
|
|
|
"""WebUI delete must not delete Hermes Agent memory for external channels."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
sid = 'gw_delete_weixin_safe_001'
|
|
|
|
|
cleanup_test_sessions.append(sid)
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id=sid, source='weixin', title='Weixin Session')
|
|
|
|
|
_, import_status = post('/api/session/import_cli', {'session_id': sid})
|
|
|
|
|
assert import_status == 200
|
|
|
|
|
|
|
|
|
|
_, delete_status = post('/api/session/delete', {'session_id': sid})
|
|
|
|
|
assert delete_status == 200
|
|
|
|
|
|
|
|
|
|
remaining = conn.execute(
|
|
|
|
|
"SELECT COUNT(*) FROM messages WHERE session_id = ?",
|
|
|
|
|
(sid,),
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
assert remaining == 2
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.
Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)
Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 20:34:59 +00:00
|
|
|
def test_imported_cron_sessions_hidden_from_sidebar_by_default(cleanup_test_sessions):
|
|
|
|
|
"""Cron sessions already imported into the WebUI store should stay hidden from the sidebar."""
|
|
|
|
|
from api.models import Session
|
|
|
|
|
|
|
|
|
|
sid = 'cron_imported_20260427'
|
|
|
|
|
cleanup_test_sessions.append(sid)
|
|
|
|
|
s = Session(
|
|
|
|
|
session_id=sid,
|
|
|
|
|
title='Hourly Cron Import',
|
|
|
|
|
messages=[{'role': 'user', 'content': 'run hourly job', 'timestamp': time.time()}],
|
|
|
|
|
model='openai/gpt-5',
|
|
|
|
|
)
|
|
|
|
|
s.is_cli_session = True
|
|
|
|
|
s.save(touch_updated_at=False)
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
assert sid not in {session.get('session_id') for session in data.get('sessions', [])}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cron_sessions_hidden_from_sidebar_by_default():
|
|
|
|
|
"""Cron-run sessions are background/internal and should not appear in the default sidebar list."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id='cron_job123_20260427', source='cron', title='Nightly Cleanup')
|
|
|
|
|
_insert_gateway_session(conn, session_id='gw_noncron_001', source='telegram', title='Visible Chat')
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
sessions = data.get('sessions', [])
|
|
|
|
|
ids = {s['session_id'] for s in sessions}
|
|
|
|
|
assert 'gw_noncron_001' in ids, "Non-cron agent session should still appear"
|
2026-05-30 01:59:22 +00:00
|
|
|
cron_row = next((s for s in sessions if s['session_id'] == 'cron_job123_20260427'), None)
|
|
|
|
|
assert cron_row is None or cron_row.get('default_hidden') is True, (
|
|
|
|
|
"Cron session should either be omitted from /api/sessions or marked "
|
|
|
|
|
"default_hidden so the default sidebar filter keeps it hidden"
|
|
|
|
|
)
|
fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.
Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)
Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 20:34:59 +00:00
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, 'cron_job123_20260427', 'gw_noncron_001')
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_importable_agent_rows_can_opt_into_cron_source():
|
|
|
|
|
"""Diagnostic callers can opt out of the default cron exclusion explicitly."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
try:
|
|
|
|
|
_insert_agent_session_row(conn, session_id='cron_diag_20260427', source='cron', title='Cron Diagnostic')
|
|
|
|
|
|
|
|
|
|
from api.agent_sessions import read_importable_agent_session_rows
|
|
|
|
|
|
|
|
|
|
default_rows = read_importable_agent_session_rows(_get_state_db_path(), limit=None)
|
|
|
|
|
assert 'cron_diag_20260427' not in {row.get('id') for row in default_rows}
|
|
|
|
|
|
|
|
|
|
diagnostic_rows = read_importable_agent_session_rows(
|
|
|
|
|
_get_state_db_path(),
|
|
|
|
|
limit=None,
|
|
|
|
|
exclude_sources=None,
|
|
|
|
|
)
|
|
|
|
|
assert 'cron_diag_20260427' in {row.get('id') for row in diagnostic_rows}
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, 'cron_diag_20260427')
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
def test_gateway_session_has_message_count():
|
|
|
|
|
"""Gateway sessions report correct message_count from state.db."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id='gw_msg_001', source='discord', title='Msg Count Test', message_count=5)
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
sessions = data.get('sessions', [])
|
|
|
|
|
gw = next((s for s in sessions if s['session_id'] == 'gw_msg_001'), None)
|
|
|
|
|
assert gw is not None
|
|
|
|
|
assert gw.get('message_count') == 5, f"Expected message_count=5, got {gw.get('message_count')}"
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, 'gw_msg_001')
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gateway_sessions_multiple_sources():
|
|
|
|
|
"""Sessions from multiple gateway sources (telegram, discord, slack) all appear."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id='gw_multi_tg', source='telegram', title='TG Chat')
|
|
|
|
|
_insert_gateway_session(conn, session_id='gw_multi_dc', source='discord', title='DC Chat')
|
|
|
|
|
_insert_gateway_session(conn, session_id='gw_multi_sl', source='slack', title='SL Chat')
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
sessions = data.get('sessions', [])
|
|
|
|
|
gw_ids = {s['session_id'] for s in sessions if s.get('session_id') in ('gw_multi_tg', 'gw_multi_dc', 'gw_multi_sl')}
|
|
|
|
|
assert len(gw_ids) == 3, f"Expected 3 gateway sessions, got {len(gw_ids)}: {gw_ids}"
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, 'gw_multi_tg', 'gw_multi_dc', 'gw_multi_sl')
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gateway_session_messages_readable():
|
|
|
|
|
"""Gateway session messages can be loaded via /api/session."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id='gw_read_001', source='telegram', title='Readable')
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
data, status = get(f'/api/session?session_id=gw_read_001')
|
|
|
|
|
assert status == 200
|
|
|
|
|
msgs = data.get('session', {}).get('messages', [])
|
|
|
|
|
assert len(msgs) >= 2, f"Expected at least 2 messages, got {len(msgs)}"
|
|
|
|
|
assert msgs[0].get('role') == 'user'
|
|
|
|
|
assert msgs[0].get('content') == 'Hello from Telegram'
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, 'gw_read_001')
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 04:59:07 +00:00
|
|
|
def test_session_prefers_state_db_messages_over_stale_local_snapshot(cleanup_test_sessions):
|
|
|
|
|
"""Stale local JSON for messaging sessions should not mask newer state.db messages."""
|
|
|
|
|
from api.models import Session
|
|
|
|
|
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
sid = 'gw_masking_regression_001'
|
|
|
|
|
cleanup_test_sessions.append(sid)
|
|
|
|
|
base_ts = time.time() - 120
|
|
|
|
|
stale_messages = [
|
|
|
|
|
("user", "Old local user", base_ts + 1),
|
|
|
|
|
("assistant", "Old local assistant", base_ts + 2),
|
|
|
|
|
]
|
|
|
|
|
fresh_messages = [
|
|
|
|
|
("user", "Fresh user 1", base_ts + 10),
|
|
|
|
|
("assistant", "Fresh assistant 1", base_ts + 11),
|
|
|
|
|
("user", "Fresh user 2", base_ts + 12),
|
|
|
|
|
("assistant", "Fresh assistant 2", base_ts + 13),
|
|
|
|
|
]
|
|
|
|
|
expected_tail = fresh_messages[-1][1]
|
|
|
|
|
expected_total = len(stale_messages) + len(fresh_messages)
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid,
|
|
|
|
|
source='telegram',
|
|
|
|
|
title='Regression Telegram Chat',
|
|
|
|
|
message_count=expected_total,
|
|
|
|
|
started_at=base_ts + 1,
|
|
|
|
|
)
|
|
|
|
|
# Replace the two auto-inserted starter messages with a controlled sequence
|
|
|
|
|
# so we can assert ordering across local+state updates.
|
|
|
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
|
|
|
|
for role, content, ts in stale_messages + fresh_messages:
|
|
|
|
|
_insert_message(conn, sid, role, content, ts)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE sessions SET message_count = ? WHERE id = ?",
|
|
|
|
|
(expected_total, sid),
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
s = Session(
|
|
|
|
|
session_id=sid,
|
|
|
|
|
title='Legacy Local Telegram Snapshot',
|
|
|
|
|
workspace=str(pathlib.Path.home() / '.hermes'),
|
|
|
|
|
model='openai/gpt-5',
|
|
|
|
|
messages=[{"role": r, "content": c, "timestamp": t} for r, c, t in stale_messages],
|
|
|
|
|
)
|
|
|
|
|
s.is_cli_session = True
|
|
|
|
|
s.session_source = 'messaging'
|
|
|
|
|
s.source_tag = 'telegram'
|
|
|
|
|
s.raw_source = 'telegram'
|
|
|
|
|
s.source_label = 'Telegram'
|
|
|
|
|
s.save(touch_updated_at=False)
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
data, status = get(f'/api/session?session_id={sid}')
|
|
|
|
|
assert status == 200, data
|
|
|
|
|
session = data.get('session', {})
|
|
|
|
|
msgs = session.get('messages', [])
|
|
|
|
|
assert len(msgs) == expected_total, f"Expected {expected_total} messages, got {len(msgs)}"
|
|
|
|
|
assert msgs[-1].get('content') == expected_tail
|
|
|
|
|
assert session.get('message_count') == expected_total
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 23:26:38 +00:00
|
|
|
def test_messaging_session_message_count_matches_deduped_display_messages(cleanup_test_sessions):
|
|
|
|
|
"""Thread sessions must not advertise raw DB rows that display merge dedupes away."""
|
|
|
|
|
from api.models import Session
|
|
|
|
|
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
sid = 'gw_display_count_regression_001'
|
|
|
|
|
cleanup_test_sessions.append(sid)
|
|
|
|
|
base_ts = time.time() - 60
|
|
|
|
|
rows = [
|
|
|
|
|
("user", "Thread question", base_ts + 1),
|
|
|
|
|
("assistant", "", base_ts + 2),
|
|
|
|
|
("assistant", "", base_ts + 2),
|
|
|
|
|
("tool", '{"ok": true}', base_ts + 3),
|
|
|
|
|
("assistant", "Thread answer", base_ts + 4),
|
|
|
|
|
]
|
|
|
|
|
raw_db_count = len(rows)
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid,
|
|
|
|
|
source='discord',
|
|
|
|
|
title='Discord Thread Count Regression',
|
|
|
|
|
message_count=raw_db_count,
|
|
|
|
|
started_at=base_ts,
|
|
|
|
|
)
|
|
|
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
|
|
|
|
for role, content, ts in rows:
|
|
|
|
|
_insert_message(conn, sid, role, content, ts)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE sessions SET message_count = ? WHERE id = ?",
|
|
|
|
|
(raw_db_count, sid),
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
# A stale WebUI sidecar can exist for the same messaging thread. The API
|
|
|
|
|
# display merge dedupes repeated blank assistant separators, so the
|
|
|
|
|
# advertised count must match the returned display coordinate space, not
|
|
|
|
|
# the raw state.db row count.
|
|
|
|
|
s = Session(
|
|
|
|
|
session_id=sid,
|
|
|
|
|
title='Legacy Discord Snapshot',
|
|
|
|
|
workspace='/tmp/hermes-webui-test',
|
|
|
|
|
model='openai/gpt-5',
|
|
|
|
|
messages=[{"role": "user", "content": "Thread question", "timestamp": base_ts + 1}],
|
|
|
|
|
session_source='messaging',
|
|
|
|
|
raw_source='discord',
|
|
|
|
|
source_tag='discord',
|
|
|
|
|
source_label='Discord',
|
|
|
|
|
)
|
|
|
|
|
s.save(touch_updated_at=False)
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
data, status = get(f'/api/session?session_id={sid}&messages=1&resolve_model=0&msg_limit=100')
|
|
|
|
|
assert status == 200, data
|
|
|
|
|
session = data.get('session', {})
|
|
|
|
|
msgs = session.get('messages', [])
|
|
|
|
|
assert msgs[-1].get('content') == 'Thread answer'
|
|
|
|
|
assert len(msgs) < raw_db_count, "fixture must exercise display dedupe"
|
|
|
|
|
assert session.get('message_count') == len(msgs)
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 04:59:07 +00:00
|
|
|
def test_sessions_prefers_state_db_metadata_for_messaging_overlap(cleanup_test_sessions):
|
|
|
|
|
"""Sidebar metadata for messaging sessions should come from state.db, not local JSON snapshots."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
sid = 'gw_sidebar_metadata_regression_001'
|
|
|
|
|
cleanup_test_sessions.append(sid)
|
|
|
|
|
now = time.time()
|
|
|
|
|
rows = [
|
|
|
|
|
("user", "Hello", now - 30),
|
|
|
|
|
("assistant", "Welcome", now - 29),
|
|
|
|
|
("user", "Need details", now - 5),
|
|
|
|
|
]
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id=sid, source='weixin', title='Live metadata chat', message_count=len(rows), started_at=now - 30)
|
|
|
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
|
|
|
|
for role, content, ts in rows:
|
|
|
|
|
_insert_message(conn, sid, role, content, ts)
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
stale = [
|
|
|
|
|
{"role": "user", "content": "stale one", "timestamp": now - 100},
|
|
|
|
|
{"role": "assistant", "content": "stale two", "timestamp": now - 99},
|
|
|
|
|
]
|
|
|
|
|
from api.models import Session
|
|
|
|
|
local = Session(
|
|
|
|
|
session_id=sid,
|
|
|
|
|
title='Stale Sidebar',
|
|
|
|
|
messages=stale,
|
|
|
|
|
model='openai/gpt-4',
|
|
|
|
|
)
|
|
|
|
|
local.is_cli_session = True
|
|
|
|
|
local.session_source = 'messaging'
|
|
|
|
|
local.source_tag = 'weixin'
|
|
|
|
|
local.raw_source = 'weixin'
|
|
|
|
|
local.source_label = 'Weixin'
|
|
|
|
|
local.save(touch_updated_at=False)
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200, data
|
2026-06-18 01:19:20 +00:00
|
|
|
session = next((item for item in data.get('sessions', []) if item.get('session_id') == sid), None)
|
|
|
|
|
assert session is not None, f"{sid} missing from /api/sessions (test-isolation)"
|
2026-05-03 04:59:07 +00:00
|
|
|
assert session.get('message_count') == len(rows)
|
|
|
|
|
expected_updated = max(ts for _, _, ts in rows)
|
|
|
|
|
assert abs(float(session.get('updated_at') or 0) - expected_updated) < 1.0
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
_remove_test_sessions(conn, sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_archiving_messaging_session_keeps_state_db_history(cleanup_test_sessions):
|
|
|
|
|
"""Archiving a messaging session should persist metadata without importing full transcript."""
|
|
|
|
|
from api.models import Session
|
|
|
|
|
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
sid = 'gw_archive_metadata_only_001'
|
|
|
|
|
cleanup_test_sessions.append(sid)
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=sid,
|
|
|
|
|
source='discord',
|
|
|
|
|
title='Archive Safe',
|
|
|
|
|
message_count=2,
|
|
|
|
|
started_at=time.time() - 20,
|
|
|
|
|
)
|
|
|
|
|
# Do not create a local session first; archive should create minimal metadata only.
|
|
|
|
|
data, status = post('/api/session/archive', {'session_id': sid, 'archived': True})
|
|
|
|
|
assert status == 200, data
|
|
|
|
|
archived = data.get('session', {})
|
|
|
|
|
assert archived.get('archived') is True
|
|
|
|
|
remaining = conn.execute(
|
|
|
|
|
"SELECT COUNT(*) FROM messages WHERE session_id = ?",
|
|
|
|
|
(sid,),
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
assert remaining >= 2
|
|
|
|
|
|
|
|
|
|
local = Session.load(sid)
|
|
|
|
|
assert local is not None
|
|
|
|
|
assert local.messages == [], "Archive should not import historical messages into local JSON"
|
|
|
|
|
assert local.archived is True
|
|
|
|
|
|
|
|
|
|
session_data, session_status = get(f'/api/session?session_id={sid}')
|
|
|
|
|
assert session_status == 200, session_data
|
|
|
|
|
assert session_data.get('session', {}).get('archived') is True
|
|
|
|
|
assert session_data.get('session', {}).get('message_count') == 2
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
def test_importing_older_gateway_session_preserves_original_timestamps_and_order():
|
|
|
|
|
"""Importing an older gateway session should not bump it above newer WebUI sessions."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
older_started_at = time.time() - 1800
|
|
|
|
|
imported_sid = 'gw_import_old_001'
|
|
|
|
|
newer_webui_sid = None
|
|
|
|
|
try:
|
|
|
|
|
newer_webui, status = post('/api/session/new', {'model': 'openai/gpt-5'})
|
|
|
|
|
assert status == 200, newer_webui
|
|
|
|
|
newer_webui_sid = newer_webui['session']['session_id']
|
|
|
|
|
|
|
|
|
|
rename, rename_status = post(
|
|
|
|
|
'/api/session/rename',
|
|
|
|
|
{'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'},
|
|
|
|
|
)
|
|
|
|
|
assert rename_status == 200, rename
|
2026-06-23 15:46:37 +00:00
|
|
|
from api.models import Session
|
|
|
|
|
from tests.conftest import TEST_WORKSPACE
|
|
|
|
|
newer_webui_session = Session(
|
|
|
|
|
session_id=newer_webui_sid,
|
|
|
|
|
title='Newer WebUI Session',
|
|
|
|
|
workspace=str(TEST_WORKSPACE),
|
|
|
|
|
model='openai/gpt-5',
|
|
|
|
|
created_at=newer_webui['session']['created_at'],
|
|
|
|
|
updated_at=time.time(),
|
|
|
|
|
profile='default',
|
|
|
|
|
messages=[{
|
|
|
|
|
'role': 'user',
|
|
|
|
|
'content': 'newer visible row',
|
|
|
|
|
'timestamp': time.time(),
|
|
|
|
|
}],
|
|
|
|
|
tool_calls=[],
|
|
|
|
|
)
|
|
|
|
|
newer_webui_session.save(touch_updated_at=False)
|
|
|
|
|
rename_refresh, rename_refresh_status = post(
|
|
|
|
|
'/api/session/rename',
|
|
|
|
|
{'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'},
|
|
|
|
|
)
|
|
|
|
|
assert rename_refresh_status == 200, rename_refresh
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
|
|
|
|
|
_insert_gateway_session(
|
|
|
|
|
conn,
|
|
|
|
|
session_id=imported_sid,
|
|
|
|
|
source='discord',
|
|
|
|
|
title='Older imported gateway session',
|
|
|
|
|
started_at=older_started_at,
|
|
|
|
|
)
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
imported, imported_status = post('/api/session/import_cli', {'session_id': imported_sid})
|
|
|
|
|
assert imported_status == 200, imported
|
|
|
|
|
imported_session = imported['session']
|
|
|
|
|
assert abs(imported_session['created_at'] - older_started_at) < 2, imported_session
|
|
|
|
|
assert abs(imported_session['updated_at'] - older_started_at) < 5, imported_session
|
|
|
|
|
|
|
|
|
|
sessions_payload, sessions_status = get('/api/sessions')
|
|
|
|
|
assert sessions_status == 200, sessions_payload
|
|
|
|
|
ordered_ids = [item['session_id'] for item in sessions_payload.get('sessions', [])]
|
|
|
|
|
assert newer_webui_sid in ordered_ids, ordered_ids
|
|
|
|
|
assert imported_sid in ordered_ids, ordered_ids
|
|
|
|
|
assert ordered_ids.index(newer_webui_sid) < ordered_ids.index(imported_sid), ordered_ids
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, imported_sid)
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
if imported_sid:
|
|
|
|
|
try:
|
|
|
|
|
post('/api/session/delete', {'session_id': imported_sid})
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
if newer_webui_sid:
|
|
|
|
|
try:
|
|
|
|
|
post('/api/session/delete', {'session_id': newer_webui_sid})
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
def test_gateway_sse_stream_endpoint_exists():
|
|
|
|
|
"""GET /api/sessions/gateway/stream returns a response (200 or 200-range)."""
|
|
|
|
|
# The SSE endpoint requires show_cli_sessions to be enabled
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
try:
|
|
|
|
|
req = urllib.request.Request(BASE + '/api/sessions/gateway/stream')
|
|
|
|
|
with urllib.request.urlopen(req, timeout=5) as r:
|
|
|
|
|
assert r.status in (200, 204), f"Expected 200/204, got {r.status}"
|
|
|
|
|
# SSE should have content-type text/event-stream
|
|
|
|
|
ctype = r.headers.get('Content-Type', '')
|
|
|
|
|
assert 'text/event-stream' in ctype, f"Expected text/event-stream, got {ctype}"
|
|
|
|
|
except Exception as e:
|
|
|
|
|
# Timeout is acceptable — means the connection is held open (SSE behavior)
|
|
|
|
|
if 'timed out' in str(e).lower() or 'timeout' in str(e).lower():
|
|
|
|
|
pass # Good: SSE keeps the connection open
|
|
|
|
|
else:
|
|
|
|
|
raise
|
|
|
|
|
finally:
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
fix(sessions): surface gateway SSE failures and add polling fallback (#828)
* fix(sessions): surface gateway SSE failures and add polling fallback
- add a JSON probe mode for the gateway SSE endpoint
- detect watcher-unavailable 503s from the browser
- fall back to periodic session refresh with a toast
- add probe payload tests and endpoint coverage
Fixes #635
* fix(sessions): surface gateway SSE failures and add polling fallback (#826)
Absorbed from PR #826 by @cloudyun888 (fixes #635).
When the gateway watcher thread is not running, the browser now shows a
toast notification and falls back to 30-second periodic polling for session
sync. Previously the SSE failure was completely silent with no user feedback.
Changes from original PR:
- Deleted misplaced test_gateway_sse_probe_unit.py (was at repo root, not
discovered by `pytest tests/`); unit tests moved into tests/test_gateway_sync.py
- _gateway_sse_probe_payload now checks watcher._thread.is_alive() rather
than just watcher is not None — a watcher instance with a dead poll thread
now correctly reports unavailable and activates the polling fallback
- probeGatewaySSEStatus catch(e) now starts the polling fallback on network
error rather than silently swallowing the failure
- Added 5 unit tests covering all watcher-alive/dead/missing/disabled branches
Co-authored-by: cloudyun888 <269269188+86cloudyun-afk@users.noreply.github.com>
* cleanup(gateway): public is_alive() + dedup probe/live watcher-alive check + changelog
Three small cleanups on top of @cloudyun888's PR #826 absorption:
1. Add GatewayWatcher.is_alive() public accessor so routes.py doesn't
reach into the private _thread attribute. The existing private-
attribute check stays as a defensive fallback for any older in-
memory instance or test double that doesn't implement the full API.
2. Dedupe the watcher_alive computation in _handle_gateway_sse_stream:
the live-SSE path now calls _gateway_sse_probe_payload(...) and reads
its watcher_running field instead of re-deriving the same logic
inline. Keeps probe and SSE in sync automatically.
3. CHANGELOG trailer was (#826, fixes #635, @cloudyun888) — this PR is
#828, so updated to (#828, absorbs PR #826 by @cloudyun888, fixes
#635) matching the repo convention for absorbed PRs (see #805).
Added two regression tests:
- test_gateway_watcher_is_alive_public_method — covers the three
lifecycle states (before start, while running, after stop).
- test_probe_payload_prefers_public_is_alive — asserts the probe
uses watcher.is_alive() rather than poking _thread when the
public method exists.
Full suite: 1735 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: cloudyun888 <269269188+86cloudyun-afk@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 04:18:55 +00:00
|
|
|
def test_gateway_sse_stream_probe_reports_status():
|
|
|
|
|
"""Probe mode returns JSON watcher status instead of holding open an SSE stream."""
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
try:
|
|
|
|
|
req = urllib.request.Request(BASE + '/api/sessions/gateway/stream?probe=1')
|
|
|
|
|
with urllib.request.urlopen(req, timeout=5) as r:
|
|
|
|
|
assert r.status == 200, f"Expected 200, got {r.status}"
|
|
|
|
|
ctype = r.headers.get('Content-Type', '')
|
|
|
|
|
assert 'application/json' in ctype, f"Expected application/json, got {ctype}"
|
|
|
|
|
data = json.loads(r.read().decode('utf-8'))
|
|
|
|
|
assert data['enabled'] is True
|
|
|
|
|
assert 'watcher_running' in data
|
|
|
|
|
assert data['fallback_poll_ms'] == 30000
|
|
|
|
|
finally:
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
def test_gateway_webui_sessions_not_duplicated():
|
|
|
|
|
"""If a session_id exists both in WebUI store and state.db, it's not duplicated."""
|
|
|
|
|
# Create a WebUI session with a known ID
|
|
|
|
|
body = {}
|
|
|
|
|
d, _ = post('/api/session/new', body)
|
|
|
|
|
webui_sid = d['session']['session_id']
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Insert the same session_id into state.db as a gateway session
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
_insert_gateway_session(conn, session_id=webui_sid, source='telegram', title='Dup Test')
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
sessions = data.get('sessions', [])
|
|
|
|
|
matching = [s for s in sessions if s['session_id'] == webui_sid]
|
|
|
|
|
assert len(matching) == 1, f"Expected 1 entry for {webui_sid}, got {len(matching)}"
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
conn2 = sqlite3.connect(str(_get_state_db_path()))
|
|
|
|
|
_remove_test_sessions(conn2, webui_sid)
|
|
|
|
|
conn2.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/session/delete', {'session_id': webui_sid})
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gateway_sessions_no_state_db():
|
|
|
|
|
"""When state.db doesn't exist, /api/sessions works fine (no gateway sessions)."""
|
|
|
|
|
_cleanup_state_db()
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
try:
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
# Should succeed with just webui sessions (or empty)
|
|
|
|
|
assert 'sessions' in data
|
|
|
|
|
finally:
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_sessions_still_work():
|
|
|
|
|
"""CLI sessions (source='cli') still appear alongside gateway sessions."""
|
|
|
|
|
conn = _ensure_state_db()
|
|
|
|
|
try:
|
|
|
|
|
_insert_gateway_session(conn, session_id='cli_legacy_001', source='cli', title='CLI Legacy')
|
|
|
|
|
_insert_gateway_session(conn, session_id='gw_new_001', source='telegram', title='GW New')
|
|
|
|
|
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
|
|
|
|
|
|
data, status = get('/api/sessions')
|
|
|
|
|
assert status == 200
|
|
|
|
|
sessions = data.get('sessions', [])
|
|
|
|
|
agent_ids = {s['session_id'] for s in sessions if s.get('session_id') in ('cli_legacy_001', 'gw_new_001')}
|
|
|
|
|
assert len(agent_ids) == 2, f"Expected 2 agent sessions (cli + gateway), got {len(agent_ids)}"
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
_remove_test_sessions(conn, 'cli_legacy_001', 'gw_new_001')
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
post('/api/settings', {'show_cli_sessions': False})
|
fix(sessions): surface gateway SSE failures and add polling fallback (#828)
* fix(sessions): surface gateway SSE failures and add polling fallback
- add a JSON probe mode for the gateway SSE endpoint
- detect watcher-unavailable 503s from the browser
- fall back to periodic session refresh with a toast
- add probe payload tests and endpoint coverage
Fixes #635
* fix(sessions): surface gateway SSE failures and add polling fallback (#826)
Absorbed from PR #826 by @cloudyun888 (fixes #635).
When the gateway watcher thread is not running, the browser now shows a
toast notification and falls back to 30-second periodic polling for session
sync. Previously the SSE failure was completely silent with no user feedback.
Changes from original PR:
- Deleted misplaced test_gateway_sse_probe_unit.py (was at repo root, not
discovered by `pytest tests/`); unit tests moved into tests/test_gateway_sync.py
- _gateway_sse_probe_payload now checks watcher._thread.is_alive() rather
than just watcher is not None — a watcher instance with a dead poll thread
now correctly reports unavailable and activates the polling fallback
- probeGatewaySSEStatus catch(e) now starts the polling fallback on network
error rather than silently swallowing the failure
- Added 5 unit tests covering all watcher-alive/dead/missing/disabled branches
Co-authored-by: cloudyun888 <269269188+86cloudyun-afk@users.noreply.github.com>
* cleanup(gateway): public is_alive() + dedup probe/live watcher-alive check + changelog
Three small cleanups on top of @cloudyun888's PR #826 absorption:
1. Add GatewayWatcher.is_alive() public accessor so routes.py doesn't
reach into the private _thread attribute. The existing private-
attribute check stays as a defensive fallback for any older in-
memory instance or test double that doesn't implement the full API.
2. Dedupe the watcher_alive computation in _handle_gateway_sse_stream:
the live-SSE path now calls _gateway_sse_probe_payload(...) and reads
its watcher_running field instead of re-deriving the same logic
inline. Keeps probe and SSE in sync automatically.
3. CHANGELOG trailer was (#826, fixes #635, @cloudyun888) — this PR is
#828, so updated to (#828, absorbs PR #826 by @cloudyun888, fixes
#635) matching the repo convention for absorbed PRs (see #805).
Added two regression tests:
- test_gateway_watcher_is_alive_public_method — covers the three
lifecycle states (before start, while running, after stop).
- test_probe_payload_prefers_public_is_alive — asserts the probe
uses watcher.is_alive() rather than poking _thread when the
public method exists.
Full suite: 1735 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: cloudyun888 <269269188+86cloudyun-afk@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 04:18:55 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Unit tests for _gateway_sse_probe_payload ────────────────────────────────
|
|
|
|
|
# These replace the deleted repo-root test_gateway_sse_probe_unit.py and account
|
|
|
|
|
# for the watcher_alive check (thread existence + is_alive()).
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
import threading
|
|
|
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
|
from api.routes import _gateway_sse_probe_payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_probe_payload_when_disabled():
|
|
|
|
|
"""Probe returns 404 when show_cli_sessions is False."""
|
|
|
|
|
body, status = _gateway_sse_probe_payload({'show_cli_sessions': False}, watcher=None)
|
|
|
|
|
assert status == 404
|
|
|
|
|
assert body['ok'] is False
|
|
|
|
|
assert body['enabled'] is False
|
|
|
|
|
assert body['watcher_running'] is False
|
|
|
|
|
assert body['error'] == 'agent sessions not enabled'
|
|
|
|
|
assert body['fallback_poll_ms'] == 30000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_probe_payload_when_watcher_missing():
|
|
|
|
|
"""Probe returns 503 when enabled but no watcher instance."""
|
|
|
|
|
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=None)
|
|
|
|
|
assert status == 503
|
|
|
|
|
assert body['ok'] is False
|
|
|
|
|
assert body['enabled'] is True
|
|
|
|
|
assert body['watcher_running'] is False
|
|
|
|
|
assert body['error'] == 'watcher not started'
|
|
|
|
|
assert body['fallback_poll_ms'] == 30000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_probe_payload_when_watcher_instance_no_thread():
|
|
|
|
|
"""Probe returns 503 when watcher exists but _thread attribute is missing/None."""
|
|
|
|
|
class _FakeWatcher:
|
|
|
|
|
_thread = None
|
|
|
|
|
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=_FakeWatcher())
|
|
|
|
|
assert status == 503
|
|
|
|
|
assert body['watcher_running'] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_probe_payload_when_watcher_thread_alive():
|
|
|
|
|
"""Probe returns 200 when enabled and watcher thread is alive."""
|
|
|
|
|
class _FakeWatcher:
|
|
|
|
|
pass
|
|
|
|
|
w = _FakeWatcher()
|
|
|
|
|
t = threading.Thread(target=lambda: None)
|
|
|
|
|
t.daemon = True
|
|
|
|
|
t.start()
|
|
|
|
|
w._thread = t
|
|
|
|
|
# Thread may finish fast — loop-start a live daemon thread for reliability
|
|
|
|
|
import time as _time
|
|
|
|
|
done = threading.Event()
|
|
|
|
|
live = threading.Thread(target=done.wait, daemon=True)
|
|
|
|
|
live.start()
|
|
|
|
|
w._thread = live
|
|
|
|
|
try:
|
|
|
|
|
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=w)
|
|
|
|
|
assert status == 200
|
|
|
|
|
assert body['ok'] is True
|
|
|
|
|
assert body['watcher_running'] is True
|
|
|
|
|
assert body['fallback_poll_ms'] == 30000
|
|
|
|
|
finally:
|
|
|
|
|
done.set()
|
|
|
|
|
live.join(timeout=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_probe_payload_when_watcher_thread_dead():
|
|
|
|
|
"""Probe returns 503 when watcher instance exists but thread has exited."""
|
|
|
|
|
class _FakeWatcher:
|
|
|
|
|
pass
|
|
|
|
|
w = _FakeWatcher()
|
|
|
|
|
t = threading.Thread(target=lambda: None)
|
|
|
|
|
t.start()
|
|
|
|
|
t.join() # wait for it to finish
|
|
|
|
|
w._thread = t
|
|
|
|
|
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=w)
|
|
|
|
|
assert status == 503
|
|
|
|
|
assert body['watcher_running'] is False
|
|
|
|
|
assert body['ok'] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gateway_watcher_is_alive_public_method():
|
|
|
|
|
"""GatewayWatcher.is_alive() is the public API the probe uses. Cover all
|
|
|
|
|
three states: before start(), while running, after stop()."""
|
|
|
|
|
from api.gateway_watcher import GatewayWatcher
|
|
|
|
|
w = GatewayWatcher()
|
|
|
|
|
# Before start(): no thread
|
|
|
|
|
assert w.is_alive() is False, "is_alive() must be False before start()"
|
|
|
|
|
# After start(): thread running
|
|
|
|
|
w.start()
|
|
|
|
|
try:
|
|
|
|
|
assert w.is_alive() is True, "is_alive() must be True while running"
|
|
|
|
|
finally:
|
|
|
|
|
w.stop()
|
|
|
|
|
# After stop(): thread cleared
|
|
|
|
|
assert w.is_alive() is False, "is_alive() must be False after stop()"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_probe_payload_prefers_public_is_alive():
|
|
|
|
|
"""Regression guard: _gateway_sse_probe_payload must call watcher.is_alive()
|
|
|
|
|
rather than poking at _thread directly when the public method exists."""
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
class _WatcherWithPublicApi:
|
|
|
|
|
def is_alive(self):
|
|
|
|
|
calls.append('is_alive')
|
|
|
|
|
return True
|
|
|
|
|
# _thread is deliberately absent — must not be accessed.
|
|
|
|
|
|
|
|
|
|
body, status = _gateway_sse_probe_payload(
|
|
|
|
|
{'show_cli_sessions': True},
|
|
|
|
|
watcher=_WatcherWithPublicApi(),
|
|
|
|
|
)
|
|
|
|
|
assert status == 200
|
|
|
|
|
assert body['watcher_running'] is True
|
|
|
|
|
assert calls == ['is_alive'], (
|
|
|
|
|
"probe must prefer the public is_alive() method over poking _thread"
|
|
|
|
|
)
|