Commit b45a217e0 gated the TELEGRAM_RICH_MESSAGES_HINT extension behind
a config read at the top-level ``platforms.telegram.extra.rich_messages``
key, but the Telegram adapter reads the same setting from the canonical
``gateway.platforms.telegram.extra.rich_messages`` path. When users set
the setting in the canonical location (the only one documented), the
lookup returned None and the extension never fired — the model degraded
pipe tables to bullet lists, task lists to plain dashes, and never
produced <details> blocks or block math.
Fix: merge both ``gateway.platforms.telegram.extra`` and the top-level
``platforms.telegram.extra`` with the same precedence the adapter uses
(top-level leaf wins), so config-wizard writes and dashboard-setup keys
are visible alongside the canonical gateway location. Narrow the
except-guard to ImportError so real config-stack failures surface.
Deduplicate the composed JSON payload construction — both the group
and DM branches build the identical json.dumps result, so hoist it
above the if/else to match the pattern already used in send().
The _standalone_send() function (used by the send_message tool for
proactive/scheduled sends) has the same bare `@<id> text` bug that
send() had before PR #44444. SimpleX's `@<x>` syntax resolves x as a
display name, not a contactId — the daemon silently drops messages
when it cannot find a contact named "6".
Use the structured `/_send @<id> json [...]` form, matching what
send_image, send_document, and the send() fix already use.
Fixes#46265
Adds regression test for the case where both disk and live start_time
are known integers but differ (e.g. stale value from a previous run).
The self-PID short-circuit must fire regardless — start_time only
guards PID reuse for *other* PIDs. Inspired by #81495's test case.
After Discord reconnect, on-disk start_time can be null while the live
record has a fingerprint. Requiring equality made the gateway treat its
own PID as a foreign token holder (#81468).
Open the selected microphone at its reported default rate and convert each capture block to the 16 kHz frame expected by wake-word engines. Add a regression covering a 48 kHz WASAPI device.
Co-authored-by: clyu168 <clyu168@126.com>
Two claim-failure diagnostic paths (cronjob_tools.py:629,921) still used
the old inline 'not enabled or state==paused' check. After get_job()
normalizes via effective_job_state, a half-paused record has
state='scheduled' and enabled=True, so the inline check returned False —
mislabeling the job as 'already being fired' instead of 'paused/disabled'.
Also hoists effective_job_state/is_job_runnable to the top-level import in
cronjob_tools.py (was function-local) and updates console_engine.py's
_format_job to use effective_job_state instead of the old inline
state-or-enabled derivation — a fourth display path the original PR missed.
Follow-up to PR #81287.
pause_job already sets enabled=false atomically with state/paused_at, but
get_due_jobs only checked enabled — so a contradictory record
(enabled=true + paused_at/state=paused) still fired. That was the 07-30
outage failure mode: list looked frozen, fleet kept merging.
- is_job_runnable / effective_job_state: pause markers gate fire; display
derives from the scheduler-honoured enabled flag so half-paused never
renders as [paused]
- get_due_jobs self-heals enabled=false + logs error on contradiction
- claim_job_for_fire uses is_job_runnable (paused_at counts too)
- list/format paths use effective_job_state
- behavioural tests: pause blocks due fire; half-pause self-disables
SessionResumeTooLargeError said 'across its lineage' even when the CLI
mid-setup path counted only the tip segment; the exception now takes a
scope phrase.
With sessions.max_*_messages: 0 the guards previously still ran an
unbounded COUNT (full lineage for resume) — the exact pathological work
disabling them is meant to avoid. Live callers use the raise side
effect only, so return 0 without touching the messages table.
The salvage's getLatestSessionMessages/getAllSessionMessages split left
two desktop test files mocking the old getSessionMessages name (vi.mock
partial-mock let the real function through, so calls hit the un-mocked
path). Also braces the new single-line if guards per the curly lint
rule.
OFFSET paging made the streaming export O(n^2) on huge transcripts;
after_id keyset paging keeps each page seek O(1). Adds after_id to
SessionDB.get_messages (ascending-only, guarded against latest/offset
combos).
The mid-setup CLI resume path loads only the tip session's rows, so
gate it with a tip-only count instead of the full-lineage count (which
over-rejected heavily-compressed sessions). Transient guard failures
(locked DB, adaptor stores) now log and proceed instead of blocking
resume with a new error.
The remote interceptor rebuilt session/messages requests from pathname
only, silently dropping limit/offset/order. Against a paginating remote
backend, getAllSessionMessages would refetch the same default page until
the safe-load guard threw, breaking export/artifacts/branch for remote
sessions over one page.
sessions.max_resume_messages / sessions.max_export_messages (default
20000, 0 disables) replace the hardcoded hard-rejects, and the CLI
'sessions export' guard becomes per-session instead of cumulative so
full-DB backups of many small sessions keep working. Error guidance now
points at the config override instead of the (corruption-only) repair
command.
Thread.start() failure is RuntimeError; catching BaseException here
swallowed KeyboardInterrupt/SystemExit without re-raise (unlike _worker,
which forwards them into the future).
- Heartbeat tests: holder signals a threading.Event after acquiring
_SKILLS_PROFILE_LOCK; the scenario waits on it via run_in_executor
instead of sleeping 50ms and hoping.
- Fix the TestConfigMutationLock comment to describe the probabilistic
slow-save interleave the code actually implements.
- New regression test: PUT /api/dashboard/plugin-providers must hold
_CONFIG_MUTATION_LOCK — a concurrent locked writer survives.
Two follow-ups to the off-loop move, from external review (both verified,
the second larger than reported):
- Config read-modify-write handlers moved to worker threads could now
interleave — _CONFIG_LOCK covers each load/save individually, never the
span between them; the event loop used to serialize these accidentally.
New _CONFIG_MUTATION_LOCK (worker-threads only, so it can never block
the loop) held across the whole load→mutate→save span in all seven RMW
handlers. update_config_raw skipped: it's a full-document replace with
no server-side read, so a lock cannot close its client-side window.
- The review flagged two skills routes still taking _SKILLS_PROFILE_LOCK
on the event loop; a systematic audit of hermes_cli/web_routers/ found
24 on-loop routes (skills 5, mcp 9, tools 10, cron 1). All moved to the
same inner-_run + asyncio.to_thread pattern, mutating ones under the
mutation lock, uniform lock order (_SKILLS_PROFILE_LOCK →
_CONFIG_MUTATION_LOCK). Await-safe _config_profile_scope routes, plain
def routes, and already-threaded routes unchanged.
Regression tests: concurrent theme+font updates both survive (fails with
the lock nulled: "theme write lost to a concurrent font write"); event
loop stays responsive while the profile lock is held during GET
/api/skills. 214 tests passing across the touched suites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The diagnostics loop watchdog caught GET /api/config freezing the gateway
event loop for >1s, stack-sampled blocking on _SKILLS_PROFILE_LOCK inside
_profile_scope. Any async handler that entered _profile_scope (process-wide
threading lock) or called load_config()/save_config() on-loop could stall
every chat and WebSocket at once while a slow lock-holder ran.
Move 28 such handlers to the existing inner-_run + asyncio.to_thread
pattern (contextvar-safe: the whole scope enter/body/exit stays inside one
worker thread). Handlers using the await-safe _config_profile_scope, plain
def endpoints (FastAPI threadpool), and tui_gateway's contextvar-only
decorator are unaffected and unchanged.
Regression test holds _SKILLS_PROFILE_LOCK in a thread while calling
GET /api/config and asserts an event-loop heartbeat keeps ticking; it fails
against the pre-fix code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the 'describe everything in thorough detail' auto image-preprocess
prompt with a concise 2-4 sentence summary prompt so image-bearing gateway
messages stop generating ~2000-char descriptions (35s+ on local models).
Prompt-only variant of #10852: the max_tokens=500 cap and the
preserve_max_tokens aux-client plumbing from the original PR are
intentionally dropped to stay compatible with the max-tokens-knob policy
direction (#75253 removes hardcoded vision caps).
Fixes#10809
Both entry points read $selectedStoredSessionId, which is the WORKSPACE pane's
session — so whichever tile was fronted, the main tab went into the HUD. Tabs
exist precisely so those aren't the same question.
`getActiveComposer()` already answers it for the focus bus, healing to the
visible surface when its cached claim is buried, and a tile's routing key is its
stored session id. One resolver now, shared by the titlebar button and ⌘⇧H.
Coming back re-resumes through the tile delegate when the target is an open
tile. The ordinary resume path enforces "a session is either main or a tile,
never both" and would have closed the tile to take it into main, quietly
rearranging tabs the user opened on purpose.
An auth failure on the very first connect returned out of the run loop
instead of parking. That ended the run task, and the task is the only
listener on _reconnect_event — so the server stayed dead for the life of
the process. `hermes mcp login`, a /mcp refresh, and the 300s self-probe
all had nothing left to wake, and the only cure was a full restart.
_classify_mcp_failure already calls 401/403 "permanent" and documents
that run() parks those immediately; the early return above it meant auth
was the one permanent failure that never got there. Park it with the
others and keep the tailored log line, now pointing at `hermes mcp
login <server>`.
The sheet is sized to the transcript, so it changed height on every reply with
no transition — the panel twitched to each new size instead of growing into it.
Animated on the reveal timing.
The edge flip was reading the window, which stopped being the same thing as the
HUD once the sheet started sizing itself: a tall window's top edge reaches the
screen top while the visible bar is still well down the display, so it flipped
far too early. It now measures the visible panel, against a threshold
proportional to the display rather than an exact-flush rule — the HUD hugs its
bar, so its visible top can never actually touch the screen edge and a
zero-tolerance test would never fire at all.
Parked at the top the whole gradient flipped with the layout, which inverted the
thing it exists to do. The transcript reads oldest-to-newest downward wherever
the bar is, so the newest turn is at the bottom either way — flipping the mask
faded the newest reply and left the oldest solid, leaving a stray bubble hanging
over the desktop with the reply dissolved beneath it.
Only the anchor flips now: the sheet hangs from the bar instead of standing on
it, the thread's padding moves, and the exit chip drops clear. The fade always
runs away from the newest message.
Four things the band's content-sizing exposed, all of them stale assumptions
from when it was a full-window box.
A leftover `:focus-within` rule still painted the band's own background, so
focusing an empty session filled the whole window with a solid slab even though
the sheet had correctly collapsed to nothing.
The exit chip anchored to the band rather than the sheet, so on a short
transcript it floated in empty space at the window's corner. It now rides the
sheet, and drops clear of the bar when the layout is flipped.
Bottom clearance read `--composer-measured-height`, a surface var that never
reaches here, so it silently fell back to the root estimate and reserved ~20px
more than the bar occupies. The bar's real height is measured alongside the
band's.
Top-edge mode was never re-checked after the sheet started sizing itself: the
sheet stayed pinned to the bottom while the transcript hung from the top. Both
the anchor and the measurement now flip with the layout.
The model pill shed its label at 440px and the row stacked at almost the same
width, so the chevron bought nothing — which is the one thing a progressive
collapse is meant to avoid.
Sized off what the controls actually cost rather than a guess. With the full
pill they take ~284px, so at 440 the inline input was ~156px against a 128px
minimum: a few words wrapped, and wrapping is what stacks the row. Compacting
at 560 sheds the label while the input still has ~276px, and spends the ~110px
the chevron frees on keeping the row single for another stretch.
Global, not HUD-only.
An empty session painted a full-height slab of glass with nothing in it. The
sheet now grows up from the bar to fit the transcript and caps at the window, so
a fresh chat is just the bar.
Measured from the topmost row down to the bar and written straight to the
element — it changes on every stream flush, and the sheet resizing must not
re-render the tree.
The renderer half. HUD mode reuses the app's own chat surface and only changes
the frame around it: no titlebar, no statusbar, no pane tree. The band and the
bar tile the window between them with no dead margins, and the band runs the
window's full height with the opaque bar sitting on its bottom edge, so there
is no seam to compute and none to drift as the composer grows.
Visibility is a WoW chat frame: the transcript shows while a turn is recent or
the composer has focus, then holds and fades. A bottom-anchored gradient mask
carries the fade on both the sheet and the text, and flattens away on focus so
nothing is dimmed while you are reading. Only the composer never fades — it is
the interface.
The window is mouse-transparent everywhere it isn't really there, so clicks over
the faded band reach the app behind it; `pointer-events` can't do this, since
the click never reaches the page at all.
A transparent, frameless, always-on-top window that renders the real chat
surface, so its composer is the app's composer rather than a lookalike that
drifts. Main owns the window, its remembered geometry, and click-through.
Leaving is a handoff, not a window close. The gateway binds a session's event
stream to exactly one socket, so a turn started in the HUD streams only there
and the app window hears nothing — no deltas, no turn-complete, no draft clear,
and nothing to poll for mid-turn. So the app re-resumes the session the HUD
ended on, which rebinds the transport, reconciles the transcript, and picks up
an in-flight turn. Main carries the session id across, since it is the only
party that outlives the HUD's renderer.
Drafts are per-renderer state backed by shared localStorage, and the map is
read once at module load, so two windows on the same session diverge the
moment either types. Adds the two verbs a handoff needs: `reloadPersistedDrafts`
to merge another window's writes in (keeping local attachments, which are never
persisted), and a draft-sync bus so a composer can be told to flush its live
text down to the stash or repaint from it.
Dispatched synchronously, unlike the focus bus — a flush has to complete before
the window that will read it is created.
Resuming a session that is mid-turn somewhere else collapsed the thread down
to the in-flight prompt, and the user's own message never appeared at all
until a reload.
Two wrong premises in the resume path, neither specific to any one surface:
`omit_messages` was read as "empty transcript" rather than "no transcript in
this response". Desktop asks session.resume/activate to omit messages because
REST is the transcript authority, so mid-turn the live projection got
reconciled against an empty list and rebuilt the thread out of itself. The
response already carries `messages_omitted`; nothing read it. It now grafts
the projection onto the cache (or the REST prefetch) instead.
The settle path skipped hydration whenever the window had streamed the reply,
on the assumption that streaming a reply means owning the whole turn. True for
a turn you started, false for one you adopted: it arrives reply-first with no
prompt row, and nothing ever backfilled it. Sessions now carry
`adoptedRunningTurn`, set when a resume lands on an already-running turn and
consumed when it settles.
The windows-compat change-detector checked for the literal string
'start_new_session=True', but the systemd scope isolation path
conditionally uses start_new_session=False (the scope creates its own
session/cgroup). Assert 'start_new_session=' instead — the value may
now be a variable.
The entry_data/lock_held path (used by mark_turn_active/clear_turn_active)
was eagerly constructing a full O(n) snapshot of all routing entries under
the lock on every turn, even when the DB upsert would succeed. Defer the
fallback_data construction to the except branch where it's actually needed,
and respect lock_held to avoid re-acquiring a non-reentrant _lock.