Commit graph

7187 commits

Author SHA1 Message Date
nesquena-hermes
ab1d909aaa Release exp: gateway terminal errors + persist pending turn (#5969, closes #5940). Thanks @rodboev.
Gateway terminal provider errors classified+surfaced (not generic empty turn), session_id-bound,
pending turn persisted through reload incl repeated-prompt (is_exact_checkpoint). Codex SAFE r4 +
stream-gate GREEN all modes. Shipped solo (unbundled from #6002 which has a test-isolation CI fail).
2026-07-13 03:42:36 +00:00
nesquena-hermes
f777175cf9 Merge branch 'rebase-5969b' into stage/5969-solo 2026-07-13 03:41:39 +00:00
Rod Boev
064ad82ee6 fix(#5940): key pending-turn recovery to exact checkpoints 2026-07-13 03:41:39 +00:00
Rod Boev
0c8f647e9c fix(#5940): drop unused gateway error adapter 2026-07-13 03:41:39 +00:00
Rod Boev
a87977da33 fix(#5940): settle gateway terminal errors before emitting apperror 2026-07-13 03:41:39 +00:00
Rod Boev
e6dea2a32e fix(#5940): attach session identity to gateway apperrors 2026-07-13 03:41:39 +00:00
Rod Boev
60ec437393 fix(#5940): preserve gateway terminal errors after stream failures 2026-07-13 03:41:39 +00:00
Rod Boev
bc692cc8ac fix(#5940): classify gateway terminal provider errors instead of generic empty turns 2026-07-13 03:41:39 +00:00
nesquena-hermes
1ab1540ecd
Merge pull request #6001 from nesquena/stage/5994-fork-streaming
Release exp: fork-from-here during active streaming (#5994, closes #5993)
2026-07-12 19:37:25 -07:00
nesquena-hermes
4b72dea9ab Release exp: fork-from-here during active streaming (#5994, closes #5993). Thanks @kertisalex.
Past committed messages fork during a live response; only the streaming message +
just-sent uncommitted prompt are blocked (toast). Codex SAFE round2 (off-by-one fixed,
_isLastMsg busy-gated, _pending not carried forward), stream-gate GREEN (hide_all RED
was load flake, isolated re-run clean), 99 tests.
2026-07-13 02:25:30 +00:00
kertisalex
90ee9484e0 fix: also block fork on pending user message during streaming
Extends the live-message guard to reject both _live assistant rows and
_pending user rows that haven't been committed server-side yet. Adds a
belt-and-suspenders check: when S.busy is true, the last message in
S.messages is also blocked regardless of flags, closing a timing window
where _live might be momentarily absent on the streaming row.

The user's optimistically-pushed message (messages.js:1603) now carries
_pending:true so forkFromMessage can distinguish it from committed
messages.
2026-07-13 02:25:30 +00:00
kertisalex
5023053ebf fix: allow fork from here during active streaming
Removes the blanket S.busy guard from forkFromMessage() that silently
swallowed all fork clicks while the agent was generating a response.
Instead, only blocks fork when the clicked message itself is the one
currently being streamed (msg._live), showing a clear toast.

Skips _ensureAllMessagesLoaded() during streaming to avoid visual
flicker — absoluteKeepCount is captured before any async work so the
fork data is unaffected.
2026-07-13 02:25:30 +00:00
nesquena-hermes
54520ef35b
Merge pull request #5999 from nesquena/fix/5979-cold-catalog-preserve
fix(#5979 follow-up): preserve custom-proxy vendor namespace on cold-catalog sends
2026-07-12 19:12:42 -07:00
nesquena-hermes
6d0fdcf9e2 test(#5979): add streaming explicitly_picked lifecycle test (Fable fast-follow)
Fable's one recommended fast-follow: the streaming glue that computes
_explicitly_picked from the persisted signature (streaming.py:7905-7931) had no
direct coverage. Adds test_streaming_explicitly_picked_computation_5979 mirroring
that exact comparison across deliberate-pick (honored), stale-after-model-change
(ignored), stale-after-provider-change (ignored), and never-picked (unmarked).

Both advisors passed on the signature mechanism: Codex SAFE TO SHIP (session-update
hole verified closed: update->reload->cold openai/gpt-5.4 strips to gpt-5.4;
b3nw's deliberate pick preserves), Fable 'ship it' (clean data-model invalidation,
correct layering, no #433/#3872/#548 regression). Independent 10-scenario matrix
through the real resolver all correct.
2026-07-13 02:09:21 +00:00
nesquena-hermes
53a4b35555 harden #5979 round 3 (Codex): signature-based explicit-pick provenance
Codex found a verified lifecycle hole: the bare boolean model_explicitly_picked
was not cleared on /api/session/update (api/routes.py:14438-14460 changes+persists
the model/provider without touching it), so after an update+reload the flag stayed
True and a cold #433 stale leftover (openai/gpt-5.4) was wrongly preserved instead
of stripped to gpt-5.4.

Fix (Codex's prescription): replace the unscoped boolean with a SIGNATURE bound to
the routing context.
- api/models.py: new model_explicit_pick_signature(model, provider) ->
  '<model>\x1f<provider>'; Session.model_explicit_pick_signature persisted field
  (replaces the boolean; in METADATA_FIELDS, restored via kwargs, survives restart).
- api/routes.py /api/chat/start: on a fresh explicit_model_pick, stamp the signature
  of the resolved model+provider.
- api/streaming.py: treat the send as a deliberate pick ONLY when the persisted
  signature equals the signature recomputed from the session's CURRENT persisted
  model+provider. Any model/provider change (chat-start, session-update,
  normalization, provider repair) yields a different signature and auto-invalidates
  the stale pick — no per-mutation-site clearing needed.

So: deliberate pick on unchanged context -> preserve (b3nw); any switch away ->
signature mismatch -> unmarked -> legacy strip (#433 stays correct even after a
session-update+cold-restart). Adds signature match/invalidate/persist tests.
75 focused tests pass (resolver + sprint40 + the 2 streaming-mock suites).
2026-07-13 01:58:20 +00:00
nesquena-hermes
3559a867a9 test: fix resolve_model_provider mocks to accept explicitly_picked kwarg
Two streaming tests monkeypatch resolve_model_provider with a fixed-arg lambda
(_model only). The send path now calls it with explicitly_picked=, so the mocks
raised TypeError. Widened both lambdas to (_model, **_kw). Caught by the full
suite (not the focused resolver tests, which call the real function).
2026-07-13 01:45:07 +00:00
nesquena-hermes
45cb5bd3e6 feat(#5979): persisted explicit-pick provenance for cold custom-proxy resolution
Nathan's call: adopt Codex's complete mechanism instead of the unconditional
cold-preserve. The cold-catalog custom-proxy decision is now gated on whether the
user DELIBERATELY selected the model this session, closing the residual where a
stale #433 leftover would preserve+400 on a cold+no-disk send.

Mechanism:
- resolve_model_provider(model_id, *, explicitly_picked=False): in the custom-arm
  cold branch (no warm provenance, not config-declared), explicitly_picked=True
  preserves the vendor namespace verbatim (deliberate pick, proxy routes on it);
  otherwise the legacy redundant-prefix strip runs (stale cross-provider leftover,
  #433). Warm endpoint-advertised provenance still wins over the flag.
- Session.model_explicitly_picked: new persisted field (default False, restored
  from metadata via **kwargs, listed in METADATA_FIELDS so it survives a restart
  — b3nw's exact cold scenario).
- /api/chat/start stamps it: True on a fresh explicit_model_pick; cleared only
  when the resolved model actually CHANGED without a pick (real switch away);
  preserved across plain follow-up sends of the same model (the onchange marker
  is one-shot, so per-send explicit_model_pick alone would drop the intent).
- streaming.py reads s.model_explicitly_picked and passes explicitly_picked= into
  resolve_model_provider (inside the profile scope).

Independent end-to-end matrix (all pass): ben default cold, ben picked non-default
cold+warm, ben stale cold (strips), #433 warm+cold, #3872 bedrock, #548 zai-org.
67 resolver+sprint40 tests pass incl. new explicit-pick + stale-companion +
persistence round-trip.
2026-07-13 01:43:17 +00:00
nesquena-hermes
f42b4d14f5 harden #5979 follow-up round 2 (Codex): profile-fingerprint-gated warm no-op
Codex finding: the warm helper's fast no-op (if _models_cache_provenance is not
None: return) ignored WHICH profile the resident provenance belongs to. The
catalog globals are process-wide, so profile B's resident provenance could block
profile A from loading A's own valid disk cache — A would then resolve against
B's advertised ids (verified: profile A provenance resident + profile B bare-only
disk cache -> A's openai/gpt-5.4 wrongly preserved).

Fix: gate BOTH the fast no-op and the post-lock recheck on a current-profile
fingerprint match (_provenance_is_current: prov[1] == _models_cache_source_fingerprint()).
On mismatch the helper falls through, loads THIS profile's disk snapshot, and
republishes provenance stamped with the current fingerprint.

Tests: rewrote noop-when-warm to use the CURRENT runtime fingerprint (genuinely
hits the fast path) and added ignores-foreign-profile-resident (foreign fp -> falls
through -> loads this profile's disk snapshot). 66 resolver+sprint40 tests pass.

NOTE: Codex also re-raised the cold+no-disk #433 case (a stale openai/gpt-5.4 on a
bare-only relay preserves instead of strips when no catalog exists at all). Fable
weighed this exact residual and judged cold-preserve strictly better than master
(loud-fail-and-heal vs silent-recurring-truncation). The two advisors diverge;
surfacing to Nathan rather than auto-adopting the heavier persisted-explicit-pick
mechanism.
2026-07-13 01:43:17 +00:00
nesquena-hermes
e3152572aa harden #5979 follow-up per Codex gate: non-blocking disk-only warm + profile scope
Codex gate found two real issues in the send-path warm:

1. Blocking lock wait (CORE): get_available_models(prefer_cache=True) still
   acquires _available_models_cache_lock and can wait up to ~60s (unbounded in
   synchronous rebuild mode) on an in-flight rebuild — unacceptable on the send
   hot path. Rewrote warm_models_catalog_provenance_if_cold as a genuinely
   non-blocking, disk-only publish: try the cache lock NON-BLOCKING (return
   immediately if busy — a concurrent build will publish provenance itself),
   read ONLY the on-disk cache via _load_models_cache_from_disk (no network, no
   rebuild), publish snapshot+fingerprint + _sync_models_cache_provenance().
   Verified: returns in ~3.5ms while the lock is held (was a 60s block).

2. Profile scope (CORE): the streaming worker is a separate thread that does NOT
   inherit the HTTP handler's request-profile TLS. Both the warm and the resolve
   read profile-keyed config (cache path + source fingerprint via
   get_active_profile_name), so a cold send from a NAMED profile could resolve
   against the DEFAULT profile's config and route to the wrong provider/base_url
   (get_available_models reloads config on path mismatch). Wrapped warm + resolve
   in profile_scope_for_detached_worker(_resolved_profile_name) so both see the
   owning session's profile (no-op for default/root).

Tests: rewrote never-live-rebuilds (now asserts warm NEVER calls
get_available_models — reads disk directly) and added a non-blocking regression
(warm returns <2s while the cache lock is held). 65 resolver+sprint40 tests pass;
b3nw end-to-end + non-blocking + disk-warm all verified.
2026-07-13 01:43:17 +00:00
nesquena-hermes
6745827eb5 fix(#5979 follow-up): preserve custom-proxy vendor namespace on cold-catalog sends
The earlier #5979 fix (PR #5980, exp-v0.52.53) resolved the WARM-catalog and
config-declared cases, but b3nw confirmed truncation persists on exp-v0.52.55/56.

Root cause (reproduced on master, confirmed by Codex + Fable):
b3nw's config is provider: custom:llm-proxy, model.default: x-ai/grok-4.5, no
models[] allowlist. His DEFAULT (x-ai/grok-4.5) preserves via config-declaration,
but a model he SELECTS in the picker that isn't the default (x-ai/grok-composer-2.5-fast)
truncates on every COLD send. The send path (api/streaming.py:7857) calls
resolve_model_provider without warming the catalog, and the #1855 chat/start fast
path deliberately skips the catalog when a session already has a persisted
model+provider -- so 'cold at send' is the designed state after any restart /
settings-save / cache-expiry, not a rare race. With provenance None and no config
declaration, the #5980 tri-state fell through to the LEGACY family strip
(if prefix in _PROVIDER_MODELS and _is_first_party_model: return bare), and
grok-composer-2.5-fast had graduated into the x-ai first-party catalog -> stripped.

Fix (two changes, both advisor-verified):
1. Flip the custom-arm cold default from family-strip to PRESERVE-verbatim
   (api/config.py). For an explicit custom / custom:<slug> proxy the strip now
   requires POSITIVE provenance (endpoint advertises only the bare id, #433). A
   wrong strip destroys routing info the user can't recover; a wrong preserve
   fails loudly and self-heals on warm. This also honours _endpoint_advertised_model_ids's
   own documented 'None -> preserve verbatim' contract and removes the data-driven
   flaw where a model graduating into the static catalog silently flips routing.
2. Network-free provenance warm on the send path
   (warm_models_catalog_provenance_if_cold, called in streaming.py before the
   resolve). prefer_cache=True republishes provenance from the durable disk cache
   (no live probe, no _cfg_lock held on the worker thread -> no lock-order
   deadlock), so #433's bare-only-strip stays exact whenever a disk cache exists.

Residual (accepted, strictly better than master): a #433-shaped wrong-preserve
now requires memory cold AND disk cache absent/invalid (first run, post-config-save
window, schema/version bump). It fails loudly and heals on the next /api/models or
session-visit rebuild. Under master b3nw's failure was the mirror image but silent,
recurring every send, and unfixable short of declaring every model in config.

Supersedes rodboev's #5996, which targeted the providers.<slug> arm and does not
cover b3nw's actual custom:llm-proxy config (confirmed by Codex).

Tests: rewrite the cold-fallback test (strip -> preserve), sharpen the
foreign-profile test so trusted-strip vs ignored-preserve genuinely diverge, add
b3nw's exact cold non-declared custom:slug case, and add three warm-helper tests
(publishes-from-disk, noop-when-warm, never-live-rebuilds). 64 resolver+sprint40
tests pass; b3nw end-to-end verified; #433 positive-provenance strip verified.

Thanks @b3nw for the CLI-vs-WebUI comparison that localized it.
2026-07-13 01:43:17 +00:00
nesquena-hermes
9f6513e78f
Merge pull request #6000 from nesquena/stage/5990-false-noresponse
Release exp: no false no-response after final tool (#5990, closes #5686)
2026-07-12 18:33:57 -07:00
nesquena-hermes
5e9116db95 Release exp: no false no-response after final tool (#5990, closes #5686). Thanks @jtstothard.
Final-answer detection understands structured content parts (text after tool-use
boundaries); genuine empty completions still show no_response; terminal provider
error still surfaces as apperror (never masked). Codex SAFE round2 (3 precedence
findings fixed), stream-gate GREEN all modes. Maintainer: updated the #5940 source-
assert test for the _captured_terminal_failure rename.
2026-07-13 01:28:48 +00:00
nesquena-hermes
1656f812f2 Merge branch 'rebase-5990' into stage/5990-false-noresponse 2026-07-13 01:27:48 +00:00
Jay Stothard
c8bf3a1058 fix(streaming): preserve terminal failure precedence 2026-07-12 22:39:15 +00:00
Jay Stothard
fe222d4ba2 fix(streaming): avoid false no-response after final tool answer 2026-07-12 22:39:15 +00:00
nesquena-hermes
3bf21c8ffb
Merge pull request #5992 from nesquena/stage/5975-cron-scope
Release exp: cron unread scoped to active profile (#5975, closes #5960)
2026-07-12 12:31:30 -07:00
nesquena-hermes
a9cf45f411 Release exp: cron unread scoped to active profile (#5975, closes #5960). Thanks @jbbottoms.
4-round convergence: source+profile marker tagging, clear-on-switch incl legacy/untagged,
stale-poll rejection, default<->renamed-root alias equivalence (no over/under-clear).
Codex SAFE round 4 (58 tests), focused suite 13/13.
2026-07-12 19:27:05 +00:00
nesquena-hermes
6abb6a4327 Merge branch 'rebase-5975' into stage/5975-cron-scope 2026-07-12 19:26:11 +00:00
Jaret Bottoms
30e32cb11e fix(cron): restrict reverse root-alias match to proven root names
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.
2026-07-12 19:20:53 +00:00
Jaret Bottoms
617c1f451d fix(cron): drop stale pre-switch session-list completion marks
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.
2026-07-12 19:20:53 +00:00
Jaret Bottoms
b17e76d171 test: allow optional meta arg in polling unread mark assertion
CI shard 2 failed test_issue856 structural check that required the exact
two-arg _markSessionCompletionUnread call; #5975 now passes cron meta.
2026-07-12 19:20:53 +00:00
Jaret Bottoms
4c5a21cef3 fix(cron): close untagged persisted unread leak + root-alias over-clear
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.
2026-07-12 19:20:53 +00:00
Jaret Bottoms
991b884ed2 fix(cron): clear persisted unread markers on profile switch
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.
2026-07-12 19:20:53 +00:00
Jaret Bottoms
2844a5ad31 no-mistakes(document): Document profile-scoped cron unread alerts 🔧🟠 2026-07-12 19:20:53 +00:00
Jaret Bottoms
a8ef99b830 no-mistakes(review): Reset cron unread state on alternate profile switches 🔧🟠 2026-07-12 19:20:53 +00:00
Jaret Bottoms
67e527fd74 fix(cron): guard profile-switch polling races 2026-07-12 19:20:53 +00:00
Jaret Bottoms
1a64fc68dd fix(cron): scope unread badges to active profile 2026-07-12 19:20:53 +00:00
nesquena-hermes
f8546915ab
Release exp-v0.52.55: localize manual Docker update guidance (#5973, #5959) (#5985)
* fix(i18n): localize manual Docker update guidance

* Release exp-v0.52.55: localize manual Docker update guidance (#5973, #5959)

---------

Co-authored-by: KaliShodan <25535214+jbbottoms@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-11 23:53:02 -07:00
nesquena-hermes
8f38bc0bf6
Release exp-v0.52.54: stabilize cross-platform WebUI test suite (#5970) (#5984)
* test: stabilize cross-platform WebUI suite

* Release exp-v0.52.54: stabilize cross-platform WebUI test suite (#5970)

---------

Co-authored-by: Taha Bakhit <git@tahabakhit.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-11 23:48:38 -07:00
nesquena-hermes
286277ab3d
fix(config): custom/proxy providers keep the full vendor namespace (#5979) (#5980)
fix(config): custom/proxy providers keep the full vendor namespace on the model id (#5979)
2026-07-11 22:44:45 -07:00
nesquena-hermes
12a68ba27d test(#5979): stub grok-4.5 into x-ai catalog instead of asserting live presence
CI's hermes-agent catalog version may not contain grok-4.5, so asserting the
live _PROVIDER_MODELS['x-ai'] already lists it failed on CI (shard 4, all py
versions). Reproduce the data-driven first-party trigger deterministically by
stubbing grok-4.5 into the x-ai catalog for the test (restored in finally),
regardless of the agent version CI runs against. The other #5979 tests preserve
x-ai/grok-4.5 via the advertised-full / cold-unknown-prefix paths, which don't
depend on x-ai being a known first-party prefix.
2026-07-12 05:41:08 +00:00
nesquena-hermes
1443288dee harden #5979 round 3 (Codex): lock-free atomic (snapshot,fingerprint) provenance
The round-2 fix acquired _available_models_cache_lock in the resolver to read
the snapshot+fingerprint consistently. Codex found that introduces a DEADLOCK:
config-save takes _cfg_lock -> _available_models_cache_lock while catalog-refresh
takes _available_models_cache_lock -> _cfg_lock (lock-order inversion), and it
also makes every send wait behind a catalog rebuild.

Fix: publish an immutable (snapshot, publisher_fingerprint) tuple atomically via
_sync_models_cache_provenance() at every cache publish/invalidate site (9 sites),
and have _endpoint_advertised_model_ids read that single global with ONE lock-free
load. Reading one tuple can't tear (GIL), acquires no lock (no ordering edge, no
deadlock), and never blocks behind a rebuild. A reader racing a publish sees the
previous consistent pair, never a torn snapshot/fingerprint.

Verified: two-thread probe — resolver completes in 0.0001s while another thread
holds _available_models_cache_lock (was the deadlock). Real catalog build publishes
the provenance tuple and closes the P0 end-to-end. Adds a deadlock-regression test.
2026-07-12 05:41:08 +00:00
nesquena-hermes
036c671f14 harden #5979 round 2 (Codex): atomic snapshot+fingerprint read + tri-state cold fallback
Two more real issues from the second Codex gate:

1. Atomic provenance read: _endpoint_advertised_model_ids read the global
   snapshot and its source fingerprint as two unlocked steps, while publishers
   assign the new snapshot BEFORE computing the fingerprint (streaming.py
   publish sites). In that window profile B's freshly-published catalog could
   validate against profile A's still-current fingerprint and strip against the
   wrong catalog. Fix: acquire _available_models_cache_lock (RLock) across the
   snapshot read + fingerprint check + memo build, so the pair is always
   consistent. The lock is held only for the two-assignment publish (the network
   rebuild runs before it), so the per-send acquisition never blocks on a probe.

2. Tri-state cold fallback (was: unconditional cold->preserve): an unconditional
   verbatim fallback newly broke the #433 relay cold path (openai/gpt-5.4 sent
   to a relay that only accepts gpt-5.4; master stripped it). Now:
     (1) config declares the full id verbatim (model.default / model.models /
         custom_providers[].models) -> preserve (network-free, survives a cold
         restart -> #5979 works cold via b3nw's model.default)
     (2) endpoint catalog advertised it -> full=preserve / bare-only=strip
     (3) genuinely unknown (cold + not declared) -> LEGACY family heuristic, so
         the edge is never worse than pre-fix: first-party redundant prefix
         still strips (#433 cold), intrinsic/unknown preserved (#3872/#548).
   The #5979 active-user path can't reach (3): a selected id is either
   config-declared or in the catalog the dropdown was built from.

New _model_id_declared_in_config() provides the config-provenance signal.
Tests updated: cold-fallback-to-legacy, config-declared-cold-preserve,
foreign-profile-catalog-ignored (proves untrusted snapshot is not used).
2026-07-12 05:41:08 +00:00
nesquena-hermes
0bdf555779 harden #5979 per Codex gate: union extra_models bucket + profile-isolation fail-safe
Codex regression gate found two real issues on the first cut:

1. extra_models bucket: a provider's advertised ids can be split across the
   catalog's 'models' (visible) and 'extra_models' (picker overflow) buckets.
   Reading only 'models' could miss a bare id that overflowed, leaving the
   #433 stale prefix unstripped. _endpoint_advertised_model_ids now unions
   both buckets.

2. Profile isolation (profiles are islands): _available_models_cache is a
   process global, so a concurrently-active foreign profile could publish the
   snapshot a different profile then reads for provenance — potentially
   stripping an advertised full id against another profile's catalog. The
   accessor now rejects the snapshot unless its published source fingerprint
   (whose config_yaml axis is the profile-specific config path) matches the
   current runtime fingerprint; any mismatch fails safe to verbatim preserve.

Adds tests: extra_models-counts-as-advertised (#433 via overflow bucket) and
foreign-profile-catalog-preserves-verbatim (isolation fail-safe). Test helpers
now stamp the source fingerprint exactly as the real publish sites do.
2026-07-12 05:41:08 +00:00
nesquena-hermes
4349ff4302 fix(config): custom/proxy providers keep the full vendor namespace (#5979)
Resolve the model id for custom/proxy providers by ENDPOINT PROVENANCE
instead of a catalog-family guess. The old gate stripped a vendor prefix
whenever the bare id was first-party of the prefix's home vendor, which
could not distinguish a namespace the proxy actually routes on
(x-ai/grok-4.5 -> keep) from a stale cross-provider leftover on a relay
that only serves bare ids (openai/gpt-5.4 -> strip). A model graduating
into the first-party catalog (agent commit adding grok-4.5) silently
flipped a working custom-proxy id from preserved to stripped -> HTTP 400.

New _endpoint_advertised_model_ids() reads the already-published catalog
snapshot (never builds/probes/touches disk; O(1) per send via a
snapshot-identity memo) and answers what the endpoint advertised:
  - full vendor/model advertised -> preserve (#5979, #3872, #548)
  - only the bare id advertised   -> strip the redundant prefix (#433)
  - cold/unbuilt catalog          -> preserve verbatim (fail-safe toward
                                     the id the user actively selected)
The non-custom first-party-proxy strip arm is unchanged.

Rewrites the two pinned #433 tests to model provenance (bare-only
advertised) and adds #5979 coverage: advertised-full preserve (bare +
named custom), cold-catalog preserve-verbatim, and both-advertised
prefers-full.

Thanks @b3nw for the report and precise root-cause analysis.
2026-07-12 05:41:08 +00:00
nesquena-hermes
7d94b1c890
Release exp-v0.52.52: keep transcript pinned across composer auto-resize (#5962, #5514) (#5983)
* fix(chat): keep transcript pinned across composer auto-resize (#5514)

Reported by @JeffArcLogic.

autoResize() sets the textarea to height:'auto' before measuring scrollHeight
and restoring the height. The transient collapse grows the flex:1 #messages
viewport and the browser clamps a bottom-anchored scrollTop down by the collapse
delta, stranding the reader a few px above the bottom on every keystroke while
the composer is multi-row (delta proportional to composer height) — and the
clamp's async scroll event sticky-unpins the reader, dead-ending the #5516
grow-path re-pin and stream auto-follow. The prior #5516 fix only re-pinned on
NET growth, so a steady-state keystroke was missed.

Fix: snapshot #messages.scrollTop before the height round-trip and restore it
after, undoing the transient clamp within the same synchronous task. Protects
pinned AND near-bottom scrolled-up readers, takes no _programmaticScroll latch,
inert to iOS toolbar reflows. The grow-gated re-pin + ResizeObserver remain for
genuine net growth.

* test(#5514): make near-bottom-unpinned case non-vacuous (Codex gate nit)

The companion unpinned case used scrollTop=7900, below the transient clamp max
(7903), so it never actually clamped and passed on master too. Move it to 7990
(inside the clamp zone: 7903 < 7990 < settled-bottom 8023) so it genuinely
exercises the transient-clamp yank the fix prevents.

* Release exp-v0.52.52: keep transcript pinned across composer auto-resize (#5962, #5514)

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-11 22:37:23 -07:00
nesquena-hermes
3722f2a743
Release exp-v0.52.51: refresh reasoning chip after empty boot (#5967, #5954) (#5982)
* fix(#5954): refresh the reasoning chip after empty boot hydration

* test(#5954): resolve the boot harness path from the test file

* Release exp-v0.52.51: refresh reasoning chip after empty boot hydration (#5967, #5954)

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-11 22:17:11 -07:00
nesquena-hermes
9643cdd5c1
Release exp-v0.52.50: transparent-stream lazy settled DOM + per-turn cap (#5974, #5966) (#5981)
* fix(transparent-stream): defer settled tool-detail DOM + cap per-turn rows (#5966)

Reported by @curtisszmania-cytocync.

Transparent Stream built a DOM node for every activity event of every settled
turn at load, and eagerly materialized each tool row's full detail body — a
15+ prompt reasoning-heavy history produced thousands of subtrees and tipped the
tab into a multi-GB freeze. The #5860/#5839 lazy-worklog fix did not cover
Transparent Stream (it never collapses).

Two-part surgical fix in static/ui.js, mirroring #5860's proven machinery:
- Row-detail deferral: a settled+collapsed transparent tool row renders
  header-only; its heavy .tool-card-detail body builds on first expand
  (_materializeTransparentToolDetail), recovering the tool call from the scene
  after an innerHTML cache round-trip. Same anchor-suppressed post-processing as
  the eager path, so an expanded row is byte-identical.
- Per-turn row cap: a settled turn with >CAP+SLACK (30+10) rows renders the last
  30 behind a clean 'Show earlier steps (N)' affordance that materializes the
  prefix in place with viewport compensation. Just-settled turn (keep-open token)
  and already-revealed turns are exempt; true Trace tool-count is stashed so the
  label stays honest; Expand-all reveals capped rows; cache-restore re-wires the
  affordance. Live rendering is untouched.

Server-side #5858 (scene serialize-after-messages) already covers the container
RSS half on the experimental channel; this is the browser-DOM half.

* style(#5966): compact 'Show earlier steps' pill (hug content, left-aligned)

Constrain the affordance to fit-content width + align-self:flex-start so it
reads as a light inline control on the activity rail, not a full-width bar.

* fix(#5966): address Codex gate findings + Fable UX fast-follows

Codex SHIP-ONLY-WITH-FIXES (3 SILENT defects), all fixed + browser-verified:
- F1: buildToolCard PRE-BUILDS .tool-card-detail when a tool has args/output, so
  the old `!detail` defer guard skipped exactly the heavy rows. Defer now fires on
  settled+collapsed+has-detail and STRIPS any prebuilt body (verified: 400 shell
  tool rows -> 0 detail bodies mounted; expand still materializes byte-identically).
- F2: stamp owner rawIdx on rows + affordance; dataset recovery and cache-restore
  rebind resolve the scene-owning message (multi-segment turns own it on a later
  segment, not the first) with a scene-owning-segment fallback.
- F3: persist revealed-turn state in a session/owner-keyed Set + invalidate the
  session HTML cache on reveal, so a rebuild/switch-away never re-caps a turn the
  user expanded (verified: reveal 30->400, full re-render stays 400).

Fable SHIP-UX fast-follows: t()-with-fallback label (+ en locale keys), 640px
touch breakpoint, stale comment fixed.

Tests: 21 pass (added has-detail deferral+strip, detail-less no-defer, owner-idx
recovery, persistent-reveal, i18n).

* fix(#5966): address Codex re-gate findings (r2)

Codex re-gate SHIP-ONLY-WITH-FIXES — 2 more SILENT defects, both fixed + browser-verified:
- F1(r2): _materializeTransparentToolDetail rebuilt via the thinner
  _transparentToolDetailHtml(), dropping buildToolCard's richer detail (diff
  coloring / show-more / canonical shell-command lead). Now transplants
  buildToolCard(tc)'s own .tool-card-detail node so an expanded deferred row is
  byte-identical to the eager path (verified: canonical structure + 595-char body).
- F2(r2): after an in-session renderMessages() rebuild (next send) an already-open
  settled tool row got re-deferred, and disclosure-restore only toggled .open →
  open-but-empty card. _setWorklogDetailDisclosureOpen now materializes a deferred
  transparent row BEFORE toggling open (verified: expand → rebuild → still open
  WITH content). Likely the same defect the RegressionGate caught intermittently.

Tests: 22 pass (added canonical-detail + disclosure-restore-materialize).

* fix(review): add show_earlier_steps i18n keys to all locales (#5966)

The transparent-stream row-cap "Show N earlier steps" pill added two new
i18n keys (show_earlier_steps, show_earlier_step_one) to the English block
only, so the strict locale-parity tests (zh/ko/ru/zh-Hant/...) failed with
missing-key assertions and CI shard 0 went red across 3.11/3.12/3.13.

Add both keys to all 14 non-English locale blocks with the English string as
fallback (the repo's established add-with-English-fallback convention for new
UI keys). Inserted after each block's collapse_all anchor. All 197 locale
tests + the #5966 suite green; node --check clean.

* fix(review): extract _transparentToolRowHasDetail into the #5700 harness

#5966 added a _transparentToolRowHasDetail() call into the settled transparent
render (_anchorSceneTransparentNodeForRow) to decide whether to defer a
collapsed tool row's detail body. The pre-existing #5700 timestamp test extracts
that render fn into a node harness but didn't extract the new helper, so the
extracted fn hit a ReferenceError → node exited non-zero → 3 #5700 tests failed
(RED across CI shards; the PR updated its own #5966 harness but missed this one).

Add `eval(extractFunc('_transparentToolRowHasDetail'))` to the harness. The
helper self-guards its _toolActionKind/_toolCardAllowsDetail deps (returns true
when absent), and the #5700 timestamp lives on the row HEADER (rendered even when
detail is deferred), so all 20 #5700 assertions pass unchanged — confirming this
was a harness gap, not a timestamp regression.

* fix(#5974): hide Show-earlier-steps pill when a capped transparent turn is collapsed (Fable UX gate)

Fable UX gate SHIP-WITH-UX-FIXES: the .transparent-earlier-steps pill wasn't in the
.assistant-turn[data-transparent-turn-collapsed=1] hide rule, so collapsing a capped
turn via the name-tag toggle left an orphan pill. Added it to the hide rule.
(i18n finding was already resolved — both pill keys present in all 15 locales w/
English fallback + show_earlier_step_one is live at ui.js:12918, not dead.)

* Release exp-v0.52.50: transparent-stream lazy settled DOM + per-turn cap (#5974, #5966)

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-07-11 21:56:20 -07:00
nesquena-hermes
6361d920ff
Release exp-v0.52.49: dictation preserves composer text (#5895) (#5978)
* fix(dictation): preserve composer text when server STT commits

Dictation via the server STT path (MediaRecorder → POST /api/transcribe)
replaced any text already in the composer with the recognized transcript.
Browser SpeechRecognition worked correctly; only the async path was broken.

Root cause: the MediaRecorder.onstop handler calls _setRecording(false)
before awaiting _transcribeBlob's fetch. _setRecording(false) clears the
module-level _prefix. By the time the fetch resolves and _commitTranscript
runs, _prefix is '' — so the existing append logic collapses to
'just the transcript' and overwrites the textarea.

Browser SR dodged this because sr.onend builds the merged string into a
local variable before invoking _setRecording(false). Server STT is async
and could not use that pattern without help.

Fix:
- _commitTranscript accepts an optional prefixOverride parameter.
- _transcribeBlob snapshots _prefix into a local before the await.
- Pass the snapshot through to _commitTranscript.

Add a Settings → Voice → 'Append dictation to composer' toggle (default
ON) so users can opt into replace mode if they prefer the old buggy
behavior. The bug fix changes the default to append, matching what the
browser SR path always did.

i18n: new keys dictation_append_label / dictation_append_desc added to
all 14 locale blocks in static/i18n.js (English fallback where no native
translation exists).

Files: static/boot.js (+43/-7), static/index.html (+7), static/i18n.js (+30).

* fix(dictation): snapshot _prefix in onstop, not inside _transcribeBlob

First attempt at the append-mode fix took the snapshot inside
_transcribeBlob via 'const prefixSnapshot = _prefix'. By the time
_transcribeBlob runs (awaited from recorder.onstop), the sync block
above has already called _setRecording(false), which clears _prefix
inside _setRecording's !on branch. So the snapshot was always '' and
the append path fell through to the replace branch.

Move the snapshot into recorder.onstop, before _setRecording(false)
runs. Pass it as a parameter into _transcribeBlob so the call site
doesn't need to re-read the (now-cleared) module-level _prefix.

No behavior change for the browser-SR path — sr.onend still builds
the merged string into a local variable before invoking
_setRecording(false), and was already correct.

* fix(dictation): trim comments to fit test_590_recording_stops_before_transcribe

test_bugbatch_apr2026.py::test_590_recording_stops_before_transcribe slices
700 chars from 'recorder.onstop' and asserts that _setRecording(false)
comes before _sendRawAudio(/_transcribeBlob( inside that window. With
the verbose 6-line comment block from the previous fix, the actual
call sites landed at offsets 834/890 — past the 700-char window — so
the test's .find() returned -1 and the assertion failed mechanically.

Also: the verbose comment in _transcribeBlob contained the substring
'recorder.onstop', which made the test's first .find() land in the
comment instead of the actual handler, returning a wrong onstop_start.

Trim both comment blocks to single lines that fit within the test's
window and don't contain the literal 'recorder.onstop' substring.
Functional behavior unchanged from the previous fix; this is purely
a comment-shape adjustment to satisfy the brittle test.

* fix(tests): anchor test_590 on handler definition + widen window per review

The reviewer's fix-spec for test_590_recording_stops_before_transcribe
was more thorough than my single-shot comment trim:

  (1) Reword the _transcribeBlob comment so it doesn't contain the
      literal 'recorder.onstop' substring.
  (2) Make the test robust to the handler growing: anchor on
      'recorder.onstop=async' instead of 'recorder.onstop', and
      widen the fixed 700-char window to 1200.

Applied both. With these changes the test passes regardless of how
much comment text gets added in or around the handler in the future.
Also restored the verbose explanatory comments in both locations
(the previous a7f84027 had trimmed them to fit the brittle 700-char
window — that workaround is no longer needed).

Backward-compat check: the new test logic also passes against
origin/master (verified locally), so this change doesn't regress
the existing green-master invariant.

* fix(dictation): preserve user's mid-transcription edits in async server STT

Greptile flagged this as a 4/5 concern on the append-mode branch:
the async server-STT path appended the dictated text to the stale
snapshot (`_prefix` captured at recording start), which clobbered
any keystrokes the user made during the ~1s transcription round-trip.

Before this fix, with append mode ON:
  User typed before mic:    'Hello, '
  User typed during STT:    'Hello, from K'  (typed while waiting)
  Dictated text:            'world'
  Result:                   'Hello, world'   (lost 'from K')

After this fix:
  User typed before mic:    'Hello, '
  User typed during STT:    'Hello, from K'
  Dictated text:            'world'
  Result:                   'Hello, from K world'  (live state preserved)

The fix prefers live `ta.value` as the append base when the textarea
is non-empty, falling back to the `prefixOverride` snapshot only
when the textarea is genuinely empty (the original empty-composer path
where nothing to race with).

Adds a regression test in test_raw_audio_upload.py that asserts the
new `ta.value ||` fallback structure on `_commitTranscript`.

* fix(dictation): don't resurrect cleared text on async transcription resolve

Greptile P1 (re-reviewed at 20:07 UTC): the previous race fix
(`ta.value || (prefixOverride ?? _prefix)`) treats an empty
`ta.value` as falsy and falls back to the recording-start snapshot.
If the user explicitly clears the textarea while server transcription
is in flight, the cleared text reappears with the dictated text
appended — the user's intent is violated.

Fix: when prefixOverride IS passed (server-STT path), trust live
ta.value UNCONDITIONALLY — even when empty. The fallback to _prefix
only fires when no prefixOverride is given AND ta.value is empty,
which is purely defensive (no current caller hits this branch — the
browser-SR path commits inline in sr.onend without calling this
function).

Concrete repro:
  User typed before mic:    'Hello, '
  User clears textarea during STT
  Dictated text:            'world'
  OLD (0239bb4e) result:    'Hello, world'  (cleared text resurrected)
  NEW result:               'world'        (user intent respected)

Greptile's suggested one-liner:
  const base = prefixOverride !== undefined ? ta.value : (ta.value || _prefix);
Applied with expanded inline comment documenting the three concerns
this function balances (race, clear-during-STT, defensive fallback).

* Release exp-v0.52.49: preserve composer text on server STT commit (#5895)

---------

Co-authored-by: Konstantin M <kopamedve@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-11 20:47:54 -07:00
nesquena-hermes
b2ae71e01a
Release exp-v0.52.48: post-compression context metering (#5872, #4685) (#5977)
* fix(#4685): meter post-compression model context

* Release exp-v0.52.48: meter post-compression model context (#5872, #4685)

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-11 20:41:43 -07:00