The #7535 ownership guard skipped the global model.base_url backfill for
two shapes that master handled:
- A `model` section with `base_url` but no `provider` declares no owner,
so `_providers_match_for_context("", <session provider>)` was false and
the endpoint was dropped, silently falling back to 256,000 instead of
querying the configured /v1/models (the profile-setup path writes this
shape).
- `opencode_go` and `opencode-go` are two spellings of the same built-in
provider, but `_canonical_context_provider` does not fold underscores,
so the owner session lost its URL.
Allow the backfill when no owner is declared, and compare both sides
through api.config._canonicalise_provider_id, which folds `_` to `-` while
keeping distinct `custom:*` slugs distinct. The #7535 case (a custom:ark
owner vs an opencode-go session) still resolves to an empty base_url.
The three get_available_models() tests in tests/test_model_cache_metadata.py
depend on the rebuild finishing inside _LIVE_REBUILD_BUDGET_SECONDS (4.0s).
On a loaded host the rebuild exceeds it and api/config.py::get_available_models
serves the over-budget stale payload, failing the rebuilt-metadata assertions.
Pin the budget to 0.0 (synchronous rebuild), the same pattern already used by
tests/test_issue2513_custom_provider_remote_models.py and
tests/test_copilot_provider_model_settings_not_allowlist.py.
Closes#7735
A session on a built-in registry provider (e.g. opencode-go, endpoint in
PROVIDER_REGISTRY, empty base_url by design) had its empty base_url slot
backfilled with the global model.base_url owned by a *different* provider
(custom:ark). The context-length resolver then probed the wrong endpoint's
/models catalog and fell back to the generic 128K catalog entry for a
1M-context model, while the on-disk session and SSE stream carried 1,000,000.
Only fill the global model.base_url slot when the session provider IS the
configured model.provider owner, mirroring the ownership predicate used for
model_cfg's API key in _context_length_config_api_key_for_provider.
test_delete_cli_session_still_opens_writable_connection asserted on
inspect.getsource(models.delete_cli_session) text, so it failed only in
single-process full-suite runs when an earlier test leaves a
monkeypatched/decorated wrapper bound on api.models.
The writable-connection contract is already covered end to end by
tests/test_issue1494_state_db_fd_leak.py::test_delete_cli_session_closes_connection,
which performs a real delete and asserts the rows are gone. Remove the
source oracle without a textual replacement; keep a short comment
pointing at the behavioral coverage.
static/commands.js and static/messages.js both declared
const _BACKEND_EXECUTED_AGENT_COMMANDS at file scope. static/index.html
loads them consecutively as classic defer scripts in the same global
lexical realm, so the second declaration threw
SyntaxError: Identifier '_BACKEND_EXECUTED_AGENT_COMMANDS' has already
been declared, aborting messages.js entirely.
The constant was unused in both files (a duplicate authority mirror, not
a genuinely shared source), so both declarations are removed. The
existing three-way parity test is preserved, and a new regression
evaluates commands.js followed by messages.js in one shared
classic-script vm realm, asserting both files evaluate cleanly and the
dispatch consts of both files are bound.
The #7240 fix peeled the leading @custom: segment on first-colon split, but a
plain-lane custom id whose model carries a tag/variant colon
(@custom:ollamacloud/qwen3.5:397b) was still misread as slug=ollamacloud/qwen3.5,
rendering only "397b". Two changes:
- _dynamicModelLabels (the backend-populated catalog) is now consulted BEFORE
the string peel: the backend knows the true provider/model split and ships an
exact m.label per routing id, so catalogued ids — including plain-lane colon
models and collision cases — render verbatim.
- The legacy string fallback now treats a slash-bearing first segment as the
model itself (a provider slug is a config key or host:port authority and
never contains '/'), mirroring the '/' means routable rule api/config.py
applies when building ids.
Tests: add plain-lane @custom:ollamacloud/qwen3.5:397b controls, a
post-catalog dynamic-label control, and a collision case proving labels stay
distinct by full routing id. Driver now accepts an optional pre-hydrated
catalog map.
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
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
`skills.disabled` entries are already excluded from the backend skill-command
map, but the composer still offered them as selectable slash commands and as
`/use` sub-args: both picker surfaces read `/api/skills` and ignored the
`disabled` flag the endpoint reports per skill.
Add one `_isSkillDisabled()` gate in static/commands.js and apply it at the two
places that turn `/api/skills` rows into completions:
* `_buildSkillCommandEntry()` returns null for a disabled skill, which keeps
`_skillCommandCache` (and every consumer of it, including the dropdown) clean;
* `_loadSlashSkillSubArgs()` skips disabled skills when building `/use`
sub-args.
Both caches still rebuild from `/api/skills`, so a skill toggled off in the
skills panel disappears from the picker on the next load.
Adds tests/test_issue7509_disabled_skills_slash_picker.py, which runs the real
static/commands.js in a vm context with a mocked `api()` and asserts on
`getSlashAutocompleteMatches()` for both surfaces, over 0 / 1 / many skills,
enabled / disabled / flag-absent entries and a disable-then-refresh cycle.
Fixes#7509
`_strip_oob_markers_from_messages()` matched on marker presence alone and passed
the whole row to `_strip_oob_blocks()`, so a user row that legitimately quotes a
complete `[OUT-OF-BAND USER MESSAGE]…[/OUT-OF-BAND USER MESSAGE]` example lost
that text on the next settlement — and the shortened pre-match row broke
active-turn identity matching, which duplicated the turn.
The transport only ever appends a consumed `/steer` wrapper to the turn's last
tool result, so the scrub now considers `role == "tool"` rows only and rebuilds
just their `content` (shallow copy): user/assistant rows, stable ids, reasoning
metadata, and turn bookkeeping are never touched.
Measured on the focused file, helper restored to the pre-fix body as sabotage:
- pre-fix body: 1 failed, 7 passed — the new regression prints the deleted
example plus 3 user rows instead of 2 (the duplicated turn)
- with the fix: 8 passed
Neighbors: test_context_history_sanitization.py 20 passed; writeback/settle
family (12 files) 120 passed, 5 skipped; media slice (7 files) 197 passed,
18 subtests.
Docs: docs/rfcs/webui-pending-intent-controls.md now records that the transport
wrapper is dropped from the settled display and the persisted context state, is
scoped to the carrier row, and that a transcript stored before this behavior
heals on its next settlement.
The display merge carries earlier turns' rows across verbatim, so a wrapper
persisted by a pre-fix settle would stay rendered for the rest of the session.
Scrub session.messages after the merge — the merge's identity matching still saw
the rows unchanged — so the persisted display copy is marker-free even when the
row was not first written by this turn. Covered by
test_settle_heals_a_transcript_that_already_stored_the_wrapper.
A mid-turn /steer is delivered to the agent as an [OUT-OF-BAND USER MESSAGE ...]
block appended to the turn's last tool result. The model-facing gateway history
always dropped that wrapper, but the writeback that produces session.messages
(rendered verbatim) and session.context_messages kept it, so the frontend showed
the raw control wrapper as a chat bubble.
Strip it in _prepare_marker_clean_writeback, the chokepoint both writebacks are
built from, reusing _strip_oob_blocks. Rows without a marker are returned
untouched so stable ids / reasoning metadata keep riding the same objects and the
scrubbed rows still compare equal to the rows persisted by earlier turns.
The /api/models cache identity shipped in #7556 (release #7558) changed from a
stat identity to content fingerprints with volatile-key deny-lists, and the #7556
review flagged that this runtime contract had no project-level documentation.
Record the three source-fingerprint axes and their rationale, the one-directional
deny-list invariant, the stat-identity fallbacks (including the deep-tree
RecursionError path), the independent schema/version stamps, and the change
protocol; index the document from docs/CONTRACTS.md. Docs only.
The positive control asserted on_disk > walked, i.e. it froze the agent
package's index-walk exclusion list. In an environment whose
agent.skill_utils yields every SKILL.md (no pruning) both sets are equal
and the assertion failed, which is what broke CI on the previous head.
Expectations are now derived from the paths the walk actually yielded, so
the test keeps asserting the property that belongs to this code - the
compatible total stays strictly below the walked file count via platform
filtering and frontmatter-name deduplication - without freezing another
package's exclusion policy.
Review follow-up on the #7305 regression fix.
- Drop the local shadow of ``agent.skill_utils`` (an ``os.walk`` +
frontmatter parser + always-True platform filter). A local re-implementation
cannot preserve the index walk's directory exclusions, frontmatter-name
identity or platform filtering, so it reported inaccurate counts: a SKILL.md
declared for another platform was counted as enabled, and support /
dependency / VCS / inactive org-mirror files were counted as skills.
- The Agent-absent branch now reports the stats as unknown — a stable
``(0, 0)``. GET /api/profiles still cannot 500, and the picker never shows a
count it cannot verify (the UI omits the skills line when the total is 0).
- Tests now pin that contract: force ``ImportError`` for ``agent.skill_utils``
(and ``hermes_cli``) and assert the unknown-stats result, the fallback row
from ``_default_profile_dict()``, and the isolated-mode ``/api/profiles``
path end to end. The fixture reproduces the shapes a partial local scan gets
wrong — a directory name differing from the declared ``name``, two
directories declaring the same name, a declared name disabled for the webui
platform, a platform-incompatible skill, and the paths the index walk prunes
(support dirs of a skill package, dependency/VCS/metadata dirs, an inactive
org mirror) — and a positive control runs the same tree through the real
Agent-backed path so the fixture cannot degrade into a tree with nothing to
count.
Title generation via the aux client ran without the Agent's ambient
conversation context, so relay targets that key on it (OpenCode Go)
rejected the completion call with HTTP 400.
Publish the webui session id as the Agent conversation context around
the aux completion (restoring it in a finally block) and pass it from
all three title entry paths: background update, background refresh and
the on-demand regenerate helper.
Refs #7470
greptile P2: the Python mirror's handle_ul/handle_ol closed the parent
<li> before opening the child <ul>/<ol> for same-marker nested lists
('- parent\n - child'), so structural assertions passed without
validating renderMd()'s parent-child hierarchy. Child lists now render
inside the parent <li>, byte-matching renderMd() for the two-level case;
adds a mirror regression test asserting the exact nested structure.
openItem() received ordered but dropped it from the item constructor, so
it.ordered was always undefined and every item looked unordered at the
task-list check. An ordered item whose text begins with [x] or [ ] (e.g.
'1. [x] shipped') was silently converted to a task icon and lost its
literal prefix. Task-list rendering now applies only to unordered items.
- Fix _providerFromModelValue and _getOptionProviderId to correctly parse
@custom:<slug>:<model> format (non-greedy, stops at first colon after @custom:)
- This prevents truncation of colon-bearing model IDs like model-a:free
- The restore path (_isSelectedModelRow) now correctly matches the row
because the provider is parsed correctly
- Related: #6884, #6895, #6221 re-gate
The open/import transcript stitcher (get_state_db_session_messages with
stitch_continuations=True) runs its own lineage SELECT that omitted
session_source/model_config, so the shared _is_continuation_session guard
added for the listing path never fired there: an explicit branch/delegate
child (model_config._branched_from/_delegate_from) starting within the 2s
tolerance window got its rows stitched into the parent transcript on open.
Select both identity columns when the schema has them (NULL AS fallback for
older state.db schemas), so the same guard applies in the stitch path.
Tests (tests/test_session_lineage_metadata_api.py):
- branched/delegate children inside the tolerance stay out of the parent
transcript on open/import (fail before this fix, pass after);
- old-schema rows without the identity columns still stitch genuine
compression continuations (no crash, no behavior change).
After the send path emits the bare model id (e.g. "model-a:free"), the
restore/refresh path compared the qualified catalog row value
(@custom:hetmer.net:model-a:free) against the bare state model, so no
row rendered as active/"Selected". Normalize both sides through
_modelPickerOptionIdentity (the same identity used for picker dedup)
and keep the provider equality gate, so exactly one row is restored.
Adds a round-trip regression test (send -> persist -> restore) with a
colon-bearing custom model id asserting exactly one "Selected" row with
the model intact, and updates the #5748 source-assertions to the
normalized comparison.
Re-gate #7021: two silent sidebar regressions on the #6931 continuation
tolerance.
1. MUST-FIX 1 — branch marker in model_config. The session_source=='fork'
guard only covers WebUI-created forks (/api/session/branch). Explicit
Agent branches/delegates carry no session_source: they are marked in the
model_config JSON column (_branched_from / _delegate_from). The caller
queries did not select model_config at all, so an Agent branch starting
inside the 2s tolerance window of its parent's compression passed the
widened check and vanished from the sidebar. Fix: select model_config in
the classifier's caller queries (get_cli_sessions, lineage report, lineage
metadata) and reject a child whose _branched_from / _delegate_from points
at the parent being classified, BEFORE the tolerance is applied. The
marker must reference this parent: compression continuations inherit the
rotated agent's model_config verbatim (mirrors hermes-agent
_NON_CONTINUATION_CHILD_FILTER_SQL).
2. MUST-FIX 2 — unbounded title fallback removed. Titles are user-controlled
and non-unique, so parent_title == child_title beyond the tolerance window
collapsed independent same-title branches arbitrarily far apart. The
fallback is gone entirely; only the bounded 2s early-side tolerance
(CONTINUATION_STARTED_AT_TOLERANCE_SECONDS) remains, after the reliable
non-continuation guards.
Tests: same-title independent child outside tolerance stays visible (was
collapsed); _branched_from/_delegate_from children inside the tolerance stay
visible (reviewer's adversarial probe); unit coverage for marker parsing
(JSON string, dict, unparsable) and marker-points-at-other-session
preservation. All 4 new tests are RED on the previous head and GREEN here.
Closes#6931
The #6884 prefix strip built its prefix from explicitProvider, which
re-parses the value at its LAST colon. For a colon-bearing model id
rendered by the normal catalog path (e.g. @custom:backup:model-a:free,
no data-model), the strip truncated the model to just "free". Build the
strip prefix from the option metadata's authoritative provider
(routedProvider, e.g. custom:backup), falling back to the parsed
provider only when no option metadata exists. The #1771 gate is kept:
non-custom namespaces like @safe:gpt-4o-mini are still preserved.
Adds regression coverage for the colon-bearing model case beside the
existing #6884 normal-catalog test.
_is_continuation_session() required float(child.started_at) >= float(parent.ended_at).
A compression/cli_close handoff can persist the continuation row a few ms BEFORE
the parent's ended_at lands (write-order race), so the strict comparison returned
False despite every other piece of evidence (direct parent_session_id link, same
source, end_reason=compression/cli_close, non-fork). The backend then labeled the
continuation as relationship_type=child_session, causing duplicate same-title
sidebar rows and spurious child-session entries (#6931).
Allow a small tolerance (2s) for handoff-consistent timing; beyond it, an exact
title match on the parent/child rows still confirms the same handoff. Fork,
cross-source, and non-compression/cli_close guards are preserved, and materially
overlapping children remain separate child sessions.
Adds API-level regression tests (sub-second overlap forms one lineage; material
overlap stays a child session; same-title evidence beyond tolerance) plus focused
unit coverage of the classifier.
Closes#6931
The strip introduced for #6884 must only apply to @custom:<slug>:<model>
values rendered by the catalog (which never set data-model). A generic
@<provider>: strip also rewrote non-custom qualified ids like
@safe:gpt-4o-mini, breaking the #1771 contract that fallback session
models keep their provider namespace. Gate the strip on the parsed
provider being 'custom' or 'custom:<slug>'.
The cycle-aware burst tracking re-gate reset the measurement burst at the
top of renderMessages(), but the added block pushed the session-load
loading-placeholder guard past the 1400-char window asserted by
test_renderMessages_preserves_loading_placeholder_for_session_switch, and
the standalone renderMessages harness in test_anchor_fallback_ownership.py
lacked the new _resetMessageVirtualMeasurementBurst dependency. Compact the
reset (identical behavior: every unmarked external render starts a fresh
burst before any early return or rAF coalescing) and add the helper stub to
the harness. CI test shard 2 is green again.
Replace the flat 2-render burst cap with cycle-aware tracking: unseen
monotonic window keys proceed (genuine convergence A->B->C->settled is
never capped), while repeated oscillating keys (A->B->A->B) terminate the
burst so the rAF/measure loop stays bounded. Thread an internal-measurement
provenance marker through both rAF layers into renderMessages() and reset
burst state on every unmarked (externally initiated) render trigger, so a
pending internal callback can never starve a legitimate new cycle.
_scheduleMessageVirtualMeasurementRefresh() reset _messageVirtualMeasurementRetryCount
every time _messageVirtualMeasurementCycleKey changed. When WebKit alternates
between two measured window-metric sets (A -> B -> A -> B) after
getBoundingClientRect(), each key transition renewed the two-render budget and
the requestAnimationFrame/geometry-measurement loop never terminated
(WebContent ~30% CPU for minutes after session recovery).
The MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS budget now caps the whole
convergence burst globally; the cycle-key branch only records the new key.
The budget still resets at the correct lifecycle boundaries:
_markMessageVirtualMeasurementsSettled() and _clearMessageVirtualHeightCache().
Adds a deterministic node-based regression test driving the real ui.js
function with alternating keys ['A','B','A','B'] (before: 4 scheduled
re-renders; after: 2), plus a stable-key guard and a structural test.
- _handle_memory_read: pass the request-owned get_config_snapshot() into
_external_notes_sources_enabled() so no ambient/process-global config is
re-read later in the handler (review requirement).
- tests: add cross-profile forced-reload regressions for both directions
(A disabled/B enabled and inverse), covering memory_enabled and
user_profile_enabled for read (no content/path/mtime) and write (403 +
byte-identical file).
- tests: adapt _external_notes_sources_enabled mock arity in
test_3866_project_context_memory_section.py to the new snapshot param.
Browser race in static/ui.js: _autoScrollFollow can be undefined when
scroll/scrollpin listeners fire before boot.js initializes settings.
Bare global reference (ReferenceError) occurs on the golden path.
Fix: wrap both consumer sites with a typeof guard that defaults to
true (matching the boot.js fallback) when the variable hasn't been set.
Closes#6606