Removes the local re-import of api.streaming._session_payload_with_full_messages
inside test_in_tail_duplicate_guard_refuses_bounded_and_full_revision_accepted;
it is already imported at module level (line 17) and unused in the function body,
tripping F401 on CI's hosted lint. Mechanical maintainer fix per auto-fix policy.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
* perf(session-load): memoize the compression-lineage display stitch
_webui_sidecar_lineage_messages_for_display re-merged multi-thousand-row
parent+child transcripts on EVERY GET /api/session (measured 5.1s per
request on a real 5.2k-message lineage, py-spy: merge_session_messages_
append_only + _matching_visible_duplicate dominate). The merge result is
now cached per session id, validity keyed by the exact stat signature of
the child sidecar AND every snapshot parent involved, bounded LRU(16).
Any write to any involved sidecar invalidates naturally; cache hits and
the populating call hand out shallow-copied rows so caller-side display
metadata cannot corrupt cached state.
Measured: 5.17s cold -> 0.002s warm on the real session; invalidation
after touch re-merges. Tests: tests/test_lineage_display_cache.py
(correctness, copy isolation, child/parent invalidation, no pollution
for lineage-less sessions).
* perf(api): memoize inactive-session display merges
* perf(api): skip state load on cached transcript reopen
* test(api): remove unused lineage-cache import
* fix(api): reject streaming key without state database
* test(api): isolate display cache fixture
* fix(api): bind cache publication to read signature
* fix(api): harden display cache invalidation
* fix(api): bind cache hits to lineage provenance
* fix(api): close remaining display cache scope races
* fix(api): scope display cache revision to target
* fix(api): evict unverifiable lineage provenance
* fix(api): keep incomplete lineage provenance uncached
---------
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
* Fix Hermes agent port configuration
* fix: bind dashboard to 0.0.0.0 so port-forwarded hosts can reach it
Restore the full three-container compose file (previous commit replaced
it wholesale) and change only the dashboard port binding: 127.0.0.1:9119
prevents access via the server IP, which is exactly the issue reported
in #6876.
* compose: restore loopback-only dashboard binding, document opt-in
The dashboard runs --insecure; publishing it on 0.0.0.0 exposes an
unauthenticated service on every host interface. Restore the
127.0.0.1:9119:9119 binding that matches the file convention (all
services are loopback by default) and document the opt-in exactly
like the webui service does (#6876).
* fix: bindable ports for remote access (issue #6876)
---------
Co-authored-by: mercael91 <mercael91@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
_split_webui_provider_model_value() and _strip_webui_provider_prefix()
split provider-qualified model values at the last colon. That amputates
any model name carrying its own tag: @ollama:qwen3.8:27b-mtp-q8_0
resolved to model 27b-mtp-q8_0 with provider ollama:qwen3.8, and the
truncated name was sent upstream, which 404'd. The splitter also cannot
distinguish a tagged model from a multi-segment custom provider ID, so
@custom:mykey:model-a:free was affected the same way.
Both sites now delegate to config._parse_provider_qualified_model_id(),
the grammar routes.py and the gateway adopted in #6723. The import is
function-local, matching the existing api.config imports in this module.
_split_webui_provider_model_value() persists its result into profile
config, so the mangled name outlived the session that produced it.
Fixes#7182.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(export): resolve session exports against document base URI
* test(export): cover subpath deep links
---------
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
The two static-catalog-change invalidation tests asserted the newly-appended
opencode-go model appeared in the group's visible `models` list. That held when
the test was written (opencode-go had 14 static entries), but the curated list
has since grown to 27 — past the model-picker overflow threshold — so the
rebuilt group now splits into a visible `models` head (capped) plus an
`extra_models` overflow tail, and the appended entry (added at the end) lands in
`extra_models`. Cache invalidation itself works correctly; the assertion was
stale.
Assert the appended model is surfaced across the combined picker catalog
(`models` + `extra_models`) so the test stays a pure cache-invalidation
regression check, independent of the overflow cap. Verified non-vacuous (a
genuinely-absent id still fails). No production change.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
The test module only did importorskip('mcp'), which checks the package is
present but not that it exposes the 1.x Server.list_tools/call_tool decorator
API that mcp_server.py is built on. On an environment carrying mcp>=2.0.0
(which dropped that API), importing mcp_server hard-exits (SystemExit) at its
own SDK version guard, so every test errored at setup instead of skipping.
Reuse the exact predicate mcp_server.py uses (hasattr(Server, 'list_tools'))
to module-level-skip when the installed SDK is out of the supported range.
CI, which pins mcp>=1.28,<2 via requirements-dev.txt, still runs the full
module; a box with a newer mcp now skips cleanly instead of producing
spurious errors/failures.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(ui): suppress false loopback-only warning when public URL is configured
When a user configures status.browser_url (e.g., via a reverse proxy
at https://dashboard.example.com), the Dashboard link correctly opens
that public URL, but _applyDashboardStatus() still displayed the
'loopback-only' warning because it only checked the browser's hostname.
Fix: suppress the warning when status.browser_url is present — the
user has explicitly configured a public URL, so the loopback bind
is intentional and the warning is a false positive.
Closes#6459
* fix(ui): base loopback warning on resolved dashboard target hostname (#6573)
* fix(ui): complete loopback classifier for dashboard warning (#6573)
---------
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(workspace): new chat inherits the current conversation's workspace
`newSession()` resolved the workspace of a new chat as:
switchWs || S._profileDefaultWorkspace || S.session.workspace
`S._profileDefaultWorkspace` is global to the profile, hydrated from
`/api/profile/active`. Preferring it over the current conversation meant a new
chat opened from a MES conversation landed on the profile-global value instead
of MES. With several conversations running in parallel on different workspaces,
the context the user was actually in was systematically lost.
The precedence is worse than it looks, because that "profile default" is not a
stable configured value: `get_profile_default_workspace()` reads
`last_workspace.txt` BEFORE the configured key (api/workspace.py ~419-427), and
for the `default` profile that file is the GLOBAL one. `set_last_workspace()`
rewrites it on every `/api/chat/start` and `/api/session/update` of ANY
conversation. So "profile default wins over the current session" degrades in
practice to "whatever workspace another conversation last used wins over the
conversation I am in" -- observed live: the pointer flipped mid-operation
because a concurrent turn wrote to it.
New precedence:
switchWs || S.session.workspace || S._profileDefaultWorkspace
Preserved invariants:
- the one-shot profile-switch flag still wins first and is still consumed (#823);
- a blank page (no loaded session) still falls back to the profile default,
which remains required for the empty-composer workspace chip (#804/#5169);
- `_profileDefaultWorkspace` is never nulled by `newSession()` (#823).
This deliberately diverges from upstream #4755, which made the profile default
win. That decision assumed a stable configured default; on a shared/global
`last_workspace.txt` it inverts its own intent. The upstream test is rewritten
with the rationale inline so a future rebase does not silently restore the bug,
plus a new test that evaluates the real expression under node and proves the
blank-page fallback still holds.
Pre-existing failures in tests/test_workspace_stale_recovery.py (3) were
verified identical with and without this change.
* fix(workspace): recover stale inherited workspace
---------
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
The periodic checkpoint thread fires whenever a tool call completes and
unconditionally rewrites the whole session sidecar. A completed tool call
is not proof that anything the checkpoint persists actually changed:
`run_conversation()` mutates an internal copy of the transcript, and the
turn bookkeeping fields (`active_stream_id`, `pending_user_message`,
`pending_started_at`) are written once before the run starts. So the
recurring rewrite normally re-persists byte-identical state.
Each rewrite serializes the entire session while holding the GIL, so its
cost scales with transcript size. Because the HTTP server and the agent
workers share one interpreter, that cost is added latency for every
concurrent request — not just for the session being written.
Measured on this deployment (1,622 sidecars, 6.65 GB, median 2.31 MB,
28.8% above 5 MB, max 35.9 MB):
sidecar write per 10-min turn (40 wakeups)
1.8 MB ~100 ms 4.1 s -> 0.2 s
24.7 MB ~1.4 s 41.6 s -> 2.1 s
A checkpoint now writes only when the state it persists actually moved,
compared through a cheap fingerprint that allocates nothing. The
fingerprint covers every field the checkpoint can legitimately advance
mid-run: session identity (compression rotates the SID), turn
bookkeeping, and the length of the persisted message projections.
Fail-closed on both sides: an unreadable fingerprint returns None and
forces the write, and a 5-minute idle refresh keeps `updated_at` from
going stale on a long quiet turn (the sidebar sorts on it). Crash
recovery is unchanged — any real mutation is still persisted within one
15 s interval.
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
The WebUI clarify bridge resolved the prompt timeout from a local, divergent
copy (legacy clarify.timeout, 120s fallback) and treated 0 as invalid, so
agent.clarify_timeout: 0 (unlimited) could not be honored and there was no
way to make a clarification card wait for the user.
Resolve the timeout with the agent core's canonical resolve_clarify_timeout()
(clarify.timeout -> agent.clarify_timeout -> 3600, <= 0 = unlimited) against
the session's own profile config (the streaming worker thread does not inherit
the request's per-profile context), wait with a null deadline when unlimited,
and stop the frontend rendering an expired countdown for non-positive timeouts.
- api/streaming.py: per-session profile config resolution, canonical resolver
with a compatibility fallback, and an extracted _await_clarify_response()
helper that polls in 1s slices and honours cancellation.
- api/clarify.py: preserve timeout_seconds <= 0 and emit no expires_at.
- static/messages.js: _clarifyExpiryMs() returns 0 for non-positive timeouts
so the card stays up without a countdown.
- tests/test_sprint42.py: resolution matrix, per-session profile divergence,
unlimited resolve-later / cancel-exit, and no-expiry payload.
Closes#7159
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>