Commit Graph
32 Commits
Author SHA1 Message Date
b3nw 34cc5dbeb2 fix: resolve transparent footer metadata from final segment (#6068 gate r2)
A tool-using Transparent Stream turn renders multiple assistant segments
(activity segment(s) then the final answer). Turn metadata — including
_usedModel — is stamped only on the final assistant message, but the
transparent footer read it via turn.querySelector('.assistant-segment'),
which returns the FIRST segment and thus no metadata. Combined with the
generic footer yielding model ownership to transparent mode, the model
label disappeared entirely on exactly the tool turns where per-turn
attribution matters most.

- Add _transparentTurnMetaMessage(turn): scan .assistant-segment
  [data-msg-idx] last→first for the final metadata-bearing assistant
  message, falling back to the last assistant segment.
- Use it for the transparent footer's duration/model/TTFT/usage instead
  of querySelector's first match.
- Non-blocking UX (per gate): preserve the full uncompacted model id in a
  hover title on both the transparent .lf-model span and the settled
  .msg-used-model-inline chip.
- Tests: behavioral multi-segment harness asserting exactly one .lf-model
  (and the correct final-segment metadata + hover title), plus wiring
  checks for the segment-scan helper.
2026-07-21 16:02:50 +00:00
b3nw 9bf876598d fix: stamp post-run agent.model + one model label per turn (#6068 gate)
Addresses the three gate-review blockers on #6113:

- Fallback attribution: read the served model from agent.model AFTER the
  run (the agent mutates it when a fallback fires) instead of the pre-run
  resolved_model, for both the persisted _usedModel and SSE used_model.
- Gateway double-label: suppress the additive chip whenever
  _gatewayRouting is present, and pass msg._usedModel to
  _formatGatewayModelLabel as its model fallback when routing omits
  used_model.
- Transparent double-label: skip the generic .msg-used-model-inline chip
  when the transparent turn footer owns the model label (.lf-model).

Also per Greptile: settled footer now orders the model chip after the
duration (elapsed - model - ...) to match the transparent footer, and the
test file gains its trailing newline.
2026-07-21 16:02:50 +00:00
b3nw 1598940091 test: behavioral _usedModelTurnChipLabel cases for #6068
Address gate review on PR #6113: eval the real footer helper in Node and
assert present, gateway-suppressed, and absent cases instead of ui.js greps.
2026-07-21 16:02:49 +00:00
b3nw 2e8d213e99 test: stub _usedModelTurnChipLabel in renderMessages Node harness
Fixes CI shard 2 after #6068 wired renderMessages to the new footer helper.
2026-07-21 16:02:49 +00:00
b3nw 30aef85b18 feat: stamp per-turn used model in footer stats (#6068)
Record resolved_model on assistant messages as _usedModel, expose used_model
in the usage SSE payload, and render model chips in transparent and settled
turn footers while suppressing duplicates when gateway routing already shows
used_model.
2026-07-21 16:02:49 +00:00
b3nwandb3nw 34fcc3a387 fix: drop messages-None writeback guard, reframe for invariant lineage
Per Greptile's review on #5717: the previous
`if result.get('messages') is not None` guard created an asymmetric
condition where the silent-failure classifier could miss the injected
fallback when the agent result had `messages: None` (which never
actually happens in practice since `finalize_turn` always returns a
list, but the asymmetry read as code smell). Drop the guard; the
writeback is now unconditional on `isinstance(result, dict)`.

Per the maintainer's review on #5717: there may be a
`finalize_turn` invariant on the agent branch this WebUI ships
against that already appends the fallback as an assistant row, in
which case the live bug is gone and this PR becomes a regression
guard. Reframe the test docstrings as dual-purpose pins of the user-
visible closure-text contract so they read correctly under both
interpretations.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-07 00:04:27 +00:00
b3nwandb3nw 02692cd64a fix: surface agent's graceful summary on iteration-limit exhaustion (closes #5494)
The WebUI already surfaces the model-generated summary when the agent's
tool-iteration budget is exhausted. The remaining gap: when the agent
hits the limit but its summary call yields no usable assistant content
(common with reasoning-only responses), the user sees a bare
`tool_limit_reached` error instead of any closure text.

`hermes-agent.handle_max_iterations()` always returns a non-empty
`final_response` (either the summary or a graceful fallback), and
`AIAgent` exposes that string on the result dict. The WebUI, by
contrast, only reads `result['messages']`, so an empty summary
left no final assistant answer and routed to the error path.

Inject `result['final_response']` as a final assistant turn when
`max_iterations_reached` fires and no usable answer exists in
`messages`. The existing `_mark_latest_assistant_tool_limit_status`
flow then attaches the status card and the user sees the closure
text instead of the bare error. Hermes-agent parity preserved (it's
literally the same string); the bare-error path stays for the case
where the agent died entirely (no fallback either).

Tests cover: fallback-injection surfaces closure text + status card,
no double-injection when a real summary already exists, the existing
'died entirely' apperror path is unchanged, and three unit cases for
the helper's skip conditions.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-07 00:02:27 +00:00
b3nw 2ddef230ff fix: drop auto-delete; manual-instruction only (PR #5688 round-3)
Address round-2 gate cert (issuecomment-4896321817) on sha:dcec232d:

1. BRICK (Codex strace-verified): v2's fcntl-flock holder check on
   .git/index.lock was wrong. Codex straced git 2.43.0 and proved
   that git uses O_CREAT|O_EXCL + rename(2), NOT advisory locking,
   so an fcntl-flock probe returns False for a lock file a live git
   process is holding open. The 'fail-closed' claim was wrong:
   auto-delete could still race and corrupt the index.

2. CORE (Codex): if .git/index.lock vanished between _is_lock_held
   and os.remove, the FileNotFoundError surfaced as a 'remove-failed'
   diagnostic instead of 'already gone' and prevented the apply retry.

Fix: stop deleting lock files from the server entirely. The only thing
that removes a lock is the user, on the host, via the manual command
surfaced in the response. Once the lock is gone, the user re-clicks
Update Now and the normal non-destructive apply path runs.

Implementation:

api/updates.py:
  - Removed _is_lock_held (fcntl-flock probe) -- was provably unsafe.
  - Removed _try_remove_lock (os.remove path) -- was provably unsafe.
  - Removed _GIT_LOCK_FILES_REMOVABLE (no longer enumerable by name).
  - Added _inventory_locks(path) -> dict: pure inventory of well-known
    + other lock files under .git/. Never touches anything.
  - Rewrote apply_clear_lock to inventory-only + manual-instruction.
    When .git/index.lock is absent, runs the normal non-destructive
    apply path; when present, returns ok=False with the exact 'rm -f'
    command + an explanation of why the server cannot do this safely
    (O_CREAT|O_EXCL cannot be detected with advisory probes).
    CORE-1 race evaporates: there is no os.remove to race against.

api/routes.py:
  - Updated the /api/updates/clear_lock comment to reflect v2.2.

static/ui.js:
  - applyClearUpdateLock: when res.lock_held, call new
    _renderLockManualInstruction(target, res) which surfaces the exact
    'rm -f <path>' command in a copyable code block plus an
    'I've removed the lock -- retry update' button that POSTs the
    same endpoint again; the second call takes the success branch
    and re-runs the normal apply path. Also lists any other lock
    files present so the operator can investigate.

tests/test_updates.py:
  - Removed all 4 tests for the deleted v2 helpers (_is_lock_held
    Returns*, _try_remove_lock*).
  - Added test_v2_probe_helpers_removed: regression guard that
    fails loud if either helper is ever reintroduced.
  - Rewrote test_apply_clear_lock_removes_unheld_lock_and_runs_normal_update
    and test_apply_clear_lock_refuses_when_lock_held for the v2.2
    contract. The 'refuses' test monkeypatches os.remove to record
    any deletion attempt and asserts the call list is empty -- a
    strict runtime guard against future regressions to auto-delete.
  - Added 7 new tests: test_inventory_locks_* (4), test_apply_clear
    lock_with_no_lock_runs_normal_update, test_apply_clear_lock_with
    lock_present_returns_manual_instruction, test_apply_clear_lock
    listing_includes_other_locks, test_apply_clear_lock_rejects
    unknown_target, test_apply_clear_lock_rejects_not_git_repo.

Verification:
  ./scripts/test.sh tests/test_updates.py          -> 73 passed
  ./scripts/test.sh tests/test_update_banner_fixes.py -> 102 passed
  ./scripts/test.sh tests/test_api_timeout.py      -> 8 passed
  python3 scripts/ruff_lint.py --diff upstream/master -> 0 new violations
  Full ./scripts/test.sh -> 12137 passed, 18 failed (all 18 pre-existing
    flakes in unrelated subsystems; verified not v2.2 regressions)
2026-07-06 19:02:16 +00:00
b3nw dcec232dfa fix: add btnClearUpdateLock markup for PR #5688 v2 frontend path
Address Greptile P1 review (discussion r3530941293) on commit e8f7e28a:
the v2 frontend recovery path was dead at the DOM level. static/ui.js
references $('btnClearUpdateLock') but no matching element existed in
static/index.html, so users hitting a lock conflict never saw a clickable
affordance -- the error panel was the only visible state.

Add the missing button next to btnForceUpdate in the update-banner action
row:

  <button class="update-btn" id="btnClearUpdateLock"
          style="display:none"
          onclick="applyClearUpdateLock(this)">
    Clear lock and retry update
  </button>

Style mirrors the neutral update-btn (NOT the red --error used by the
destructive Force update) so the recovery affordance doesn't read as a
warning. Hidden by default; revealed by the existing _showUpdateError()
helper when res.lock_conflict is set.

Regression tests in TestClearLockButton (3 tests):

- test_clear_lock_button_exists
- test_clear_lock_button_hidden_by_default
- test_clear_lock_button_calls_applyClearUpdateLock_handler

These lock in the contract that the button element must be present and
correctly bound; future refactors that rename the id or strip the button
will fail loud. Mirrors the existing TestIndexHtmlBanner conventions
for btnForceUpdate.

Verification:

- ./scripts/test.sh tests/test_updates.py  -k lock -> 35 passed
- ./scripts/test.sh tests/test_update_banner_fixes.py  -> 102 passed
- ./scripts/test.sh tests/test_api_timeout.py  -> 8 passed
- ruff_lint --diff upstream/master  -> 0 new violations
2026-07-06 17:46:30 +00:00
b3nw e8f7e28a3c fix: address gate cert BRICKs + Greptile P1 on PR #5688
Address the RED gate cert on PR #5688 (2 BRICK + 1 CORE) and the
incoming Greptile review (1 P1 + 3 P2):

- BRICK-1 (race-safety): drop the mtime heuristic and any age-based
  lock removal. Add /api/updates/clear_lock with a fail-closed
  fcntl.flock holder probe (refuses if any process still holds the
  file, on POSIX). On non-POSIX, fails closed. Touches only
  .git/index.lock (the only well-known short-lived lock git creates).
- BRICK-2 (destructive path coupling): remove lock_conflict from the
  force-button condition. Add a separate btnClearUpdateLock that calls
  /api/updates/clear_lock -- never apply_force_update. The recovery
  path never runs checkout/clean/reset --hard.
- CORE-1 (unconditional pre-cleanup): apply_force_update no longer
  iterates .git/**/*.lock. Lock cleanup is exclusively the clear_lock
  endpoint's job.
- Greptile P1: when a pull-lock error fires after a stash was pushed,
  run git stash pop (with apply+drop fallback) so the user's local
  modifications aren't stranded in the stash silently. v2 tests
  cover the stashed and the clean cases.
- Greptile P2 (broad 'lock file' substring): tightened _GIT_LOCK_SIGNATURES
  to specific git error strings ('index.lock': file exists, '.lock':
  file exists, 'another git process seems to be running',
  'unable to create .git/index.lock') so unrelated ref-transaction
  lock-loss messages no longer trigger a lock-conflict response.
- Greptile P2 (redundant git_dir.exists / magic number): both gone as a
  side effect of removing the apply_force_update cleanup loop entirely.

Refactor: rebased onto current upstream/master so PR is no longer
'behind' 130 commits.

Tests:
- 14 new v2 tests covering holder probe (true/false/NOGIL probe),
  _try_remove_lock (refuse-on-held/success-on-unheld),
  apply_clear_lock (success/refused), signature set parameter table
  including false-positive class, apply_force_update no-touch
  contract, pull-lock stash restore, pull-lock no-stash-when-clean.
- 2 v1 tests removed (they encoded the unsafe behavior).
- 2 v1 parametrize rows dropped (their positive cases relied on the
  broad 'lock file' substring).

Closes #5687
2026-07-06 17:37:05 +00:00
b3nw 3c5b00f928 fix: detect and recover from stale git lock files during self-update
- Detect lock file errors (lock_conflict flag) on fetch/status/pull
  failures in _apply_update_inner (#1)
- Expose Force Update button on frontend when lock_conflict is
  received (#3)
- Proactively clean stale lock files (>30s old) in apply_force_update
  using rglob discovery instead of a hardcoded path list (#2)
- Add 10 tests: _is_git_lock_error parametrized unit tests,
  _apply_update_inner lock path coverage, and apply_force_update
  stale/recent lock behavior
2026-07-06 17:17:21 +00:00
b3nw 6f9c648d23 fix(tests): more _read_profile_model_config stubs need 3-tuple 2026-07-01 20:33:09 +00:00
b3nw 6d06001b6f fix(tests): update _read_profile_model_config callers/assertions for 3-tuple return 2026-07-01 20:24:57 +00:00
b3nwandb3nw a477069e3a fix(model): address gate feedback #4857080754 — slug names, list models, get_config, single YAML parse
- _repair_bare_custom_provider_model matches display-named providers via
  _custom_provider_slug_from_name (custom:my-proxy matches 'My Proxy').
- _ordered_custom_provider_model_ids now handles dict keys, list strings,
  and list dicts with id/model/name, aligned with api/config.py catalog.
- config_obj=None fallback uses get_config() instead of raw cfg.
- _read_profile_model_config returns profile config dict too, avoiding a
  second YAML parse on hot display paths.
- Tests added for slug matching, list-form models, and get_config fallback.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-01 20:12:38 +00:00
b3nwandb3nw 3682d237d7 test: fix CI stubs for profile_config; widen #1855 source window
CI Tests job (run 28525484615) failed one test per shard:
- Stubs for _resolve_compatible_session_model_state lacked profile_config
  (wakeup wiring spy, start_session_turn runtime adapter fixture).
- #1855 structural test used 6k char slice; resolver helper outgrew window.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-01 15:00:04 +00:00
b3nwandb3nw 1feb6f8280 fix(model): gate feedback — profile-scoped repair, malformed config (#5317)
Address nesquena-hermes gate certification on PR #5317:
- _repair_bare_custom_provider_model: coerce config values via str();
  use api.config._custom_provider_entries; optional config_obj for
  profile config.yaml (not process-global cfg).
- Thread profile_config from _load_profile_config_dict through session
  display, chat/start, wakeup, goal, and sync resolvers.
- Tests: malformed name=None entry; profile vs global collision.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-01 14:35:56 +00:00
b3nwandb3nw da43de72b8 fix(model): repair bare custom-provider suffix on resolver slow path (#5225)
Mirror the #5128 fast-path suffix repair when profile context is present
but requested_provider is omitted, which async delegation completion can
trigger via process_wakeup.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-06-30 01:01:17 +00:00
b3nwandb3nw 7a04b7e9aa fix: gate bare-model repair on profile provider match (#5128 review)
When session model_provider differs from profile model.provider, do not
expose profile_default_model for suffix repair. Fast path also requires
profile_provider to match requested_provider when profile_provider is set.

Addresses cross-provider mis-resolution (custom:other-proxy + bare suffix).

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-06-28 21:52:06 +00:00
b3nwandb3nw 159d8a96f4 fix: repair bare custom-provider model on process-wakeup fast path
Refs #5127.

When _resolve_compatible_session_model_state takes the #1855 fast path with
a bare runtime model suffix and a slash-qualified profile default, repair to
the full qualified ID for custom/custom:* providers.

Also load profile model.default when the session already has model_provider
so wakeup turns can apply that repair.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-06-28 18:34:10 +00:00
b3nwandb3nw 6e499eb1dd fix(session): semantic context truncate on rewind + evict agent
Refs nesquena/hermes-webui#5096

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-06-28 18:01:56 +00:00
b3nwandb3nw bf1f8339b8 fix(ui): use absolute keep_count for edit and regenerate truncate
Refs nesquena/hermes-webui#5096

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-06-28 18:01:54 +00:00
b3nwandb3nw aacf860ccc fix(session): truncate context_messages when branching from fork point
Refs nesquena/hermes-webui#5096

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-06-28 17:59:46 +00:00
b3nw 121ac7f253 fix: coerce msgContent to string in completion notification preview
Greptile P1 on #5036: block-array content can make msgContent return a
non-string; normalize before .replace() in preview helper.
2026-06-27 04:50:08 +00:00
b3nw a84fc8e5c2 Fix completion notification preview to use settled final answer
The SSE done handler previously used assistantText.slice(0, 100), which
captures the start of the live-stream accumulator (early tokens and interim
updates) rather than the final answer shown after tools settle.

Add _completionNotificationPreviewText() using anchor final-answer projection
and settled message content, with a cleaned live-stream fallback. Hoist
lastAsst so background completions on non-active panes still get preview text.

Update PWA notification regression tests.

Closes #5035
2026-06-27 04:50:08 +00:00
b3nw 9bb1dc8573 fix(#4085): surface dirty install state in update-check payload
The update-check payload now carries a `dirty: bool` reflecting the
working-tree state vs HEAD, computed via the same `git diff-index
--quiet HEAD --` primitive already used by `_dirty_suffix()`. A dirty
install at-or-past the latest release tag used to silently report
"Up to date" with no remediation affordance; the Settings panel
can now read this flag to offer `apply_force_update` (which already
does `git checkout .` + `git reset --hard` to reset to a clean
latest).

The probe is conservatively clean on real errors (timeout, missing
git, fatal) so a transient probe failure never produces a false-
positive "local changes" alert.

Closes #4085
2026-06-13 06:05:06 +00:00
b3nw 9ac9f8d690 fix(#4059): log unresolved custom provider key templates 2026-06-12 16:23:35 +00:00
b3nw 8ee8523340 test: isolate context-length metadata stub 2026-06-12 16:18:50 +00:00
b3nw b2eda41b49 fix(#4059): pass custom provider keys to context probes
Resolve custom provider API keys from the matched config snapshot and pass them through session hydration plus streaming fallback context-length probes. This prevents authenticated /v1/models endpoints from falling back to the default 256K window and clobbering larger persisted session metadata.
2026-06-12 15:53:48 +00:00
b3nw 68716b3e4f fix: generalized reasoning capability heuristics for custom providers (#3377) 2026-06-02 17:11:32 +00:00
b3nw d06776a4c8 fix: model picker snaps to wrong model with multi-slash IDs (#3360)
When a custom/proxy provider serves models whose IDs share the same base
name across vendor prefixes (e.g. vendor_a/deepseek/deepseek-v4-pro vs
vendor_b/deepseek/deepseek-v4-pro), several normalization functions use
split('/').pop() (or split('/')[-1]) which discards all segments except
the last.  This causes three user-facing symptoms: (1) clicking one
model selects a different colliding model, (2) configured-model badges
attach to the wrong dropdown entry, and (3) the model-chip label in the
composer bar is truncated to just the base model name.

Root cause: all three callers take only the last slash-segment instead
of stripping only the first (provider) segment and preserving the
remaining vendor hierarchy.

Fix 1 — _findModelInDropdown (static/ui.js): Move the exact string match
before the provider-aware normalized match.  Previously, when all models
share the same provider ID (common with LLM proxy setups), the normalized
match returned whichever colliding option appeared first in DOM order,
even though an exact match existed.

Fix 2 — _normalizeConfiguredModelKey (static/ui.js) and _norm_model_id
(api/config.py): Replace split('/').pop() / split('/')[-1] with a first-
segment-only strip (regex on frontend, split('/',1) on backend), matching
the strategy already used by _findModelInDropdown's norm lambda.  This
prevents multi-slash IDs from colliding in badge assignment and the
configured-entry dedup set.  Additionally, strip colon-qualified provider
prefixes (e.g. custom:name/) before the slash strip so badge-key variants
like 'custom:llm-proxy/opencode_go/model' merge correctly with the bare
'opencode_go/model' in the configured section dedup.

Fix 3 — getModelLabel (static/ui.js) and _get_label_for_model
(api/config.py): Same split('/').pop() to first-segment-strip change so
the composer-bar model chip and backend label preserve vendor context
(e.g. shows 'opencode_go/deepseek-v4-pro' instead of 'deepseek-v4-pro').

Verification: 9 new regression tests (test_issue3360) covering exact-
match priority, multi-slash normalization, and backend/frontend parity.
Updated 1 existing test (test_norm_model_id_trailing_empty_guard) that
asserted the old split('/').pop() pattern.  All 25 related tests pass.

AI Usage: Gemini (gemini-2.5-pro), via Antigravity IDE, pair-programmed.

(cherry picked from commit a454fecd2b3a83f7da34473c883be069171aeac9)
2026-06-02 05:08:30 +00:00
b3nw 160cd03c18 fix(chat): reset reasoning accumulator per turn and prefer reasoning_content (closes #2565)
Two confirmed bugs in the thinking/reasoning display:

1. reasoningText was initialized once when the SSE stream opened and never
   reset between turns. On the done event, the last assistant message
   received the union of every turn's reasoning. Now reset at both turn
   boundaries: tool (alongside existing liveReasoningText reset) and
   interim_assistant (the other turn boundary where prior reasoning closes).

2. ui.js renderMessages preferred m.reasoning (which could be corrupted by
   bug 1) over m.reasoning_content (the clean per-turn value from the
   backend). The fallback now reads m.reasoning_content || m.reasoning.

Both fixes are needed: bug 2 alone cannot cover providers that stream
reasoning events without populating reasoning_content on the final API
message.

Updated test_streaming_race_fix.py to scope its reconnect-accumulator
guard to the _wireSSE preamble only, since turn-boundary resets inside
event listeners are intentional and correct.

9 new regression tests in test_issue2565_reasoning_accumulation.py.
2026-05-24 04:08:40 +00:00
b3nw 1f56fad73f fix(chat): flush pending render before segment reset at tool/interim boundaries
Fixes #2713 — live assistant text can truncate at tool-call segment
boundaries during streaming.

Before _resetAssistantSegment() in the tool and interim_assistant SSE
handlers, synchronously flush any pending rAF render work so tokens that
arrived during the 66ms throttle window are written to the DOM before
assistantBody is cleared. Without this flush, the pending _doRender
callback fires after assistantBody is null and skips the write silently,
causing the tail of the pre-tool segment to disappear from the live view.

Implementation:
- Extract _flushPendingSegmentRender() helper (guarded by assistantBody
  && _renderPending) that cancels the pending rAF and synchronously
  writes via smd/renderMd/esc — same cascade as _doRender.
- Call the helper from both the tool and interim_assistant handlers
  before their respective _resetAssistantSegment() calls.
- Normal cases where the rAF has already fired are unaffected (guard
  skips immediately).

Completed transcripts were never affected (renderMessages rebuilds from
the full assistantText accumulator on done).

Adds tests/test_issue2713_streaming_segment_flush.py with 11 static
analysis regression tests pinning the helper shape and call-site
ordering.
2026-05-24 04:08:33 +00:00