Forks and other child sessions keep their own project_id, so a fork moved to
No project stays Unassigned. A per-render memoized, cycle-safe resolver is
shared by partitioning, reference scoping and the Unassigned chip.
The chip check now reuses the ancestor map built by _partitionSidebarSessionRows,
so a child whose project parent is only a hidden reference row is not counted
as unassigned. Document project inheritance for subagent sessions in README.
The replay-authority predicate accepted any non-empty assistant message,
but a new turn copies the full transcript into INFLIGHT.messages. In an
established conversation that content is the previous turn's reply, so
`canSeed` came back true with both stale cursor halves preserved and the
server kept skipping the range that rebuilds the body.
Message-based evidence now counts only from a current `_live` assistant
positioned after the latest user boundary; a historical assistant row
fails closed to floor 0 and an empty event id.
Refs #7640 (blocker on #7651)
Subagent child sessions come from state.db and never carry a project_id,
so the project filter dropped them. Resolve a child's project from its
nearest ancestor for the project filter, reference scoping and the
Unassigned chip.
A transparent-mode SSE drop followed by a run-journal re-attach could settle a
turn with its footer (duration / model / TTFT / Done) painted over a blank
message body. The cached INFLIGHT entry kept `lastRunJournalSeq` (206 in the
report) while its live assistant projection was gone; re-attach passed that
cursor to the server as `after_seq`, so every journal event that rebuilds the
message body was skipped and only the tail came back. A manual reload - a
zero-floor replay - restored the message, which is what proves the server-side
journal was complete the whole time.
- `_inflightCanSeedJournalReplay()`: only recoverable live output (assistant
content/reasoning, `lastAssistantText`/`lastReasoningText`, live DOM, tools,
burst anchors, a populated anchor scene) authorises a nonzero replay floor.
- `_runJournalReplayFloorForInflight()` / `_runJournalReplayEventIdForInflight()`
gate BOTH halves of the cursor. The server reads `after_event_id` before
`after_seq`, so zeroing the seq alone would still resume at the stale event id
and skip the same range.
- `_normalizeInflightReplayCursorForReattach()` re-validates the selected
recovery object immediately before reattach (both `loadSession` reattach
paths), so a cursor that outlived its projection cannot reach the wire.
`_inflightHasVisibleLiveState()` keeps its keep-vs-discard role: a bare user row
has to stay visible across a mid-turn reload until pending/live reconciliation
runs, so replay authority lives in the floor predicate rather than overloading
that contract.
Fixes#7640
Fixes the #7310 stall class: six frontend recovery paths (offline/bfcache
refreshSession, stream-end settle, cancel sync, /compress preflight, /retry,
/undo) issued bare GET /api/session reads that re-walked, re-redacted and
re-serialized the ENTIRE transcript (4-15s / 28MB on a 5k-row session,
#7625), and the same shape shipped the full session-level tool_calls list
because windowing was gated on msg_limit being present.
Changes:
- Server: add explicit msg_limit=all escape hatch; window session-level
tool_calls by actual truncation (length comparison) instead of the
presence of a limit parameter. The bare no-limit shape keeps its
full-transcript contract (pinned by
test_state_db_reconciliation_preserves_sidecar_order_when_timestamps_collide
and the run-journal snapshot test).
- Client: refreshSession / _restoreSettledSession / cancel sync /
/compress preflight / /retry / /undo now request the bounded tail
(msg_limit=30) and restore the _messages_truncated/_messages_offset paging
gate instead of hardcoding 'not truncated'; jump-to-start
(_ensureAllMessagesLoaded) and outline jump opt in to the full transcript
via msg_limit=all.
Tests: test_session_default_window.py covers helper semantics, per-call-site
bounded/all shapes, truncation-signal restoration, and a regression guard
that no bare GET /api/session fetch remains in static/. 454 related tests
pass; reverse-verified (reverting the fixes turns the tests red).
The slash-skill caches (_slashSkillCache, _slashSkillCachePromise,
_skillCommandCache) outlived a profile switch, so the outgoing profile's
/api/skills payload kept hiding skills that the incoming profile enables
(#7509).
- call invalidateSlashSkillCaches() from both switch handlers
(static/panels.js switchToProfile, static/sessions.js
_switchProfileForSessionLoad), after the /api/profile/switch request
- bump a generation counter in invalidateSlashSkillCaches() and capture it
in both loaders: a reply issued for the outgoing profile can no longer
commit once the switch has landed
- tests: in-flight race and cache-refresh regression on both picker
surfaces, plus a check that both switch paths call the invalidation
* fix(i18n): route sidebar source labels through t() (#7580)
_sessionSourceLabel() hardcoded the two visible tab labels as English
template literals and assigned the result straight to textContent, so
non-English locales rendered untranslated sidebar tabs. Route both
filters through t() with the established {0}-interpolation pattern
(same shape as kanban_comments_count) and add explicit en + ru keys
so English remains the fallback for locales without a translation.
- static/sessions.js: return t("sessions_source_cli", n) /
t("sessions_source_webui", n) instead of template literals
- static/i18n.js: add the two keys to the en and ru bundles
- tests/test_issue2351_cli_session_source_filter.py: replace the
hardcoded-English regressions with locale-key assertions and a new
interpolation test; verify the template-literal fallback is gone
so a regression can no longer ro-introduce it silently
* i18n(sessions-source): add source-tab labels to the remaining 13 locales
Codex gate: the runtime fallback is non-regressive, but the repo enforces a
locale key-parity contract — 9 parity tests failed because the two new keys
existed only in EN+RU. Add sessions_source_webui / sessions_source_cli to it,
ja, es, de, zh, zh-Hant, pt, ko, fr, cs, tr, pl, vi, matching each locale's
established translation of 'session' and preserving the {0} count placeholder.
Placed after each locale's _label line so the {0} literal does not fall inside
test_japanese_locale.py's brace-bounded _label regex.
Co-authored-by: Yularzhi <Yularzhi@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@hermes.local>
Co-authored-by: Yularzhi <Yularzhi@users.noreply.github.com>
Nest delegated subagent sessions under the parent conversation that spawned them instead of forcing them to the sidebar top level.
Co-authored-by: lowlandsheperd <lowlandsheperd@users.noreply.github.com>
Classify Signal gateway sessions as messaging so they group, label, and list like other gateway platforms.
Co-authored-by: webtecnica <webtecnica@gmail.com>
esc() is HTML-only escaping; the browser decodes attribute entities BEFORE
parsing an inline handler, so onclick="fn('${esc(id)}')" let any id
containing a quote break out of the JS string literal. _kanbanJsArg solved
this for kanban dependencies only; promote it to jsArg() in ui.js and route
every inline-handler argument through it (panels.js cron/kanban/notes/
passkeys/gateway/checkpoints, sessions.js handoff hint, onboarding.js OAuth
device code). Adds a Playwright round-trip proof and a source guard against
reintroducing the esc()-only pattern.
* 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>
* fix: route overlapping custom_providers models by active endpoint
When two custom_providers[] entries list the same bare model id (e.g.
dogapi and packyapi both advertising 'claude-sonnet-5'),
resolve_model_provider scanned them in config write order and returned
the first match, silently ignoring the active provider's base_url. A
user whose model.base_url pointed at packyapi got routed to dogapi
because dogapi appeared earlier in config.
Now, when the active provider resolves to a named custom provider
(custom:<slug>, including via model.base_url -> named-slug matching),
that provider claims models it owns before the ordered scan runs. Falls
through to the legacy first-match scan when the active provider is bare
'custom' with no base_url to disambiguate, so existing single-endpoint
setups are unaffected.
Adds 3 regression tests covering: shared-model active-endpoint wins,
provider-unique model still routes by ownership, and bare-custom
no-base_url preserves legacy order.
* fix: extend active-endpoint routing to aux, handoff, and slug-collision paths
Review follow-up: the overlapping-id fix must reach three sibling
resolution paths that still routed on the wrong endpoint.
1. Auxiliary slot base_url (config.py set_auxiliary_model): a bare
resolve_model_provider(model) ignored the SELECTED aux provider, so a
custom:A slot was persisted with the active custom:B endpoint when both
listed the model. Look up the selected provider's own custom entry via
resolve_custom_provider_connection. (NOT model_with_provider_context
here: the @custom:<slug>:model form resolves base_url to None, which
would drop the base_url entirely.)
2. Handoff summary (routes.py): read s_obj.model_provider and pass
model_with_provider_context(model, model_provider) so a session pinned
to custom:A is not rerouted to the active custom:B. base_url is
backfilled from the provider's own custom entry downstream.
3. Normalized-name slug collision (config.py resolve_model_provider): two
distinct provider names can normalize to the same slug (e.g. "Foo Bar"
and "foo-bar"). The active-slug guard now matches the EXACT entry by
base_url instead of re-deriving it from the non-unique slug, and fails
closed (falls through to the ordered scan) on genuine ambiguity.
Adds 4 regression tests: normalized-slug base_url disambiguation,
slug-collision fail-closed, aux-slot selected-provider base_url, and
session-provider-context handoff routing.
* fix: fail closed on custom-provider slug collisions and break aux deadlock
resolve_model_provider() returns a provider slug (custom:<slug>) and the
credential lookup (resolve_custom_provider_connection) later resolves the
API key from the FIRST same-slug entry regardless of base_url. So when two
distinct provider names normalize to the same slug (e.g. "Foo Bar" and
"foo-bar") and both list the requested model, returning the slug silently
pairs one entry's endpoint with another entry's credential. Now raise a
clear AmbiguousCustomProviderError instead of guessing, on both the
active-slug guard and the ordered-scan fall-through paths.
Also: an explicit, unambiguous named custom provider now stays authoritative
even when model.base_url is stale/absent and points at neither entry — a
stale URL is no longer evidence to demote it to config order.
And fix a self-deadlock in set_auxiliary_model(): it held the non-reentrant
_cfg_lock while calling resolve_custom_provider_connection() -> get_config()
-> reload_config_if_stale(), which re-acquires the same lock. The selected
custom provider's base_url is now resolved inline from the config_data
already loaded under the lock.
Tests: collision cases now assert the ambiguity error; added a stale-URL
explicit-provider regression and a watchdog-guarded aux no-deadlock
regression (both fail before the fix).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: fail closed on custom-provider slug collisions across all four paths
The previous cut only guarded the symmetric bare-model case, and it built
collision membership from entries that OWN the requested model. But credential
resolution scans ALL same-slug entries and first-matches regardless of
ownership, so several slug-only boundaries could still pair one entry's
endpoint with another entry's credential — including the asymmetric case where
only one colliding entry lists the model.
Introduce one shared, pure/lock-safe helper (_custom_provider_slug_key +
_unique_custom_provider_entry) that groups ALL named custom_providers entries by
a single canonical slug normalization and raises AmbiguousCustomProviderError
whenever a consumed slug maps to >=2 entries. Apply it at every slug-only
boundary so endpoint and credential can never come from different entries:
- bare resolution (resolve_model_provider): all-entry membership for both the
active-slug guard and the ordered ownership scan (was owner-only).
- provider-qualified @custom:<slug>:model return (the session/send/handoff
model_with_provider_context shape), which previously bypassed the check.
- credential lookup (resolve_custom_provider_connection), replacing its private
first-match-by-slug scan and its duplicate _slugify.
- auxiliary persistence (set_auxiliary_model), replacing its own inline
first-match-by-slug; the ambiguity now propagates (fails the save) instead of
being swallowed, while other best-effort errors stay non-fatal. Still resolves
from the in-scope config_data so it remains lock-safe (no deadlock regression).
Tests: add fail-closed regressions for the asymmetric A-first-non-owner/B-owner
bare case (active-endpoint and bare-custom paths), the model_with_provider_context
qualified handoff shape, the credential boundary called directly, and the
auxiliary-save ambiguity (asserts nothing is persisted). All five fail before
this change and pass after.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: one canonical custom-provider slug identity, point-of-return ambiguity, profile-scoped credentials
Deep re-gate follow-up (5 findings):
1. Collision detection now derives from the single slug PRODUCER
(_custom_provider_slug_from_name) instead of a looser private key, so
producer-level collisions on punctuated/parenthesized names (e.g.
'Foo (Bar)' vs 'foo-bar', both -> custom:foo-bar) are detected. Previously
the looser key normalized 'Foo (Bar)' to 'foo-(bar)' and missed the
collision, reintroducing the endpoint-A/credential-B bug for those names.
2. The all-entry ambiguity check now runs ONLY at the point a custom:<slug> is
about to be returned (via a _finalize() guard covering every custom-return in
resolve_model_provider, including the final active-provider fallback), not up
front. An unrelated collision on the active slug no longer blocks lanes that
resolve to a different provider (@openrouter, a non-colliding @custom:other).
3. streaming.py now resolves endpoint AND credential inside ONE profile scope:
the main path extends the scope over runtime-key + custom-provider resolution,
and both self-heal retry paths bind the session profile. This stops a detached
worker from pairing a named profile's endpoint with the default profile's key.
4. Handoff summary: AmbiguousCustomProviderError now returns HTTP 400 with the
actionable rename message instead of degrading to a 200 local fallback the UI
treats as success.
5. UI: aux + default model saves surface the server's message and abort (retain
dirty state) instead of swallowing it into a generic failure / "settings
saved"; the handoff catch shows the 400 rename message verbatim.
AmbiguousCustomProviderError gains a .message attribute for verbatim forwarding.
Tests: parenthesized-name collision fails closed on all direct paths; unrelated
lanes are not blocked by an active collision; custom overrides resolve inside the
passed profile scope; handoff returns 400 (not a persisted fallback); JS
error-surfacing source guards. Verified the finding #1/#2 cases fail before and
pass after (old key misses 'Foo (Bar)'/'foo-bar'; old up-front check over-blocks).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(ui): prevent OOM on tab focus for long sessions
Coordinate visibility-recovery and session-updated refresh paths so they
never start concurrent full-transcript loads, window the O(N) pre-render
scans to the virtual render window, and bound the render-window expansion.
Closes#6999
* fix(ui): full turn context + collision-free cache hash (#7006 review rework)
Addresses the maintainer's CHANGES_REQUESTED on PR #7006:
- Turn-context maps (_assistantTurnFinalVisibleContentMap /
_assistantTurnVisibleContentMap) receive the FULL visWithIdx again.
The virtualized render window (head+tail around a gap) cut through
assistant runs and merged distinct turns across an omitted user
boundary, which could duplicate the final answer in Worklog/Thinking
or feed later-turn prose into the reasoning echo-strip. Turn context
is input, not output: it must always be complete.
- _addBoundedHash now streams the FULL string form through the FNV-1a
loop (no length+head+tail clip, no slice copies), and object fields
(attachments, tc.args, compression-anchor keys) are walked key-by-key
so no integral JSON.stringify() is allocated. Same-length middle-only
edits of tool args, attachment metadata, tool snippets, or
compression-anchor keys no longer collide in _sessionHtmlCache.
Regression tests:
- tests/test_issue6999_turn_context_virtual_gap.py: multi-assistant turn
with a virtualized gap proves full-context echo-strip suppresses the
exact final-answer echo, and documents the gapped head+tail failure.
- tests/test_issue2613_render_cache_signature.py: Node harness executing
the real _messageRenderCacheSignature() proves same-length middle-only
mutations change the signature for every structured field while the
old clip scheme collides on the same data.
Closes#6999
* fix(ui): coalesce session-updated under refresh guard; insertion-order cache hash
Re-gate round 2 for #6999:
- session-updated frames arriving while the external-refresh guard is held
are latched per SID (max announced count) and drained by the refresh
owner's finally with ONE guarded follow-up, instead of being dropped
(production does not guarantee a second event).
- _hashObjectInto walks keys in insertion order (matching Object.entries
render projection) with an explicit per-key index discriminator, so
opposite-insertion-order argument objects no longer share a cache
signature; recursion depth is now threaded (children increment, not
restart).
* test(jump-scroll): stub _loadingSessionId + _drainSessionUpdatedPendingCount in extraction harness
#7006 adds a reference to the pre-existing module var _loadingSessionId and a new
_drainSessionUpdatedPendingCount() helper inside refreshActiveSessionIfExternallyUpdated.
The jump-to-answer scroll-settle tests extract that function into a node harness, which
didn't declare them -> ReferenceError (19 parametrized failures). Add the missing stubs
to the harness preambles (no-op drain, since these tests exercise scroll ownership, not
the coalesce path -- real drain behavior is covered by test_issue6999_session_updated_coalesce.py).
Test-harness-only; production static/ untouched.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
* fix: include terminal backend identity in agent cache signature (#5937) (#6175)
* fix(mobile): preserve extension panels in drawer sync
* fix: close mobile sidebar drawer when opening main-view create forms
The add-space/new-profile/new-skill/new-cron forms render in the main
view, which on phones is covered by the full-screen sidebar drawer
(z-index 200). Opening one from inside the drawer only focused the
input (keyboard popped up) while the form stayed hidden underneath.
- openWorkspaceCreate / openProfileCreate / openSkillCreate /
openCronCreate now close the drawer after rendering the form,
mirroring their *_Detail siblings.
- move the fromRailClick drawer re-open in switchPanel() into the
synchronous section: it used to run after await loadXxxPanel(),
racing (and re-opening over) a drawer close issued from inside the
drawer during the panel load window.
Verified in Playwright (390x844): drawer closes and stays closed after
plus-click, form visible with focus on the path input; desktop (1280x800)
unchanged; 83 mobile + 26 workspace tests pass.
* fix: close mobile sidebar after project quick-create plus
The per-project '+' button (_attachProjectQuickCreateButton) created the
new project conversation but never closed the mobile sidebar drawer, so on
phones the tap looked like a no-op: the session was created, just hidden
underneath the full-screen drawer (z-index 200).
Close the drawer in the success path after the sidebar repaint, mirroring
$('btnNewChat').onclick in boot.js and the #5409 close in
_openSidebarSession. Failure path keeps the drawer open so the error toast
stays visible for retry.
Adds static regression tests pinning the guarded close call and its
position after the repaint.
* test: pin quick-create drawer close to the success path (try block)
Greptile review note: the string-presence and ordering assertions alone
would still pass if the guarded closeMobileSidebar call were moved into
the later catch branch, silently reintroducing the mobile drawer bug.
Add an explicit assertion that the close sits before the success-path
catch(err), so a regression that moves it into the failure branch is
caught.
* docs(changelog): stamp batch1-p1 (#6921#6933#6671#6175)
---------
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: Rodrigo Gomes da Silva <rodrigo.smscom@gmail.com>
Co-authored-by: silent-reader-cn <silent-reader-cn@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
When switching sessions in loadSession(), the window._compressionUi state
was not cleared, causing phantom 'Compressing context' barriers to appear
on fresh sessions that never triggered compression.
This complements PR #6572 which handles the case where compressed SSE
events are lost/delayed, but does not cover the session switch scenario.
Root cause: loadSession() tears down approval/clarify/composer state but
missed the compression UI state, which is per-session and must not leak.
Fix: Clear compression state immediately after hideClarifyCard() in the
loadSession() teardown sequence.
* fix(cron): mark running cron sessions as active in sidebar
* fix(cron): include cron_running in effective-streaming predicate + test updates (#6734)
* fix(cron): longest-prefix-first cron job matching in session list cache (#6728)
* fix(cron): require exact run-timestamp suffix in cron_running prefix match
Gate re-review (Codex) found the longest-prefix-first match could misattribute:
cron_job_prefixes is built ONLY from RUNNING jobs, so when the true longer owner
(backup_full) is NOT running, a running shorter job (backup) swallowed a completed
cron_backup_full_YYYYMMDD_HHMMSS session (its prefix cron_backup_ still matched),
deferring that row's completion/unread transition. Now the text after a matched
cron_{jid}_ prefix must be EXACTLY a run timestamp (\d{8}_\d{6}), so a shorter job
id can't claim a longer job id's session regardless of which jobs are running.
Regression test added (fails without the guard).
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
---------
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Project active run-journal recovery snapshots into a non-mutating compact transport form before returning them from /api/session, removing duplicate representations while preserving the live message timestamp. Thanks @allenliang2022.
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
The mid-stream reconnect path could render the just-sent user message below
the live streaming assistant response it triggered, because
`refreshSession()` appended `pending_user_message` at the end of
`S.messages` unconditionally while `loadSession()` correctly inserted
it before the first live assistant message. When the client went through
its reconnect/recovery path mid-turn (typical on iOS PWA screen
lock/resume) the live assistant tail rendered above the prompt.
Centralize every recovery/reconnect reinsertion through one
identity-aware helper (`_mergePendingSessionMessage`) that:
- inserts the owning user turn before its live assistant/Worklog tail,
- appends only when no live tail exists,
- deduplicates against the current tail user row,
- remains idempotent across repeated resume probes.
`refreshSession()` now calls the same helper instead of pushing at
the end. `loadSession()` continues to use the helper unchanged.
Adds `tests/test_issue6419_pending_user_reconnect_order.py` pinning:
- the helper is defined exactly once at module top level,
- it inserts before live assistant and falls back to append,
- `refreshSession()` uses the helper and does not push unconditionally,
- `loadSession()` still merges before renderMessages in the INFLIGHT
reattach branch.
Renames the inner end-marker in
`tests/test_gateway_sse_reconnect_dedupe.py` to a stable inside-loadSession
boundary (`// Phase 2a:`) since the helper is no longer nested.
* feat(core): add registerHermesSessionOpenHandler + renderTranscript extension hooks
- registerHermesSessionOpenHandler(fn): register a handler that fires on
session open. Return {cancel:true} to prevent navigation.
- renderTranscript(container, messages, opts): render messages into any
DOM container using core's renderMd pipeline. Skip tool messages.
- Wire _hermesNotifySessionOpen into loadSession: pre-load guard at top,
post-load notification at end for extensions to hook into.
- Follows existing registerHermesTtsEngine extension registration pattern.
This gives extensions like chat-tiling a sanctioned API instead of DOM
hacking to intercept and render session transcripts.
* fix: address 3 gate-fail blockers from PR #5508 review
Fixes the three core issues identified by nesquena-hermes code review:
1. XSS sink in renderTranscript (boot.js)
- Fallback to textContent when window.renderMd is unavailable
- innerHTML exclusively for successful renderMd output
2. Stale _loadingSessionId reset in cancel branch (sessions.js)
- Remove premature nulling of _loadingSessionId
- Cancel path simply returns without touching loading-guard state
3. Pre-open veto bypasses profile/import side-effects (sessions.js)
- Move cancellable preload hook to start of _openSidebarSession,
before external session import and profile switching
- Pass internal _preloadNotified flag to skip duplicate preload
in loadSession while retaining post-load notification
Closes#5508 gate-fail items.
* fix: use module-level flag instead of call argument to avoid test regression
The _preloadNotified approach broke test_static_sessions_js_switches_profile
before_opening_all_profiles_row because it changed the loadSession call
signature from loadSession(sid, loadOpts) to loadSession(sid, Object.assign(...)).
Switch to a module-scoped boolean _hermesSessionOpenAlreadyFired set by
_openSidebarSession before calling loadSession, checked by loadSession's
pre-hook guard. The call signature stays unchanged.
Test 2 (test_load_session_rearms_stream_on_every_early_return) also passes.
* fix: compact pre-hook comment to keep loadSession within test window limits
* fix: replace global _hermesSessionOpenAlreadyFired flag with per-call opts._preloadNotified
The module-level boolean introduced in 381fa0ef had two problems:
1. ReferenceError on direct loadSession() calls — the flag was undeclared
when called outside _openSidebarSession, breaking saved-session restore.
2. Never reset after first sidebar open — all subsequent direct calls
silently skipped the cancellable preload handler.
Per the maintainer's review (PR #5508), replace the global with a per-call
option _preloadNotified passed by _openSidebarSession via Object.assign.
This keeps the call signature stable for existing tests and eliminates
the stale global state leak.
Test adjustments:
- test_issue1611: widened loadSession call literal assertion
- test_session_channel_option_x: body slice 14000→15000 to accommodate
the slightly longer function body
* chore: remove unrelated files from commit
* fix: address 3 review issues — preload-only cancel, drop inner wrapper, pass _preloadNotified on retry
- Only honor {cancel:true} when opts.preload===true (boot.js)
- Drop .msg-body-inner wrapper, render directly into .msg-body (boot.js)
- Carry _preloadNotified:true through cross-profile 409 retry (sessions.js)
* recommit
* Update static/boot.js
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* fix(extensions): resolve canonical sid before preload hook + pass _preloadNotified on continuation retry
PR #5508 review follow-up (review 4690636760):
1. Move _resolveSessionIdFromSidebarLineage() before the preload hook so
extensions always see the canonical sid, not the raw sidebar click id.
2. Pass _preloadNotified:true on the continuation-session retry path to
prevent duplicate preload events to extensions.
3. Add functional test_extension_session_hooks.py — actually drives the
new hook registration, preload-veto, transcript rendering, and
_preloadNotified bridge in Node (13 tests, all green).
* Remove unused pytest import
Removed unused import of pytest from test file.
* fix: address 2 gate-blocking veto-ordering defects (PR #5508)
Blocker 1 (CORE): cross-profile retry now passes _preloadNotified:true
so the pre-hook doesn't re-fire after destructive side-effects already
ran (stream teardown, message clear, profile switch). A {cancel:true}
on that second fire was stranding the UI profile-switched with a
cleared transcript.
Blocker 2 (SILENT): closeMobileSidebar() was called synchronously
BEFORE _openSidebarSession()'s veto guard in three places (tap-to-open,
child-session, lineage-segment). A {cancel:true} still closed the
sidebar out from under it. Removed the three premature calls; moved
a single closeMobileSidebar() inside _openSidebarSession AFTER the
veto guard so it only runs when the open actually proceeds.
Added 3 regression tests asserting {cancel:true} leaves NO side-effect.
* CHANGELOG: extension session-open handler + renderTranscript API (#5508)
---------
Co-authored-by: Sean <seanos1a@gmail.com>
Co-authored-by: ChonSong <85378550+ChonSong@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* feat: expose msg_limit ceiling via /api/session metadata, frontend reads dynamically (#6177)
Backend exposes _MAX_MSG_LIMIT as _msg_limit_max in every /api/session
response. Frontend reads it dynamically, falling back to _MSG_LIMIT_MAX
for older servers. This removes the hand-mirrored coupling between the
two layers.
Removed test_msg_limit_ceiling_drift.py since the mirror pattern that
required the drift guard is replaced by dynamic metadata.
This is the standalone metadata-decoupling piece from #6206,
without the clamp/paging that already shipped in exp-v0.52.98 via #6152/#6154.
* fix(session): declare _msgLimitMax at module scope + CHANGELOG + gate fixes (#6214 follow-up)
The submitted PR used _msgLimitMax at two read sites (boundedReloadLimit in
_ensureMessagesLoaded, useBeforePaging in _loadOlderMessages) but never declared
it and read it before assignment -> undefined on cold load -> full-transcript
fetch every load (regression) + implicit global. Declared `let _msgLimitMax =
_MSG_LIMIT_MAX;` at module scope so the reload-width paths always read a defined
value (the static fallback) until the server's _msg_limit_max lands.
Also: defined the ceiling globals in the two inline node-harness tests that copy
_ensureMessagesLoaded's body (test_cross_session_message_load_isolation,
test_session_unread_dot_on_visit), updated the source-string assertion in
test_webui_external_refresh_frontend, and added 3 decoupling tests (backend field
present, module-scope declaration + fallback, both paths read the live ceiling)
replacing the deleted drift-guard.
Gate: Codex adversarial SAFE TO SHIP (executable probes: cold-load fallback,
mixed-version omission, live update, cross-session isolation, over-ceiling bare
refresh, msg_before row preservation). Full sharded suite green.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
---------
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix(routes): clamp GET /api/session msg_limit to a server-side ceiling
Memory: ?msg_limit= had no upper bound — a client could request
msg_limit=1000000 (or the frontend's msg_limit=9999 outline-jump path) and
force the server to assemble and serialize an unbounded message payload. The
frontend's real pagination grows windows by ~30 at a time (initial load 30,
load-older +30, msg_before paging 30), so only the outline-jump and
pathological/hostile cases ask for huge values.
Add _MAX_MSG_LIMIT (500) and clamp at the query-parse site
(max(1, min(int(_msg_limit), _MAX_MSG_LIMIT))). The ceiling is generous — far
above any legitimate visible-row window — so real pagination is unaffected. The
existing _messages_truncated signal already covers the clamped case: when the
request exceeds the ceiling, _message_window_for_display returns
_messages_offset > 0 and the client learns more rows exist (load-older still
works). The bare no-msg_limit path is intentionally unchanged — it is the
documented "give me everything" escape hatch used by branch/undo/jump-to-start
flows that need the full transcript for correct global indices.
Test: tests/test_session_msg_limit_ceiling.py — the ceiling constant is
reasonable (100..2000), an over-ceiling request is clamped and signals
truncation, and legit sizes (5/30/60/100) are unaltered. Existing window/tail
tests (19) unchanged.
Contract Routing
- Task type: memory-bound fix (unbounded msg_limit request payload).
- Touched areas: GET /api/session query parsing (api/routes.py).
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md.
- State layer mutated: none (read-only endpoint response shaping).
- Contract change (additive): ?msg_limit=N is now clamped to _MAX_MSG_LIMIT=500.
Compatibility: the only frontend caller above the ceiling is outline.js's
msg_limit=9999, which now receives up to 500 rows + _messages_truncated=true
(load-older still works). All other callers (30/60) are unaffected. The bare
no-msg_limit path is unchanged.
Release note: GET /api/session now caps ?msg_limit at 500 rows server-side,
bounding the worst-case payload for oversized requests.
Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
* fix(frontend): route msg_limit-heavy paths around the server ceiling
PR #6152 clamps GET /api/session ?msg_limit= to a max of 500 server-side. Two
frontend paths relied on unbounded msg_limit and silently broke for sessions
> 500 visible rows:
1. outline.js _jumpToMessage sent msg_limit=9999 as a "give me everything" hack
so it could scroll to any message by absolute index (msg-user-<rawIdx>).
Clamped to 500, the target row's DOM id doesn't exist for early messages in
long sessions, and the jump silently no-ops (line 119 `if (r)` is falsy).
Fix: drop msg_limit=9999 — use the bare messages=1 path, which #6152 left
unbounded precisely for callers that need the full transcript. This restores
exact pre-#6152 behavior for outline jumps.
2. sessions.js _loadOlderMessages grows msg_limit by +_INITIAL_MSG_LIMIT each
load (30 -> 60 -> ... -> 500 -> clamped). Past 500 the server returns the
same clamped tail, olderMsgs shrinks to 0, and endless-scroll / "Load
earlier" silently stalls — the head becomes unreachable. Fix: once the grown
requestedLimit meets/exceeds the new _MSG_LIMIT_MAX mirror (500), switch the
primary fetch to msg_before paging (a fixed _INITIAL_MSG_LIMIT backward page
keyed off _oldestIdx) — the same bounded paging the existing race-fallback
uses, so the head stays reachable for arbitrarily long transcripts. The
response is handled by the existing prepend path (tailMatches is naturally
false for a page, so the !tailMatches branch reuses the fetched data).
Added _MSG_LIMIT_MAX = 500 constant in sessions.js mirroring backend
_MAX_MSG_LIMIT, documented as kept-in-sync.
Verification: node --check passes on both files; the backend window-contract
tests (test_session_tail_payload, test_session_message_window_renderable_tail,
19 tests) still pass. No JS test harness exists in this repo (vanilla JS,
manual browser verification per TESTING.md) — manual verification needed: open a
> 500-message session, (a) click an outline entry for an early message and
confirm it scrolls+flashes, (b) scroll up past 500 loaded rows and confirm
"Load earlier" keeps producing older messages without stalling.
Contract Routing
- Task type: frontend follow-up to a backend memory fix (#6152).
- Touched areas: outline jump-to-message (static/outline.js), endless-scroll /
load-older (static/sessions.js _loadOlderMessages).
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md.
- State layer mutated: client-side S.messages (the active-session transcript),
same as the existing load-older path. No backend state change.
- Scope boundaries: does not weaken the #6152 clamp. The bare messages=1 path
(outline) was intentionally left unbounded by #6152; the msg_before paging
path (load-older) is bounded by _INITIAL_MSG_LIMIT and never hits the ceiling.
Release note: fixes outline jump-to-message and endless scroll silently
breaking on sessions longer than 500 messages after the server-side msg_limit
ceiling (companion to #6152).
Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
* test(frontend): update static-source assertion for refactored _loadOlderMessages fetch
The static-source test test_session_message_loads_keep_explicit_longer_timeouts
asserted the exact multi-line shape of the _loadOlderMessages fetch strings.
The #6154 refactor wrapped them in a useBeforePaging ternary (tail-growth vs
msg_before paging), reindenting them — the contract the test guards (both
fetches keep timeoutMs:120000 for large state.db loads) is preserved, but the
literal source shape changed, so the assertion failed on shard 0.
Updated the assertions to match the new source shape while keeping the test's
intent: assert each fetch URL + its {timeoutMs:120000} survive in sessions.js.
Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
* test(msg-limit): stopgap drift guard for _MSG_LIMIT_MAX === _MAX_MSG_LIMIT
Reviewer guidance (#6154 round-2): the stopgap drift-test belongs in THIS PR
(#6152) because _MAX_MSG_LIMIT is defined here. The frontend _MSG_LIMIT_MAX
mirror lives on #6154 (not master yet), so the assertion SKIPS when the JS
constant is absent and activates automatically once both branches land on master.
The guard: extract _MAX_MSG_LIMIT from api/routes.py and _MSG_LIMIT_MAX from
static/sessions.js via regex; assert equality when both present. If they drift,
load-older silently stalls at the wrong value (the exact regression #6154 fixed).
Retired by #6177 (metadata-exposure), which removes the mirror entirely.
Verified: on this branch the cross-check SKIPS (_MSG_LIMIT_MAX absent, as
expected — #6154 not landed) and the backend-existence test PASSES. Extraction
logic confirmed correct against simulated match (500==500 passes) and drift
(999!=500 fails) cases.
Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
* fix(session): prevent silent row-loss in msg_limit ceiling paging (gate follow-up)
Adversarial review of #6152/#6154 reproduced two silent row-loss regressions in
static/sessions.js; both fixed here, plus a stale source-string assertion update
and the CHANGELOG entry:
1. _loadOlderMessages: gate the suffix-continuity heuristic on !useBeforePaging so
a raw-row-heavy msg_before cursor page whose visible text repeats the current
tail can't be misclassified as the cumulative tail and wholesale-replace it
(previously dropped 470 of 940 rows). msg_before pages are always prepended.
2. loadSession same-session refresh: only apply the width-hint msg_limit when it
is <= _MSG_LIMIT_MAX; above the ceiling the backend would clamp and silently
shrink an already-loaded >500-row transcript to the last 500. Over-ceiling
reloads now use the bare full-transcript path (no msg_limit / no expand_renderable).
3. tests/test_webui_external_refresh_frontend.py: update the reloadLimitParam
source assertion to the boundedReloadLimit form.
Re-gate: Codex adversarial SAFE TO SHIP (940/940 rows retained on cursor paging,
1000/1000 on refresh, no seam duplication, small sessions still use the fast tail
window); targeted + sessions/pagination/regression suites green.
Co-authored-by: Ruby Hartono <rh-id@users.noreply.github.com>
* test: fix two node-harness tests broken by the boundedReloadLimit change
- test_issue3162_ensure_messages_loaded: replace the brittle fixed 4500-char
window (which the boundedReloadLimit lines pushed the carry-forward past) with
robust brace-balance function-body extraction.
- test_cross_session_message_load_isolation: define _MSG_LIMIT_MAX=500 in the node
harness env; _ensureMessagesLoaded now references it, and an undefined value made
boundedReloadLimit=null -> the fetch URL dropped msg_limit/expand_renderable and
mismatched the ordered api() harness -> stall/timeout.
---------
Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Ruby Hartono <rh-id@users.noreply.github.com>
/api/session/new treated an absent 'worktree' key as false, so the
agent's config-level 'worktree: true' default was never consulted for
WebUI sessions even though the WebUI reuses the agent's own worktree
helper. Both callers should agree on same-agent/same-repo isolation.
Three-value model:
- body 'worktree' absent -> agent config default applies (profile-aware
via get_config_for_profile_home, so a user with worktree:true in one
profile but not another gets per-profile behavior)
- explicit true/false -> honored verbatim; explicit always wins
Non-git workspaces: a config-default request degrades to a plain
session and surfaces 'worktree_skipped' in the payload; an explicit
worktree:true keeps the hard 400.
Frontend (same change, load-bearing): newSession() forwards explicit
true/false and omits the key otherwise; the boot-time auto-bind
(_maybeBindFreshDefaultWorkspaceSession) and the onboarding session
send worktree:false explicitly so a config default can never mint a
worktree + branch from merely opening the UI. Deliberate 'New Chat'
sends nothing and inherits the default.
Tests: route matrix (config on/off x body absent/true/false), non-git
fallback vs explicit 400, explicit-false-beats-config-true, config
helper (ambient/named-profile/fail-soft), static UI assertions that
auto-bind and onboarding opt out and New Chat paths don't pin a value.
Fixes#6022
The round-3 equivalence shortcut accepted any origin when the active
profile was literal 'default', so another profile's cron marker was
treated as active-owned and never cleared on switch (reverse
under-clear). Match now requires the origin name itself to resolve to
the root profile: the literal 'default' alias, or a roster entry
flagged is_default. Unknown names fall back to exact-name semantics.
Adds profile-a -> default regressions covering tagged and
legacy-untagged old-profile markers (cleared) alongside current-root,
renamed-root-alias, and non-cron markers (kept), plus a no-roster
fail-closed case.
Greptile #5975 P1: capture _cronPollGeneration before /api/sessions await and
re-check in _applySessionListPayload before completion-unread marking so a
delayed profile-A response cannot remount cron dots after switch to B.
Also skip inactive-profile cron marks in single-profile mode. Tests cover the
race, positive path, and collateral call-shape contracts.
Address hermes-webui #5975 re-gate: tag session-list completion markers for
cron rows, resolve/migrate legacy untagged markers from sidebar metadata, and
use default/renamed-root profile equivalence when clearing inactive-profile
cron dots so current-root markers stay unread.
Gate review on #5975: sticky all-profile sidebars still showed old-profile
cron dots because hermes-session-completion-unread was not profile-scoped.
Tag cron markers with source+profile, drop inactive-profile cron markers in
the shared switch reset (both paths), and leave ordinary chat unread alone.