Compare commits

..
Author SHA1 Message Date
Teknium 372e94b49a fix(errors): classify throttle messages before token-overflow patterns; add new overflow shapes
Port from anomalyco/opencode#37848 (+ dev-branch twin #37840): expand
context-overflow patterns and guard against rate-limit messages that
mention tokens.

- 'Throttling error: Too many tokens, please wait before trying again.'
  (AWS Bedrock / proxy shape) classified as context_overflow and routed a
  healthy session into compression on every throttle. Added 'throttling'
  to _RATE_LIMIT_PATTERNS, which the message-only path checks BEFORE the
  overflow list.
- 'Input length N exceeds the maximum allowed input length of M tokens.'
  (Together/Fireworks shape) fell through to unknown — no compression
  recovery. Added 'maximum allowed input length' to overflow patterns.
- 'request_too_large' / 'Request exceeds the maximum size' (Anthropic 413
  type re-wrapped without a status code by aggregators/proxies) fell
  through to unknown. Added to _PAYLOAD_TOO_LARGE_PATTERNS.

All three shapes proven live on main before the fix; 265 classifier +
bedrock tests and 238 sibling rate-guard/compression tests pass.
2026-07-23 17:10:32 -07:00
Teknium d0d116be2e fix: getattr-guard min_tail_user_messages for __new__ test doubles
Bare ContextCompressor.__new__ doubles (test_compress_focus,
cross_session_guard, image_tokens, pre_compress_memory_context) skip
__init__ and lack the attribute — the documented compression-path
test-double pitfall. Guard with getattr default 1 + int type pin
(bool excluded).
2026-07-23 17:03:49 -07:00
Teknium d43cc2ca80 fix(compress): gate N-user tail guarantee to actionable turns, behavior-preserving default
Follow-up fixes on top of the salvaged #22566 mechanism:

- N-collector now counts only REAL actionable user turns via
  _is_actionable_user_turn + _is_synthetic_compression_user_turn —
  the same filter pair _find_last_user_message_idx uses post-#69291.
  The contributor's bare role=='user' + _is_context_summary_content
  check let blank platform echoes and continuation/todo rows consume
  N slots, silently degrading the guarantee.
- Default flipped 3 -> 1 (behavior-preserving): a default of 3 was
  measured to change the tail cut on transcripts whose budget covers
  only the last turn. min_tail_user_messages=1 delegates to the
  existing single-user anchor; N>1 is opt-in, and the call site is
  gated so the default path is byte-identical to main.
- Hardened config parse in agent_init (bool rejected, fractional
  floats rejected, floor 1) matching the max_attempts parser shape.
- Wired the recurring external-PR config gaps: hermes_cli/config.py
  DEFAULT_CONFIG + cli-config.yaml.example (PR only had cli.py).
- Regression tests: blank echoes / synthetic rows don't count toward
  N; tool-call/result pairs never split by the N-boundary (no-orphan
  both directions); N-guarantee wins over tail_token_budget and the
  _MAX_TAIL_MESSAGE_FLOOR (floor is a minimum, not a cap); default
  parity pin; DEFAULT_CONFIG pin.
2026-07-23 17:03:49 -07:00
JerryandClaude a9c868225e feat(compress): preserve recent N user messages during context compression
Add _ensure_last_n_user_messages_in_tail to guarantee the last N user
messages survive compression in the uncompressed tail, with surrounding
assistant/tool context preserved.

- Add min_tail_user_messages parameter (default 3) to ContextCompressor
- New _ensure_last_n_user_messages_in_tail method generalizes single-user protection
- Skip context-summary handoff banners when counting user messages
- User messages are clean boundaries — skip _align_boundary_backward
- Wire through cli.py, agent_init.py, and gateway cache busting keys

Config:
  compression:
    min_tail_user_messages: 3

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 17:03:49 -07:00
Teknium 69365109b3 fix(compression): mark raw skill_view bodies summarized away, not only pre-pruned rows
_collect_ghosted_skill_names() covers both ghost-skill shapes in the
compressed middle window: rows already demoted to a [SKILL_PRUNED: ...]
marker AND raw skill_view bodies (> _SKILL_VIEW_PRUNE_MIN_CHARS) that
survived Phase-1 inside an earlier protected tail and then aged into the
compression window — the summarizer paraphrases those instructions away
too. Shared threshold constant between the emit site and the scan.
Pinned by a live-probe-shaped test (real compress(), mocked aux LLM).
2026-07-23 16:58:06 -07:00
Teknium 28f73d32e9 test(compression): ghost-skill defense suite — marker round-trip, protected prune, real-compress survival
21 tests pinning the salvaged #44166 behavior:
- marker emit + extractor round trip (patterns adapted from PR #32375
  by @LeonSGP43, with credit)
- no-duplicate re-injection when the canonical marker survived (the
  original PR's presence-check defect)
- Phase-1 protection for just-loaded / user-referenced skills, and the
  Pass-4 pressure override that keeps #61932 fixed
- deterministic marker survival through a REAL compress() with a mocked
  aux LLM: drop → re-injected, keep → not duplicated, static-fallback
  path, iterative re-compression via rehydrated handoff
- markers never classify as handoff content (classify_summary_content /
  _strip_context_summary_handoff_message untouched)
- SKILLS_GUIDANCE Skill Safety Rule renders with real newlines
2026-07-23 16:58:06 -07:00
Teknium 44c67fca91 fix(compression): ghost-skill defense — canonical marker constant, protected-skill prune guard, deterministic marker survival
Salvage rework of PR #44166 (@dolphin-creator) onto current main:

- ONE canonical prune marker: _skill_pruned_marker(name) builds
  '[SKILL_PRUNED: ... reload with skill_view(name='X')]'; both emit
  sites and the survival presence check use the same string, fixing the
  original PR's defect where the emitted marker was '[SKILL_PRUNED:'
  but the presence check looked for '[SKILL_PRUNED]' (re-injection
  duplicated markers that had survived).
- Phase-1 prune (_prune_old_tool_results) now threads a protected-skill
  set: skills whose skill_view call is within the last 10 messages, in
  the protected tail, or named in a tail user message keep their full
  bodies. Pass-4 pressure demotion deliberately overrides the guard so
  the #61932 dead-end shape cannot return.
- P2 deterministic marker survival: skill names are extracted from the
  summarizer INPUT (and the previous summary) before the aux LLM call
  and any dropped canonical markers are re-injected afterward under a
  '## Pruned Skills' section — routed through _redact_compaction_text,
  appended to the summary body only (never in front of SUMMARY_PREFIX
  or scaffolding start-of-content markers; classify_summary_content is
  unaffected). Same treatment on the static fallback path, re-applied
  after its size cap since truncation cuts exactly where markers land.
- Summarizer prompt gains a '## Pruned Skills' copy-verbatim section.

Fixes #32106.
2026-07-23 16:58:06 -07:00
Jonathan Boisson 6816f2f02c fix(P1+P2): marker names skill_view(name='X') + DEDUP rule for repeated [SKILL_PRUNED] markers
Surgical reapply of the marker-alignment and dedup-guidance halves of
PR #44166 commits 52341f6ca3 / 3d8a31432d / ae07412e4b onto current main:
- the [SKILL_PRUNED: ...] marker embeds the exact reload call
  skill_view(name='<skill>') so the model can act without guessing
- SKILLS_GUIDANCE Skill Safety Rule gains rule 4 (DEDUP): after one
  reload, remaining markers for the same skill are historical artifacts

Fixes #32106 (part).
2026-07-23 16:58:06 -07:00
Jonathan Boisson 5faef80a43 fix: ghost skill P0/P1 mitigation - [SKILL_PRUNED] marker + safety rule 2026-07-23 16:58:06 -07:00
Teknium 9b868f6f67 chore: contributor email mappings for wen0531 and iniak 2026-07-23 16:55:41 -07:00
Teknium df051c17cc fix(vertex): surface vertex in the /model picker — credential gate + curated model list
Community verification of #56688 (zmack12344321) found two follow-up gaps
that kept Vertex invisible in the /model menu even after registry
registration:

1. hermes_cli/model_switch.py: list_authenticated_providers() had a
   credential gate hard-coded to API keys (with an aws_sdk special case
   only) — add a vertex branch using has_vertex_credentials(), mirroring
   the aws_sdk shape.
2. hermes_cli/models.py: Vertex's OpenAI-compatible endpoint has no
   /models listing route, so without a curated _PROVIDER_MODELS entry the
   picker only ever showed the current model — add a Gemini curated list.

Follow-up to #56688.
2026-07-23 16:55:41 -07:00
srojk34 3ea35d6711 fix(vertex,moa): register vertex in PROVIDER_REGISTRY and HERMES_OVERLAYS
The Vertex AI provider (added same-day, commit c73e74386) was never added to
either of the two provider registries that agent/auxiliary_client.py and the
MoA slot-resolution chain depend on, breaking Vertex outside the main
conversation loop:

1. hermes_cli/auth.py::PROVIDER_REGISTRY had no "vertex" entry. The
   plugin-auto-extend loop that normally fills gaps explicitly skips
   non-api_key auth types (`if _pp.auth_type != "api_key": continue`), and
   Vertex was never hand-declared like "bedrock" is. Because
   resolve_provider_client() in agent/auxiliary_client.py gates everything
   on `pconfig = PROVIDER_REGISTRY.get(provider)` and returns (None, None)
   immediately when pconfig is None, its `elif pconfig.auth_type == "vertex"`
   branch was permanently dead code — every auxiliary Vertex call (vision,
   title generation, reflection, context compression, MoA reference/
   aggregator slots) failed outright, not just a MoA-specific edge case.

2. hermes_cli/providers.py::HERMES_OVERLAYS also had no "vertex" entry, so
   hermes_cli.providers.get_provider("vertex") returned None. This backs
   _preserve_provider_with_base_url() in agent/auxiliary_client.py, which a
   MoA slot's resolved (base_url, api_key) pair needs to keep its "vertex"
   identity instead of silently collapsing to "custom" — losing the
   identity _refresh_provider_credentials() needs to re-mint an expired
   OAuth2 token (~1h lifetime) on a 401, and permanently breaking every
   subsequent call in that MoA preset for the rest of the session.

Fix mirrors the existing "bedrock"/aws_sdk entries in both registries
exactly, plus adds a "vertex" branch to _refresh_provider_credentials() (it
had branches for openai-codex/nous/anthropic/xai-oauth but not vertex,
so a 401 fell through to `return False` without evicting the stale cached
client).

- hermes_cli/auth.py: hand-declared vertex ProviderConfig(auth_type="vertex")
  in PROVIDER_REGISTRY, matching bedrock's shape.
- hermes_cli/providers.py: vertex HermesOverlay(auth_type="vertex") in
  HERMES_OVERLAYS + "Google Vertex AI" label override.
- agent/auxiliary_client.py: vertex branch in _refresh_provider_credentials
  that re-mints the token via get_vertex_config() and evicts the stale
  cached client.
- 8 new regression tests across tests/hermes_cli/test_vertex_provider.py and
  tests/agent/test_auxiliary_client.py: registry membership, end-to-end
  resolve_provider_client("vertex", ...) building a working client (proving
  the previously-dead branch is now reachable), and the 401-refresh/cache-
  eviction path.
2026-07-23 16:55:41 -07:00
iniak a7d78ad685 fix: filter invalid MoA slot providers 2026-07-23 16:55:41 -07:00
liuhao1024 d4c6ae7b11 fix(moa): preserve save_traces/trace_dir on GUI config save
MoaConfigPayload does not declare save_traces or trace_dir, so
set_moa_models() overwrites cfg["moa"] with a dict that lacks these
hand-edited keys.  Use dict.update() to merge instead of replace.

Fixes #58819
2026-07-23 16:55:41 -07:00
Teknium 85b2d52b71 test(moa): regression tests for JSON-string reference_models parsing (follow-up to #59497) 2026-07-23 16:55:41 -07:00
刘文 3638abfbf9 fix(moa): parse JSON string reference_models in _normalize_preset
When reference_models is stored as a JSON string (e.g. from hermes moa
configure or hand-edited config.yaml), _normalize_preset silently
falls back to hardcoded defaults because the string fails both
isinstance(x, list) and isinstance(x, dict) checks.

Add json.loads() parsing before the type checks so both formats work.
2026-07-23 16:55:41 -07:00
liuhao1024 c1f5f0f911 fix(doctor): recognise 'moa' as a valid internal provider
MoA (Mixture of Agents) is a legitimate internal provider used by
Diagnosis presets and multi-model aggregation. When a MoA preset sets
model.provider to 'moa', hermes doctor incorrectly reports it as
'unrecognised' and suggests changing it, which would break the MoA setup.

Add 'moa' to the known_providers set alongside 'openrouter', 'custom',
and 'auto' so doctor recognises it as valid.

Fixes #58759
2026-07-23 16:55:41 -07:00
0xDevNinja d661886c90 test(cli): assert HermesCLI.__init__ wires moa:<preset> to the moa provider
The existing tests cover _normalize_moa_model() in isolation and a local
precedence expression, but not the __init__ wiring itself. Add two
init-level regression tests: constructing HermesCLI(model='moa:strategy')
strips the prefix to model='strategy' and forces requested_provider='moa',
and the moa: prefix wins over an explicit --provider. Both fail if the
override is dropped from the requested_provider resolution.

Refs #56828
2026-07-23 16:55:41 -07:00
0xDevNinja 8d72845399 fix(cli): resolve moa:<preset> model in non-interactive mode
hermes chat -Q -m moa:strategy failed with 'model moa:strategy is not
supported' (HTTP 401/400): the raw model string was passed straight to
the real provider. The MoA virtual provider only got wired up through the
interactive /moa command and the model picker, never through the -Q
one-shot startup path.

resolve_runtime_provider already handles requested_provider == 'moa', and
agent_init builds the MoAClient off provider == 'moa' (surface-agnostic).
The only gap was mapping the moa:<preset> model string to that provider.

Add _normalize_moa_model() and apply it in HermesCLI.__init__ before
provider resolution: a moa:<preset> model sets requested_provider='moa'
and model=<preset>, so the existing MoA path runs in non-interactive mode
too. The moa: prefix wins over an explicit --provider (previously
--provider deepseek -m moa:strategy silently dropped MoA).

Fixes #56828
2026-07-23 16:55:41 -07:00
Teknium b7a05b6b6f fix: re-anchor summary-input bound to current main + bound iterative path
Follow-ups on top of the cherry-picked #27748 mechanism:
- move the cap constant to module level with full rationale comment
  (class attribute aliases it so subclasses/tests can override)
- bound the iterative-update path too: the PREVIOUS SUMMARY block is
  passed through _bound_summary_input so a pathological rehydrated
  handoff cannot blow up the prompt (previous summary + new turns each
  capped)
- extra regression tests: byte-identical small-input passthrough
  (identity), direct bound+marker unit check, bound-after-per-message-
  truncation shape (hundreds of under-_CONTENT_MAX turns), iterative
  path bounded, marker vs classify_summary_content non-collision
- contributor email mapping for @robgfl45
2026-07-23 16:44:53 -07:00
Cluster2 80ece3867b fix: bound compression summary input 2026-07-23 16:44:53 -07:00
Teknium fa4800414c feat(compression): prompt-cache reclaim gate + hardened wiring for proactive prune
Follow-ups on top of the cherry-picked #62644 mechanism, porting it to
current main and closing the salvage-review requirements:

- proactive_prune_min_reclaim_tokens (default 4096): a prune only COMMITS
  when it reclaims a meaningful token batch, measured on the pruned output.
  A committed prune rewrites already-sent history and invalidates the
  provider prompt-cache prefix; this hysteresis gate keeps those breaks
  episodic/amortized (like a compression boundary) instead of firing every
  tool iteration. 0 disables the gate. (Design point credited to the
  #62389 review cycle's prune_minimum_tokens.)
- Standard no-op caller contract: every skip path returns the INPUT list
  object; the loop commits only on 'result is not messages' + non-zero count.
- Loop call is getattr+callable guarded (plugin engines predating the hook,
  SimpleNamespace test doubles) and exception-swallowed at debug level.
- Config parse follows the compression.max_attempts hardened semantics:
  booleans rejected, fractional floats rejected, integral floats/numeric
  strings accepted; negative trigger = disabled.
- cli-config.yaml.example documented (all three keys) and gateway
  _CACHE_BUSTING_CONFIG_KEYS extended so hot-reload rebuilds the agent.
- Tests: min-reclaim gate both directions, input-object no-op contract,
  no-orphan tool_call_id pairing in BOTH directions (#69830 pin rule),
  default-off zero-behavior-change pin, config parse seam, and behavioral
  loop-wiring tests (consulted/commit/no-op/absent-method/raising).
2026-07-23 16:44:12 -07:00
Kolektori cb481e2f2b feat(compression): proactive tool-result pruning for large-window models
The phase-1 tool-result prune only runs inside compress(), which fires
near 50% of the context window, so it never triggers on large-window
models; old tool outputs then ride in history and are re-sent every turn.

Add prune_tool_results_only(): the same no-LLM prune on a separate, low
proactive_prune_tokens trigger, run as an elif to the compression branch.
Opt-in (default 0), protects the recent tail by message count.

Add the method to the ContextEngine base as a no-op default so pluggable
engines inherit it safely (the post-tool-call path never AttributeErrors on
a non-built-in engine); the built-in compressor supplies the real prune.
Register both keys under the top-level compression config with defaults and
document them.
2026-07-23 16:44:12 -07:00
Teknium 66fdcfa3bd fix: harden salvaged preflight display rollback for test-double density
Follow-up to the #54805 cherry-pick (skill guard rules):
- turn_context.py: snapshot_preflight_display_tokens gets a
  getattr+callable guard (SimpleNamespace compressor doubles / plugin
  context engines lack the ContextCompressor-only method) and the
  snapshot value is type-pinned to a real int (bool excluded) before
  arming the rollback — MagicMock compressors return truthy Mocks.
- turn_finalizer.py: interrupted pinned 'is True', the
  _turn_received_provider_response read pinned 'is not True' (MagicMock
  auto-attrs are truthy), and the compressor rollback method call gets a
  getattr+callable guard. Rollback stays display-only: it never touches
  _ineffective_compression_count or any durable guard, and preserves the
  -1 post-compaction sentinel.
2026-07-23 16:38:06 -07:00
izumi0uu 17a81ac89e fix(context_compression): roll back interrupted preflight state pollution
Interrupted turns can seed a speculative display token count before the provider receives the request. Restore that display-only seed when interruption wins the race, while preserving completed post-compaction state and treating a successful provider response independently of optional usage metadata.

Constraint: #54776 remains reproducible on current main, while review #4702305384 identifies anti-thrashing rollback as stale and usage receipt as an unreliable response-completion signal.
Rejected: Restore anti-thrashing counters from a preflight snapshot | current main derives their verdict from real provider usage after a completed compaction boundary.
Confidence: high
Scope-risk: narrow
Directive: Keep interrupted preflight rollback display-only, and never infer provider completion from the presence of usage metadata.
Tested: ./.venv/bin/python -m pytest -q tests/run_agent/test_413_compression.py (29 passed); turn-finalizer/conversation-loop tests (31 passed); context-compressor targeted tests (12 passed); infinite-compaction targeted tests (3 passed); ruff; git diff --check.
Not-tested: End-to-end interactive interrupt through CLI or gateway transport.
2026-07-23 16:38:06 -07:00
Teknium 6a8d31856f chore: map kinsonnee@gmail.com -> WOLIKIMCHENG for #59526 salvage 2026-07-23 16:27:06 -07:00
root 34678d2f2e fix(compression): skip empty post-handoff summary windows 2026-07-23 16:27:06 -07:00
Teknium eebc2286fc fix(gateway): retry-next-message semantics for compression_deferred + regression suite
Gateway half of the #49874 salvage: pass compression_deferred through
both _run_agent_inner result dicts and guard the compression-exhausted
auto-reset block with it — a lock-contended defer keeps the session
intact (the concurrent compressor is actively shrinking it) instead of
wiping it via reset_session.

Regression tests:
- tests/run_agent/test_compression_lock_defer.py — provider-mock 413 and
  400-overflow turns whose compression pass lost the lock end as
  compression_deferred (failed=False, no compression_exhausted); flag
  unset keeps the terminal exhaustion path byte-identical; type-pin
  tests vs MagicMock agents and junk flag values; cap=1 e2e proving the
  refunded pre-API defer leaves the budget for the provider-proven
  413 retry.
- tests/agent/test_preflight_lock_defer.py — a lock-skipped preflight
  pass stops the loop WITHOUT arming preflight_compression_blocked;
  plain no-op still arms it; MagicMock junk does not defer.
- tests/gateway/test_compression_deferred_soft_result.py — AST pin that
  the deferred branch guards the auto-reset chain and performs no
  session mutation (mirrors test_35809_auto_reset_clean_context.py).
2026-07-23 16:23:57 -07:00
helix4u 056a40aa4d fix(agent): defer turns during compression lock contention instead of exhausting
A lock-loser compression pass returns its input unchanged, which the
automatic compression sites misread as 'cannot compress further': the
preflight loop armed the insufficient-progress blocker, the pre-API gate
burned a shared attempt, and a lock-contended 413/overflow retried into
the attempt cap and returned compression_exhausted — which the gateway
answers with a full session auto-reset (#9893/#35809). A temporary
concurrent-compression defer wiped the session.

Consume the landed #69870 lock-skip signal on every automatic path
(preflight in turn_context, pre-API pressure gate, 413 handler, overflow
handler, post-tool compaction): when a pass no-ops AND the type-pinned
lock-skip flag is set, refund the attempt (never count it toward the cap
or the insufficient-progress blocker), and when the turn cannot proceed
(provider already proved the request does not fit) end it with a soft
compression_deferred result — distinct from compression_exhausted — so
the gateway keeps the session intact and the next message retries after
the concurrent compressor finishes.

The new compression_skipped_due_to_lock() reader is type-pinned
(is True or isinstance(str)) per the MagicMock auto-attribute rule, and
compress_context() now also clears the signal at the very top of every
attempt (per-attempt state rule, #58629/#69853) so a stale value can
never make a later breaker/codex no-op look like lock contention.

Salvaged from PR #49874; rebuilt on main's #69870
_compression_skipped_due_to_lock signal instead of the PR's parallel
_compression_deferred_by_lock triple.
2026-07-23 16:23:57 -07:00
Teknium fdefb2d38c fix(compression): prefer psutil.pid_exists for lease liveness probe; add same-pid self-reclaim guard
Hardening on top of the salvaged dead-PID lease reclamation from PR #65775
(@the3asic):

- Probe via psutil.pid_exists (hard dependency; CONTRIBUTING.md critical
  rule #1) with the contributor's os.kill(pid, 0) POSIX probe retained
  only as a scaffold-phase fallback when psutil is missing.
- Same-process holders (pid == os.getpid()) are never probed and never
  self-reclaimed — another thread's live lease is owned by the lease
  refresher/release path.
- Any probe doubt (exceptions, permission errors) conservatively keeps
  the lease until normal TTL expiry; Windows stays TTL-only.
- Tests: psutil-first dead-pid reclaim (probe call pinned), os.kill
  fallback path, probe-doubt keeps lease, same-pid no self-reclaim,
  legacy holder + Windows paths assert NO probe via either API.
2026-07-23 16:21:19 -07:00
3ASiC 6ab8428b88 fix(compression): keep PID probing POSIX-only 2026-07-23 16:21:19 -07:00
3ASiC 8cd49c496f fix(compression): reclaim locks from dead processes 2026-07-23 16:21:19 -07:00
golldyck 69339aab91 fix(compaction): skip compression when it can't reduce tokens
compress_trajectory (and _async) replaced the compressible middle region with
a [CONTEXT SUMMARY] turn without checking that the region is actually larger
than the summary. When a large protected system prompt dominates the budget,
the compressible middle can be tiny; replacing e.g. a 2-token middle with a
~60-token summary GROWS the trajectory (tokens_saved negative), marks it
was_compressed, and still spends a summarization call — the opposite of the
intent, on exactly the hard over-budget cases.

Add a net-savings guard mirroring the code's own comment (net_savings =
region_tokens - summary_target_tokens): if the safely-compressible region is
no larger than summary_target_tokens, return the trajectory unchanged. Applied
to both the sync and async paths. Add sync+async regression tests.
2026-07-23 16:21:03 -07:00
Rain bc7212cf93 feat(moa): per-reference-model max_tokens override
MoA reference_max_tokens is preset-level — one cap for all reference
models. When mixing a verbose model with a terse one, a single cap is
either too tight for the terse model or too loose for the verbose one.

Now each reference slot can optionally carry its own max_tokens:

  reference_models:
    - provider: openrouter
      model: deepseek/deepseek-v4-pro
      max_tokens: ***        # per-slot cap, overrides preset-level
    - provider: openai-codex
      model: gpt-5.5
      # no max_tokens → falls back to preset-level reference_max_tokens

_clean_slot (moa_config.py) preserves an optional max_tokens field on
the slot dict, coerced via _coerce_int_or_none. _run_reference
(moa_loop.py) reads slot-level max_tokens first, falling back to the
preset-level cap passed by the caller. Slots without the field are
unaffected — backward compatible.

Type hints on slot-handling functions updated from dict[str, str] to
dict[str, Any] to reflect the now-heterogeneous slot shape.
2026-07-23 16:17:27 -07:00
auiandClaude Fable 5 ead9d7b256 test: cover gemini-native max_tokens forwarding in _build_call_kwargs
Requested in review: builder-level assertions that the gemini-native
branch forwards max_tokens (provider names and the native
generativelanguage.googleapis.com base_url, max_tokens=600), plus a
control showing gemini models on OpenAI-compatible endpoints — including
Gemini's own /openai compatibility endpoint — keep the existing omission
behavior (#34530).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:17:27 -07:00
aui 4ee74fa5df fix: forward max_tokens to gemini-native so MoA reference cap applies
_build_call_kwargs omitted max_tokens for every provider except
anthropic-compat endpoints and NVIDIA NIM. Gemini's native
generateContent maps max_tokens -> maxOutputTokens and, when it is
omitted, applies a fixed 65,535-token ceiling (not "the model's full
budget"), so dropping the value made MoA's reference_max_tokens a
silent no-op for gemini advisors — they ran effectively uncapped
(observed ~2900 output tokens against a configured cap of 600),
inflating per-turn MoA latency.

Forward max_tokens for the gemini-native path (provider name or native
base_url). Gemini supports maxOutputTokens, so the cap is safe here;
providers that reject max_tokens (Copilot, GPT-5 max_completion_tokens,
ZAI vision) are unaffected — they still omit it as before.
2026-07-23 16:17:27 -07:00
Janig88 3dce1b967f fix(auxiliary): scope max_tokens to moa_reference only (not aggregator)
Per review feedback from teknium1: reference_max_tokens is an advisors-only
contract. The aggregator is the acting model and must not be capped by the
reference budget. Changed _is_moa from startswith('moa_') to exact match on
'moa_reference'. Added regression test proving aggregator does NOT receive
max_tokens.
2026-07-23 16:17:27 -07:00
Janig88 289fad1868 fix(auxiliary): thread task=task through _build_call_kwargs in fallback helpers 2026-07-23 16:17:27 -07:00
Janig88 3616ce006a fix: use auxiliary_max_tokens_param for Copilot GPT-5 compat
Copilot review pointed out that hardcoding kwargs['max_tokens'] would
400 on models requiring max_completion_tokens (GPT-5 family, Copilot).
The existing auxiliary_max_tokens_param() helper already selects the
correct parameter name per model — use it instead of hardcoding.

Test updated to parametrize expected_key so the Copilot gpt-5.5 case
correctly asserts max_completion_tokens instead of max_tokens.

Addresses Copilot review comments on both files.
2026-07-23 16:17:27 -07:00
Janig88 32a4faa2d5 fix(auxiliary): honor max_tokens for MoA reference/aggregator tasks
PR #56756 added reference_max_tokens to cap MoA advisor output and cut
turn latency. The value is correctly threaded through five layers of MoA
code (moa_config → conversation_loop → aggregate_moa_context →
_run_references_parallel → _run_reference → call_llm(task='moa_reference',
max_tokens=800, ...)).

However, _build_call_kwargs() in auxiliary_client.py silently drops
max_tokens for all OpenAI-compatible providers (PR #34845, which fixed
endpoints and NVIDIA NIM keep it. This means reference_max_tokens never
reached the API for the vast majority of providers.

The bug affects every OpenAI-compatible MoA reference/aggregator slot:
Z.AI (coding plan), OpenRouter, OpenAI, GitHub Copilot, and local
providers. Only Anthropic-compat endpoints (MiniMax, /anthropic URLs)
worked — by coincidence, not MoA-aware design.

Fix: thread the 'task' parameter through all six _build_call_kwargs()
call sites. When task starts with 'moa_', max_tokens is always included
in the request kwargs regardless of provider. Non-MoA auxiliary tasks
(compression, titles, vision, etc.) keep PR #34845 behavior unchanged.

Verified end-to-end:
- Z.AI GLM-5.2 with max_tokens=50 → returned exactly 50 tokens
- Z.AI GLM-5.2 with max_tokens=20 → returned exactly 20 tokens
- Z.AI GLM-5.2 uncapped → returned 315 tokens
- 7 new regression tests covering 4 providers, Anthropic wire, non-MoA
  tasks, and prefix-matching boundary
- 288 auxiliary_client tests pass (was 281, +7 new), 84 MoA tests pass
- Zero regressions
2026-07-23 16:17:27 -07:00
srojk34 cc1725cbe5 fix(moa): stop reference_max_tokens from also capping the aggregator
aggregate_moa_context's single max_tokens parameter was applied to
both the reference fan-out (_run_references_parallel) and the
aggregator's own synthesis call_llm. #53580 explicitly removed a
hardcoded cap from the aggregator call because it truncated long
aggregator syntheses; #56756 (reference_max_tokens, added to speed up
the advisor fan-out) reintroduced the same shared cap by passing it to
both calls, silently regressing #53580's fix.

Rename the parameter to reference_max_tokens (matching the caller's
own moa_config key) and stop forwarding it to the aggregator's
call_llm invocation, which now always runs uncapped as intended.
2026-07-23 16:17:27 -07:00
ethernet 5be99b6fce ci(js-tests): split check into parallel matrix shards per workspace (#70252)
Every npm workspace package now defines check:* scripts (check:unit,
check:lint, check:bundle, check:typecheck, etc.) that fan out to
separate matrix runners in CI. The check umbrella script chains all
shards for local dev.

The matrix discovery in the workspaces job queries npm workspaces,
finds check:* scripts (in package.json insertion order), falls back to
check when none exist, and emits an include matrix. No hardcoded
package names — the workflow is fully auto-derived from workspace
metadata.

Previously every package ran a single check script on one worker, and
the fix step (lint:fix + prettier) ran as a separate CI step with
special-cased run_fix gating to avoid running on every shard. Now that
lint is just another check:lint shard, the run_fix field and the fix
step are gone entirely — lint runs in its own runner like everything
else.
2026-07-23 18:45:16 -04:00
Prathamesh Chaudhari 4c9628eab5 fix(anthropic): coerce empty/whitespace-only text blocks on the request path (#69512) (#69517)
An assistant message with an empty or whitespace-only text content block —
produced by context compression or certain tool-call flows — is rejected by
the Anthropic Messages API with HTTP 400 "text content blocks must contain
non-whitespace text". Because the blank block is stored in session history and
replayed verbatim every turn, the session is permanently wedged behind the same
400.

The Bedrock adapter already guards this via _safe_text() (#9486); the native
Anthropic path never got the same treatment. _sanitize_replay_block() rebuilt
text blocks with the raw stored text, and the _convert_assistant_message()
guard only caught a fully empty block list, not a list still containing a
whitespace-only text block.

Add a _safe_text() helper mirroring the Bedrock one and apply it at both points:
the ordered-blocks replay path and a final in-place walk of the converted
content list. Both are self-healing — sessions that stored blank blocks recover
on the next API call. Only text blocks are coerced; thinking/tool_use/image
blocks are untouched.

Fixes #69512
2026-07-23 16:36:37 -04:00
hermes-seaeye[bot]andgithub-actions[bot] 053f162f94 fmt(js): npm run fix on merge (#70291)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 20:23:02 +00:00
SHL0MSandSHL0MS 53bdcacf17 fix(desktop): stop assistant reply rendering twice after a tool-call turn (#70232)
The renderer showed two assistant bubbles for one turn — a partial
streamed copy plus the clean final copy (#63679). A reload fixed it, so
the persisted transcript was correct; this was live reconciliation.

Root cause is in completeAssistantMessage (use-message-stream/index.ts).
message.interim fires for BOTH verify-on-stop candidates AND ordinary
tool-call turns (tui_gateway _load_interim_assistant_messages), and the
interim seal clears streamId + sets interimBoundaryPending. So at
message.complete the streamId fast-path is skipped and it enters the
fallback. There the settle-onto-interim branch was gated on
responsePreviewed — true ONLY for verify-on-stop. A normal tool-call
turn whose final text matched its sealed interim satisfied neither
settle branch and fell through to append a brand-new bubble. Two rows,
distinct ids, id-based dedup cannot collapse them → renders twice.

Fix: settle onto the sealed interim whenever the final CONTINUES it —
final == interim, final starts with interim (streamed + trailing delta),
or interim starts with final (streaming dropped characters). This is
gated on existing.interim so it only ever collapses a genuine sealed
interim, and a genuinely DIFFERENT final still appends as its own
bubble. responsePreviewed is retained as an OR so the verify-on-stop
continuation-budget case (final text rewritten, no shared prefix) still
settles as before.

The prior test that asserted a non-previewed identical interim+final
produced TWO bubbles was encoding the bug; rewritten to assert one.
Added coverage: prefix-extended non-previewed final collapses; a
genuinely different final still appends (no over-collapse).

Community analysis on the issue (seedSeenBubbleKeys / kind-guard) was
against a pre-refactor v0.7.0 bundle — those symbols no longer exist;
this is the current-code root cause.

Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
2026-07-23 16:13:01 -04:00
ethernet 75afaf46da test(gateway): use valid API server key in env override test (#70274)
Keep the explicit-disable regression test on the usable-key path after
the API server loader began rejecting weak API_SERVER_KEY values.
2026-07-23 19:56:28 +00:00
Teknium c4f5a45d5d fix(gateway): tolerate bare GatewayRunner instances in the ignored-channel guard
Gateway tests construct GatewayRunner via object.__new__ without __init__;
the new #51899 guard accessed self.config directly and crashed those
runners (AttributeError). Use getattr with None default — the guard
no-ops without config, matching the documented object.__new__ pitfall.
2026-07-23 12:01:24 -07:00
Teknium 66513768c1 chore: map contributor emails for mwbrooks and replygirl
Note: #51899's commit was authored as a placeholder identity
(hermes-agent@example.invalid); it was re-stamped to @byshubham's
numeric noreply on this branch, so no mapping file is needed. Other
salvaged commits use GitHub noreply addresses.
2026-07-23 12:01:24 -07:00
replygirl d9fe008db8 fix(slack): prefer live send adapter and try multi-workspace tokens individually
Two related Slack delivery fixes for send_message text sends:

- Route Slack text delivery through _send_via_adapter so the live
  in-process gateway adapter (multi-workspace aware, channel→client
  mapping, adapter-side gates) is preferred, with the plugin's
  _standalone_send as the out-of-process fallback — matching how the
  media path already behaves.
- _standalone_send: SLACK_BOT_TOKEN can be a comma-separated list in
  multi-workspace installs and slack_tokens.json carries OAuth
  per-workspace tokens; the standalone Web-API path used to send the
  literal comma-joined string, which Slack rejects as invalid_auth.
  Try each token individually, retrying on token-scoped errors
  (invalid_auth / not_in_channel / channel_not_found …) and stopping on
  terminal ones. User-DM resolution (U…/W… targets) also tries each
  token.

Adapted from #47547 by @replygirl — the original patched the legacy
tools/send_message_tool.py::_send_slack helper, which moved to the
Slack plugin's _standalone_send in #41112.

Salvaged from #47547
2026-07-23 12:01:24 -07:00
dirtyren 8685fea0ce fix(slack): resolve channel IDs to human-readable names
Previously, Slack sessions and the channel directory stored raw channel
IDs (e.g. C0ATFHY907L) as chat_name, making it impossible for operators
to identify channels in send_message listings or session data.

Changes:
- Add _channel_name_cache and _resolve_channel_name() to SlackAdapter
  that calls conversations.info (channels) or users.info (DMs) with
  in-memory caching to avoid repeated API calls
- Use resolved names in _handle_slack_message() build_source() calls
- Use cached names in _seed_assistant_thread_session()
- Enrich session-sourced entries in channel_directory._build_slack()
  by cross-referencing API results and falling back to conversations.info
  + users.info for DMs and private channels not in the bot's scope

Fixes: channel directory and session origins now show readable names
like 'general' or 'John Doe' instead of 'C0xxx' / 'D0xxx'.
2026-07-23 12:01:24 -07:00
Teknium da131aef3a feat(slack): bridge slack.ignored_channels through the YAML→env config path
Follow-up to the #51899 pick, folding in the config-bridge half of the
competing PR #46925 (@bhanusharma, earliest submitter for the
ignored-channel gate):

- _apply_yaml_config: translate config.yaml slack.ignored_channels into
  SLACK_IGNORED_CHANNELS (list or CSV), env-var-wins like every other
  bridged Slack key.
- SlackAdapter._slack_ignored_channels / gateway.run's
  _slack_ignored_channels_from_gateway_config: fall back to the
  SLACK_IGNORED_CHANNELS env var when PlatformConfig.extra carries no
  value, so top-level slack: blocks (which flow through the env bridge,
  not extra) are honored at both the adapter and runner gates.
- conftest: force-clear SLACK_ALLOWED_CHANNELS / SLACK_IGNORED_CHANNELS /
  SLACK_DISABLE_DMS between tests (config-loader side-effect leak class).
- Tests: env-bridge translation + precedence in test_config.py, env
  fallback + extra-wins in test_slack_runner_ignored_channels.py.

Credit: #46925 by @bhanusharma proposed the same gate with the YAML→env
bridge; #51899 (picked as the base) carries the wider outbound/runner
coverage. Closes #46925 as consolidated here with first-submitter credit.
2026-07-23 12:01:24 -07:00
2001Y 0a5d8a16fc feat(slack): support long app descriptions in the manifest generator
Add --long-description / --long-description-file to `hermes slack
manifest` so the generated app manifest can carry Slack's
display_information.long_description (175–4,000 characters), with
validation of the length bounds, mutual-exclusion with --slashes-only,
and UTF-8 file input. Also propagate the manifest command's exit status
through cmd_slack so validation failures reach the shell.

Squash of the two commits from PR #65256 — one commit per contributor
on this salvage branch.

Salvaged from #65256
2026-07-23 12:01:24 -07:00
AhmetArif0 805c22c836 fix(streaming): add Slack streaming=false default to match Discord
PR #37303 added per-platform streaming defaults and the commit message
explicitly called out "Discord/Slack/etc. only have edit-based streaming
(repeated editMessage), which flickers and is noticeably jankier" — but
only discord.streaming=false was shipped. Slack uses the same edit-based
streaming mechanism and has the same flicker problem, yet it was left to
follow the global switch (default true when streaming is enabled).

Add "slack": {"streaming": False} to DEFAULT_CONFIG["display"]["platforms"]
alongside the Discord default. The same deep-merge semantics apply: a user
who explicitly sets display.platforms.slack.streaming: true keeps their
value unchanged. The dashboard schema gains a slack.streaming toggle
automatically since it is generated from DEFAULT_CONFIG.

Update test_per_platform_streaming_defaults.py to cover slack in all
existing assertions and rename the resolver test to reflect both platforms.
2026-07-23 12:01:24 -07:00
Michael Brooks 2946805299 feat(slack): set HermesAgent User-Agent on slack-bolt client
Hermes already identifies itself on outbound calls to model providers
and monitoring endpoints — see hermes_cli/models.py and
plugins/model-providers/gmi/__init__.py
("Attribution so GMI can identify traffic from Hermes Agent"). The
Slack adapter is the one outbound surface that doesn't carry the
same identifier, so HermesAgent-driven Slack traffic is
indistinguishable from any other Bolt-Python app at the Slack
platform layer.

This sets `user_agent_prefix=f"HermesAgent/{_HERMES_VERSION}"` on the
AsyncWebClient instances constructed in gateway/platforms/slack.py
and threads them through AsyncApp via its `client` kwarg. Both
kwargs are first-class in slack-sdk and slack-bolt; no new
dependencies. Resulting header looks like:

    HermesAgent/<version> Python/3.x slackclient/3.x ...

No behavioral change for users — the Slack API ignores User-Agent
semantically; it lands in logs and analytics. Reversible.

Tests in tests/gateway/test_slack.py:
- TestSlackUserAgent pins the prefix shape and runs connect()
  end-to-end (multi-token config) to assert every AsyncWebClient
  carries the prefix and AsyncApp receives the pre-built client.
- TestSlackProxyBehavior fakes updated to tolerate the new kwargs
  via **_kwargs so they don't break on future passthroughs.
2026-07-23 12:01:24 -07:00
annguyenNous c5b62fdbae fix(gateway): guard chained .get() against None intermediate values
.get("key", {}) only applies the default when the key is ABSENT.
When the key exists with value None (null in JSON), .get() returns
None and the subsequent .get() raises AttributeError.

Fix: replace .get("key", {}).get(...) with (.get("key") or {}).get(...)
which handles both missing keys AND None values.

8 instances across 6 files:
- gateway/run.py: tool_call function name check
- acp_adapter/server.py: tool name/description extraction
- gateway/platforms/qqbot/onboard.py: API response task_id
- gateway/platforms/yuanbao.py: message content parsing (x2)
- gateway/platforms/slack.py: block text extraction (x2)
- tui_gateway/server.py: error message extraction
2026-07-23 12:01:24 -07:00
David Metcalfe 241bc112e8 fix(platforms): clear home channel when setup prompt left blank
Blank (or whitespace-only) answers to the home-channel prompt in the
interactive setup wizards previously left any previously saved
*_HOME_CHANNEL / *_HOME_ROOM env value in place, so operators could not
clear a stale home channel by re-running setup. Strip the prompt input
and call remove_env_value() on blank answers across the Discord, Slack,
Feishu, Matrix, Mattermost, WeCom and WhatsApp plugin setup wizards,
with per-adapter wizard tests covering set/clear/whitespace flows.

Squash of the three commits from PR #58421 (setup-wizard fix, matrix/
wecom extension, and 6-adapter test coverage) — one commit per
contributor on this salvage branch.

Fixes #12423
Salvaged from #58421
2026-07-23 12:01:24 -07:00
vexclawx31 ee62aab1a7 fix(slack): honor disable_dms setting 2026-07-23 12:01:24 -07:00
byshubham f8f5ce7da5 fix(slack): honor ignored channels before gateway dispatch 2026-07-23 12:01:24 -07:00
Teknium 543941f709 fix(slack): tolerate bare adapter instances in _remember_channel_team ambiguity map
The C13 log-noise test (merged while this branch was in flight) builds a
bare SlackAdapter without __init__; the new _channel_teams ambiguity map
crashed it. getattr-guard, same pattern as the sibling defensive inits.
2026-07-23 12:00:34 -07:00
Teknium 5ee1c426de chore: map contributor emails for C9 salvage (jordanhubbard, trac3r00, benjamin2026-dot) 2026-07-23 12:00:34 -07:00
Teknium 9439f11717 test(slack): pin download-token workspace routing (#59742)
Five regression tests for _resolve_download_token and the download
helpers: explicit team wins, URL-embedded team id routes to the owning
workspace, unknown/no-match fall back to the primary token, and an
end-to-end _download_slack_file_bytes call asserts the Authorization
header carries the owning workspace's token.

A/B: all five fail with the fix commit reverted, pass with it applied.
2026-07-23 12:00:34 -07:00
benjamin2026-dot ca8aee87e8 fix(slack): route file downloads to the owning workspace via URL-embedded team id
Salvaged from #59742 (downloads half only). Main already resolves the
event-level team id from the channel→workspace cache before downloads
(the file-EVENT half was covered by #30456), but when neither the event
nor the cache knows the workspace, both download helpers silently fell
back to the PRIMARY workspace token — Slack then returns an HTML login
page instead of file bytes for any non-primary workspace file.

Slack private file URLs embed the owning workspace id
(files-pri/<TEAM_ID>-<FILE_ID>/...), so _resolve_download_token now
prefers: explicit team_id -> URL-embedded team id -> primary token.
Both _download_slack_file and _download_slack_file_bytes route through
it.

Deferred from #59742 (out of this correctness cluster's scope): the
channel→team disk persistence, the pre-send conversations.info probe
loop, and the thread-file ingestion feature.

Reapplied from #59742 by @benjamin2026-dot onto the current adapter
(original targeted the pre-#30456 download sites).
2026-07-23 12:00:34 -07:00
Teknium c1c991ae9e fix(gateway): unify legacy-Slack-key recovery on the claim-once path
Conflict-resolution follow-up composing #68925 (Bob) with the already-
applied #20583/#66398 (jordanhubbard) recovery design:

- #68925's caller-level second _query_recoverable_session pass (via
  lookup_session_key=) referenced a variable that no longer exists —
  the legacy exact-key fallback now lives INSIDE
  _query_recoverable_session, which also claims the legacy key once per
  process and rewrites the peer row to the scoped key. Drop the dead
  caller-level pass.
- Keep #68925's _recovered_row_matches_source_scope origin guard wired
  into both recovery paths: a scoped channel lookup refuses rows whose
  recorded origin names another workspace (or no workspace at all).
- Routing-index migration adoption policy documented at the site:
  origin names a workspace -> exact match only; scope-less DM -> first
  workspace claims once; scope-less channel -> refuse.
2026-07-23 12:00:34 -07:00
Bob 06be0e69b6 fix(gateway): namespace Slack sessions by workspace 2026-07-23 12:00:34 -07:00
Teknium ed67f9aacc test(slack): adapt main's marker-mechanism tests to workspace-scoped markers
Two tests on main pinned the pre-#20583 bare-ts marker mechanism
(_mentioned_threads entries and _slash_command_contexts key shape).
The salvaged workspace scoping intentionally changes both:

- _mentioned_threads now records (team_id, ts) markers when the event
  carries a team id, so the top-level-mention test asserts the scoped
  tuple.
- _slash_command_contexts keys are (team_id, channel, user) 3-tuples
  when the slash payload includes team_id.

Follow-up to the #20583 cherry-pick (jordanhubbard).
2026-07-23 12:00:34 -07:00
Jordan Hubbard a60b00e12d fix(slack): isolate workspace-local routing 2026-07-23 12:00:34 -07:00
Teknium f50c3d904c fix(delegation): persist origin_session_id in durable dispatch records
origin_session_id (the api_server wake self-post target) lived only in
the in-memory record: durable dispatch persistence and abandoned-
delegation recovery omitted it, leaving completions recovered after a
process restart unroutable to api_server sessions. Persist it in the
async_delegations table (CREATE TABLE + ALTER TABLE migration for legacy
DBs), restore it on recovery, and expose it via get_durable_delegation.

Also adds the contributors mapping for ianks (PR #64998 author).
Follow-up to #64998 (sweeper review F3).
2026-07-23 11:55:17 -07:00
Teknium 0796a981d7 fix(api_server): bind chat_id (raw session id) on /v1/runs
/v1/runs bound only session_key at its _bind_api_server_session call, so
tools.async_delegation._current_origin_session_id() — which reads the
request-scoped HERMES_SESSION_CHAT_ID — returned "" on that route and
runs-originated background delegations stayed forced-sync with no wake
target. Bind chat_id/session_id the same way the other agent-entry routes
do via _run_agent(). Follow-up to #64998 (sweeper review F2).
2026-07-23 11:55:17 -07:00
Teknium 2cc0ff44b6 fix(kanban): advance notify cursor only after a successful wake self-post
On non-push adapters (api_server) the wake self-post IS the delivery, but
the cursor advanced before the self-post ran and a failed/exhausted post
was swallowed by the best-effort except — permanently losing the event.

Reorder the else-branch: for non-push adapters run the self-post FIRST and
only advance the cursor once it succeeds. A failure rewinds the pre-send
claim (same guarantee as the existing SendResult(success=False) path) so
the next tick retries, with the same MAX_SEND_FAILURES drop threshold.
Push-capable adapters keep the pre-existing advance-then-best-effort-wake
behavior. Follow-up to #64998 (sweeper review F1).
2026-07-23 11:55:17 -07:00
Ian Ker-Seymer 246eacea7b fix(gateway): deliver kanban/delegate wake-ups to api_server sessions
Wake-ups for kanban notifications and background delegation completions were
injected via handle_message() using a build_session_key()-derived key, which
can never match the raw X-Hermes-Session-Id key that api_server sessions run
under — so the wake landed in a session nobody was reading. On top of that,
ApiServerAdapter.send() reports failure without raising, and that was treated
as a successful delivery, so the notify cursor advanced past events that were
permanently lost; and background delegation was forced synchronous on
api_server since there was no way to wake the session afterward.

Fix: route wake-ups for non-push adapters through a self-post to
/v1/chat/completions with the original session id, treat non-raising send
failures as failures (rewind instead of advancing the cursor), and re-enable
background delegation whenever a session id is available to wake.

The origin session id is captured from the request-scoped api_server chat_id
binding rather than HERMES_SESSION_ID: constructing a child agent calls
set_current_session_id() with the subagent's internal id, clobbering that
variable right before dispatch would read it and misrouting the wake into
the subagent's own session.

Related: #56580, #64609, #53027, #63169, #56531, #50319, #64113
2026-07-23 11:55:17 -07:00
Teknium 185b08a2eb chore: add contributor mapping for elphamale 2026-07-23 11:55:01 -07:00
Teknium cd6fb2b167 fix(prompt): scope api_server MEDIA: hint to actual interception behavior
Correction to the previous commit (PR #68402): the claim that api_server
never intercepts MEDIA: tags is inaccurate on current main.
_resolve_media_to_data_urls() (gateway/platforms/api_server.py) DOES
inline image MEDIA: tags (<=5MB, image extensions only) as base64 data
URLs on the four main endpoints (_handle_session_chat,
_handle_session_chat_stream, _handle_chat_completions, _handle_responses).

The real gaps elphamale's PR points at are narrower:
- the /v1/runs output path (_handle_runs) never calls the resolver;
- non-image filetypes are never resolved anywhere (_MEDIA_IMG_EXT is
  image-only).

Reword the hint to teach both halves: images via MEDIA: work on the
chat/completions/responses endpoints; non-image files and anything on
the runs endpoint must fall back to plain file paths in the response
text. Update the test to pin the scoped guidance instead of a blanket
prohibition.
2026-07-23 11:55:01 -07:00
elphamale 08abc5eba8 fix(prompt): forbid MEDIA: tags in the api_server platform hint
Every PLATFORM_HINTS entry for a messaging platform (Telegram, WhatsApp,
Discord, Slack, Signal, WebUI, desktop) teaches the model the MEDIA:/path
convention because an interception mechanism actually resolves it there
(native attachment delivery, or a validated/inlined data URL). The cli
entry, which has no such mechanism, explicitly tells the model NOT to use
it and to state the path in plain text instead.

The api_server entry had neither instruction. Its /v1/runs handler never
routes the final response through any MEDIA: resolver (confirmed against
source: none of the four call sites of the api_server module's media-tag
resolver are inside its runs-endpoint handler), so a MEDIA:/path tag there
renders as inert literal text in the API response — exposing a raw host
filesystem path to the caller with no delivery ever taking place. Nothing
platform-specific told the model not to use a convention it's correctly
taught for several sibling platforms in this same dict, so the general
cross-platform habit could surface here too, unlike cli where an explicit
prohibition already closes the gap.

Mirrors cli's prohibition, adapted for api_server's actual constraint: no
"state the path in plain text" fallback, since a typical API caller has no
filesystem access to the host at all. Points at "a registered file-delivery
tool" generically rather than naming any specific tool, since api_server
toolsets are deployment-defined.
2026-07-23 11:55:01 -07:00
Teknium 25b3cd2ced fix(desktop): i18n the gateway auth error summary + clean regex char classes
Follow-up to the salvaged PR #39439:
- Replace the hardcoded English gateway-auth summary with a
  notifications.errors.gatewayAuthFailed i18n key (en/ja/zh/zh-hant + types)
- Fix the malformed ['"'] regex character classes (duplicate quote) in
  notifications.ts error matchers
- Add regression test: gateway_auth_failed maps to the gateway auth
  summary, provider invalid_api_key still maps to the OpenAI summary (#39365)
2026-07-23 11:54:47 -07:00
annguyenNous 32f7c5afaf fix(gateway): distinguish gateway auth 401 from provider API key errors
The api_server adapter returned error code "invalid_api_key" for
API_SERVER_KEY authentication failures, which the Desktop error
classifier misidentified as a provider (OpenRouter/OpenAI) key
problem — showing "OpenRouter API key missing" when the real issue
was gateway auth.

Changes:
- gateway/platforms/api_server.py: return "gateway_auth_failed" code
  with descriptive message for API_SERVER_KEY auth failures
- apps/desktop/src/store/notifications.ts: add "gateway_auth_failed"
  handler before "invalid_api_key" to show correct error message
- agent/error_classifier.py: add "gateway_auth_failed" to auth patterns
- tests: update test_session_api.py to expect new error code

Fixes #39365
2026-07-23 11:54:47 -07:00
Teknium 6ced760a3d test(gateway): cover gateway.api_server YAML discovery + extra bridge; docs: document config.yaml support
Follow-up for salvaged PR #66633 (fixes #66630):
- 5 tests: nested gateway.api_server discovery, port/key/host/model_name
  bridged into extra, explicit extra wins over top-level key, non-platform
  gateway keys (streaming/timeout) not misparsed, gateway.platforms.api_server
  path regression-guarded.
- docs: api-server.md (en + zh-Hans) now documents the gateway.api_server
  YAML section instead of 'not yet supported'.
2026-07-23 11:54:32 -07:00
kyssta-exe 53e6435908 fix(gateway): parse gateway.api_server YAML config section (#66630)
The gateway ignored  config when defined through
YAML (config.yaml). The API server only started when environment
variables like  and  were set,
even though other gateway subsections like  were
processed correctly.

Two changes in gateway/config.py's load_gateway_config():
1. Merge platform configs placed directly under gateway.*
   (e.g. gateway.api_server) via _merge_platform_map, matching
   the existing gateway.platforms.* and platforms.* merge
   paths.  Only keys matching known Platform values are picked up;
   non-platform keys like streaming are safely ignored.
2. Bridge api_server-specific keys (port, key, host, cors_origins,
   model_name) from the top-level config block into the extra
   dict so PlatformConfig.from_dict preserves them — matching what
   _apply_env_overrides already does for env var values.
2026-07-23 11:54:32 -07:00
cifangyiquan 089f09fa5f fix(api_server): mark rejected API_SERVER_KEY as non-retryable fatal error
When _api_key_passes_startup_guard() rejects the key (missing,
placeholder/too short, or fail-closed unverifiable strength), connect()
returned a bare False with no fatal-error info. gateway.run's reconnect
watcher treats that as transient and re-queues with backoff forever —
each retry re-instantiating the adapter and its ResponseStore sqlite
connection. Observed in production (#37011): ~501 leaked connections
(1002 fds) over ~2.5 days until EMFILE made the whole gateway
unresponsive.

Set a non-retryable fatal error (api_server_key_invalid) in connect()
when the guard rejects, covering all three rejection branches, so the
platform drops from the reconnect queue; recover with
`/platform resume api_server` after fixing the key. Same treatment as
the port-conflict guard (api_server_port_in_use, #65665 / bda8bd76a8).

Tests mirror the port-conflict precedent: each rejection path asserts
connect() is False, has_fatal_error True, fatal_error_retryable False,
and fatal_error_code api_server_key_invalid, plus a strong-key control.

Re-implementation of #38803 by @cifangyiquan against current main —
their patch targeted the old inline guard in connect() which was since
extracted to _api_key_passes_startup_guard() (and gained the
fail-closed branch in 683059feb5), so the original diff no longer
applies. Their production diagnosis and non-retryable direction
preserved.

Refs: #38803, #37011
2026-07-23 11:54:19 -07:00
arimu1andClaude Sonnet 4.6 9e4b89857a fix(gateway): require usable API_SERVER_KEY to enroll the api_server platform at load time
Salvaged from PR #36180 (commits 68dfeb4b16 and 86f437509b by arimu1),
re-applied onto current main with the incidental black-reformat churn
stripped out (~1,700 lines -> the semantic change + tests).

Previously gateway/config.py enrolled the api_server platform on
`api_server_enabled or api_server_key`, so API_SERVER_ENABLED=true with
no key (or a weak/placeholder key) still loaded the platform: the
adapter is instantiated (ResponseStore/SQLite opened in __init__), the
reconnect watcher spins, and the startup guard refuses at connect() —
logging errors forever. Now the platform is enrolled only when
API_SERVER_KEY passes the same strength bar as the adapter's startup
guard (has_usable_secret, min_length=16), via a shared
_has_usable_api_server_key() helper.

The no-op `lambda cfg: True` connected-checker for API_SERVER is also
replaced with the same key check, so get_connected_platforms() only
reports the platform "up" when it could actually start.

Known limitation (intentionally out of scope): a YAML config with
`platforms.api_server.enabled: true` and no key still loads the
platform; this gate covers the env-override path only.

Dropped from the original PR: EMAIL/SMS checker additions (scope creep
beyond the PR title; absent on current main) and the wholesale black
reformat of gateway/config.py and tests.

Fixes #36111

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:54:05 -07:00
Teknium 1f45504609 chore(contributors): map 2001Y machine-local email 2026-07-23 11:50:28 -07:00
Teknium 80dcff8458 chore: map C11 salvage contributor emails + fixture kwarg fix
shivasymbl, z23, Skywind5487, 2001Y, gonzalofrancoceballos, kylezh.
briandevans' noreply mapping already present; mzkarami's mapping file
shipped inside #66204's own commit.

Also fixes the TestFormatMessageTableIntegration fixture to match
PlatformConfig's current signature (no 'name' kwarg).
2026-07-23 11:50:28 -07:00
Teknium eaa35ae68f fix(gateway): balance code fences on every remaining chunk-split path
Widen #48476's fence guarantees to the two splitters that still emitted
fence-broken chunks:

* GatewayStreamConsumer._split_text_chunks (fallback final send): close
  the orphaned ``` at each chunk boundary and reopen it — with the
  original language tag — on the next chunk, mirroring
  BasePlatformAdapter.truncate_message's contract.  Headroom is reserved
  so balanced chunks stay within the platform limit.
* Slack block_kit._split_text (3000-char section chunking): same
  close/reopen balancing for mrkdwn section text carrying fences.

With these, every chunk boundary — non-streaming send
(truncate_message), streaming overflow (_truncate_for_stream via
adapter.truncate_message per #45938), fallback final
(_split_text_chunks), final-send balance (ensure_closed_code_fences),
and Block Kit section splits — delivers fence-balanced chunks.

Regression tests probe each path with fenced fixtures, assert per-chunk
balance, limit compliance, language-tag reopening, and prose passthrough.
2026-07-23 11:50:28 -07:00
shivasymbl c934c533db feat(slack): opt-in Block Kit markdown block rendering for standard markdown
Slack's Block Kit `markdown` block accepts standard markdown (tables,
headers, task lists, fenced code with syntax highlighting, links) and
lets Slack translate it natively — eliminating the lossy markdown→mrkdwn
conversion for the rendered layout.  Enable via
platforms.slack.extra.markdown_blocks.

Safety rails added on top of the original design:

* opt-in (default off) — Slack documents the block for 'apps that use
  platform AI features' and does not guarantee availability across all
  app types / surfaces, so unconditional adoption is not safe yet
* the mrkdwn-converted text field is ALWAYS kept as the
  notification/search/accessibility fallback
* content over Slack's 12k cumulative markdown-block cap declines to the
  rich_blocks renderer / plain text path
* the existing block-rejection retry (invalid_blocks / msg_too_long /
  too_many_blocks) re-sends the plain mrkdwn payload, so an unsupported
  surface degrades gracefully instead of dropping the message
* when both modes are enabled, markdown_blocks is preferred over the
  local rich_blocks renderer; rich_blocks remains the fallback

Adapted from #8554 by @shivasymbl — the original patched the deleted
gateway/platforms/slack.py and switched unconditionally; reimplemented
against the plugin adapter's _maybe_blocks/sanitize_blocks pipeline.

Fixes #8552.
2026-07-23 11:50:28 -07:00
Kyle Zhang a7738796e1 feat(slack): wrap and align GFM tables for proper mrkdwn rendering
Slack mrkdwn has no table syntax — GFM pipe tables render as literal-pipe
noise with a raw |---|---| separator row.  Wrap detected tables in ```
fences so they render as monospace preformatted text, and pad cells to
per-column max display width (East-Asian Wide / Full-width chars counted
as 2 columns) so columns stay aligned even with CJK content.

Tables already inside fenced code blocks are left untouched, and the
emitted fences carry no language tag so they compose with the
lang-tag-strip pass.  This covers the plain-mrkdwn text path; the opt-in
rich_blocks path already renders native Block Kit table blocks.

Reapplied from #16648 by @kylezh — the original patched
gateway/platforms/slack.py, which was migrated to
plugins/platforms/slack/adapter.py in the plugin migration.

Fixes the non-rich_blocks table path described in #8552.
2026-07-23 11:50:28 -07:00
2001Y 72f714ca36 fix(gateway): preserve adapter overflow splitting in streams 2026-07-23 11:50:28 -07:00
2001y b86ca7cae5 fix(gateway): keep overflow stream chunks editable 2026-07-23 11:50:28 -07:00
skywind b932997f44 fix: also close orphaned single-backtick inline-code spans
ensure_closed_code_fences previously only handled triple-backtick
(```) code-block fences. Single backtick (`) inline-code spans have
the same problem: an orphaned opening backtick causes the remainder
of the message to render as inline code on Discord and other platforms.

After balancing triple-backtick fences, strip complete ```...```
regions and count remaining standalone backtick markers. If odd,
append a closing backtick. Same trade-off as the triple-backtick fix:
a stray closing backtick may create a brief empty inline-code span,
which is far less harmful than the rest of the message being inline code.
2026-07-23 11:50:28 -07:00
skywind 3e2fe3fcab fix: close orphaned code fences on all send paths
Adds `ensure_closed_code_fences()` helper to detect text with an odd
count of triple-backtick markers (indicating an unclosed code block)
and append a closing fence.

Applies the fix to all four identified gap paths:
  G1: truncate_message early-break path for final chunk
  G2: _send_or_edit streaming edit path (most commonly hit)
  G3: overflow split first chunk (covered by G2's fix)
  G4: _send_fallback_final fallback send path

Closes: #TBD
2026-07-23 11:50:28 -07:00
skywind 9ed5aef1e6 test: code fence tracking coverage for all send/split paths
- A/B/C: truncate_message with reasoning format, carry_lang, multiple blocks
- D: last-chunk early-break gap
- E: _filter_and_accumulate preserves triple-backtick outside think blocks
- F: _split_text_chunks has no fence tracking
- G: reasoning truncation (short pass-through, long gap)
- H: stream consumer accumulator has no fence state
- I: fix stub
- J: edit path bypasses truncate_message entirely
- K: overflow split first chunk via edit path
- L: fallback final + split_text_chunks noop

Four gaps identified:
  G1: truncate_message last-chunk early-break path
  G2: streaming edit path bypasses truncate_message
  G3: overflow split first chunk via edit path
  G4: split_text_chunks + truncate_message no-op
2026-07-23 11:50:28 -07:00
skywind 5e44413b7c fix: escape triple-backtick in reasoning before wrapping in outer code block
B1: When reasoning content contains ``` e.g. model quoting code in
its thinking, wrapping it in an outer ``` for display causes the
inner fence to break the outer block.

Adds escape_code_fences_for_display() in gateway/stream_consumer.py,
called from gateway/run.py before wrapping reasoning in the outer
``` display block.
2026-07-23 11:50:28 -07:00
mzkarami 132e0005c8 fix(slack): avoid duplicating rich-text link messages 2026-07-23 11:50:28 -07:00
Gonzalo Franco Ceballos cc64f0289a fix(slack): add bold-text zero-width-space guard for trailing non-word chars
Slack's mrkdwn parser can fail to recognize the closing * of a bold span
when it is immediately preceded by a non-word character (), ], }, ., :,
em-dash, ...), mis-rendering the span and in reported cases truncating
the rest of the message.  Insert a zero-width space (U+200B) between the
last character and the closing * whenever the last character is not
alphanumeric or underscore.

Reapplied from #35144 by @gonzalofrancoceballos — the original patched
gateway/platforms/slack.py, which was migrated to
plugins/platforms/slack/adapter.py in the plugin migration.
2026-07-23 11:50:28 -07:00
z23 b810711e4e fix(gateway): strip language tag from Slack fenced code blocks
Slack's mrkdwn does not strip the optional language tag from fenced
code blocks like GitHub-flavored markdown does — it renders
```text\nfoo\n``` as a code block whose literal first line is "text".
The agent emitted ```text fences around raw command output, which
surfaced "text" as the first line of every such block.

Drop the tag from the opening fence in format_message() before stashing
the block behind a placeholder. Stripping only fires for a genuine
opening fence — a ``` at the start of a line, tagged with a single
token (no spaces or backticks) — and the original line ending is
preserved. The fence-protection regex deliberately matches loosely, so
a mid-line ``` (e.g. an inline ```span``` wrapping across a newline)
can be grouped as an "opening fence" whose first line is real content;
differential fuzzing against the pre-change formatter (40k generated
messages) confirms the only behavioral delta is the tag strip itself.

The Block Kit renderer is unaffected: render_blocks() intercepts fences
itself before mrkdwn_fn is applied, so this only changes the mrkdwn
surfaces that still go through format_message() — the plain-text
fallback field (notifications, search indexing, accessibility),
slash-command ephemeral replies, and standalone cron delivery.

Originally written against gateway/platforms/slack.py; ported to
plugins/platforms/slack/adapter.py after the adapter migration in
5600105478.

Manually verified against a live Slack workspace (pre-migration
adapter; the ```text case strips identically) — code blocks no longer
carry a literal "text" first line.
2026-07-23 11:50:28 -07:00
briandevans 8e6d1a9a53 fix(slack): stop double-decoding HTML entities when escaping message text
format_message unescapes already-escaped input before re-escaping, so that
pre-escaped text doesn't get double-escaped. That unescape was three
sequential str.replace calls, which re-scan each other's output:

    "&amp;lt;"  --(&amp; -> &)-->  "&lt;"  --(&lt; -> <)-->  "<"

The & produced by the first replace pairs with the following "lt;" and
decodes a second time. "&amp;lt;" is the wire form of the literal text
"&lt;", so the text is silently destroyed: Slack receives "&lt;" and renders
"<". Anyone writing about HTML or markup ("&amp;lt;b&amp;gt;" -> "<b>")
loses their literal text, with no error.

re.sub scans left-to-right and never re-scans its own replacements, so a
single pass fixes it. The escape pass on the next line is left untouched --
it is correctly ordered (& first, so the &s it inserts aren't re-escaped).

Only the double-decode cases change; every other input is byte-identical
before and after. This is the same round-trip invariant the neighbouring
test_pre_escaped_{ampersand,lt,gt}_not_double_escaped tests already assert,
extended to the case they miss. Affects the plain mrkdwn path (send,
edit_message) and Block Kit sections, which route section text through
format_message.
2026-07-23 11:50:28 -07:00
Teknium 50a6dc7efc fix(gateway): duck-type set_reaction_handler on adapter wiring sites
Test doubles and third-party adapter objects don't all implement the new
set_reaction_handler; calling it unconditionally hung the multiplex
secondary-reconnect test (the AttributeError was swallowed into an
awaited-forever path). getattr-guard all three wiring sites — same
duck-typing convention the sibling setters rely on for MagicMock, but
explicit, so bare objects work too.
2026-07-23 11:50:08 -07:00
Teknium 1f25791b1f chore(release): map C17 reaction-cluster contributor emails
- john.kattenhorn.personal@gmail.com -> johnkattenhorn (#33111)
- harrison@medmetricsrx.com -> harrisonmedmedmetrics (#44508)
- kevin@fleetsmarts.net -> Kev-fs (#45265)
2026-07-23 11:50:08 -07:00
558dab0e3e feat(slack): opt-in reaction triggers, removed events, hooks, channel handoff
Build the full reaction pipeline on top of the #29916 base:

- Opt-in gate: slack.reaction_triggers (default OFF — reaction events
  stay acked-and-dropped so busy channels don't wake the agent on every
  emoji). 'true' routes reactions on the bot's OWN messages; an explicit
  emoji-name list routes those emojis from any message (handoff flows).
- reaction_removed events now route too, distinguished by the
  cross-platform text convention reaction:added:<emoji> /
  reaction:removed:<emoji> (matches the Feishu and Photon adapters, so
  agents and skills see one shape everywhere).
- Authorization: the reactor becomes the synthesized message's user, so
  the early _is_user_authorized gate and allowed_channels whitelist
  apply exactly as for typed messages. _hermes_force_process only skips
  the mention requirement (a reaction on the bot's own message is
  definitionally addressed to the bot), mirroring Feishu/Photon.
- Gateway hooks (#33111 by @johnkattenhorn): every human reaction on a
  message item fires reaction:added / reaction:removed through the new
  BasePlatformAdapter.set_reaction_handler → GatewayRunner
  ._handle_reaction_event → HookRegistry.emit, independent of the
  routing opt-in. Documented in hooks.md.
- Channel handoff (#45265 by @Kev-fs): slack.reaction_trigger_target
  routes the reaction turn to a configured channel (top-level via
  _hermes_no_thread_response + reply-anchor suppression in
  gateway/platforms/base.py) or C123:<ts> thread.
- Manifest: reaction_removed event subscription added alongside
  reaction_added/reactions:read.
- Docs: slack.md Reaction Triggers section; hooks.md event table rows.

Also credits #44508 by @harrisonmedmedmetrics (inbound reaction_added
handling — same plumbing class, superseded by this consolidated shape).

Co-authored-by: johnkattenhorn <john.kattenhorn.personal@gmail.com>
Co-authored-by: Kev-fs <kevin@fleetsmarts.net>
Co-authored-by: harrisonmedmedmetrics <harrison@medmetricsrx.com>
2026-07-23 11:50:08 -07:00
Benjamin Ross 9c9b057b73 fix(slack): forward reaction_added events to the message pipeline
Slack reaction_added events were explicitly acked and dropped, so a user
reacting to a bot message (👍 to approve, ✅ to acknowledge) produced
nothing. Forward them through the normal message pipeline as synthesized
MessageEvents whose text is the reaction emoji (translated to unicode
for common names), keeping the downstream auth gate, thread-context
fetch, dedup, and skill routing unchanged.

- Self-reactions and non-message items are dropped; reactions on
  messages not sent by this bot are dropped (Feishu-adapter parity).
- The reacted-to message's thread parent becomes the synthesized
  thread_ts so the reaction lands in the same session as a reply would.
- Manifest gains reactions:read scope + reaction_added bot event.

Salvaged from PR #29916 by @bpross.
Related: #33111, #44508, #45265 (same cluster).
2026-07-23 11:50:08 -07:00
Teknium 9ddcb58a21 chore(contributors): add email mapping for metamon-p (PR #36220 salvage) 2026-07-23 11:49:44 -07:00
metamon 91799405aa fix(gateway): hint Slack/Discord channels at the prior auto-reset session
Salvaged from PR #36220, ported onto the current SessionStore (SQLite-
backed get_or_create_session; activity check is last_prompt_tokens) and
the sidecar-note reset path (context notes now ride turn_sidecar_notes
instead of prepending to context_prompt).

Long-lived Slack/Discord channels/threads lose their context on
daily/idle session resets, and the agent can bind a new request to an
unrelated recent session (observed: a Discord thread reset caused a PR
in the wrong repository). Record prev_session_id when an auto-reset
replaces a session with real activity, persist it, and append a
deterministic one-line hint to the auto-reset context note pointing the
agent at session_search for that specific prior session. No LLM calls,
no channel-history APIs, no extra DB lookups; other platforms and
activity-free resets are untouched.

Refs #36220. Co-authored-by: metamon <269728612+metamon-p@users.noreply.github.com>
2026-07-23 11:49:44 -07:00
MrAbsaroka 61ea271900 feat(slack): elapsed typing heartbeat — 'still working… (2m03s)' on long turns
Salvaged from PR #45702 (heartbeat half). A multi-minute turn showed a
static 'is thinking...' assistant status that reads as stuck and provokes
mid-turn 'you there?' pings. Derive the fallback status label from the
turn's elapsed time (>=30s → 'still working… (NmSSs)'), riding the
existing _keep_typing refresh — zero extra API calls.

Ported onto the current plugin adapter: the start time rides the tracked
_active_status_threads entry (workspace-scoped key), so it shares the
existing bounds/eviction and resets when stop_typing clears the status.
Explicit live-status phrases (set_status_text) and configured
typing_status_text always win; only the built-in default label changes.

The PR's other half (top-level channel follow-up coalescing) is NOT
included — dispatch semantics changed on main (busy-input active-turn
redirect, #30170 demotion) and need a fresh design pass.

Refs #45702. Co-authored-by: MrAbsaroka <mrabsaroka@gmail.com>
2026-07-23 11:49:44 -07:00
Teknium 8d1b1372e9 test(gateway): pin automatic /goal continuation drain — no user nudge required
Issue #47699 reported Slack /goal continuations being enqueued by
_post_turn_goal_continuation -> _enqueue_fifo but never drained until the
next real inbound message woke the session. On the current tree the
continuation lands in the adapter pending slot while the
_process_message_background frame is still live, so the in-band pending
drain (and the finally-block late-arrival drain) spawns the follow-up
turn automatically — the reported stall is not reproducible on main.

Pin the two halves of that contract so it can't silently regress:
  1. a continuation placed in the FIFO during the handler frame is
     consumed as a second turn without any new user message, and
  2. the runner's goal hook enqueues under the same session key the
     adapter drain resolves (key mismatch would orphan the event).

Refs #47699. Reported-by: joesu-angible
2026-07-23 11:49:44 -07:00
Teknium 6bb0eac398 fix(gateway): don't re-deliver consumed background completions as raw watcher messages
process(wait) marks a completion consumed and returns the exit code +
output inline. The gateway process watcher's agent-notify branch honored
that (skipping the synthetic agent turn), but its skip FELL THROUGH to
the plain text-notification branch, which re-sent the same completion to
the chat as a raw '[Background process ... finished with exit code ...]'
message — a duplicate delivery of output the agent had already read and
was summarizing (observed on Slack with
display.background_process_notifications: all, but platform-agnostic).

Guard the raw-notification branch on is_completion_consumed(), same as
the agent-notify branch. poll() stays read-only and never marks consumed
(#10156), so status checks still can't suppress autonomous delivery.

Fixes #65379. Reported-by: hergert
2026-07-23 11:49:44 -07:00
ethernet a4bc1ca502 fix(timeline): persist typed display events (#69771)
* fix(desktop): hide persisted agent-only history scaffolding

Filter verification-stop nudges and context-compaction handoffs at the
stored-history mapper boundary. Preserve a real reply when a compaction
handoff shares its stored message.

* test(desktop): build persisted E2E sessions through the real agent

Drive tui_gateway.entry over its stdio JSON-RPC transport against the mock
provider, wait for real completion events, and persist normal session history
through AIAgent and SessionDB. Migrate resume and hidden-history coverage,
including real compression and live verify-on-stop scaffolding, then remove
the unused direct SessionDB import scripts.

* fix(desktop): use the provisioned Python for real-session E2Es

Run the stdio gateway through uv's synced project environment outside the
Nix dev shell, while retaining the fully provisioned Nix Python when the
shell advertises HERMES_PYTHON_SRC_ROOT.

* fix(nix): expose the provisioned Python environment to uv

Mark the Nix-built Python environment active in the dev shell so the shared
E2E session builder can always run through `uv run --active --no-sync`.

* fix(timeline): persist typed display events

* fix(timeline): strip display-only fields from provider payloads, preserve through rewrites, fix /resume display history

Three review findings from PR #69771:

1. Provider payload leak: display_kind and display_metadata were forwarded
   to the provider API as unknown message fields. Strict OpenAI-compatible
   backends can reject the next request after a model switch or resumed
   typed event. Strip both from the per-request api_msg copy in
   conversation_loop alongside the existing api_content pop.

2. Rewrite/import data loss: _insert_message_rows preserved display_kind
   but silently dropped display_metadata. After replace_messages,
   archive_and_compact, or session import, async-delegation completion
   events lost their task counts and fell back to generic display text.
   Add display_metadata to the INSERT columns and bind tuple.

3. CLI /resume stale recap: startup --resume A set _resume_display_history
   from A's lineage. A subsequent in-session /resume B loaded B only into
   conversation_history via get_messages_as_conversation, leaving the stale
   A display projection. _display_resumed_history preferentially read the
   stale attribute, showing A's recap for B. Switch /resume to
   get_resume_conversations and update _resume_display_history alongside
   conversation_history.

Tests: 890 Python (5 files), 35 desktop TS — all green.

* feat(tui): render typed display events as ◈ markers in the Ink TUI

The TUI was not handling display_kind at all — model switch markers and
async delegation completions rendered as opaque user messages with the
full [System: ...] text, and hidden compaction handoffs were visible.

Wire display_kind through the full TUI chain:

- _history_to_messages (tui_gateway/server.py) forwards display_kind
  and display_metadata to the gateway transcript payload.
- GatewayTranscriptMessage (gatewayTypes.ts) gains both fields.
- Msg.kind (types.ts) gains 'event' value.
- toTranscriptMessages (domain/messages.ts) maps:
  - hidden → skip entirely
  - model_switch → event "model changed"
  - async_delegation_complete → event "N background agents finished"
    (or "background agent work finished" without metadata)
- messageGroup (blockLayout.ts) routes event to its own group, with
  SELF_SPACED + PAINTS_TRAILING_GAP so it owns its margins.
- messageLine.tsx renders event-kind as a dim ◈ marker with no gutter,
  matching the CLI's ◈ event rendering.
- 4 new TUI tests for hidden/model_switch/async_delegation mapping.

TUI typecheck: clean. TUI lint: 0 errors (2 pre-existing warnings).
TUI tests: 9 passed (1 pre-existing failure on main, unrelated).
2026-07-23 14:46:24 -04:00
joaomarcos beffbab3d7 docs(bedrock): add cachePoint architecture infographic
Verified line-by-line against agent/bedrock_adapter.py on this branch:
allowlist split (Nova primary, Claude bearer-token fallback only,
everything else rejected), exact system→tools→messages[-2] insertion
order, and the usage normalization formula/field mapping.
2026-07-23 11:45:07 -07:00
joaomarcos 4dccfcd9b7 feat(bedrock): add Converse API prompt caching (cachePoint)
Claude-on-Bedrock already gets prompt caching via the AnthropicBedrock
SDK path. This adds it to the raw Converse API path used for non-Claude
models (Amazon Nova, and Claude when bearer-token auth forces Converse
routing, #28156) — a conservative model allowlist inserts cachePoint
blocks after tools, system, and the message before the newest turn, and
extracts cacheReadInputTokens/cacheWriteInputTokens into usage so cost
accounting picks them up through the existing Anthropic-style fallback.

Ref: relatorio-cache-performance-provedores-ia.md, P0 item 1.
2026-07-23 11:45:07 -07:00
joaomarcos 12096b1e3d docs: add SQLite FD leak infographic and report updates for #69678 2026-07-23 11:45:07 -07:00
Teknium 8e4b5d8774 harden(slack): CDN-allowlist inbound file URLs, DNS-pin token downloads, widen token-file perms warning
Follow-up hardening on top of the C14 cherry-picks (#57860/#44026/#66742/#60009):

- Slack file downloads (_download_slack_file/_download_slack_file_bytes)
  now require an https URL on a Slack CDN host (files.slack.com,
  *.slack.com Enterprise Grid, *.slack-files.com legacy shares) before
  attaching the bot token. url_private/url_private_download only ever
  point at the Slack CDN, so a forged file object from a malicious
  workspace app or compromised event stream pointing the Bearer-token
  download at an arbitrary PUBLIC host (token exfiltration) is now
  refused — a hole #44026's generic private-IP SSRF check alone could
  not close.
- The same two download paths now use create_ssrf_safe_async_client
  (from #57860) so the preflight-validated hostname is resolved once,
  validated, and dialed by IP — closing the DNS-rebinding TOCTOU window
  for the token-bearing inbound fetches as well.
- #60009's slack_tokens.json permission warning is generalized into
  utils.warn_if_credential_file_broadly_readable() (POSIX-only,
  fail-quiet) and wired into the other read path with the same gap:
  google_chat's load_user_credentials(). google_chat already writes
  0o600 via _write_private_json; the read-time warning covers
  hand-provisioned/legacy files. Nothing in-repo writes
  slack_tokens.json (user/OAuth-provisioned), so there is no write
  path to chmod for Slack.

Security tests both directions: non-CDN/lookalike/http URLs and
connect-time DNS rebinds are blocked before any TCP connect; real
files.slack.com, Enterprise Grid, and slack-files.com URLs still reach
the network layer; 0o600 files stay silent while 0o644/0o640 warn with
a chmod hint. A/B: all 10 new download-guard tests fail with the
hardening reverted and pass with it applied.
2026-07-23 11:44:43 -07:00
AlexFucuson9 fccc222bd7 fix(slack): warn when slack_tokens.json is group/world-readable
The OAuth multi-workspace token file contains plaintext bot tokens for
all saved Slack workspaces. Unlike the Google Chat adapter which sets
0o600 when writing credentials, the Slack token file has no permission
enforcement — a default umask 022 makes it world-readable.

Fix: check file permissions on read and emit a warning log with remediation
instructions if the file is group- or world-readable.
2026-07-23 11:44:43 -07:00
Frowtek 1d7db1ba17 fix(slack): neutralize prompt injection in thread-context backfill
`SlackAdapter._fetch_thread_context` formats each prior thread message as
`{name}: {msg_text}` and joins them with newlines into the block the call
site prepends *raw* into the model turn (`text = thread_context + text`).
Both fields are attacker-influenceable — any thread participant sets their
own Slack display name and message text — and neither was neutralized, so
an embedded newline let a thread message break out of its line and pose as
a fresh markdown section (a fake "## SYSTEM" / "## Override" heading) inside
the context the model reads when first mentioned mid-thread.

This is the same indirect-prompt-injection vector already closed for the
sibling untrusted sinks: the sender-name prefix
(`neutralize_untrusted_inline_text`), the reply quote, and the relay
channel-context renderer. The Slack thread-context backfill — the default
whenever the bot is mentioned in a thread with no active session — was the
missed sink. (The existing `[unverified]` tagging marks *who* a message is
from; it does nothing about newline structure, so an authorized sender can
inject just as easily.)

Flatten both fields with `neutralize_untrusted_inline_text` before
interpolation. The body uses `max_chars=0` so message text is not truncated
(thread context caps the message *count*, never per-message length); the
display name keeps the default bound. `parent_text` keeps the raw message
(its own reply-context sink neutralizes separately). A well-behaved message
is preserved byte-for-byte.

Adds a regression test covering a hostile display name, a hostile message
body, benign passthrough, and the no-truncation guarantee.
2026-07-23 11:44:43 -07:00
zapabob 2e08b778ab fix(security): validate inbound Slack file URLs against SSRF
_download_slack_file and _download_slack_file_bytes fetched Slack-supplied URLs with the bot token attached and follow_redirects=True, but without the is_safe_url pre-flight check or per-redirect guard that the outbound send_image path already uses. A URL that resolves to (or 3xx-redirects into) a private/internal address could reach internal services and leak the bot token (CWE-918). Add the same pre-flight + _ssrf_redirect_guard hook to both inbound sibling paths.
2026-07-23 11:44:43 -07:00
Eugeniusz Gilewskiandteknium1 0cd4afeafd fix(security): guard remaining preflighted HTTP fetches
Several platform fetch paths called is_safe_url before constructing ordinary httpx clients, leaving a second DNS lookup at connection time. This preserved the rebinding window for Slack batch images, Feishu documents, Telegram URL-photo fallback, and WeCom remote media.

Route each path through create_ssrf_safe_async_client and the shared redirect guard so direct connections validate and dial vetted IPs while configured proxies remain an explicit trusted egress boundary. Add per-path regressions that change DNS from public at preflight to metadata at connect time.

The Skills Hub provenance fixture intentionally serves content over loopback. Opt that test-scoped server into private-address access so it keeps exercising the real HTTP transport without weakening production blocking.

Related #8033

Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-07-23 11:44:43 -07:00
Eugeniusz GilewskiandTom Qiao 42626da1ce fix(security): pin DNS resolutions for SSRF-safe fetches
Install connect-time DNS validation for Hermes-owned direct httpx clients so SSRF-sensitive fetch paths dial a vetted IP instead of re-resolving after preflight. This preserves Host/SNI semantics for direct HTTP(S) connections and keeps proxy routing as an explicit trusted egress boundary.

Wire the guarded clients into media cache downloads, vision downloads, Skills Hub direct/raw fetches, and platform attachment fetch paths that already perform SSRF preflight and redirect validation.

Fixes #8033

Co-authored-by: Tom Qiao <zqiao@microsoft.com>
2026-07-23 11:44:43 -07:00
Teknium ca988df8d0 test(slack): importorskip real slack_sdk/slack_bolt in transient-edit tests
CI shards run without the slack extras; the two #64267 tests import the
real SDK (SlackApiError, the lazy-rebind path) and errored with
ModuleNotFoundError. Skip on bare environments — classification coverage
for stdlib exception types (OSError/TimeoutError/cert errors) still runs
everywhere.
2026-07-23 11:36:49 -07:00
Teknium 21ac2b50dc chore: map contributor emails for Slack C6 progress-cards salvage
- rt.cms012@gmail.com -> trac3r00 (#68378; commit authored as
  'Minseo-Choi' — trac3r00's display name, same account)
- 15167896+2001Y@users.noreply.github.com -> 2001Y (#64267)
- hello@jeromeiveson.com -> Trantor-develops (#57196)
- boumagent@gmail.com -> patp (#18859)
- dorukardahan@hotmail.com -> dorukardahan (#17184) was already mapped.
2026-07-23 11:36:49 -07:00
Teknium 5c89cc4359 fix(slack): clear assistant status on every send() exit path
Widening for #24117 ('is thinking...' stuck after the response was
sent): the stuck-thinking class is a missing-cleanup-on-error-path bug.
send()'s status clear was gated on thread_ts already being resolved and
on reaching the normal post-message path, which left the Slack
Assistant status visible when a turn ended through any sibling exit:

- exception BEFORE _resolve_thread_ts (slash-context handling,
  formatting, DM resolution) — the 'if thread_ts: stop_typing' clear
  never ran
- empty/whitespace-only final response (no_text guard early-return)
- ephemeral slash replies (Slack only auto-clears assistant status on
  real thread replies; ephemerals never count)

Add _clear_thread_status_quietly() — a best-effort stop_typing wrapper
that never masks the caller's SendResult — and wire it at every send()
exit plus the finalize paths of edit_message. stop_typing already
handles the untracked-thread fallback (clearing an unset status is a
no-op on Slack's side), so this is pure coverage widening.

Also add the #18859 unit tests for _resolve_progress_thread_id's new
reply_in_thread gate (synthetic-thread drop, real-thread keep, event-id
fallback suppression, default unchanged).

A/B: 3 of the 4 new status-clear tests fail with the adapter widening
reverted and pass with it applied; the fourth pins the existing
cleanup-must-not-mask-result contract.
2026-07-23 11:36:49 -07:00
patp d35b003f02 fix(gateway): respect reply_in_thread=false for Slack progress messages
The Slack adapter honours platforms.slack.extra.reply_in_thread=false
in _resolve_thread_ts, but the Gateway's progress-message path forced
event_message_id as the thread_id for Slack regardless. The first
progress message ('terminal: …', 'Processing…') created a thread that
all subsequent edits and the final answer inherited, defeating the
user's reply_in_thread=false setting.

Check the live Slack adapter's reply_in_thread flag before applying the
event_message_id fallback, and treat a synthetic source.thread_id (==
the event's own message ts, used only for session keying) as 'no
thread' so progress messages stay at the channel/DM top level.

Folds both #18859 commits (reply_in_thread gate + synthetic thread_id
drop) into main's extracted _resolve_progress_thread_id helper — the
original patched the pre-refactor inline block; the gate now composes
as a keyword argument so Mattermost/other platforms keep the default
fallback behavior.
2026-07-23 11:36:49 -07:00
Doruk Ardahan e40a38aa29 fix(slack): avoid assistant status on synthetic top-level threads
When reply_in_thread=false, top-level channel events carry their own
message ts as metadata.thread_id for session keying. Calling
assistant.threads.setStatus on that ts activated a Slack assistant
thread ('is thinking...') before the actual response was sent, and the
flat reply then never cleared it.

send_typing now routes through the same _resolve_thread_ts synthetic-
thread guard as message sending, and the gateway threads message_id
through progress/status metadata so the adapter can distinguish real
threads from synthetic top-level session keys.

Reapplied from #18859-sibling PR #17184 by @dorukardahan (both commits:
fix + progress-metadata test) onto current main via 3-way apply — the
original patched gateway/platforms/slack.py, moved to
plugins/platforms/slack/adapter.py in the plugin migration.
2026-07-23 11:36:49 -07:00
Jerome Iveson 62747aa584 fix(slack): delete stale progress messages 2026-07-23 11:36:49 -07:00
2001Y fa8a3e328e fix(slack): preserve progress edits on network failures 2026-07-23 11:36:49 -07:00
Minseo-Choi f716b876a8 fix(slack): edit status bubbles in place instead of posting new ones
Progress/status callbacks (context-pressure, compression retries,
model fallback) route through _send_or_update_status_coro, which
edits the previous bubble for the same status_key when the adapter
implements send_or_update_status — but only Telegram did. On Slack
every status event posted a fresh thread message, so a compression
retry loop spammed a dozen out-of-order bubbles into the thread
('Context too large 1/3... 2/3... 3/3', fallback switches, etc.).

Implement send_or_update_status on the Slack adapter following the
Telegram pattern (#30045): first call posts and caches the message ts
per (channel, thread, status_key); subsequent calls edit that message
via chat.update. Edit failure drops the cached ts and falls back to a
fresh send. Cache is FIFO-bounded.
2026-07-23 11:36:49 -07:00
Teknium 93a47dd466 chore: map yemi@lagosinternationalmarket.com -> yemi-lagosinternationalmarket
Contributor-email mapping for the #32315 salvage (thread image/file
markers reapplied onto the plugin adapter).
2026-07-23 11:35:03 -07:00
Teknium c132783ea0 test(slack): regression coverage for thread image/file context visibility
Covers cluster C1-images (#69185, #32315, #66136):
- _slack_file_marker unit tests: typed markers per mimetype family,
  hostile-filename sanitization (newlines/brackets can't fake context
  structure).
- _render_message_text appends markers; a caption-less image post no
  longer vanishes from thread context.
- Cold-start hydrate integration: prior-message images surface as
  markers in channel_context; the thread root's image is downloaded,
  delivered as media_urls, and upgrades message_type to PHOTO.
- Failure path: root-image download failure degrades to the marker,
  never blocks the turn.
- Bounds: root delivery capped at _THREAD_ROOT_IMAGE_MAX; non-image
  root attachments stay marker-only (no download).
- One-time delivery: active thread session skips the hydrate → no
  re-download/re-delivery on later turns.
- Composition: the trigger's own event files still ride alongside a
  delivered root image; Slack Connect stubs resolve via files.info;
  the collector never issues its own conversations.replies call.
- Delta refresh (#23918 path): images in new replies past the watermark
  surface as markers, with no root re-download.

A/B: 14 of 15 tests fail with the adapter fix reverted, all pass with
it applied.
2026-07-23 11:35:03 -07:00
Teknium 2d7353cd3b feat(slack): deliver thread-root images on the first mention turn
When the bot is mentioned mid-thread for the first time, the thread root
is very often the artifact the mention is about ("@bot what's in this
chart?" posted as a reply under an image) — but the root's image never
reached the agent, so it answered blind.

On the cold-start hydrate path (and only there), _collect_thread_root_images
reads the root message from the thread-context cache the immediately
preceding _fetch_thread_context call just populated (zero extra Slack API
calls in the normal case), downloads its image/* attachments through the
existing authenticated _download_slack_file helper, and delivers them as
media_urls/media_types on the same MessageEvent — upgrading the message
type to PHOTO so vision routing engages.

Scope and safety:
- One-time delivery by construction: the cold-start path is guarded by
  _has_active_session_for_thread, so later turns in the same session can
  never re-download or re-deliver. No new gateway/session plumbing needed.
- Bounded by _THREAD_ROOT_IMAGE_MAX (4); non-image root attachments stay
  text-only markers.
- Slack Connect stubs (file_access=check_file_info) resolve via files.info.
- Best-effort: a failed download degrades to the [image: ...] marker from
  the thread context — never an error turn.
- Also hardens the video mimetype fallback (mimetype can be empty) so
  media_types entries are always non-None strings.

Adapted from #69185 by @KCAYAAI — the original plumbed MessageEvent media
through gateway/base.py, run.py and session.py with durable one-time
delivery markers (2,441 lines); this lands the user-visible behavior
adapter-locally by reusing the session guard already on the hydrate path.
2026-07-23 11:35:03 -07:00
Yemi 26a2fda8d7 fix(slack): surface thread images/files as markers in fetched thread context
Images and files posted in a Slack thread before the bot joins were
invisible to the agent: _fetch_thread_context renders text only, and a
caption-less image post was dropped from context entirely (empty text →
skip). "@bot what do you think of the chart above?" read as a question
about nothing.

_render_message_text now appends a compact, sanitized marker per file
attachment — [image: chart.png], [video: demo.mp4], [audio: note.m4a],
[file: report.pdf (application/pdf)] — so the agent can SEE that prior
thread messages carried attachments and ask for a re-share when it needs
the bytes. Filenames are stripped of newlines/brackets so a hostile name
can't fake context structure. Because both thread-context formatting and
parent-text rendering go through _render_message_text, markers appear on
the cold-start hydrate, the explicit-mention delta refresh, restart
rehydration, and reply_to_text.

Reapplied from #32315 onto the current adapter (original patched the
pre-plugin gateway/platforms/slack.py, moved in the plugin migration;
annotation labels reworked to per-file typed markers, download side
handled separately).
2026-07-23 11:35:03 -07:00
Teknium 3ec5a06f4f test(slack): mark block-privacy fixture message as human-authored (client_msg_id)
#58478's caplog test predates main's unlabeled-bot users.info probe
(#69xxx wave-2 gating): events without client_msg_id now hit
_resolve_user_is_bot, which the fixture's mock client doesn't wire up
(AttributeError on _user_is_bot_cache). Real human-authored Slack
messages carry client_msg_id — add it to the fixture so the test
exercises the intended block-extraction path.
2026-07-23 11:34:11 -07:00
Teknium bc56002124 chore(contributors): add email mappings for slack C13 log-noise salvage
- sdevinarayanan@asymbl.com -> shivasymbl (#38847)
- mycodeisbad@gmail.com -> peterw (#69028; commit author name 'wpeterr'
  — PR opened by the peterw account, mapping follows the PR author)

LeonSGP43, ooiuuii, ygd58 mappings already present; nanckh and
haran2001 author via GitHub noreply addresses (no mapping needed).
2026-07-23 11:34:11 -07:00
Teknium 44f6f8435b test(slack): behavioral log-noise/privacy suite + keep clarify choice text out of INFO logs
Follow-up hardening for the C13 log-noise cluster:

- plugins/platforms/slack/adapter.py: clarify button resolution logged
  the full chosen option text at INFO (choice=%r). Choice text is user
  content — log the choice INDEX at INFO and the (truncated, %.100r)
  text at DEBUG only. Widens #58478's principle: no message content
  above DEBUG level anywhere in the adapter.

- tests/gateway/test_slack_log_noise.py (new): behavioral suite pinning
  the cluster's invariants:
  * catch-all ack registered AFTER every named handler (registration
    order is bolt's dispatch priority — no shadowing);
  * catch-all fires for an unsubscribed event type (reaction_added),
    logs only a DEBUG line naming the type, and never logs content;
  * named handlers still dispatch (message → _handle_slack_message);
  * end-to-end inbound message run leaves NO message text or block
    content in any adapter log record (caplog at DEBUG);
  * #30185's event-arrival diagnostic is metadata-only;
  * clarify resolution: INFO carries index/user only, text is
    DEBUG-only (fails with the adapter fix reverted — A/B verified).

Content-leak audit of all 139 logger call sites in the adapter found
two above-DEBUG leaks: the clarify choice line (fixed here) and none
else carrying message text; remaining sites log error strings, URLs
via safe_url_for_log, ids, and counts. The block-extraction DEBUG
preview was already removed by #58478 (chars= length only).
2026-07-23 11:34:11 -07:00
ygd58andshivasymbl 565ea19a49 test(slack): pin catch-all event matcher registration and non-shadowing
Regression test for the catch-all ack: a re.compile(r'.*') event matcher
must be registered (after every named handler, so it never shadows
message/app_mention/reaction/file routing) and must match unhandled
subscribed event types like member_joined_channel / channel_archive /
pin_added.

Salvaged from #64218 (test half only — its adapter-side catch-all is a
duplicate of #38847, which landed as the base commit of this cluster
with first-submitter credit). Fixes #6572.

Co-authored-by: shivasymbl <sdevinarayanan@asymbl.com>
2026-07-23 11:34:11 -07:00
wpeterr 390ddfd028 fix(slack): quiet Slack display defaults — no heartbeat/busy-ack breadcrumbs in channels
Slack posts are durable workspace messages, not an ephemeral terminal
status area. Default long_running_notifications and busy_ack_detail to
off for Slack so long-running agent work does not leave permanent
operational breadcrumbs like 'Working — 9 min — iteration 12/90' in
channels. Both remain opt-in per platform via
display.platforms.slack.*.

Also covers the platform-generic shutdown-notification mute path with a
regression test (gateway_restart_notification=false must suppress both
the active-session interruption notice and the home-channel copy).

Salvaged from #69028 (quiet-defaults half only). The PR's other half —
the channel_session_scope_channels session-scoping feature — is a new
config feature outside this log-noise cluster and overlaps the session
scoping territory reworked by merged wave-1/2 Slack session work; it is
deliberately not taken here.
2026-07-23 11:34:11 -07:00
luyifan ece3dd1a4f fix(slack): avoid logging block text previews 2026-07-23 11:34:11 -07:00
shivasymbl 93102e91cf fix(slack): add catch-all event handler to prevent Slack auto-disabling Event Subscriptions
Without a catch-all handler, slack-bolt returns HTTP 404 for every
unhandled bot event (user_change, user_huddle_changed, reaction_added,
etc.) and never sends the Socket Mode ack. On active Slack workspaces
where the app is subscribed to high-volume events, this produces a
near-100% un-acked failure rate that crosses Slack's >95%/60-min
threshold and triggers automatic disabling of the app's Event
Subscriptions — silently killing all inbound event delivery.

Place a catch-all re.compile(r".*") handler AFTER the specific event
handlers so bolt's router matches those first. Truly unhandled events
are silently acked (200) and logged at DEBUG. The failure rate stays
near 0% regardless of which events the Slack app manifest subscribes to.

Fixes #6572
2026-07-23 11:34:11 -07:00
nanckh c1529e58da fix(gateway): quiet Slack missing_scope channel directory fallback
Treat Slack users.conversations missing_scope as an expected limited-scope app condition and fall back to session history without recurring warnings.

Add tests for not-ok and SlackApiError-like missing_scope responses.
2026-07-23 11:34:11 -07:00
haran2001 0eb5cb5e07 fix(slack): surface bot-event arrival and allow_bots interop diagnostics (#30091) 2026-07-23 11:34:11 -07:00
LeonSGP43 42534605b7 fix(slack): throttle channel directory warnings 2026-07-23 11:34:11 -07:00
Teknium e027f43f70 fix(gateway): key Slack capability gate into the prompt pin; defer positive note to tool schemas
Follow-up to the #68627 cherry-pick (cluster C15 — Slack platform
capability-note accuracy; earliest report/fix: #6545 by @daikeren):

1. Session/prompt stability: the pinned session-context render
   (_pinned_session_context_prompt) is keyed by _ephemeral_change_key,
   whose contract requires every rendered input to appear in the key.
   The new _slack_tools_loaded() gate reads config + the live MCP
   registration map, so its state is now hashed into the key exactly
   like the existing Discord gate — a gate flip re-renders ONCE (a
   legitimate bust); within a session the note stays byte-stable for
   the life of the conversation (A/B: the new parity test fails with
   this key change reverted, passes with it).

2. Derived, non-overpromising positive note: rather than hardcoding a
   capability list that can drift stale again (the original bug class),
   the tools-present note tells the agent to consult the actual loaded
   Slack tool schemas for supported operations — the schemas ARE the
   source of truth, so the note cannot overclaim ops a given Slack
   toolset/MCP server doesn't expose (e.g. a read-only history server).

3. Tests: parity test proving a gate flip changes both render and key;
   byte-stability test proving three consecutive turns in one Slack
   session return the identical pinned object (sha256-equal); autouse
   fixture pins the new gate so key<->render parity is env-independent.
2026-07-23 11:32:24 -07:00
ygd58 96f21e8a54 fix(gateway): make Slack platform note capability-aware when slack tools present
Ports #63234 forward onto current main per teknium1's review.

gateway/session.py hard-coded the stale-API disclaimer for every Slack
session regardless of whether Slack tools were actually loaded. This
contradicted the system prompt when MCP or native slack tools were
present, causing the agent to refuse Slack API actions it could
actually perform (issue #6536).

Per review, the original predicate only checked the native 'slack'
toolset, missing Slack MCP servers (registered under mcp-<server> in
tools/mcp_tool.py) entirely. _slack_tools_loaded() now checks two
independent paths:

1. Native 'slack' toolset + SLACK_BOT_TOKEN (as before, but now calls
   _get_platform_tools() with include_default_mcp_servers=True instead
   of False, so a default-enabled MCP server also counts).
2. A connected MCP server that has ACTUALLY registered tools into the
   live registry (new tools.mcp_tool.get_registered_mcp_server_names()),
   whose name suggests Slack. This is session-scoped in the sense that
   matters here: MCP servers connect once per gateway process (not
   per-session), so checking the live per-server tool-registration map
   is the correct availability-filtered signal -- unlike the earlier
   get_all_tool_names() approach this replaces, which conflated ALL
   built-in tool names process-wide, this only inspects the small,
   purpose-built MCP server-name map.

Added a real regression test that registers a tool via the actual
tools.mcp_tool._track_mcp_tool_server() tracking function (not a mock
of the capability check) to verify a genuine Slack MCP server is
detected, plus a negative case for an unrelated MCP server.

5/5 Slack-specific tests pass; 126/126 in the full
tests/gateway/test_session.py file.
2026-07-23 11:32:24 -07:00
Teknium 24d7333eb1 test(gateway): cover msgraph_webhook port/host/secret bridging + contributor mapping for #57320
Follow-up to the salvaged PR #57320 commit: the PR bridged port/host/secret
for MSGRAPH_WEBHOOK but only tested webhook and api_server. Adds a test
covering the msgraph_webhook branch including extra-precedence, plus the
contributors/emails mapping for kjames2001.
2026-07-23 11:31:46 -07:00
James Huang 74db4bfe68 fix(gateway): bridge top-level port/host into extra for webhook and api_server
WebhookAdapter and ApiServerAdapter read port/host from config.extra, but
PlatformConfig.from_dict only populates extra from the 'extra:' sub-key in
the YAML platform section. Top-level keys like port and host are silently
ignored, causing the adapter to fall back to DEFAULT_PORT (8644).

This causes silent port conflicts in multi-profile setups: a profile that
configures 'platforms.webhook.port: 8649' still binds 8644, colliding with
the default profile's webhook on the same port.

Fix: extend the shared-key bridging loop in load_gateway_config() to bridge
top-level port/host/secret into extra for WEBHOOK, MSGRAPH_WEBHOOK, and
API_SERVER platforms, following the same pattern already used for
dm_policy, allow_from, gateway_restart_notification, and other keys.

The extra dict takes precedence: if port is already under 'extra:', the
top-level value does not clobber it.
2026-07-23 11:31:46 -07:00
Teknium 6b1b2e6f0e chore: map shubhambc09@gmail.com -> navahc09
haran2001's commit uses the numeric GitHub noreply
(56040092+haran2001@users.noreply.github.com) — no mapping file needed.
2026-07-23 11:31:30 -07:00
Teknium 6d4f7f4049 docs(slack): gap pass — mention-gating decision table, allow_bots deep dive, clarify buttons, ephemeral slash replies, cron/DM targeting
Documents user-facing wave-1+2 Slack behavior that had no docs coverage:
- decision table for require_mention / free_response_channels /
  require_mention_channels / thread_require_mention / strict_mention /
  ignore_other_user_mentions and how they compose
- 'Accepting messages from other bots' section with the post-#69483
  semantics: allow_bots=mentions requires a CURRENT mention from
  peer bots (text or Block Kit blocks); thread state never admits them
- clarify one-tap buttons (choice buttons + Other free-text mode,
  in-place resolution, double-click guard, expiry message)
- slash replies are ephemeral: replace-ack, chunking, 5-post cap with
  explicit truncation notice, postEphemeral fallback, never-public rule
- cron deliver targeting (slack -> home channel, slack:C... channel,
  slack:U... resolved to DM) incl. standalone sender + MEDIA uploads
- send_message media + bare-user-ID DM resolution and caption behavior

Refs #26184.
2026-07-23 11:31:30 -07:00
navahc09 2b226be8e3 docs(messaging): document the '!' prefix for Slack thread commands
Salvaged from #45765 by @navahc09 — kept the PR's callout structure
and placement, rewrote the content to match current behavior:
Slack blocks native slash commands in threads and never delivers them,
so Hermes recognises a leading '!' as an alternate command prefix.
Post-C3 command fixes the bang form also works behind a mention
(@Hermes !cmd) and with leading whitespace; unknown '!' tokens pass
through to the agent unchanged. Cross-linked the detailed
slack.md section.
2026-07-23 11:31:30 -07:00
haran2001 4f7109551a docs(slack): document bot message handling 2026-07-23 11:31:30 -07:00
Teknium ada06ee7b0 test(gateway): loosen hygiene-timeout wall-clock bound to flake policy minimum (#70202)
0.15s missed by 1-8ms on busy CI shards twice today (runs 30025889952,
30026770278 — branches not touching this file). The assertion pins
'handler did not block on the timeout path' (blocking = seconds); 2.0s
keeps the contract per the loose-bounds flake policy.
2026-07-23 11:29:59 -07:00
baenregod 3638136e7e fix(auth): count MoA preset slots as explicit provider configuration
A user who configured a provider only inside a MoA preset (advisor or
aggregator slot) has explicitly opted into that provider — the consent
gate (is_provider_explicitly_configured) now scans moa.reference_models,
moa.aggregator, and all moa.presets.* slots, so Claude Code OAuth pool
seeding and the auxiliary auto-fallback chain treat MoA-only Anthropic
users consistently with model.provider users.

Salvaged from PR #57778 (trimmed): the auxiliary_client fallback half of
the original PR was independently landed on main in ddd3a2d247 and is
dropped here; a secret-scrubber artifact in the gate test fixture is
restored to the real placeholder token.
2026-07-23 11:21:13 -07:00
davidgut1982andClaude Opus 4.8 c7fd3eb377 fix(gateway): honor explicit api_server enabled:false under env key
_apply_env_overrides() force-set ``api_server.enabled = True`` whenever
API_SERVER_KEY (or API_SERVER_ENABLED) was present in the environment.

In multiplex mode, a secondary profile pins
``platforms.api_server.enabled: false`` in its config.yaml so that it
shares the default profile's API-server listener instead of binding its
own port. That profile still inherits the process-level env, including
API_SERVER_KEY, so the unconditional re-enable flipped api_server back on
and tripped the MultiplexConfigError check.

Honor an explicit disable, flagged by ``_enabled_explicit`` in the
platform's extra. Use ``extra.pop("_enabled_explicit", False)``: the
api_server branch is terminal (unlike the migrated plugin platforms, no
later registry pass re-enables api_server), so popping consumes the flag
in a single read and avoids the double-read hazard, while the final
per-platform cleanup remains a no-op.

Adds a regression test asserting that with API_SERVER_KEY set, a config
with api_server explicitly enabled:false + _enabled_explicit:true survives
_apply_env_overrides() as enabled=False (fails without the fix), while the
key is still wired through for the shared listener.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 11:21:06 -07:00
wjq990112 78312c192d fix(moa): preserve custom provider context metadata
Preserve compatible custom provider metadata through MoA aggregator context resolution and cover the resolver and compressor paths.
2026-07-23 11:21:04 -07:00
Teknium 4ab3bf66f1 test(moa): cover the one-shot /moa aggregator path for slot extra_body
Follow-up to #60168's salvage: aggregate_moa_context() is the third
independent MoA call path; assert its aggregator call receives the
custom-provider request_overrides.extra_body via **agg_runtime.
2026-07-23 11:20:54 -07:00
panding 1d603fe822 fix(moa): pass custom extra_body to slots 2026-07-23 11:20:54 -07:00
srojk34 2962ba2b7b fix(auxiliary): treat explicit model:auto sentinel, not just cfg_model
'auto' is a sentinel meaning "inherit from main runtime / auto-detect",
not a literal model id -- already handled for cfg_model (config-derived)
in _resolve_task_provider_model, but not for the explicit `model` kwarg.

MoA reference/aggregator slots (agent/moa_loop.py's _slot_runtime) forward
a preset's `model:` field as this explicit argument rather than through
auxiliary.<task> config, so a MoA preset configured with `model: auto`
(a natural thing to try given the existing auxiliary.*.model: auto
convention) reached this function as the explicit `model` arg and took
the `model or cfg_model` branch, bypassing the cfg_model-only sentinel
check entirely -- sending the literal string "auto" to the wire as a
model id.

Normalize both the explicit `model` and `cfg_model` the same way, fixing
this at the single chokepoint every caller (MoA included) already goes
through, rather than patching moa_loop.py separately.
2026-07-23 11:20:43 -07:00
Teknium 0dbf639bc8 fix(windows): hidden-console daemons — extend the parent-console fix to every detached spawn path (#70205)
Extends the desktop backend's root-cause fix (aa2ae36c3f) to all remaining
console-less parent launch paths. The Windows console-flash class
(#54220/#56747) is governed by the PARENT's console: a DETACHED_PROCESS or
pythonw.exe daemon has no console, so every console-subsystem descendant
(git, gh, cmd, node, wmic, powershell) allocates its own visible conhost —
one flash per spawn, unreachable by any per-call-site CREATE_NO_WINDOW
sweep. Worse, MSDN specifies CREATE_NO_WINDOW is IGNORED when combined
with DETACHED_PROCESS, so the hide bit in the old detach bundle was dead.

Changes:
- _subprocess_compat: drop DETACHED_PROCESS from windows_detach_flags()
  and windows_detach_flags_without_breakaway(); the daemon now owns a
  single hidden console (CREATE_NO_WINDOW) that all descendants inherit.
- gateway_windows: _resolve_detached_python() returns the venv console
  python.exe (no pythonw/base-interpreter detour — the uv-shim flash
  premise only held while DETACHED_PROCESS was masking the hide bit);
  UAC handoff launches console python under SW_HIDE; cmd/vbs launchers
  render console python (vbs runs it window-style 0).
- gateway/run.py: restart watcher keeps sys.executable instead of
  swapping in GUI-subsystem pythonw.
- web_server: dashboard actions spawn sys.executable (already carries
  windows_detach_flags()).

Tests updated to pin the new invariants, including an explicit
DETACHED_PROCESS-must-stay-out regression guard.
2026-07-23 11:13:14 -07:00
Teknium d9165d7a67 fix: resolve current entry unlocked in try_refresh_matching no-hint branch
Follow-up to the #62614 salvage: try_refresh_matching (added by the
#69843 salvage after this PR's base) calls self.current() while already
holding the now-locking non-reentrant pool lock — a guaranteed deadlock
that git merges silently (no textual conflict). Use _current_unlocked()
and cover the method in the no-deadlock test.
2026-07-23 09:31:58 -07:00
solyanviktor-starandClaude Fable 5 769381fb3e fix(credential_pool): complete the locking boundary across the public pool surface
Follow-up to review feedback:

- Acquire self._lock in the remaining public pool-state methods:
  has_credentials, reset_statuses, remove_index, resolve_target, and
  add_entry. All of them read or rebind self._entries (and the mutating
  ones persist auth.json), so they now hold the same lock as select()
  and the query methods. None are called from within the lock, so no
  unlocked helpers are needed.
- Make the blocking test deterministic: an instrumented lock records the
  acquire attempt, and the test first waits for the worker to actually
  reach self._lock before asserting it blocks. Previously an unlocked
  method could pass if the worker thread was scheduled late.
- Extend the lock test matrix to all nine public methods; the five newly
  locked ones fail the test without this fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:31:58 -07:00
solyanviktor-star 5b794c984e fix(credential_pool): acquire the pool lock in has_available/peek/current/entries
`has_available()`, `peek()`, `current()` and `entries()` read (and, via
`_available_entries()`, mutate and persist) `self._entries` without holding
`self._lock`, while every other entry point — `select()`,
`mark_exhausted_and_rotate()`, `acquire_lease()`, `try_refresh_current()` —
guards the exact same access with the lock.

`_available_entries()` is not read-only: it prunes aged-out DEAD manual
entries (rebinding `self._entries` at the prune step) and calls `_persist()`
(writes auth.json). The gateway runs platform adapters in threads and cron
runs jobs in a ThreadPoolExecutor, so a status probe via `has_available()`
or `peek()` can race a concurrent `select()`/rotation: torn iteration of
`self._entries`, interleaved auth.json writes, or a lost token rotation.

Fix: take `self._lock` in all four query methods. Because the lock is
non-reentrant and `peek()` composes `current()` + `_available_entries()`,
add a lock-free `_current_unlocked()` helper and route the already-locked
internal callers (`_select_unlocked`, `mark_exhausted_and_rotate`,
`_try_refresh_current_unlocked`) through it to avoid self-deadlock.

Added regression tests: a no-deadlock check (peek re-entrancy) and a
lock-held-blocks-the-call check for each of the four methods.
2026-07-23 09:31:58 -07:00
AhmadandClaude Fable 5 65d42e35d4 fix(kanban): stop decompose siblings sharing one worktree checkout
Decompose children inherit the root's literal workspace_path (#37172),
so every sibling of a worktree-kind root points at the SAME checkout.
_resolve_worktree_workspace's existing-checkout shortcut then reuses
that directory on whatever branch is currently checked out, ignoring
the task's own branch_name. Net effect: sibling workers — which can be
promoted and dispatched concurrently — run in one directory on the
first sibling's branch, with no lock. Work lands on the wrong task's
branch (provenance corruption) and concurrent siblings trample each
other's index/tree.

Fix, two layers:
- decompose_triage_task: worktree-kind children no longer inherit the
  root's literal path; each child materializes its own
  <repo>/.worktrees/<child-id> at dispatch (dir/scratch inheritance
  unchanged — children legitimately share those).
- _resolve_worktree_workspace: when the requested path is an existing
  checkout of a DIFFERENT branch, fall back to a fresh
  <repo>/.worktrees/<task-id> instead of silently reusing it (heals
  rows that already carry a shared path). Same-branch reuse and the
  no-repo/own-path degenerate cases keep the legacy behaviour.

Tests: tests/hermes_cli/test_kanban_worktree_isolation.py (5); full
test_kanban_db.py + test_kanban_decompose_db.py suites pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:27:13 -07:00
Teknium c2c2449d05 chore(contributors): add email mapping for drleadflow 2026-07-23 09:22:02 -07:00
Teknium 547bf1ee9e test: isolate unmatched-hint regression from live ~/.claude credentials
Follow-up to the #65844 salvage: the new anthropic pool test must stub
read_claude_code_credentials like the sibling tests, otherwise a dev
machine's live claude_code singleton seeds a third entry and the
no-benching assertion fails outside CI.
2026-07-23 09:22:02 -07:00
BladeandClaude Fable 5 3d67f00fe1 fix(credential-pool): stop lost-update cooldown erasure and wrong-key quarantine
Two related races in credential-pool cooldown state:

1. Lost update across processes: write_credential_pool merged only
   entries missing from the caller's snapshot; for entries present on
   both sides the caller's in-memory copy won wholesale. A process
   holding a snapshot taken before another process marked a key
   exhausted would, on its next persist (e.g. a round-robin rotation),
   write the key back as healthy — erasing the cooldown so every
   process resumes hammering a rate-limited key. Merge status fields by
   last_status_at recency: adopt the on-disk status only when it is
   strictly newer AND still binding (DEAD, or EXHAUSTED with an
   unexpired cooldown), and never onto re-authed (token-changed)
   entries, so legitimate expiry-clears and fresh logins are preserved.

2. Wrong-key quarantine: when mark_exhausted_and_rotate received an
   api_key_hint that matched no entry, it fell through to
   current()/_select_unlocked() — on a freshly loaded pool that selects
   the NEXT healthy key and benches it for the full cooldown TTL,
   punishing an innocent credential. When a hint is provided but
   unmatched, rotate without marking anything instead of guessing.

Includes regression tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:22:02 -07:00
Teknium fdd3943cb1 fix(update): survive undeletable untracked files during autostash (#70161)
git stash push --include-untracked exits non-zero when it saved
everything but could not DELETE some swept untracked files from the
working tree (e.g. a root-owned packaging/ directory left behind by a
sudo'd build: 'warning: failed to remove ...: Permission denied').
The updater ran the push with check=True, so this benign partial
failure raised CalledProcessError and aborted the whole update before
it even fetched — reliably, on every run, for any user with an
undeletable untracked path in the checkout.

Fix, both ends of the class:
- _stash_local_changes_if_needed: probe refs/stash before/after the
  push. Non-zero push + fresh stash entry = changes are saved; warn,
  reset the tracked-side leftovers (they're in the stash), and
  continue the update. Non-zero push + NO stash entry = real failure;
  keep aborting.
- _restore_stashed_changes: on restore, those same undeletable files
  still sit in the tree, so 'git stash apply' exits 1 with 'already
  exists, no checkout' even though every tracked change applied and
  nothing was lost. Classify that stderr shape (strictly — any other
  error line still routes to the conflict path) as restored instead
  of resetting the tree and telling the user the restore failed.

Repro'd both halves with real git; behavioral E2E test covers
stash -> checkout -> restore round-trip with an undeletable dir.
2026-07-23 09:08:40 -07:00
Teknium 76d4b65d59 chore(contributors): add email mapping for airclear 2026-07-23 09:08:28 -07:00
Teknium 1f07fae6df perf(credential-pool): persist same-key sibling exhaustion once
Follow-up to the #68565 salvage: batch the sibling _mark_exhausted calls
behind a single _persist() instead of one auth.json write per sibling.
2026-07-23 09:08:28 -07:00
李航 0e15805e25 fix(credential-pool): exhaust all entries sharing a failed API key on 402
A 402/429/401 is an API-key–level failure (account out of balance,
rate-limited, or key rejected), but the same key can back more than one
pool entry — e.g. an explicit pool entry plus a `model_config` entry
auto-seeded from `model.api_key`, both carrying the identical
`runtime_api_key`.

`mark_exhausted_and_rotate(api_key_hint=...)` only marked the *first*
matching entry, leaving the sibling OK. `_select_unlocked()` then kept
handing back the same depleted key, so the billing-recovery `continue`
loop in the conversation retry path never converged: the request hung
until the client disconnected (~2.5min observed against DeepSeek),
emitting only `response.created` with no 402 ever surfaced to the user.

Mark every entry sharing the failed key so the pool can reach the
"no available entries" state and let the error propagate immediately.

Adds a regression test covering two entries backed by the same key.
2026-07-23 09:08:28 -07:00
Teknium 01b0451909 fix(compression): compose the blocked-warning probe with engine preflight
Two composition fixes vs the merged #69865 engine-preflight arm:
1. should_compress_info probe getattr-guarded — minimal compressor
   doubles (SimpleNamespace) and plugin engines lack it; absence means
   no block reason, no warning.
2. Engine maintenance hook stays un-consulted when any skip-branch
   fired (failure cooldown / deferred estimate / codex-native) —
   restoring the #20316 contract the warn-chain restructure broke.
2026-07-23 08:43:21 -07:00
Teknium d5c03fb369 fix(compression): guard overflow-warn dedup reset against minimal test doubles
The dedup-reset calls assumed a full AIAgent; gateway/loop test doubles
built via object.__new__ lack _clear_context_overflow_warn and crashed
in build_turn_context (caught by test_api_content_sidecar on CI slice 3).
getattr-guard all four call sites per the established test-double pitfall
pattern (AGENTS.md #17).
2026-07-23 08:43:21 -07:00
Teknium 1d1b670cb5 fix(compression): reset blocked-overflow dedup on every compression path + noise-filter survival pins
Follow-up fixes for the #62625 salvage:

- Dedup-reset gap (sweeper review): when the block clears while the
  context is STILL over threshold, execution enters the compression
  branch — the PR's 'else' reset never ran, so the warning stayed
  suppressed forever after the first block. _clear_context_overflow_warn()
  now fires on every automatic compression path: turn-context preflight,
  conversation_loop pre-API gate, and the post-tool loop-compaction gate.
- should_compress_info on current main: main refactored should_compress
  into _automatic_compression_blocked()/_locally(); the tuple variant now
  derives its reason from the same in-memory state via
  _compression_block_reason(), keeping cooldown:<s>/ineffective shapes.
- ContextEngine.should_compress_info ABC default now actually returns
  (should_compress(tokens), None) — the PR's default had a docstring but
  no return (returned None, would crash tuple-unpacking call sites).
- Below-threshold guard: the turn-context persisted-cooldown branch and
  the conversation_loop pre-API cooldown branch no longer warn when the
  estimate is under threshold (should_compress_info returns a None
  reason; the preflight pre-check is not a threshold guarantee). The
  pre-API guard also honors compression.max_attempts instead of a
  hardcoded 3, and no longer fabricates a cooldown reason.
- Noise-filter survival (#69550 composition): warning text is now a
  template constant (CONTEXT_OVERFLOW_BLOCKED_WARNING_TEMPLATE) marked
  FAILURE-CLASS, pinned un-swallowed in VISIBLE_COMPRESSION_MESSAGES and
  in new tests that execute the real _TELEGRAM_NOISY_STATUS_RE +
  _prepare_gateway_status_message.
- Contributor mapping for stanislav@local -> sl4m3.
2026-07-23 08:43:21 -07:00
Stanislav 5c8d098eb3 Address sweeper review: safe should_compress_info + cover all guards
- ContextEngine.should_compress_info() default impl so plugin engines
  (e.g. _StubEngine) don't raise AttributeError at the call site.
- Centralise warning/reset in AIAgent._warn_context_overflow_blocked /
  _clear_context_overflow_warn so turn-context and conversation-loop guards
  share identical dedup logic and reset on the real compression boundary.
- Cover conversation_loop.py pre-API (~L1007) and loop-compaction (~L4774)
  guards, not just the turn-context preflight.
- _FakeAgent mirrors the two helpers; test suite green (219 passed).

Fixes #62708
2026-07-23 08:43:21 -07:00
Stanislav b0a88899bf Surface warning when context exceeds compression threshold but compression is blocked
Previously, when a session crossed the compression threshold but compression
was skipped (summary-LLM cooldown, #11529, or anti-thrashing, #40803), the
model kept accumulating context until it hit the hard provider token limit and
silently stopped answering — with no signal to the user about why.

Changes:
- context_compressor.should_compress_info() returns a (should_compress, reason)
  tuple. reason is 'cooldown:<seconds>' or 'ineffective' when compression is
  needed but blocked. should_compress() keeps its bool contract so existing
  callers (conversation_loop.py) and regression #29335 are unaffected.
- turn_context.build_turn_context() emits a deduped _emit_warning when the
  context is over threshold but compression is blocked, advising /new or
  /compress. Dedup keys on the block *kind* (cooldown/ineffective), not the
  ticking countdown, so a cooldown doesn't re-fire the warning every turn.
- Adds tests/agent/test_turn_context_overflow_warning.py covering the tuple
  shape, both block kinds, dedup, and re-fire-after-clear.
2026-07-23 08:43:21 -07:00
Teknium 781968be5e chore: map team@williepeacock.com to peacockesq 2026-07-23 08:34:06 -07:00
Willie Peacock b9b5481d62 fix(kanban): preserve cross-profile project child routing 2026-07-23 08:34:06 -07:00
Willie Peacock 6833eabb53 fix(kanban): isolate worker-created child workspaces
Default kanban_create children now keep fresh scratch paths, while explicit dir sharing remains supported and project context resolves to a per-task worktree. Surface resolved workspace fields in create responses/events and cover scratch mutation, nesting, explicit sharing, and project inheritance.

Fixes #67567
2026-07-23 08:34:06 -07:00
Teknium 4c88e2163b chore: map rmk799@outlook.com to MustafaK99 2026-07-23 08:33:55 -07:00
Mustafa dc3e4e8428 fix(kanban): keep delegated results in worker turn
Dispatcher-spawned Kanban workers are finite one-shot processes, so detached delegation completions can outlive their only consumer. Mark that runtime as unable to deliver async completions and reuse the synchronous delegation fallback, returning required child results before the worker exits.\n\nAlso make unsupported-session notes runtime-generic and cover the delayed-child lifecycle regression.\n\nRefs #63169
2026-07-23 08:33:55 -07:00
happy5318 6bd02ae1a6 feat(image_routing): accept vision alias for custom provider models
Extend the existing candidate-name resolver in _supports_vision_override
to accept 'vision' as an alias for 'supports_vision' on per-model config,
for both the providers.<name>.models dict and the legacy list-style
custom_providers form.

Per review feedback on #31912: this extends the current resolver rather
than replacing its candidate-name logic. Named custom providers resolve
to the runtime value provider='custom' while the config keeps the
user-declared name under model.provider; that lookup path is preserved.

Adds regression tests covering model.provider=my-vllm with runtime
provider='custom' for both config shapes.
2026-07-23 08:32:09 -07:00
nullptr0807 51083d2edc fix(moa): route every Copilot credential path by target 2026-07-23 08:26:57 -07:00
gexin 02f1dd0857 fix(moa): route Copilot slots by target model 2026-07-23 08:26:57 -07:00
Teknium 4cb85fb7fc fix(compress): type-pin the lock-skip signal check at every consumer
The bare truthiness test on _compression_skipped_due_to_lock is fooled
by MagicMock auto-attributes on test-double agents (skill pitfall:
MagicMock defeats hasattr/truthiness duck-typing) — the type-ahead CLI
test's MagicMock agent took the lock-skip branch and skipped the
transcript commit. Real values are None/True/holder-string; pin the
check to 'is True or isinstance(str)' at all three consumer sites.
2026-07-23 08:19:14 -07:00
Teknium eb7be2edde fix(compress): classify unconfirmed lock-acquire failures and cover all manual-compress surfaces
Follow-up to the salvaged #57634 commits:

- agent/manual_compression_feedback.py: new describe_compression_lock_skip()
  — single source of truth for lock-skip wording. A descriptive holder
  string means another compressor CONFIRMED holds the lock ('already in
  progress (holder: ...)'); True/None means acquisition failed without a
  confirmed holder (hermes_state.try_acquire_compression_lock catches
  sqlite3.Error internally and returns False), so the message says
  'could not acquire ... the lock check failed' instead of falsely
  claiming a concurrent compression is running.
- cli.py, gateway/slash_commands.py, tui_gateway/server.py (all three
  in-process consumers: session.compress RPC, command.dispatch compress
  branch, slash.exec mirror) now route through the shared helper.
- tui_gateway/server.py command.dispatch compress branch: catch
  CompressionLockHeld explicitly — it previously fell into the generic
  'compress failed' error handler.
- Deferred-notify contract (#69324): lock-skip discards the pending
  context-engine notification (committed=False) in _compress_session_history
  and the CLI path before returning.
- tests: lock-skip wording pins per surface, VISIBLE_COMPRESSION_MESSAGES
  noise-filter carve-outs for both wordings, MagicMock signal opt-outs for
  sibling tests added on main after the original PR.
2026-07-23 08:19:14 -07:00
Ethan e8000b42e7 fix: prevent stale lock-skip signal leaking between compress_context calls
Advisor review found a critical stale-signal leak: if auto-compress
sets _compression_skipped_due_to_lock during a lock-skip, a subsequent
successful manual /compress will see the stale signal, falsely report
'Compression already in progress', and discard the compression results.

Fix:
- compress_context clears _compression_skipped_due_to_lock = None at
  entry so each call's outcome alone determines the signal.
- Unified gateway 'holder: unknown' drift to match CLI/TUI pattern
  (omit holder clause when not a descriptive string).
- Added MagicMock opt-outs in 3 sibling test files broken by the new
  signal check (test_compress_here, test_compress_focus,
  test_compress_plugin_engine).
- Added stale-signal-leak invariant test proving the fix.
2026-07-23 08:19:14 -07:00
Ethan 07cb4a697e fix(tui): show lock-hold reason when /compress no-ops 2026-07-23 08:19:14 -07:00
Ethan eed6bb14bc fix(gateway): show lock-hold reason when /compress no-ops 2026-07-23 08:19:14 -07:00
Ethan 65145d9c5a fix(cli): show lock-hold reason when /compress no-ops 2026-07-23 08:19:14 -07:00
Ethan b86367e496 fix: signal lock-hold to callers when compression skips 2026-07-23 08:19:14 -07:00
Dickson Neoh 08298dabbd fix(computer-use): handle Linux cua window metadata
Treat cua-driver's Linux `is_on_screen: null` as unknown instead of
off-screen, and skip GNOME Shell desktop/backdrop helper windows
(ding "Desktop Icons", @!x,y;BDHF) when selecting the default capture
target — they are targetable X11 windows but capture as empty.

Reconciled with the _NET_ACTIVE_WINDOW fallback from #58030: helper
windows are filtered out of the candidate pool first, then the tied
z-order active-window probe runs on the remaining real app windows.
Also falls back to the requested app name for _last_app when Linux
windows carry no app_name.

Salvaged from #54173 by @dnth.
2026-07-23 08:11:58 -07:00
Tekniumandlanyusea ec5835ab8b fix(compression): persist anti-thrash state across process restarts (#69872)
The anti-thrash guard (_ineffective_compression_count) was in-memory
only: a fresh compressor bound to a resumed, already-compacted session
started with compression_count=0 and a disarmed guard, so a
near-threshold session could legally re-compact once per process
restart, forever.

Persist the counter through the durable session-state channel,
mirroring the failure-cooldown (#54465) and fallback-streak (af7dceaf7)
pattern:

- hermes_state.py: sessions.compression_ineffective_count column
  (declarative reconciliation adds it on existing DBs) +
  get/set_compression_ineffective_count accessors.
- context_compressor.py: every strike/clear verdict routes through
  _record_ineffective_compression_verdict() which writes through to the
  session row (no-change verdicts skip the DB write);
  bind_session_state() loads the persisted value; the compression
  rotation boundary carries the counter onto the child row;
  update_model()'s reset also clears the durable copy; the
  ineffective-only fast path in _automatic_compression_blocked() is
  removed because the counter is now durable and another agent's clear
  must unblock a stale local snapshot.
- conversation_compression.py: _refresh_persisted_compression_guards
  re-reads the counter alongside cooldown + fallback streak.

Reset semantics are unchanged: any real provider reading below the
threshold still clears the counter — and now clears it durably too.

Resolves the residual gap identified in #54923 by @lanyusea (the
second-threshold mechanism was superseded by persisting the existing
guard state).

Co-authored-by: lanyusea <lanyusea@gmail.com>
2026-07-23 08:08:48 -07:00
teknium1 849c17752d fix(skills/tldraw-offline): correct the computer-use delivery note
The skill claimed Chromium/Electron 'reject synthetic clicks' so computer-use
can't drive the canvas. The Cua team disproved this on the exact v1.11.0
AppImage (Linux/X11): background delivery returns background_unavailable, but
that's the first rung, not a wall — cua-driver returns escalation:'foreground'
and its X11 XTest path (x11_xtest_fg) with delivery_mode:'foreground' clicks
through, dismissing the consent dialog and landing canvas clicks.

Corrected the note to say: climb to foreground on background_unavailable, don't
conclude Electron is unclickable. Ref: NousResearch/hermes-agent#67052.
2026-07-23 08:07:00 -07:00
teknium1 e321b83392 feat(skills): add tldraw-offline agent scripting skill
Optional skill for driving the tldraw offline desktop app via its local
HTTP control API (the same curl-based path the app's own agent skills use
for Codex/Claude Code/Cursor/Gemini) — read the canvas, make live edits,
and write embedded document scripts.

Grounded in the app's bundled script-context.d.ts and agent playbook, and
in the real tldraw SDK v5 shape schema:
- document-script contract: export default function ({ editor, helpers, signal })
- HTTP API: /api/search, /api/doc/:id/exec, /api/doc/:id/script-workspace,
  /api/doc/:id/script-status (bearer token from server.json, re-read per call)
- shape schema table validated against @tldraw/tlschema (scripts/validate_shapes.mjs, 3/3)
- interactive-UI example (scripts/counter.js) + diagram-generation (scripts/main.js)
- honest verification boundary: click->state logic verified via /exec dispatch
  (0->1->2->1->0), with documented host caveats (inotify watcher, Electron
  background-click rejection)

Tests: tests/skills/test_tldraw_offline_skill.py (15 passing).
2026-07-23 08:07:00 -07:00
Drexuxux 683059feb5 fix(api_server): fail closed when API_SERVER_KEY strength can't be verified
`_api_key_passes_startup_guard` refuses to start the API server on a weak
`API_SERVER_KEY`, and its own log says why:

    This endpoint dispatches terminal-capable agent work — a guessable key
    is remote code execution.

But the check is wrapped so that a failure to import it starts the server
anyway:

    try:
        from hermes_cli.auth import has_usable_secret
        if not has_usable_secret(self._api_key, min_length=16):
            ... return False
    except ImportError:
        pass
    return True

`hermes_cli.auth` imports httpx at module scope and pulls in a large slice of
the CLI, so an import failure is not hypothetical — a trimmed image, a partial
install, or a circular import during gateway startup all produce one. When it
happens the strength check silently disappears and only the presence check
above it remains, so a placeholder key passes.

Reproduced against the real guard with the import blocked:

    weak key, normal          : False
    weak key,   ImportError   : True    <-- starts on a 4-char key
    strong key, normal        : True

Fail closed instead: an unverifiable key does not get to expose the endpoint,
and the log names the actual problem so the operator can repair the install.
This is the posture tools/credential_files.py already takes — it refuses a
mount when its deny-list cannot be consulted rather than risking it. The catch
also widens from ImportError to Exception, so an AttributeError or an error
raised inside the check cannot reopen the same hole.

Both happy paths are untouched: a strong key still starts, a weak or missing
key is still refused with the existing messages.

Unrelated to #38803, which fixes the retry behaviour after this guard rejects
and assumes the guard ran.

tests/gateway/test_api_server.py: new TestApiKeyStartupGuardFailsClosed — a
weak key is refused when the check is unavailable, a strong key is refused too
(fail-closed), plus three controls pinning the unchanged normal paths. The two
fail-open tests fail on main; the three controls pass there. 222 passed in the
api_server suites; 1475 passed across every suite touching api_server, with
the same 8 pre-existing failures on clean main.
2026-07-23 07:34:26 -07:00
Teknium 2755bf558e fix(auxiliary): route all MoA aux resolution through one shared aggregator helper
Follow-up to srojk34's explicit-provider unwrap (PR #56691):

- Extract _resolve_moa_aggregator() as the single preset->aggregator
  resolver shared by _resolve_auto(), _resolve_task_provider_model(),
  and resolve_provider_client() so preset lookup/validation can't drift.
- When the main provider is moa, the aggregator model is now the default
  for every UNSET auxiliary model: _read_main_model_for_aux() substitutes
  the preset's acting (aggregator) model wherever fallback chains
  pre-filled from _read_main_model() (router prefill, custom-endpoint
  fallback, named-custom default, external-process default,
  _try_main_agent_model_fallback).
- Unwrap moa at the resolve_provider_client() chokepoint so direct
  callers (vision auto-detect, plugin code) can't dead-end in the
  unknown-provider branch, and unwrap the vision auto-detect main
  provider before capability probes run against the preset name.
- Real-config tests: temp HERMES_HOME + actual config.yaml exercising
  the genuine load_config()/resolve_moa_preset() boundary.
2026-07-23 07:34:24 -07:00
srojk34 cdfe562342 fix(auxiliary): unwrap explicit provider:moa to its aggregator, not the literal name
_resolve_task_provider_model() returned an explicit provider="moa" override
(from a caller-passed arg, or auxiliary.<task>.provider: moa in config.yaml)
verbatim, with no MoA-preset unwrap. Only the *implicit* "main provider is
moa" path inside _resolve_auto() unwraps to the aggregator slot (#53827) —
this function never goes through _resolve_auto() at all, so the explicit
case was never covered.

MoA is a virtual provider with no real HTTP endpoint: resolve_provider_client()
looks "moa" up in PROVIDER_REGISTRY (no such entry), falls to the
unknown-provider dead end, and call_llm surfaces a nonsensical "Provider
'moa' is set in config.yaml but no API key was found. Set the MOA_API_KEY
environment variable..." error for a provider that was never meant to be
reached over the wire.

Fix mirrors #53827's aggregator-resolution approach exactly: when either the
explicit `provider` arg or the config-derived `cfg_provider` is "moa",
resolve the named (or default) MoA preset via resolve_moa_preset() and
continue with its aggregator's real provider+model, dropping any explicit
base_url/api_key (the moa:// virtual endpoint and placeholder key belong to
the facade, not the aggregator's real provider). If the preset can't be
resolved (renamed/deleted), degrades gracefully to the pre-fix behavior
instead of raising harder.

- agent/auxiliary_client.py: _unwrap_moa_provider() helper + call sites for
  both the explicit-arg and config-derived provider="moa" cases in
  _resolve_task_provider_model(). Also tightened base_url/api_key parameter
  types to Optional[str] (matching their actual None-accepting behavior),
  which incidentally resolved 5 pre-existing ty diagnostics at call sites.
- 5 new regression tests in tests/agent/test_auxiliary_client.py: explicit
  arg unwrap, config-derived unwrap, default-preset fallback when no model
  is configured, graceful degradation on preset-resolution failure, and a
  non-moa regression guard.
2026-07-23 07:34:24 -07:00
Teknium 792ede0a36 test: pin repo root on PYTHONPATH for subprocess-boundary kanban isolation tests
The three subprocess tests spawn 'sys.executable -c' children that import
hermes_cli. From a worktree, the child resolved the MAIN checkout's editable
install instead of the tree under test, so the new DB/CLI guards appeared
missing and the tests failed with rc=0. Route the spawns through a helper
that pins the repo root under test on PYTHONPATH.
2026-07-23 07:33:36 -07:00
Teknium b327eaa3a6 chore: map trkim@vms-solutions.com to ddifa86 2026-07-23 07:33:36 -07:00
trkim a7dcf9787b fix(kanban): harden delegated-child mutation boundary 2026-07-23 07:33:36 -07:00
trkim 47bbc12e18 fix(kanban): isolate delegated children from parent task 2026-07-23 07:33:36 -07:00
Teknium a553bc74e4 test(windows): regression coverage for the six #56747 hide-flag sites
Mocked-subprocess tests asserting creationflags == CREATE_NO_WINDOW for
each path salvaged from PR #56877: tui_gateway cli.exec / shell.exec /
quick-command dispatch, the CLI quick-command exec handler, and the
Copilot ACP + Codex app-server Popen transports (pipes asserted intact).
Verified: all 6 fail with the fix reverted, pass with it applied.
2026-07-23 07:33:28 -07:00
Basil Al ShukailiandClaude Fable 5 714d7cc1a4 fix(windows): hide console flashes in GUI-reachable exec paths and provider transports (#56747)
Six spawn sites reachable from the desktop GUI / TUI gateway lacked CREATE_NO_WINDOW, so a windowless parent (pythonw/Electron) flashed a conhost per spawn: cli.exec RPC, quick-commands exec dispatch, and shell.exec RPC in tui_gateway/server.py; the CLI REPL quick-commands exec in cli.py; and the per-session provider transports in agent/copilot_acp_client.py and agent/transports/codex_app_server.py (Popen, hide-only so PIPE stdio stays intact).

All use hermes_cli._subprocess_compat.windows_hide_flags() (no-op on POSIX), matching the pattern already used at three other sites in tui_gateway/server.py. Deliberately hide-only — no detach flags, no Electron changes (per the #54220 revert history).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 07:33:28 -07:00
Teknium 1c58fe4362 chore(contributors): add email mapping for schattenan 2026-07-23 07:33:25 -07:00
Teknium 129b9f9d33 test: make interrupt-pool double's entries callable
Follow-up to the #58738 salvage: the pre-exhausted check now enumerates
pool.entries() to find the failing key, so the MagicMock pool double must
expose entries as a callable, not a bare list.
2026-07-23 07:33:25 -07:00
schattenanandClaude Fable 5 a9613d2e57 fix(credential-pool): refresh the failing entry, not current(), on auth recovery
Review follow-up: the auth path called pool.try_refresh_current() before
the hinted rotation, so a stale current() pointer could force-refresh a
different, healthy entry — consuming its single-use refresh token, or
(for non-OAuth entries, where a forced refresh marks the entry exhausted
outright) killing it entirely before api_key_hint was ever consulted.

Use try_refresh_matching(api_key_hint=...) to resolve and refresh the
entry that supplied the failing key under the pool lock, falling back to
the previous behavior when no key is known.

Adds a regression test with current() deliberately pointed at the
healthy entry: on the old code the healthy entry is exhausted by the
forced refresh and the pool ends up fully offline; with the fix the
failing entry is exhausted and recovery rotates to the healthy one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 07:33:25 -07:00
schattenanandClaude Fable 5 795bf4a9e6 fix(credential-pool): attribute failures to the key that failed, not the shared current() pointer
recover_with_credential_pool identified "which credential failed" via
pool.current(), a shared mutable pointer that is advanced by every
select() (round-robin rotation, concurrent turns, and other processes
reloading the pool reset it to None). By the time recovery ran, it
routinely pointed at a different, healthy entry — mark_exhausted_and_rotate
then stamped the failing request's error message and reset time onto that
innocent entry. With round_robin and one hard-capped key this
deterministically exhausted the healthy key too and took the entire pool
offline ("no available entries") from a single rate-limited credential.

mark_exhausted_and_rotate already supports api_key_hint for exactly this
(the auxiliary-client path passes it); the main conversation-loop path
never did. Pass agent.api_key — kept in sync with the entry in use by
_swap_credential — as the hint on all four rotation call sites, and make
the "already exhausted → rotate immediately" pre-check look up the failing
entry by key with the same fallback to current().

Adds regression tests that fail on the old attribution logic: a fresh
pool (current() is None) failing on key B must mark entry B, never
entry A.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 07:33:25 -07:00
Teknium 509960e827 fix(update): stdlib-only early recovery before hermes_cli.main imports
The hermes console entry point is hermes_cli.main:main, and main.py imports
dotenv (via env_loader) and yaml (via config) at module level. In the #57828
failure state — a failed lazy backend refresh wiping a core package's import
files while metadata survives — a normal launch crashed while importing
main.py, before _recover_from_interrupted_install() and the recovery markers
from PR #58004 could act.

- hermes_cli/_early_recovery.py: stdlib-only bootstrap repair invoked at the
  very top of main.py, before any third-party import. Probes the fragile
  core packages via real imports, force-reinstalls broken ones using the
  pyproject.toml pins, shares main.py's single-flight recovery lock, and
  never clears markers (the confirmed lifecycle stays with the full recovery
  path in main.py).
- Probe/repair tables now have one canonical home in _early_recovery, reused
  by main.py so the two layers cannot drift.
- Manual --force-reinstall fallback commands now print pinned specs via
  _lazy_refresh_repair_specs() instead of bare package names.
- tests: entry-point lifecycle coverage proving a broken dotenv import
  crashes main.py without repair and imports cleanly with it, a stdlib-only
  import guard for _early_recovery, and unit coverage for marker gating,
  lock single-flight, pinned specs, and marker preservation.
2026-07-23 07:33:16 -07:00
HexLab98 40fd2b8c08 fix(update): split core vs lazy markers; probes cannot false-clear
Keep .update-incomplete for full .[all] recovery only. Lazy refresh uses
.lazy-refresh-incomplete and clears only after confirmed import probes;
unavailable probes are indeterminate, not healthy (#58004 review).
2026-07-23 07:33:16 -07:00
HexLab98 8aa2a8bbcf fix(update): import-based recovery under Windows hermes.exe self-lock
Keep .update-incomplete across normal hermes.exe launches, heal via
package-only import probes first, and only clear the marker after repair
succeeds (#57828 / #58004 review).
2026-07-23 07:33:16 -07:00
HexLab98 a9a8ba2acf test(update): cover lazy refresh venv repair after failed installs
Add repair/probe/quarantine regression tests and update autostash mocks
for the new lazy-refresh signature.
2026-07-23 07:33:16 -07:00
HexLab98 de602b7298 fix(update): self-heal venv after failed lazy backend refresh
Upgrade pip before lazy refreshes, probe core imports when a lazy
install fails, force-reinstall corrupted packages with pyproject pins,
use package-only install (no shim quarantine) for repair, and keep the
.update-incomplete marker until refresh/repair succeeds (#57828).
2026-07-23 07:33:16 -07:00
Teknium acbc3abe8b fix(cli): widen startup worktree pruning to all .worktrees/ trees and detect squash-merged work (#69831)
The startup pruner only considered directories named hermes-* (the
hermes -w scratch trees), so salvage/review/port lanes created with raw
'git worktree add' accumulated forever — a real checkout reached 117
directories / 26 GB with trees dating back months. Two further leaks:
squash-merged branches' local commits stay unreachable from
refs/remotes/* forever, so the unpushed-commits guard preserved fully
merged scratch trees indefinitely; and preserved trees rotted silently
with no visibility.

- Pruner now covers every directory under .worktrees/ except kanban
  task trees (t_<hex>, owned by 'hermes kanban gc'). Named (non
  hermes-*) trees get a 3x timeline (72h soft / 9d hard) since they
  were created deliberately.
- New _worktree_commits_all_merged_upstream(): git-cherry
  patch-equivalence check against origin/HEAD|main|master, bounded at
  20 commits ahead, fails safe toward preserve. Lets the pruner reap
  trees whose every local-only commit already landed upstream via
  squash-merge/cherry-pick.
- Dirty guard now applies at every tier (previously the 24-72h tier
  skipped it — it only survived because the unpushed check usually
  caught the same trees).
- Trees preserved for unpushed/dirty reasons older than 7 days are
  listed in a single WARNING so in-flight work can't rot silently.
- tips.py text updated; 13 new behavior-contract tests.
2026-07-23 07:33:07 -07:00
Teknium 2e9765b34e fix(gateway): route hygiene-timeout warning via profile-aware adapter lookup + verify lock reacquire after fence cancel
- gateway/run.py: use _adapter_for_source(source) instead of the raw
  adapters.get(source.platform) map so the compression-timeout warning
  respects transport provenance, relay ingress, and multiplexed profiles
  (matches every other user-facing send in the hygiene block).
- tests: add a lock-release verification regression — a fence-cancelled
  hygiene compression must leave the per-session compression lock free so
  the next attempt (manual /compress retry) acquires it and commits
  normally.
2026-07-23 07:26:27 -07:00
kshitij ca9c30c7f0 fix(gateway): bound hygiene compression failures 2026-07-23 07:26:27 -07:00
Teknium 49a8c61cac fix(context-engine): route pre-API and idle compaction status through the quiet-engine resolver
Follow-up for the salvaged #35191: the mid-turn pre-API pressure emit in
conversation_loop.py and the idle-resume emit in turn_context.py were not
routed through automatic_compaction_status_message, so an engine with
emit_automatic_compaction_status=False still leaked those lines. Both now
resolve through the hook (phases "pre_api" and "idle") while keeping the
#69550 template constants as the default wording. Suppression also skips the
#69546 structured 'compacted' terminal edge for compress-phase events that
opened no visible phase; failure warnings (_emit_warning) remain never
suppressible, pinned by test.
2026-07-23 07:26:15 -07:00
Stephen Schoettler d81a3dbfbb fix(context-engine): adapt quiet compaction status to turn-context refactor 2026-07-23 07:26:15 -07:00
Stephen Schoettler 28dced2440 test: isolate quiet compaction status assertions 2026-07-23 07:26:15 -07:00
Stephen Schoettler 4035d70bbe fix(context-engine): honor quiet compaction status 2026-07-23 07:26:15 -07:00
Teknium 2ca38e5df4 test(compression): pin scaffolding-tail standalone append + stale-snapshot refresh
Covers the follow-up hardening: continuation-marker and summary-as-user
tails keep the flagged standalone snapshot (zero-user provenance #69292
verified via _transcript_has_real_user_turn on the projected rows),
stale snapshot rows are refreshed in place, a previously merged snapshot
is stripped before re-injection, and an all-completed todo store injects
nothing (#26981).
2026-07-23 07:25:44 -07:00
Teknium 06a2d77372 fix(compression): gate todo-snapshot merge on real-user tails, refresh stale snapshots
Follow-up hardening on the salvaged merge-into-trailing-turn fix:

- Merge only into REAL user tails (_is_real_user_message probe). Merging
  into scaffolding tails (continuation marker, summary-as-user handoff)
  would upgrade them to real-user evidence after SessionDB projection
  strips the flags, breaking zero-user provenance (#69292 -
  _is_synthetic_compression_user_turn keys on the TODO_INJECTION_HEADER
  content marker, which merge-at-tail would bury mid-content).
- Strip a previously merged snapshot block before re-injection so
  repeated boundaries refresh rather than accumulate todo state, and
  refresh a bare stale snapshot row in place instead of stacking a
  duplicate (empty/stale-skip semantics from #26981 by @YLChen-007).
- Scaffolding tails keep the flagged standalone append (pre-#53890
  status quo; adjacent user rows are repaired downstream by
  repair_message_sequence / _merge_consecutive_roles).
2026-07-23 07:25:44 -07:00
Yingliang Zhang 33fd705421 fix(compression): preserve multimodal todo tails 2026-07-23 07:25:44 -07:00
Yingliang Zhang d2bb6cc251 fix(compression): merge todo snapshot into trailing user msg to avoid consecutive user/user turns
After context compression, the preserved todo list was unconditionally
appended as a standalone user message. When the compressed transcript
already ends with a user message (common case), this creates consecutive
user/user turns — a role-alternation violation some providers reject.

Fix: fold the snapshot into the trailing user message (blank-line separated)
when one exists with plain-string content. Falls back to append when the
tail is non-user, empty, or has structured (list) content.

Rebased on current upstream/main.

Closes #53890
2026-07-23 07:25:44 -07:00
Teknium 18d83b4da9 test(agent): pin the #61932 all-oversized-tail dead-end shape as compressible
Regression test for the exact issue #61932 report: head + an 8-message
protected tail made exclusively of oversized tool pairs.  Pre-fix,
compress_start >= compress_end made compress() a pure no-op and the
retry loop ended in 'Cannot compress further'; post-fix the Phase-1
pressure demotion reclaims the tail in one pass while preserving
tool_call/tool_result pairing.
2026-07-23 07:25:33 -07:00
giggling-ginger d12ea20009 test(agent): cover protected-tail last resort 2026-07-23 07:25:33 -07:00
giggling-ginger fe2ae40983 fix(agent): demote oversized tool results in protected compression tail
After multiple in-place compactions, short tool-heavy sessions can leave
nearly every remaining message inside protect_last_n while those messages
are huge completed file/tool outputs. The middle compress window then
makes no material token progress and the turn dies with
"Cannot compress further" (#61932).

Cap the prune message floor at the same bound as tail-cut, and under
pressure demote bulky protected-tail tool bodies (keeping a short recent
floor) so preflight can reclaim headroom without wiping the active ask.
2026-07-23 07:25:33 -07:00
Teknium 4fbfb26704 test(compression): audit abort paths for per-attempt in-place state reset
Follow-up for salvaged #58629: thread the previous flush baseline through
the idle-compaction caller in turn_context.py (the one caller the original
PR predates), and add regression coverage that every early-return path in
compress_context (breaker skip, no-progress, plus the completed-rotation
boundary) resets the per-attempt in-place outcome so a stale
_last_compaction_in_place from an earlier successful in-place compaction
can never baseline unflushed turns as persisted.
2026-07-23 07:25:21 -07:00
Brett Bonner 79a83830ba test(compression): verify abort persistence after restart 2026-07-23 07:25:21 -07:00
Brett Bonner 03c96b7ab5 test(compression): retain durable persistence regression 2026-07-23 07:25:21 -07:00
Brett Bonner 17b3a4bd41 fix(compression): preserve flush baseline after abort 2026-07-23 07:25:21 -07:00
Teknium 929c952596 fix(agent): wire should_compress_preflight into the turn-start preflight flow
Relocates the #20424 wiring: the preflight region moved out of
run_agent.py into agent/turn_context.py (and through the
compression.max_attempts unification, #69315), so the contributor's
elif branch is reapplied at its current home as the else arm of the
threshold dispatch chain.

Integration contracts:
- Byte-identical default: the built-in ContextCompressor inherits
  ContextEngine.should_compress_preflight() -> False, so the default
  path performs no compression and touches no turn bookkeeping
  (pinned by test_builtin_compressor_default_sub_threshold_path_unchanged).
- Attempt-cap: the engine gets exactly ONE compress() pass per turn,
  mutually exclusive with the cap-bounded threshold multi-pass loop,
  so turn-start passes stay within the resolved
  compression.max_attempts budget in every case.
- No-op blocking (#64382 / 377244f7c): an engine pass that no-ops
  (_compress_context returns the input list object) neither sets nor
  clears preflight_compression_blocked and does not re-baseline the
  flush history — a sub-threshold maintenance no-op proves nothing
  about over-threshold compressibility.
- Engine exceptions are swallowed at debug level; cooldown/defer/
  codex-native gates run before the hook is ever consulted.

Salvaged from #20424 by @Beandon13. Fixes #20316.
2026-07-23 07:25:08 -07:00
Brandon Zarnitz d1c0c33a8b fix(run_agent): call should_compress_preflight() for sub-threshold engines (#20316)
Context engines that override ``should_compress_preflight()`` (e.g. the
hermes-lcm plugin's incremental leaf-chunk compaction) never had their
hook fired by ``run_conversation`` because the preflight block exited
early once the hardcoded ``>= threshold_tokens`` check failed.  As a
result, ``LCM_DEFERRED_MAINTENANCE_ENABLED=1`` and friends were inert
and accumulated raw_backlog debt indefinitely.

Add an ``elif`` branch that delegates to the engine's preflight hook
when the legacy threshold check does not fire.  The default
``ContextEngine.should_compress_preflight()`` returns ``False`` so the
built-in ``ContextCompressor`` is unaffected; engines opting in get a
chance to ingest messages and request a single ``compress()`` pass for
deferred maintenance.  Exceptions are swallowed at debug level so a
buggy engine cannot break an otherwise-healthy turn.

Closes #20316
2026-07-23 07:25:08 -07:00
Teknium d462116226 test(cli): prove /compress type-ahead queue-drain; map contributor email
- tests/cli/test_compress_type_ahead.py: end-to-end proof of the PR #68284
  docstring claim — a prompt queued into _pending_input while /compress runs
  survives compaction untouched and is the next item process_loop drains,
  i.e. it is processed against the compacted history. Plus a structural
  guard that handle_enter never gates on _command_running /
  _command_blocks_input (read-only enforcement belongs solely to the
  TextArea Condition; a busy-gate in handle_enter would silently drop
  type-ahead input).
- tests/test_cli_manual_compress.py: update the one remaining _busy_command
  stub (added on main after the PR branched) to accept the new
  blocks_input kwarg.
- contributors/emails: map lucas@policastromd.com -> enzo2.
2026-07-23 07:24:46 -07:00
Lucas Policastro 0bc7fb2b3b fix(cli): keep composer editable during compression 2026-07-23 07:24:46 -07:00
Teknium 2cabeeabca refactor(tui): relocate /compress arg parsing into _compress_session_history choke point
Follow-up to the salvaged #35533 fix: instead of parsing 'here [N]' only in
_mirror_slash_side_effects, parse it inside _compress_session_history — the
single helper all three manual-compress routes converge on (session.compress
RPC, command.dispatch /compress|/compact, slash-exec mirror). Every route now
honors the boundary-aware forms (here [N], up to here, --keep N) with the
same head/tail split + rejoin as cli.py and gateway/slash_commands.py, and
keeps the choke point's existing guards (lock-free LLM call, history_version
race check, deferred context-engine notification).

Tests: choke-point unit tests for 'here N', degenerate-split fallback, and
focus-topic passthrough, plus endpoint-level tests on each of the three
routes.
2026-07-23 07:24:34 -07:00
AhmetArif0 9284a3402f fix(tui-gateway): parse partial compress args in /compress here [N]
_mirror_slash_side_effects() passed the raw argument after /compress
directly as focus_topic to _compress_session_history. So /compress here
3 silently used "here 3" as a summary focus topic and did a full
compress instead of preserving the last 3 exchanges verbatim.

Fix: call parse_partial_compress_args() on the argument first. When it
detects a boundary-aware form (here, here N, up to here, --keep N),
split the history into head/tail using split_history_for_partial_compress,
compress only the head via agent._compress_context, and rejoin with
rejoin_compressed_head_and_tail — exactly mirroring cli.py and
gateway/run.py's /compress here implementation (PR #35252).

Non-boundary forms (plain /compress, /compress <focus>) fall through
to _compress_session_history unchanged.
2026-07-23 07:24:34 -07:00
mattshapsss 9d4cc12605 fix(photon): register Photon as a low-verbosity display tier
Photon (managed iMessage) shipped without a _PLATFORM_DEFAULTS entry, so it
inherited the noisy global ('all') display defaults and narrated tool
progress / heartbeats / busy-ack detail into a permanent-message iMessage
thread. Register it as TIER_LOW alongside BlueBubbles and Signal, plus a
regression test guarding the tier.

Salvaged from #50511 (Photon tier piece only).
2026-07-23 07:24:21 -07:00
wz-heng 91546b8337 fix: preserve named custom provider vision overrides 2026-07-23 17:57:33 +05:30
kshitij 97d51ca20d fix: use canonical _get_platform_tools resolver for memory tool status
The PR's inline toolset resolution (checking 'memory' in cli_toolsets
list) produced wrong results for composite toolsets like 'hermes-cli'
which expand to include the memory tool. Replace with the canonical
_get_platform_tools() from tools_config.py which correctly handles
composite toolsets and all edge cases.

Update tests to mock _get_platform_tools instead of raw config.
2026-07-23 17:50:20 +05:30
kshitij c82c5786f4 test: cover config-aware memory status labels + AUTHOR_MAP entry
Add tests/hermes_cli/test_memory_status.py with 11 tests covering:
- No hardcoded 'always active' label
- memory_enabled, user_profile_enabled, memory toolset indicators
- Tool enabled/disabled via platform_toolsets.cli
- Provider still shown alongside indicators

Add huajiang@tubi.tv → thirstycrow to AUTHOR_MAP (PR #23630 salvage).
2026-07-23 17:50:20 +05:30
Hua Jiang 4c99a44e6d fix: hermes memory status now reads actual config instead of hardcoded 'always active'
The 'Built-in: always active' label was a hardcoded string that never
reflected the user's actual configuration. It now shows three separate
indicators, each reading from the real source of truth:

  - Memory injection:  reads memory.memory_enabled from config.yaml
  - User profile:      reads memory.user_profile_enabled from config.yaml
  - Memory tool:       checks if 'memory' is in platform_toolsets.cli
                       (or defaults to enabled if no explicit list)

Before:
  Built-in:  always active

After:
  Built-in (MEMORY.md / USER.md):
    Memory injection:   disabled ✗
    User profile:       disabled ✗
    Memory tool:        disabled ✗
2026-07-23 17:50:20 +05:30
kshitijk4poor 2841a9cbca chore: add contributor email mapping for fangliquanflq 2026-07-23 17:38:05 +05:30
Fangliquan 9220c0c0bb fix(agent): close tool-result tails on invalid-tool and truncated-tool early returns
Invalid-tool exhaustion and truncated-tool early returns skipped finalize_turn, leaving role=tool transcripts that become tool→user on the next turn for strict providers. Call close_interrupted_tool_sequence before persist on those paths (same as interrupt aborts).
2026-07-23 17:38:05 +05:30
kshitij 8058e01834 refactor: dedupe check_info call + fix trailing whitespace in doctor
Hoist the duplicated check_info(source_id) call out of both
if/else branches into a single call after the branch. Remove
trailing whitespace on the blank line after the except block.

Follow-up cleanup for PR #69981.
2026-07-23 17:32:38 +05:30
kshitij 7419de6ac6 refactor: simplify WAL-reset gate warning dedup + tuple handling
Consolidate the two near-identical warning strings in
_log_wal_reset_bug_once into a single logger.warning call with an
action variable. Remove overengineered defensive tuple-length handling
in is_sqlite_wal_reset_vulnerable (sqlite3.sqlite_version_info always
returns a 3-tuple). Remove extra blank line.

Follow-up cleanup for PR #69981.
2026-07-23 17:32:38 +05:30
HexLab98 a94f8e69e1 test(state): cover SQLite WAL-reset version gate and doctor probe
Assert the version matrix, fresh-DB DELETE fallback, already-WAL left
alone (no checkpoint/DELETE), fixed-SQLite WAL path, and warn-only
doctor output for vulnerable builds (#69784).
2026-07-23 17:32:38 +05:30
HexLab98 953cbc0300 fix(state): refuse WAL on SQLite builds with the WAL-reset bug
On vulnerable SQLite (e.g. 3.50.4), do not enable WAL for fresh/non-WAL
shared databases — prefer DELETE instead. Leave existing on-disk WAL
alone (no live downgrade under concurrent gateway/cron openers). Surface
Python/SQLite version details as a doctor warning (#69784).
2026-07-23 17:32:38 +05:30
brooklyn! 8fc278207b Merge pull request #69938 from NousResearch/bb/desktop-dev-fixes
fix(desktop): keep gateway session alive across Vite HMR
2026-07-23 02:22:51 -05:00
hermes-seaeye[bot]andgithub-actions[bot] 44b171fe3d fmt(js): npm run fix on merge (#69941)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 07:19:06 +00:00
Brooklyn Nicholson ecc6aec4bf fix(desktop): isolate gateway HMR survivor across vitest cases
Vitest keeps import.meta.hot truthy, so boot-effect cleanup parks the
open socket; drain it between cases so the next test boots fresh.
2026-07-23 02:15:39 -05:00
Brooklyn Nicholson cbea2acbb8 fix(desktop): gate HMR session-survival entirely out of prod builds
Guard the globalThis gateway-state container and the survivor park/adopt
calls on the import.meta.hot literal (not the runtime hmrActive() helper),
so Vite dead-code-eliminates every HMR path in production. Prod now uses a
plain module-local singleton — no globalThis, no Symbol.for — and the
survivor module drops out of the bundle entirely. Verified: gatewayRegistryState,
gatewaySurvivor, and import.meta.hot are all absent from the prod build.

Removes the now-unused hmrActive() export.
2026-07-23 02:15:25 -05:00
Brooklyn Nicholson 1943c1b750 fix(desktop): dedup profile in HMR adoptBoot 2026-07-23 02:15:25 -05:00
Brooklyn Nicholson 813ddb4555 fix(desktop): keep gateway session alive across Vite HMR
Park the live primary gateway socket on Fast Refresh dispose and re-adopt it
on remount so dev UI edits don't tear down the WebSocket. Hold gateway store
singletons on globalThis + self-accept HMR on store/gateway.ts. Prod strips
import.meta.hot — live unmount unchanged.
2026-07-23 02:15:25 -05:00
hermes-seaeye[bot]andgithub-actions[bot] 7c9d05267c fmt(js): npm run fix on merge (#69939)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 07:08:53 +00:00
brooklyn! 3e163d29bd Merge pull request #69936 from NousResearch/bb/voice-speak-whole-turn
fix(voice): speak the whole desktop turn and idle-flush held narration
2026-07-23 02:00:30 -05:00
Brooklyn Nicholson 9859e1f7df fix(voice): speak the whole turn, not just its first bubble; idle-flush held narration
Two fixes for desktop hands-free voice:

- The live speech session bound to the first assistant bubble with text, so
  a tool-calling turn spoke only the opening narration and silently dropped
  every later interim AND the final answer. The conversation selector now
  aggregates all unspoken assistant bubbles in order (turn-scoped speech);
  auto-speak keeps its latest-reply-only behavior.

- The speak-stream WS producer blocked forever on the text queue, so a
  narration line with no trailing whitespace ("Let me check.") sat in the
  sentence chunker until end-of-turn — spoken long after the tool finished,
  with the UI stuck on "Preparing audio…". Mirror the CLI speaker's idle
  flush: sentence-terminated buffers flush after 0.5s of producer silence,
  anything else after ~2s; open <think> blocks are never flushed.
2026-07-23 01:53:37 -05:00
brooklyn! 7168845dc3 Merge pull request #69909 from NousResearch/bb/computer-use-perf
perf(computer_use): cap capture size and cache vision routing
2026-07-23 01:49:05 -05:00
Brooklyn Nicholson cc1765dce2 Merge remote-tracking branch 'origin/main' into bb/computer-use-perf
# Conflicts:
#	hermes_cli/config.py
#	tools/computer_use/cua_backend.py
2026-07-23 01:39:21 -05:00
brooklyn! 93f8da55cb Merge pull request #69903 from NousResearch/bb/salvage-53841-no-overlay
fix(computer_use): disable cua-driver overlay by default on macOS/WSL (supersedes #53841)
2026-07-23 01:36:49 -05:00
brooklyn! d3d989c4c2 feat(desktop): show credit-usage notices as toasts (#69808) (#69828)
* fix(desktop): render agent credit notices as toasts (#69808)

The desktop renderer had no handler for the `notification.show` /
`notification.clear` WS events, so every credit-usage notice the backend
sends (`agent/credits_tracker.py` → `tui_gateway/server.py`) was silently
dropped. Credit warnings like "• Credits 50% used · $220.00 cap" never
appeared, even though the Ink TUI renders them in its status bar.

Add the two missing branches to the gateway-event dispatcher, delegating
to a small, pure-testable module:

- `store/agent-notices.ts` — `noticeToToast()` maps a notice to a toast
  (level → toast kind, sticky → durationMs 0, ttl → ttl_ms), and uses the
  notice `key` as the toast id. Re-emitting the same key REPLACES the
  toast, so the credits 50→75→90 line escalates in place instead of
  stacking, and a key-matched `notification.clear` maps straight to
  `dismissNotification(key)`.
- The notice `text` already carries its own glyph (• ⚠ ✕ ✓), so no toast
  icon is added.
- Notices are account-wide, so the toast shows regardless of which
  session is focused.

The Ink TUI (`ui-tui/src/app/turnController.ts`) is the reference for the
latest-wins / sticky-vs-ttl / key-matched-clear behavior.

Export `NotificationInput` so the mapping's return type can be named.

* feat(desktop): native OS credit alerts + billing-page nudge (#69808)

Round out the credit-notice handling from the previous commit with the two
optional pieces from the issue:

- Native OS notification for the urgent pair. `credits.depleted` /
  `credits.restored` also fire an Electron notification when Hermes is
  backgrounded, via a new `credits` NativeNotificationKind (the existing
  five didn't fit) with its own toggle in Settings → Notifications (the
  panel is data-driven off NATIVE_NOTIFICATION_KINDS, so the toggle and
  i18n are the only additions). The escalating usage line and grant-spent
  notice stay in-app toasts only. Dispatch is `global` (account-wide, not
  session-bound) and gated by the user's prefs + backgrounded check.
- Billing-page nudge. A `credits.*` crossing invalidates the
  `['billing','state']` query so Settings → Billing reflects the change
  immediately instead of waiting up to 30s for its poll.

`nativeNoticeInput()` is a pure mapping (urgent-key gate → native input),
unit-tested directly; the gateway-event branch does the localized-title
lookup and gated dispatch. i18n added for all four locales.

* feat(credits): report $used of $cap instead of % in the usage notice

The usage gauge is Nous subscription-cap-only (used_fraction requires a cap;
non-Nous providers emit no headers, so no notice fires). A bare percentage
implied a universal unit that doesn't exist, so report the absolute dollars
used of the cap instead: used = cap - remaining, from micros (money-safe),
clamped to [0, cap]. Still a snapshot at band-crossing (re-emits on band
change, not every turn) to keep the single escalating line and stay quiet on
append-only surfaces (messaging pushes one message per crossing).

* fix(desktop): de-dupe credit toast icon, band-color the figure, split detail

Three fixes to how agent credit notices render as toasts:
- Strip the leading severity glyph (the toast already draws a kind icon, so the
  raw text doubled it). Native OS notifications keep the glyph (no icon there).
- Icon top-margin is now 0.42ch (font-relative) instead of a fixed rem.
- Band-color the $used figure (semibold) by $used/$cap: muted <75%,
  --ui-orange >=75%, --ui-red >=90% (depleted red, restored green), reusing the
  existing --ui-* usage palette. Icon shares the accent.
- Split a trailing '. detail' into a muted secondary line (title+description
  convention) instead of an inline middot.
Generic 'accentColor' + 'meta' slots on the notification; degrades gracefully
when a notice has no figure.

* fix(desktop): billing page always fetches fresh state (team-account desync)

The x-nous-credits-* headers are best-effort and can drift out of sync,
notably in team/org accounts where another member's spend moves the shared
balance without touching this client's headers. The billing endpoint is the
source of truth, so the page no longer trusts a cache: staleTime 0 +
refetchOnMount 'always' force a fresh fetch on every open and focus (still
polling 30s while mounted). The credits.* invalidation nudge still pulls a
crossing in immediately.

* chore(desktop): dev-only credit-notice demo hotkey

Ctrl+Shift+C (and window.__creditsDemo()) steps the full credit-notice
lifecycle (usage 50->75->90, grant-spent, depleted/restored) through the real
gateway event fan-out via a new emitLocalGatewayEvent, so the toast/native/
billing-invalidation paths are testable without hitting real usage bands.
Installed only under import.meta.env.DEV, so it's tree-shaken from production.
2026-07-23 01:36:28 -05:00
Brooklyn Nicholson 8cfc9e4e27 chore(desktop): dev-only credit-notice demo hotkey
Ctrl+Shift+C (and window.__creditsDemo()) steps the full credit-notice
lifecycle (usage 50->75->90, grant-spent, depleted/restored) through the real
gateway event fan-out via a new emitLocalGatewayEvent, so the toast/native/
billing-invalidation paths are testable without hitting real usage bands.
Installed only under import.meta.env.DEV, so it's tree-shaken from production.
2026-07-23 01:28:59 -05:00
Brooklyn Nicholson 664b131c30 fix(desktop): billing page always fetches fresh state (team-account desync)
The x-nous-credits-* headers are best-effort and can drift out of sync,
notably in team/org accounts where another member's spend moves the shared
balance without touching this client's headers. The billing endpoint is the
source of truth, so the page no longer trusts a cache: staleTime 0 +
refetchOnMount 'always' force a fresh fetch on every open and focus (still
polling 30s while mounted). The credits.* invalidation nudge still pulls a
crossing in immediately.
2026-07-23 01:28:59 -05:00
Brooklyn Nicholson d010220588 fix(desktop): de-dupe credit toast icon, band-color the figure, split detail
Three fixes to how agent credit notices render as toasts:
- Strip the leading severity glyph (the toast already draws a kind icon, so the
  raw text doubled it). Native OS notifications keep the glyph (no icon there).
- Icon top-margin is now 0.42ch (font-relative) instead of a fixed rem.
- Band-color the $used figure (semibold) by $used/$cap: muted <75%,
  --ui-orange >=75%, --ui-red >=90% (depleted red, restored green), reusing the
  existing --ui-* usage palette. Icon shares the accent.
- Split a trailing '. detail' into a muted secondary line (title+description
  convention) instead of an inline middot.
Generic 'accentColor' + 'meta' slots on the notification; degrades gracefully
when a notice has no figure.
2026-07-23 01:28:59 -05:00
Brooklyn Nicholson 59a735b8f3 feat(credits): report $used of $cap instead of % in the usage notice
The usage gauge is Nous subscription-cap-only (used_fraction requires a cap;
non-Nous providers emit no headers, so no notice fires). A bare percentage
implied a universal unit that doesn't exist, so report the absolute dollars
used of the cap instead: used = cap - remaining, from micros (money-safe),
clamped to [0, cap]. Still a snapshot at band-crossing (re-emits on band
change, not every turn) to keep the single escalating line and stay quiet on
append-only surfaces (messaging pushes one message per crossing).
2026-07-23 01:28:58 -05:00
Brooklyn Nicholson 69b97a97f7 Merge remote-tracking branch 'origin/main' into bb/salvage-53841-no-overlay
# Conflicts:
#	tools/computer_use/cua_backend.py
2026-07-23 01:26:21 -05:00
Brooklyn Nicholson b8a2b9b93e feat(desktop): native OS credit alerts + billing-page nudge (#69808)
Round out the credit-notice handling from the previous commit with the two
optional pieces from the issue:

- Native OS notification for the urgent pair. `credits.depleted` /
  `credits.restored` also fire an Electron notification when Hermes is
  backgrounded, via a new `credits` NativeNotificationKind (the existing
  five didn't fit) with its own toggle in Settings → Notifications (the
  panel is data-driven off NATIVE_NOTIFICATION_KINDS, so the toggle and
  i18n are the only additions). The escalating usage line and grant-spent
  notice stay in-app toasts only. Dispatch is `global` (account-wide, not
  session-bound) and gated by the user's prefs + backgrounded check.
- Billing-page nudge. A `credits.*` crossing invalidates the
  `['billing','state']` query so Settings → Billing reflects the change
  immediately instead of waiting up to 30s for its poll.

`nativeNoticeInput()` is a pure mapping (urgent-key gate → native input),
unit-tested directly; the gateway-event branch does the localized-title
lookup and gated dispatch. i18n added for all four locales.
2026-07-23 01:25:58 -05:00
Brooklyn Nicholson 58e3d41582 fix(desktop): render agent credit notices as toasts (#69808)
The desktop renderer had no handler for the `notification.show` /
`notification.clear` WS events, so every credit-usage notice the backend
sends (`agent/credits_tracker.py` → `tui_gateway/server.py`) was silently
dropped. Credit warnings like "• Credits 50% used · $220.00 cap" never
appeared, even though the Ink TUI renders them in its status bar.

Add the two missing branches to the gateway-event dispatcher, delegating
to a small, pure-testable module:

- `store/agent-notices.ts` — `noticeToToast()` maps a notice to a toast
  (level → toast kind, sticky → durationMs 0, ttl → ttl_ms), and uses the
  notice `key` as the toast id. Re-emitting the same key REPLACES the
  toast, so the credits 50→75→90 line escalates in place instead of
  stacking, and a key-matched `notification.clear` maps straight to
  `dismissNotification(key)`.
- The notice `text` already carries its own glyph (• ⚠ ✕ ✓), so no toast
  icon is added.
- Notices are account-wide, so the toast shows regardless of which
  session is focused.

The Ink TUI (`ui-tui/src/app/turnController.ts`) is the reference for the
latest-wins / sticky-vs-ttl / key-matched-clear behavior.

Export `NotificationInput` so the mapping's return type can be named.
2026-07-23 01:25:58 -05:00
brooklyn! a2172547a8 Merge pull request #69902 from NousResearch/bb/salvage-cua-driver-path
fix(computer_use): resolve cua-driver under thin GUI PATH (supersedes #55631)
2026-07-23 01:24:04 -05:00
Brooklyn Nicholson cdc123ec2f fix(computer_use): only disable agent cursor after session handshake
Guard the post-start set_agent_cursor_enabled on _session._started so
call_tool cannot re-enter session.start() (matches the start_session
lifecycle guard).
2026-07-23 01:10:21 -05:00
Brooklyn Nicholson 8b6d34ad85 test(computer_use): pin resolved driver for update-check tests
cua_driver_update_check now short-circuits to None when no driver
resolves; CI has none installed, so pin resolve_cua_driver_cmd in the
update-check and env-sanitization tests.
2026-07-23 01:10:19 -05:00
Brooklyn Nicholson 12ad13ddca perf(computer_use): cap capture size and cache vision routing
Cut steady-state Computer Use latency without changing default behavior
or waiting on cua-driver:

- Cap screenshots via set_config(max_image_dimension) on session start
  (config: computer_use.max_image_dimension, default 1456)
- Cache aux-vision routing per (provider, model) so captures skip
  repeated load_config()
- Add computer_use.capture_after_mode (default som) so users can opt
  follow-ups down to ax (elements only) for speed
2026-07-23 00:52:43 -05:00
Brooklyn NicholsonandDavid Metcalfe f957fe3760 fix(computer_use): default --no-overlay on macOS for idle CPU
Auto-detect now disables the cursor overlay on darwin as well as
headless/WSL2 Linux. After start_session, also call
set_agent_cursor_enabled(false) when the policy is on so older drivers
without --no-overlay still tear the overlay down.

Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
2026-07-23 00:45:26 -05:00
ethernet de5ece9944 fix(ci): report E2E evidence upload failures (#69901)
Print gh-image stdout and stderr on attachment failures, then replace the
pending inline-evidence marker in the PR review comment with an escaped
failure notice before preserving the failing workflow result.
2026-07-23 05:45:17 +00:00
David Metcalfe 3d84689714 fix(computer_use): address sweeper feedback on --no-overlay subprocess + manifest probe
The hermes-sweeper review #4701565902 (2026-07-15) flagged two
consistency issues in `_cua_driver_supports_no_overlay` and one
additive-config concern:

1. `cua-backend.py:260` — the `cua-driver --help` support probe
   inherited the full parent environment. cua-driver is a third-party
   binary; every other spawn site in this file (manifest probe at
   `:214`, MCP spawn at `:697`, install probe at `:997`) uses
   `_sanitize_subprocess_env(cua_driver_child_env())`. The `--help`
   probe should match. This was a low-impact leak (only help output
   exits), but inconsistency is the wrong default for a third-party
   subprocess.

2. `cua_backend.py:238` — when the manifest returned a `command`
   different from the input `driver_cmd` parameter (e.g. a relocated
   executable at `/opt/relocated/cua-driver` while the system binary
   is at `/usr/bin/cua-driver`), the support probe ran against
   `_CUA_DRIVER_CMD` (the default) instead of the manifest-discovered
   `command`. Two failure modes:
   - The wrapper binary supports `--no-overlay` but the system binary
     doesn't → probe returns False → overlay kept despite capability.
   - The system binary supports `--no-overlay` but the wrapper doesn't
     → probe returns True → MCP spawn crashes on the unknown flag.

3. The original commit bumped `_config_version` 31→32 for an additive
   default (`computer_use.no_overlay: None`). AGENTS.md specifies that
   additive defaults in existing sections are handled by deep merge
   and should NOT trigger a version bump. After cherry-picking onto
   current `origin/main` (which is already at 33), the bump is
   effectively dropped — resolved to main's 33.

Changes:

- Add `env=_sanitize_subprocess_env(cua_driver_child_env())` to the
  `--help` subprocess (with the same import + rationale comment as
  the manifest probe).
- Pass `driver_cmd=command` (or `driver_cmd=driver_cmd` for the
  fallback path) into `_mcp_args_with_overlay_flag`, so the support
  probe runs against the binary that will actually be launched.

Tests (3 new):

- `test_help_probe_passes_sanitized_env` — verifies `subprocess.run`
  is called with an `env=` kwarg.
- `test_manifest_command_drives_support_probe` — verifies the probe
  runs against the manifest command when it differs from the input
  driver_cmd.
- `test_fallback_uses_input_driver_cmd_for_support_probe` — verifies
  the fallback path (no command in manifest) uses the input
  driver_cmd.
- `test_probe_distinguishes_support_between_binaries` — sanity check
  that the lru_cache key on `driver_cmd` prevents cross-binary
  cache leakage.

File-revert negative test confirmed all three of the new
"manifest/probe" tests are load-bearing: with the pre-fix code, they
fail (probe runs against the default binary instead of the resolved
one); with the fix, they pass. 20/20 tests in
`tests/computer_use/test_cua_no_overlay.py` green.
`TestMcpInvocationResolution` (8/8) still green.

Refs: sweeper review #4701565902
2026-07-23 00:44:43 -05:00
David Metcalfe f7a6c7a6e5 fix(computer_use): add explicit encoding to /proc/version open() 2026-07-23 00:44:43 -05:00
David Metcalfe 8d4f7a0002 fix(computer_use): refine auto-detect to headless/WSL2 only, add driver version probe
Address review feedback from cross-vendor review (Flash + GPT-OSS):

1. Auto-detect now checks for headless Linux (no DISPLAY), WSL2
   (/proc/version contains 'microsoft'), instead of all Linux.
   Desktop Linux with a compositor keeps the overlay.

2. Add _cua_driver_supports_no_overlay() that probes cua-driver --help
   to check if the flag is supported. Older drivers (< 0.6.x) reject
   unknown flags, so passing --no-overlay would crash the MCP spawn.

3. Update tests to cover headless vs desktop Linux, WSL2 detection,
   version probe, and the unsupported-driver fallback path.
2026-07-23 00:44:43 -05:00
David Metcalfe f43ff5b4bb fix(computer_use): mock _cua_no_overlay in existing tests, fix platform-dependent assertions
- Add autouse fixture to TestMcpInvocationResolution to disable
  --no-overlay flag so existing tests assert baseline args
- Make test_config_load_failure_fails_safe and test_missing_section_enables
  platform-aware (Linux auto-detect returns True, macOS/Windows False)
2026-07-23 00:44:43 -05:00
David Metcalfe 8ceada6e30 fix(computer_use): pass --no-overlay to cua-driver on Linux/WSL2 to prevent idle CPU
cua-driver's cursor overlay rendering loop can consume CPU indefinitely
when idle (#28152, #47032). On Linux/WSL2, the overlay serves no visual
purpose and the rendering path is the primary source of idle CPU usage.

Add computer_use.no_overlay config option (default: auto-detect) that
passes --no-overlay to cua-driver when enabled. Auto-detection disables
the overlay on Linux (covers WSL2, headless, containers) where it has no
benefit, and keeps it enabled on macOS/Windows where it is visually
useful.

Refs: #28152, #47032
2026-07-23 00:44:43 -05:00
be96f22026 fix(computer_use): probe resolved cua-driver path in post_setup
Keep thin-PATH resolution while asserting the absolute binary on the
already-installed version check. Map contributor emails for the salvage.

Co-authored-by: Tianqing Yun <yuntianqing@yahoo.com>
Co-authored-by: Trevor Gordon <trevorbgordon@gmail.com>
Co-authored-by: Adrian Soto Mora <adrian.soto6@gmail.com>
2026-07-23 00:44:27 -05:00
Tianqing Yun 2f9d88caee fix: resolve cua-driver across computer-use surfaces 2026-07-23 00:42:46 -05:00
Tianqing Yun 030822b68d fix: resolve cua-driver from user-local install paths 2026-07-23 00:42:46 -05:00
a8e6c0f853 fix(dashboard): isolate Desktop-inherited env from standalone launch (#69891)
Supersedes #52948 and #67402. Closes #52945.

Standalone hermes dashboard/serve was trusting HERMES_WEB_DIST and
HERMES_SERVE_HEADLESS inherited from a Desktop Electron parent, which
could serve the packaged desktop renderer ("Desktop IPC bridge is
unavailable") or disable the SPA. Drop only Electron-packaged WEB_DIST
paths (app.asar*) when HERMES_DESKTOP!=1, and clear inherited headless
for non-serve launches, while preserving caller-managed custom dist
overrides and the desktop-spawned backend path.

Co-authored-by: Bartok9 <danielrpike9@gmail.com>
Co-authored-by: Commander <commander@tianji.local>
2026-07-23 00:31:23 -05:00
brooklyn! 0721d2ea80 Merge pull request #69884 from NousResearch/bb/salvage-55707-onboarding
fix(desktop): stop spurious onboarding for keyless custom providers (supersedes #55707, #45224)
2026-07-23 00:28:29 -05:00
brooklyn! 61e7db2820 Merge pull request #69887 from NousResearch/bb/tile-resume-profile
fix(desktop): route tiled session resumes to the owning profile
2026-07-23 00:28:01 -05:00
ethernet 26f1f6a76b feat(desktop): improve tool call detail views (#69868)
* fix(desktop): improve fallback tool-call details

Show failed image-generation calls through the normal fallback row, remove duplicate normal-mode web-search JSON, and format Technical Mode payloads as readable JSON.

* feat(desktop): render terminal tool calls as transcripts

Show terminal commands with a prompt and exit status, then reveal ANSI-safe stdout and stderr in the expanded tool row.

* fix(desktop): reconcile tool calls by command

Match context-only tool starts with command-bearing completions when their IDs differ, preventing stale duplicate terminal rows. Show the web-search query above its result cards.
2026-07-23 01:27:38 -04:00
Teknium c1b0f6f3c1 feat(kanban): per-task model dropdown — set/override worker model+provider from the board (#69876)
Adds the missing write path for the per-task model_override column (which
was previously only settable via manual SQL) and pairs it with a
provider_override so cross-provider switches resolve correctly:

- kanban_db: provider_override column (+migration), set_model_override()
  with model_override_set event, create_task(model_override=,
  provider_override=), dispatcher spawns worker with -m <model>
  [--provider <name>]
- dashboard: Model row in the task drawer — dropdown fed by a new
  /model-options endpoint (build_models_payload substrate, provider-grouped,
  free-text fallback), PATCH + bulk model override support
- CLI: kanban create --model/--provider, new kanban set-model subcommand,
  show prints the provider
- agent tools: kanban_create accepts model/provider; show/list expose
  provider_override

Rate-limit recovery flow: override is settable on running tasks and takes
effect on the next dispatch, without touching the worker profile's config.
2026-07-22 22:23:24 -07:00
Brooklyn Nicholsonandoliviaaaa7788 c3602f7f05 fix(desktop): route tiled session resumes to the owning profile
`resumeTile` — the cold-resume path for a session opened in a tile / split
pane — resumed with `{ session_id, cols }` and read messages with no profile,
so a tile opening a session from another profile let the gateway fall back to
the launch-profile DB and fork the conversation into the wrong profile: the
same cross-profile bleed the recovery resumes had (#67603), just a sibling
call path. Resolve the owning profile via the shared `resolveSessionProfile`
and carry it on both the transcript prefetch and the resume RPC.

Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
2026-07-23 00:18:35 -05:00
58ffc10c17 fix(desktop): stop spurious provider onboarding from credential warnings
Narrow the setup-error matcher, match the server's empty-key contract,
and route gateway-event plus create/resume/branch through one
credential-warning policy. Preserve configured=true only for
non-authoritative transport fallback.

Co-authored-by: Yingliang Zhang <zhangyingliang@outlook.com>
Co-authored-by: Brandon R <kingdomwarrior23@gmail.com>
2026-07-23 00:18:21 -05:00
a01499136d fix(tui_gateway): skip credential warning for keyless custom providers
`no-key-required` is a valid sentinel for local/self-hosted/custom
routers. Warning on it made Desktop treat a working setup as missing
API keys.

Co-authored-by: Yingliang Zhang <zhangyingliang@outlook.com>
Co-authored-by: Brandon R <kingdomwarrior23@gmail.com>
2026-07-23 00:18:21 -05:00
Gille 7cbdcf1ba6 fix(desktop): render remote markdown images in chat (#57944)
* fix(desktop): render remote markdown images in chat

* test(desktop): cover remote markdown image resolution
2026-07-23 00:16:20 -05:00
hermes-seaeye[bot]andgithub-actions[bot] ca16a8e07d fmt(js): npm run fix on merge (#69879)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 05:14:26 +00:00
e59dcf46f1 fix(desktop): end the #67603 model-switch dup, cross-profile session bleed, and [System:] bubble (#69861)
* fix(desktop): stop model-switch dup + route recovery resumes to the owning profile

Fixes two Desktop session-reconciliation symptoms from #67603.

Symptom 1 — duplicated user bubble after a model switch. The gateway
persists model-switch / personality notices as role=user `[System: …]`
rows (tui_gateway/server.py) so strict OpenAI-compatible providers don't
reject a non-leading system message (#48338). `preserveLocalPendingTurnMessages`
paired local optimistic rows with the stored transcript by user-role
ordinal, so a marker between two real user turns shifted every later
ordinal and the optimistic row was re-appended at the bottom. The single
trailing-marker case is already covered by the compression-era
`latestAuthoritativeUser` guard, but two switches around one turn (marker
before AND after the committed prompt) still duplicated it. Exclude
`[System:` bookkeeping markers from ordinal pairing on both sides.

Symptom 2 — a session appearing under two profiles. The main resume path
already resolves a session's owning profile via `resolveStoredSession`
(cache → active backend → cross-profile probe), but the recovery
`session.resume` calls (stale runtime id, session-not-found, wedged loop,
redirect) omitted `profile`, so the gateway fell back to the launch-profile
DB and forked the conversation into the wrong profile. Route every recovery
resume — and an uncached right-click branch — through the same resolver so
the profile is carried even for sessions outside the paginated sidebar
window (the cache-miss gap).

Tests: discriminating two-switch marker test (fails before, passes after);
cache-hit + cross-profile cache-miss coverage for the recovery resume and
for branching an uncached session.

Supersedes #68665 and #63590.
Closes #67603.

Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>

* fix(desktop): scope the remembered session id per profile

A single global `hermes.desktop.lastSessionId` key remembered ONE session
across every profile, so a relaunch or cold start under profile B would try
to restore a session owned by profile A — reinforcing the impression that a
conversation had bled between profiles (#67603, second symptom).

Key the remembered id by the session's owning profile (resolved from the
session row's `profile`, falling back to the active gateway profile), read it
back for the active profile on restore, and clear an exhausted session under
its owner. The default profile keeps the original unsuffixed key so existing
installs' remembered session survives the upgrade.

Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>

* fix(gateway): hide [System:] bookkeeping markers from every transcript

Model-switch and personality notices are persisted as role=user `[System: …]`
rows so strict providers accept them mid-history, but they are model-facing
runtime metadata, not user turns. `_history_to_messages` — the single display
projection every client reads — passed them straight through, so on resume or
reload they rendered as a fake user bubble in the desktop, TUI, CLI, and web
transcripts.

Drop them in that projection. The raw marker stays in `session["history"]`
for the model, so nothing changes for inference; only the display loses a row
that never belonged to the user. This also removes the stored marker from the
payload the desktop reconciles against, killing the ordinal shift that
duplicated the optimistic prompt (#67603) at its source — the desktop-side
marker exclusion remains as a fallback for older backends.

Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>

---------

Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>
2026-07-23 00:05:25 -05:00
brooklyn! e1367b44bc fix(desktop): let clarify choices and overlays keep their keys from type-to-focus (#69869) 2026-07-23 00:04:12 -05:00
8e01309917 fix(tui_gateway): preserve websocket batch order (#69684)
* fix: serialize TUI gateway websocket sends

* fix(tui_gateway): preserve websocket batch order

* refactor(tui_gateway): drop unused _safe_send wrapper

The batch-serialization fix routes every send through _safe_send_many;
_safe_send became a dead single-line wrapper with no callers. Remove it.

---------

Co-authored-by: supplefrog <78985073+supplefrog@users.noreply.github.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-07-23 05:02:45 +00:00
Teknium b0358cf3c8 fix(slack): unify DM-resolution caches and bound wave-2 caches (C16 policy)
Post-rebase consolidation over the merged C7/C16/C10 work:
- _ensure_dm_conversation now records workspace ownership via
  _remember_channel_team and bounds _dm_conversation_cache (cap 5000)
- module-level _slack_dm_cache (C7 standalone path) bounded oldest-first
- _user_is_bot_cache (C10) bounded with _trim_oldest_dict_entries
- caption-mode contract tests updated for the C7+C8 merged media path
2026-07-22 21:58:13 -07:00
Teknium 527c68baa4 chore(contributors): add email mapping for slack media salvage (robzolkos) 2026-07-22 21:58:13 -07:00
Teknium 994a405f2d fix(slack): resolve bare user targets to DMs across all adapter send paths
Widen the #19237 send_message fix to the live adapter: send, _upload_file,
send_multiple_images, send_image, send_video, send_document,
send_exec_approval, send_slash_confirm, and send_clarify now route bare
Slack user IDs (U.../W...) through a shared _ensure_dm_conversation helper
before calling chat.postMessage / files_upload_v2, which reject user IDs.

This closes the gap in #17261 where an attachment worked when replying in
a thread but failed when directed at a user DM, and extends the DM-open
fallback to clarify/approval Block Kit prompts so gated actions can reach
a user directly.

Resolution uses the workspace-scoped client (multi-workspace installs open
the DM with the right bot token), caches per (team, user), and records the
opened D... channel in the channel→team map. On failure the original
target passes through so the downstream API call surfaces the real Slack
error.

Fixes #17261
Refs #19236
2026-07-22 21:58:13 -07:00
Teknium 4ab4894f44 fix(gateway): make post-stream media delivery explicit-only
The post-stream helper (_deliver_media_from_response) rescanned the
already-streamed response and promoted bare local filesystem paths into
real uploads via extract_local_files. Since the visible reply was already
streamed verbatim, any bare path there is either text the user has seen
or stale inspected/tool content — not an attachment request. On Slack this
uploaded images from stale inspected content after otherwise clean replies.

Post-stream delivery now honors only explicit MEDIA: directives. The
non-streaming path in gateway/platforms/base.py keeps its bare-path
auto-detect, because that path controls the visible text and strips the
path from the reply when it attaches — auto-attach is intentional there.

Regression tests: bare image/document paths in a streamed reply produce
no upload; explicit MEDIA: tags still deliver.

Fixes #20834
2026-07-22 21:58:13 -07:00
dsad c83a196402 fix(slack): gate message files before metadata fetch 2026-07-22 21:58:13 -07:00
dsad 5bb933eedf fix(slack): reject unauthorized users before event construction
Add early auth check in _handle_slack_message() that runs BEFORE any
API calls (thread context fetch, user name resolution, file downloads)
or file processing. Unauthorized users could previously trigger Slack
API calls and file downloads before the runner's _is_user_authorized
gate rejected them.

Same pattern as Telegram fix #54164: build a SessionSource and check
the runner's _is_user_authorized at the adapter level before event
construction consumes resources.

Fixes the gap where Slack has no adapter-level auth gate while Discord
and Telegram have adapter-level allowlists.
2026-07-22 21:58:13 -07:00
dsad f54e8706f7 fix(platforms): block image upload redirects to private URLs 2026-07-22 21:58:13 -07:00
Rob Zolkos 40351d0923 fix(slack): resolve file events with cached workspace team 2026-07-22 21:58:13 -07:00
davesecops 57f8ba3b19 fix(slack): open DMs for user send targets 2026-07-22 21:58:13 -07:00
HexLab98 8df8f86784 test(slack): cover send_message MEDIA delivery
Add standalone-sender media cases and route coverage; point the
non-media platform assertions at SMS now that Slack supports MEDIA.
2026-07-22 21:58:13 -07:00
HexLab98 5f4c952ab0 fix(slack): support MEDIA attachments in send_message
Slack could already deliver files in-channel via the gateway, but
send_message omitted MEDIA for Slack and told the model it was
unsupported — causing agents to inconsistently refuse PDF sends.
Wire Slack through files_upload_v2 in the standalone sender.
2026-07-22 21:58:13 -07:00
Brooklyn NicholsonandDolverin 62bfba521d fix(gateway): hide [System:] bookkeeping markers from every transcript
Model-switch and personality notices are persisted as role=user `[System: …]`
rows so strict providers accept them mid-history, but they are model-facing
runtime metadata, not user turns. `_history_to_messages` — the single display
projection every client reads — passed them straight through, so on resume or
reload they rendered as a fake user bubble in the desktop, TUI, CLI, and web
transcripts.

Drop them in that projection. The raw marker stays in `session["history"]`
for the model, so nothing changes for inference; only the display loses a row
that never belonged to the user. This also removes the stored marker from the
payload the desktop reconciles against, killing the ordinal shift that
duplicated the optimistic prompt (#67603) at its source — the desktop-side
marker exclusion remains as a fallback for older backends.

Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>
2026-07-22 23:54:05 -05:00
Brooklyn Nicholsonandoliviaaaa7788 143942d497 fix(desktop): scope the remembered session id per profile
A single global `hermes.desktop.lastSessionId` key remembered ONE session
across every profile, so a relaunch or cold start under profile B would try
to restore a session owned by profile A — reinforcing the impression that a
conversation had bled between profiles (#67603, second symptom).

Key the remembered id by the session's owning profile (resolved from the
session row's `profile`, falling back to the active gateway profile), read it
back for the active profile on restore, and clear an exhausted session under
its owner. The default profile keeps the original unsuffixed key so existing
installs' remembered session survives the upgrade.

Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
2026-07-22 23:53:59 -05:00
brooklyn!andMapurite 6096f73ce8 feat(desktop): add keyboard navigation to clarify choices (#69799)
Co-authored-by: Mapurite <272619650+mapu-og@users.noreply.github.com>
2026-07-23 04:48:32 +00:00
5fccc9aae9 fix(desktop): stop model-switch dup + route recovery resumes to the owning profile
Fixes two Desktop session-reconciliation symptoms from #67603.

Symptom 1 — duplicated user bubble after a model switch. The gateway
persists model-switch / personality notices as role=user `[System: …]`
rows (tui_gateway/server.py) so strict OpenAI-compatible providers don't
reject a non-leading system message (#48338). `preserveLocalPendingTurnMessages`
paired local optimistic rows with the stored transcript by user-role
ordinal, so a marker between two real user turns shifted every later
ordinal and the optimistic row was re-appended at the bottom. The single
trailing-marker case is already covered by the compression-era
`latestAuthoritativeUser` guard, but two switches around one turn (marker
before AND after the committed prompt) still duplicated it. Exclude
`[System:` bookkeeping markers from ordinal pairing on both sides.

Symptom 2 — a session appearing under two profiles. The main resume path
already resolves a session's owning profile via `resolveStoredSession`
(cache → active backend → cross-profile probe), but the recovery
`session.resume` calls (stale runtime id, session-not-found, wedged loop,
redirect) omitted `profile`, so the gateway fell back to the launch-profile
DB and forked the conversation into the wrong profile. Route every recovery
resume — and an uncached right-click branch — through the same resolver so
the profile is carried even for sessions outside the paginated sidebar
window (the cache-miss gap).

Tests: discriminating two-switch marker test (fails before, passes after);
cache-hit + cross-profile cache-miss coverage for the recovery resume and
for branching an uncached session.

Supersedes #68665 and #63590.
Closes #67603.

Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>
2026-07-22 23:42:43 -05:00
hermes-seaeye[bot]andgithub-actions[bot] 328e4f5a1e fmt(js): npm run fix on merge (#69852)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 04:40:24 +00:00
brooklyn!andwebtecnica dc861964fc fix(desktop): clarify options defensive rendering and layout fix (#69796)
- Replace undefined  CSS class with Tailwind v4's
   so long choice text wraps properly
  instead of overflowing the button container.
- Add  validation that strips non-string items,
  blanks, newlines, and text >200 chars — preventing garbage/JSON
  arrays from rendering as raw values.
- Add diagnostic  logging at both the gateway event
  handler and the tool-args parser when choices are dropped, so
  malformed payloads are no longer silent.
- Apply  in both the gateway event handler
  () and the inline tool-args parser
  () for consistent defense in depth.
- Add unit tests for  covering null, non-array,
  mixed-type, blank, multiline, and overlong inputs.

Closes #69122

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
2026-07-23 04:32:33 +00:00
brooklyn! 46fb10203b Merge pull request #69841 from NousResearch/bb/salvage-69428-expandable-overlay
fix(desktop): free code-block scrollbar & last-line selection from the toggle overlay
2026-07-22 23:29:34 -05:00
brooklyn!and정수환 d21165c2f0 fix(desktop): keep clarify answerable across reconnect/hydration + tool-progress off (#69795)
* fix(desktop): keep clarify lifecycle when tool progress is off

* fix(desktop): render clarify prompt from the request event

Re-authored onto the current use-message-stream/gateway-event.ts (the
original patched the pre-split use-message-stream.ts). When the tool.start
row that normally mounts the inline clarify UI is missed (stream reconnect
/ hydration race), upsert a stable pending clarify tool row from
clarify.request itself so the prompt stays answerable; a real
tool.start/complete with the same request id merges rather than duplicates.

Co-authored-by: 정수환 <centerid@naver.com>

* chore(contributors): map centerid@naver.com -> lidises

Attribution mapping for the salvaged #47544 commit.

* fix(desktop): correlate clarify rows by question so hydration can't duplicate

The hydrated row (from clarify.request's request_id) and the real tool.start
row (the model's tool_call_id) have different ids, so id-only matching appended
a second clarify card in the normal path (caught by the BLOCKING_CLARIFY e2e:
'question' resolved to 2 elements). Add 'question' to the tool match-value keys
so a clarify upsert merges into the existing pending clarify row regardless of
id (same request<->args correlation ClarifyToolPending already uses); when no
row exists yet (reconnect/hydration) it still creates one.

---------

Co-authored-by: 정수환 <centerid@naver.com>
2026-07-22 23:25:48 -05:00
brooklyn!andMatvii Sakhnenko 7d28e84e72 fix(gateway): deliver assistant prose before the clarify poll (#69775)
* fix(gateway): deliver assistant prose before clarify poll

The clarify poll is sent on a separate, agent-thread-blocking path while
buffered assistant prose (interim commentary / streamed deltas) sits in
the GatewayStreamConsumer queue, drained asynchronously. The poll won the
race, so the question rendered ABOVE its own explanation, and a redundant
'clarify: ...' tool-progress bubble wedged between them.

- Add GatewayStreamConsumer.flush_pending_sync(): a synchronous flush
  barrier (_FLUSH sentinel + threading.Event) that blocks the agent
  thread until everything queued before it is finalized and delivered.
- Call it in the gateway clarify callback before send_clarify, so prose
  always lands before the poll. Best-effort with a 3s timeout.
- Suppress the redundant clarify tool-progress bubble (the poll already
  shows the question + options).

Tests: 3 new ordering/timeout cases in test_stream_consumer.py.
(cherry picked from commit 9a6e27badb7646c839902db1d2f4a8affcf38b1e)

* chore(contributors): map matvey.sakhnenko03@icloud.com -> sakhnenkoff

Attribution mapping for the salvaged #54328 commit (Cluster C).

---------

Co-authored-by: Matvii Sakhnenko <matvey.sakhnenko03@icloud.com>
2026-07-22 23:25:35 -05:00
507d479c8c fix(clarify): one canonical timeout across CLI, TUI/desktop, and gateway (#69774)
* test(clarify-gateway): cover signature, timeout fallback, and notify paths for 100% coverage

Fixes #36531

(cherry picked from commit 5265dfe2f5d484c5fb97c71eb311cbb230c0110e)

* fix(clarify): one canonical timeout across CLI, TUI/desktop, and gateway

The clarify wait timeout was resolved three different (wrong) ways:

- CLI (`cli.py`, `hermes_cli/callbacks.py`) read a non-existent top-level
  `clarify.timeout`, so it always fell through to a hardcoded 120s instead of
  the canonical `agent.clarify_timeout` (default 3600) the gateway uses (#42969).
- The TUI/desktop bridge called `_block("clarify.request", …)` with no timeout,
  so it used the hardcoded 300s `_block` default and ignored config (#51960).
- There was no way to disable the auto-skip: a user who wanted the agent to wait
  indefinitely while they think couldn't get it.

Collapse all of this onto a single resolver:

- `tools.clarify_gateway.resolve_clarify_timeout(config)` is the one source of
  truth. Order: explicit legacy `clarify.timeout` (back-compat) → canonical
  `agent.clarify_timeout` → 3600. `<= 0` is preserved verbatim as "unlimited".
- CLI, callbacks, and the TUI bridge (`_clarify_timeout_seconds`) all route
  through it, so the three surfaces can't drift.
- `<= 0` means unlimited everywhere: `wait_for_response` and `_block` drop the
  deadline (heartbeat still fires), and the CLI hides its countdown.

Tests: resolver order / default / non-numeric / unlimited-sentinel; an
unlimited `wait_for_response` blocks until resolved rather than auto-skipping;
the TUI clarify bridge passes the configured timeout to `_block`.

Supersedes #42974 (CLI key), #51993 (TUI honors config), and #68986 (unlimited
wait); folds in #52031 (clarify_gateway coverage).

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: lkevincc0 <lkevincc0@users.noreply.github.com>
Co-authored-by: theone139344 <theone139344@users.noreply.github.com>
Co-authored-by: baauzi <baauzi@users.noreply.github.com>

---------

Co-authored-by: Christopher-Schulze <210261288+Christopher-Schulze@users.noreply.github.com>
Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: lkevincc0 <lkevincc0@users.noreply.github.com>
Co-authored-by: theone139344 <theone139344@users.noreply.github.com>
Co-authored-by: baauzi <baauzi@users.noreply.github.com>
2026-07-22 23:25:13 -05:00
3dd9d5e692 fix(tui_gateway): tolerate late clarify + terminal.read replies after timeout (#69773)
`_block()` bridges four blocking request types — secret, sudo, clarify,
terminal.read — with an identical lifecycle: on timeout the tool gives up and
returns empty, but a slow renderer (or a WebSocket reconnect that dropped
tool.complete) can still answer afterward. Only secret and sudo tolerated that
late reply; clarify and terminal.read still hit the generic 4009 "no pending
request" error, which clients surface as a raw JSON-RPC string (and at least
one desktop fork re-armed the pending request on the error).

Bring the two stragglers in line with the pair that already works:
- `_block` now emits `{event}.expire` on timeout for all four request types.
- `clarify.respond` and `terminal.read.respond` pass `allow_expired=True`, so a
  late answer resolves to `{"status": "expired"}` instead of erroring.

Tests parametrize the timeout-expiry and late-idempotent-response cases over
all four bridges; the old test asserting clarify stays a 4009 error is updated
to the new graceful contract.

Supersedes #56571 (clarify) and #64886 (terminal.read) — same root cause, one fix.

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: pierrenode <pierrenode@users.noreply.github.com>
2026-07-22 23:24:42 -05:00
936b407a72 fix(desktop): free code-block scrollbar & last-line selection from the toggle overlay
`ExpandableBlock` used a single full-width `absolute inset-x-0 bottom-0`
button that was BOTH the fade overflow cue and the expand/collapse control.
It sat on top of the whole 28px bottom strip, so it swallowed:

- the horizontal scrollbar of a wide code block (couldn't scroll sideways), and
- pointer/drag events on the block's last line (couldn't select/copy the tail).

Split the two responsibilities: the full-width fade stays as a pure
`pointer-events-none` cue, and the only clickable target is a compact toggle
pinned to the right edge (`pointer-events-auto`), clear of the draggable
scrollbar track. The inner container gains `overflow-x-auto` so wide code
gets a working scrollbar. Applies uniformly to code cards and the plain-text
fallback — no per-call prop needed.

Regression test asserts the pointer-events contract (fade `pointer-events-none`
+ full-width, toggle `pointer-events-auto` + right-pinned, not `inset-x-0`) and
that the toggle still flips `aria-expanded`.

Supersedes #69558, #69428
Fixes #69168

Co-authored-by: alelpoan <alelpoan@proton.me>
Co-authored-by: BerneYue <xiongyue_hnu@163.com>
2026-07-22 23:23:39 -05:00
Gille 390b03c455 fix(cli): persist provider on global model switch 2026-07-22 21:18:50 -07:00
Gille b3ff5fc5b1 fix(vision): resolve namespaced custom provider overrides 2026-07-22 21:18:45 -07:00
HexLab98 125fac7943 test(cli): cover custom provider false shrink warning 2026-07-22 21:17:21 -07:00
HexLab98 c428e725aa fix(cli): honor custom_providers in preflight shrink warning
Classic /model confirmation already threaded custom_providers into the
context display, but the shrink-warning path did not. Probe-down then
matched the hardcoded "qwen" catalog (131072) and falsely warned that a
1M custom endpoint had shrunk — while the status bar still showed 1M.

Pass the same fresh inventory list used by switch_model/TUI (with
agent-snapshot fallback), and fall back to agent._custom_providers inside
merge_preflight_compression_warning when the kwarg is omitted.
2026-07-22 21:17:21 -07:00
HexLab98 b0b7f15598 test(computer-use): assert exact capture skips X11 active-window probe
Cover exact_target selection and capture(pid=, window_id=) so the
xprop helper is not invoked on the hot path.
2026-07-22 21:15:18 -07:00
HexLab98 9881328651 fix(computer-use): skip X11 active-window probe for exact targets
Limit the _NET_ACTIVE_WINDOW xprop fallback to unqualified default
captures so exact pid/window_id targeting does not pay up to a 2s
subprocess probe on Linux/X11.
2026-07-22 21:15:18 -07:00
HexLab98 a57bab1c84 test(computer-use): cover Linux X11 active-window selection
Direct xprop parse-boundary coverage plus tied/unknown z_index and
higher-z-frontmost regressions for #58026.
2026-07-22 21:15:18 -07:00
HexLab98 f320f3e5d7 fix(computer-use): prefer X11 active window when z_index ties
When Linux/X11 reports the same z_index for every on-screen window,
prefer _NET_ACTIVE_WINDOW via xprop instead of list order. Keep the
higher-z-index-is-frontmost contract when ordering is informative.
2026-07-22 21:15:18 -07:00
Teknium ff6d86ed0f chore(contributors): map YLChen-007 bare-noreply email 2026-07-22 21:14:44 -07:00
Teknium f31c10a0da test/docs(slack): force-clear new gating env vars; document new options
- Add SLACK_THREAD_REQUIRE_MENTION, SLACK_IGNORE_OTHER_USER_MENTIONS and
  SLACK_REQUIRE_MENTION_CHANNELS to the conftest behavioral-env force-clear
  list so config-loader side effects can't leak between tests (same class
  of leak the existing SLACK_* entries guard against).
- Document thread_require_mention and require_mention_channels in the
  Slack messaging guide next to the other mention-gating options.
2026-07-22 21:14:44 -07:00
Teknium 2c159f00e4 chore(contributors): add email mappings for slack C10 reply-storm salvage 2026-07-22 21:14:44 -07:00
LeonSGP43 0bbf679e4c test(slack): add peer-agent smoke target 2026-07-22 21:14:44 -07:00
Teknium b416907538 feat(slack): require_mention_channels per-channel force-mention override
Port of the Slack half of #13855 (by @kshitijk4poor), reimplemented against
the plugin adapter (the original PR targets the deleted
gateway/platforms/slack.py and six other legacy adapters).

Channels listed in slack.require_mention_channels (config.yaml) or
SLACK_REQUIRE_MENTION_CHANNELS ALWAYS require an explicit @mention, even
when require_mention is false globally or the channel is in
free_response_channels — the opposite direction of free_response_channels.
Instead of duplicating the PR's inline reply-to-bot-thread/mentioned-thread/
session checks, the forced channel falls through to the SAME decision chain
as normal mention gating, so all five wake checks in
_should_wake_on_unmentioned_message keep applying (single decision path).

Credit: adapted from #13855 by @kshitijk4poor (Slack half only; the other
platform halves target deleted legacy adapters and are out of scope for
this cluster).
2026-07-22 21:14:44 -07:00
YLChen-007 38863322a4 Harden gateway mass mention handling 2026-07-22 21:14:44 -07:00
saitama 094c883bc0 Add Slack thread mention gating 2026-07-22 21:14:44 -07:00
Ben Phillips f50e1e9c51 docs(slack): document ignore_other_user_mentions
Adds the option to the Mention & Trigger Behavior section: leading-
mention semantics, opt-in default, env var, and DM/MPIM scope (1:1 DMs
unaffected; MPIMs apply it like channels).

Claude-Session: https://claude.ai/code/session_01TKsNdptNdo9CqT2u7JMdkH
2026-07-22 21:14:44 -07:00
Ben Phillips 286ec6afb3 fix(slack): count pipe-form bot mentions as mentioned in ignore gate
The ignore_other_user_mentions gate relied solely on is_mentioned, which
only recognises exact <@UID> markup, so a message mentioning the bot in
pipe form (<@UID|name>) alongside a leading other-user mention was
wrongly suppressed. The gate now also scans for the bot's own mention in
either markup before ignoring a message.

Claude-Session: https://claude.ai/code/session_01TKsNdptNdo9CqT2u7JMdkH
2026-07-22 21:14:44 -07:00
Ben Phillips 7f9cab15d8 feat(slack): add ignore_other_user_mentions option
Once the bot is @mentioned in a Slack thread it auto-follows and replies
to every later message in that thread, including ones a human addresses to
another human (e.g. "@rasha check this out"). The bot butts in.

Add slack.ignore_other_user_mentions (env SLACK_IGNORE_OTHER_USER_MENTIONS,
default off). When on, a channel/thread message whose first token @mentions
someone other than the bot is treated as addressed to that person and the
bot stays silent unless it is also mentioned. This is Slack parity for the
Discord option of the same name (#33501), adapted to Slack's thread model:
the trigger is a leading mention ("addressed to"), so a message that merely
references another user mid-sentence still reaches the bot.

The gate sits ahead of the free-response / require_mention ladder so it also
overrides the mentioned-thread auto-follow. DMs are never filtered.
2026-07-22 21:14:44 -07:00
Seth Miller 8ed766025b style(slack): wrap edited mention conditionals 2026-07-22 21:14:44 -07:00
Seth Miller 49497bcddb fix(slack): handle edited-in bot mentions 2026-07-22 21:14:44 -07:00
Hafiz Ahmad Ashfaq 69e15d630e fix(slack): filter unlabeled app and bot authored events
(cherry picked from commit e2e11dab5100ea66dd6e070638e7d33ae2ee643a)
2026-07-22 21:14:44 -07:00
Teknium 2168df37b8 fix(slack): harden parent-text wake check against None + restore fixture parity
Follow-up rework on the #51627 cherry-pick:
- Guard the 5th wake check (parent-mentioned-bot, #24848) against a None
  parent_text — _fetch_thread_parent_text is typed to return str but tests
  (and defensive callers) can surface None; 'in None' raised TypeError.
- Drop the PR's fixture-level _fetch_thread_parent_text/_fetch_thread_context
  AsyncMocks from TestThreadReplyHandling/TestAssistantThreadLifecycle: main's
  #24848 tests exercise the real parent-text path via conversations_replies
  side effects, and the blanket mocks broke them.
- _resolve_user_is_bot reworked to the workspace-scoped (team_id, user_id)
  cache key introduced by the multi-workspace name cache on main.
2026-07-22 21:14:44 -07:00
Alan Alwakeel 3f08201bac Fix Slack peer bot status routing loops 2026-07-22 21:14:44 -07:00
ethernet 406d7a67f0 fix(desktop): split steer and queue keyboard shortcuts (#69797)
Keep Enter as the Cursor-style live-turn steering gesture and assign
Ctrl/Cmd+Enter to queue the current draft. Align keybind settings,
tooltips, translations, and the Electron queue-boundary coverage.
2026-07-23 00:13:58 -04:00
hermes-seaeye[bot]andgithub-actions[bot] 2ebeede006 fmt(js): npm run fix on merge (#69823)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 04:09:14 +00:00
458 changed files with 41814 additions and 2666 deletions
+29 -11
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
packages: ${{ steps.set-matrix.outputs.packages }}
checks: ${{ steps.set-matrix.outputs.checks }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@@ -22,21 +22,40 @@ jobs:
command: npm ci --ignore-scripts
- id: set-matrix
run: |
PACKAGES=$(npm query .workspace | jq -c '[.[].location]')
if [ "$PACKAGES" = "[]" ] || [ -z "$PACKAGES" ]; then
echo "::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently)."
exit 1
fi
echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
node -e '
const { execSync } = require("child_process");
const pkgs = JSON.parse(execSync("npm query .workspace", { encoding: "utf-8" }));
if (pkgs.length === 0) {
console.error("::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently).");
process.exit(1);
}
const checks = [];
for (const pkg of pkgs) {
const scripts = pkg.scripts || {};
const subs = Object.keys(scripts).filter(s => /^check:.+$/.test(s));
if (subs.length > 0) {
for (const script of subs) {
checks.push({ package: pkg.location, script });
}
} else if (scripts.check) {
checks.push({ package: pkg.location, script: "check" });
}
}
if (checks.length === 0) {
console.error("::error::No check scripts found in any workspace package.");
process.exit(1);
}
process.stdout.write("checks=" + JSON.stringify(checks) + "\n");
' >> "$GITHUB_OUTPUT"
check:
name: Typecheck & Test
name: ${{ matrix.package }} / ${{ matrix.script }}
needs: workspaces
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
include: ${{ fromJson(needs.workspaces.outputs.checks) }}
fail-fast: false # report all failures, not just the first one
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -47,5 +66,4 @@ jobs:
- uses: ./.github/actions/retry
with:
command: npm ci
- run: npm run --prefix ${{ matrix.package }} check
- run: npm run --prefix ${{ matrix.package }} fix
- run: npm run --prefix ${{ matrix.package }} ${{ matrix.script }}
+2 -2
View File
@@ -1936,8 +1936,8 @@ class HermesACPAgent(acp.Agent):
return "No tools available."
lines = [f"Available tools ({len(tools)}):"]
for t in tools:
name = t.get("function", {}).get("name", "?")
desc = t.get("function", {}).get("description", "")
name = (t.get("function") or {}).get("name", "?")
desc = (t.get("function") or {}).get("description", "")
# Truncate long descriptions
if len(desc) > 80:
desc = desc[:77] + "..."
+67
View File
@@ -480,6 +480,7 @@ def init_agent(
checkpoint_max_total_size_mb: int = 500,
checkpoint_max_file_size_mb: int = 10,
pass_session_id: bool = False,
requested_provider: str = None,
):
"""
Initialize the AI Agent.
@@ -488,6 +489,7 @@ def init_agent(
base_url (str): Base URL for the model API (optional)
api_key (str): API key for authentication (optional, uses env var if not provided)
provider (str): Provider identifier (optional; used for telemetry/routing hints)
requested_provider (str): Original provider identity before runtime canonicalization
api_mode (str): API mode override: "chat_completions" or "codex_responses"
model (str): Model name to use (default: "anthropic/claude-opus-4.6")
max_iterations (int): Maximum number of tool calling iterations (default: 90)
@@ -568,6 +570,11 @@ def init_agent(
agent.base_url = base_url or ""
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
agent.provider = provider_name or ""
agent.requested_provider = (
requested_provider.strip().lower()
if isinstance(requested_provider, str) and requested_provider.strip()
else agent.provider
)
agent._credential_pool = credential_pool
agent.acp_command = acp_command or command
agent.acp_args = list(acp_args or args or [])
@@ -1803,6 +1810,28 @@ def init_agent(
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20))
compression_protect_last = int(_compression_cfg.get("protect_last_n", 20))
# Minimum REAL (actionable) user messages guaranteed to survive in the
# uncompressed tail (compression.min_tail_user_messages). Default 1
# preserves current behavior exactly — the existing single-user tail
# anchor. Values > 1 extend the guarantee to the last N actionable
# user turns. Booleans rejected (bool subclasses int), non-int-like
# values fall back to 1, floor at 1.
_raw_min_tail_users = _compression_cfg.get("min_tail_user_messages", 1)
if isinstance(_raw_min_tail_users, bool):
compression_min_tail_users = 1
elif isinstance(_raw_min_tail_users, int):
compression_min_tail_users = _raw_min_tail_users
elif isinstance(_raw_min_tail_users, float):
compression_min_tail_users = (
int(_raw_min_tail_users) if _raw_min_tail_users.is_integer() else 1
)
else:
try:
compression_min_tail_users = int(str(_raw_min_tail_users).strip())
except (TypeError, ValueError):
compression_min_tail_users = 1
if compression_min_tail_users < 1:
compression_min_tail_users = 1
# Cap on compression retry rounds before a turn gives up with "max
# compression attempts reached" (compression.max_attempts). Hardcoding 3
# strands sessions that legitimately need more rounds — e.g. a restart
@@ -1831,6 +1860,39 @@ def init_agent(
if compression_max_attempts < 1:
compression_max_attempts = 3
compression_max_attempts = min(compression_max_attempts, 10)
def _parse_prune_int(raw, default):
# Same parser semantics as compression.max_attempts above: reject
# booleans (bool subclasses int — YAML `true` would coerce to 1),
# reject fractional floats rather than truncating them, accept
# integral floats and numeric strings, fall back to the default on
# anything else.
if isinstance(raw, bool):
return default
if isinstance(raw, int):
return raw
if isinstance(raw, float):
return int(raw) if raw.is_integer() else default
try:
return int(str(raw).strip())
except (TypeError, ValueError):
return default
# Opt-in proactive tool-result prune trigger (0 = disabled — the
# default, so an unset key is behavior-neutral). Negative values are
# treated as disabled rather than erroring.
compression_proactive_prune_tokens = max(
0, _parse_prune_int(_compression_cfg.get("proactive_prune_tokens", 0), 0)
)
compression_proactive_prune_min_chars = _parse_prune_int(
_compression_cfg.get("proactive_prune_min_result_chars", 8000), 8000
)
compression_proactive_prune_min_reclaim = max(
0,
_parse_prune_int(
_compression_cfg.get("proactive_prune_min_reclaim_tokens", 4096), 4096
),
)
# protect_first_n is the number of non-system messages to protect at
# the head, in addition to the system prompt (which is always
# implicitly protected by the compressor). Floor at 0 — a value of
@@ -2305,6 +2367,10 @@ def init_agent(
max_tokens=agent.max_tokens,
model_thresholds=compression_model_thresholds,
threshold_tokens_cap=compression_threshold_tokens,
proactive_prune_tokens=compression_proactive_prune_tokens,
proactive_prune_min_result_chars=compression_proactive_prune_min_chars,
proactive_prune_min_reclaim_tokens=compression_proactive_prune_min_reclaim,
min_tail_user_messages=compression_min_tail_users,
)
_bind_session_state = getattr(agent.context_compressor, "bind_session_state", None)
if callable(_bind_session_state):
@@ -2569,6 +2635,7 @@ def init_agent(
agent._primary_runtime = {
"model": agent.model,
"provider": agent.provider,
"requested_provider": agent.requested_provider,
"base_url": agent.base_url,
"api_mode": agent.api_mode,
"api_key": getattr(agent, "api_key", ""),
+49 -14
View File
@@ -922,12 +922,17 @@ def recover_with_credential_pool(
)
return False, has_retried_429
# Capture the current API key before any rotation — needed to
# identify which credential actually failed when
# mark_exhausted_and_rotate is called. Without this hint the
# pool falls back to current() or _select_unlocked(), which may
# return the NEXT (healthy) entry after a prior rotation, marking
# the wrong credential as exhausted (#43747).
# Attribute the failure to the API key the agent actually dispatched the
# request with, not to pool.current(). The current() pointer is shared,
# mutable state — round-robin select() advances it on every call, and
# concurrent turns or a second process (gateway/dashboard) reloading the
# pool reset it to None — so by the time recovery runs it routinely points
# at a DIFFERENT, healthy entry. Marking that entry exhausted copies this
# request's error/reset time onto it and can take the whole pool offline
# from a single rate-limited key (#43747). ``_swap_credential`` keeps
# ``agent.api_key`` in sync with the entry in use, so it identifies the
# failing entry exactly; fall back to current()'s key only when the agent
# carries no key at all.
_api_key_hint = getattr(agent, "api_key", None) or None
if not _api_key_hint:
_cur = pool.current()
@@ -987,7 +992,16 @@ def recover_with_credential_pool(
# rotate immediately. This prevents the "cancel-between-429s" trap
# where has_retried_429 (a local var) gets reset on each new prompt,
# causing the pool to retry the same exhausted credential forever.
current_entry = pool.current()
# Prefer the entry matching the failing key over the shared current()
# pointer, for the same attribution reason as above.
current_entry = None
if _api_key_hint:
current_entry = next(
(e for e in pool.entries() if e.runtime_api_key == _api_key_hint),
None,
)
if current_entry is None:
current_entry = pool.current()
current_last_status = getattr(current_entry, "last_status", None) if current_entry else None
if current_last_status == STATUS_EXHAUSTED:
_ra().logger.info(
@@ -995,7 +1009,11 @@ def recover_with_credential_pool(
current_last_status,
)
rotate_status = status_code if status_code is not None else 429
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
"Credential %s (rate limit, pre-exhausted) — rotated to pool entry %s",
@@ -1019,7 +1037,11 @@ def recover_with_credential_pool(
if not has_retried_429 and not usage_limit_reached:
return False, True
rotate_status = status_code if status_code is not None else 429
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
"Credential %s (rate limit) — rotated to pool entry %s",
@@ -1034,7 +1056,7 @@ def recover_with_credential_pool(
# Subscription/entitlement 403s look like auth failures on the wire
# but refresh cannot fix them — the OAuth token is already valid,
# the account simply lacks the entitlement. Without this guard,
# ``try_refresh_current()`` keeps minting fresh tokens against the
# the refresh path keeps minting fresh tokens against the
# same unsubscribed account and the main agent loop spins re-issuing
# the same 403 until the user Ctrl+C's.
#
@@ -1087,9 +1109,13 @@ def recover_with_credential_pool(
agent.provider or "provider",
)
return False, has_retried_429
refreshed = pool.try_refresh_current()
# Refresh the entry that supplied the failing key, not current():
# the shared pointer can reference a different, healthy entry, and
# refreshing it would consume that entry's single-use refresh token
# (or mark it exhausted on failure) for a failure it never had.
refreshed = pool.try_refresh_matching(api_key_hint=_api_key_hint)
if refreshed is not None:
# ``try_refresh_current()`` re-mints a fresh OAuth token and reports
# ``try_refresh_matching()`` re-mints a fresh OAuth token and reports
# success even when the upstream keeps rejecting it — a single-entry
# pool (common for OAuth/Max subscribers) has nothing to rotate to,
# so a bare "refreshed → retry" loop spins forever on the same dead
@@ -1117,9 +1143,13 @@ def recover_with_credential_pool(
agent._swap_credential(refreshed)
return True, has_retried_429
# Refresh failed — rotate to next credential instead of giving up.
# The failed entry is already marked exhausted by try_refresh_current().
# The failed entry is already marked exhausted by the refresh attempt.
rotate_status = status_code if status_code is not None else 401
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
"Credential %s (auth refresh failed) — rotated to pool entry %s",
@@ -1178,6 +1208,7 @@ def try_recover_primary_transport(
agent._client_kwargs = dict(rt["client_kwargs"])
agent.model = rt["model"]
agent.provider = rt["provider"]
agent.requested_provider = rt.get("requested_provider", agent.provider)
agent.base_url = rt["base_url"]
agent.api_mode = rt["api_mode"]
if hasattr(agent, "_transport_cache"):
@@ -1341,6 +1372,7 @@ def restore_primary_runtime(agent) -> bool:
# ── Core runtime state ──
agent.model = rt["model"]
agent.provider = rt["provider"]
agent.requested_provider = rt.get("requested_provider", agent.provider)
agent.base_url = rt["base_url"] # setter updates _base_url_lower
agent.api_mode = rt["api_mode"]
if hasattr(agent, "_transport_cache"):
@@ -2005,6 +2037,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
for name in (
"model",
"provider",
"requested_provider",
"base_url",
"api_mode",
"api_key",
@@ -2033,6 +2066,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Swap core runtime fields ──
agent.model = new_model
agent.provider = new_provider
agent.requested_provider = new_provider
# Use the new base_url when provided. When it's empty AND the
# provider is actually changing, do NOT fall back to the current
# (old provider's) URL — that silently pairs the new provider label
@@ -2282,6 +2316,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
agent._primary_runtime = {
"model": agent.model,
"provider": agent.provider,
"requested_provider": agent.requested_provider,
"base_url": agent.base_url,
"api_mode": agent.api_mode,
"api_key": getattr(agent, "api_key", ""),
+36 -2
View File
@@ -1881,6 +1881,28 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
return out
_EMPTY_TEXT_PLACEHOLDER = "(empty)"
def _safe_text(text: Any) -> str:
"""Return ``text`` if it's non-whitespace, else a non-whitespace placeholder.
The Anthropic Messages API rejects requests where a text content block is
empty or whitespace-only (HTTP 400 "text content blocks must contain
non-whitespace text"). When such a block gets stored in session history —
e.g. produced by context compression — it is replayed verbatim on every
subsequent turn, permanently wedging the session. Coercing to a
non-whitespace placeholder is self-healing: the next API call recovers.
Mirrors ``bedrock_adapter._safe_text`` (#9486); ref #69512.
"""
if text is None:
return _EMPTY_TEXT_PLACEHOLDER
if not isinstance(text, str):
text = str(text)
return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER
def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Strip output-only fields from a stored Anthropic content block so it is
valid as REQUEST input on replay.
@@ -1898,7 +1920,10 @@ def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return None
btype = b.get("type")
if btype == "text":
out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")}
# Coerce empty/whitespace-only text to a non-whitespace placeholder;
# the Messages input schema rejects blank text blocks (#69512), and a
# blank block stored in history replays on every turn → permanent 400.
out: Dict[str, Any] = {"type": "text", "text": _safe_text(b.get("text", ""))}
# citations is input-valid ONLY when it's a non-empty list; the SDK
# emits citations=None on responses, which the input schema rejects.
cits = b.get("citations")
@@ -2058,7 +2083,16 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
# Anthropic rejects empty assistant content
effective = blocks or content
if not effective or effective == "":
effective = [{"type": "text", "text": "(empty)"}]
effective = [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}]
elif isinstance(effective, list):
# The all-empty guard above misses a list that still contains a
# whitespace-only text block (e.g. from a content array of blank parts,
# or compression). Those also trip "text content blocks must contain
# non-whitespace text" (#69512). Coerce text blocks in place; other
# block types (thinking/tool_use/image) are left untouched.
for blk in effective:
if isinstance(blk, dict) and blk.get("type") == "text":
blk["text"] = _safe_text(blk.get("text", ""))
return {"role": "assistant", "content": effective}
+225 -38
View File
@@ -2303,6 +2303,62 @@ def _read_main_base_url() -> str:
return ""
def _resolve_moa_aggregator(preset_name: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
"""Resolve a MoA preset to its aggregator (provider, model) pair.
"moa" is a virtual provider — the acting model of a preset is its
aggregator slot, and there is no real "moa" HTTP endpoint. Auxiliary
tasks (title generation, compression, vision, commit messages, …) don't
need the reference fan-out, so every aux resolution layer maps
provider="moa"/model=<preset> to the aggregator's real provider+model
through this single helper (shared by ``_resolve_auto``,
``_resolve_task_provider_model``, and ``resolve_provider_client`` so the
preset lookup and validation cannot drift between paths).
Args:
preset_name: The MoA preset name (usually carried in the "model"
field), or None/"" to resolve the user's default preset.
Returns:
(aggregator_provider, aggregator_model), or (None, None) when the
preset cannot be resolved (missing config, renamed/deleted preset,
or a malformed aggregator slot).
"""
try:
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
preset = resolve_moa_preset(load_config().get("moa") or {}, preset_name or None)
agg = preset.get("aggregator") or {}
agg_provider = str(agg.get("provider") or "").strip()
agg_model = str(agg.get("model") or "").strip()
if agg_provider and agg_model and agg_provider.lower() != "moa":
return agg_provider, agg_model
except Exception:
logger.debug(
"MoA aggregator resolution failed for preset %r", preset_name, exc_info=True
)
return None, None
def _read_main_model_for_aux() -> str:
"""Main model with MoA presets unwrapped to the aggregator's model.
When the main provider is ``moa``, ``_read_main_model()`` returns a MoA
*preset name* (e.g. "opus-gpt") — never a valid wire model id on any
provider. Auxiliary fallback chains that pre-fill a missing model from
the main model must use this reader instead, so unset aux models default
to the preset's acting (aggregator) model. Returns "" when the main
provider is moa but the preset cannot be resolved — sending nothing is
strictly better than sending a preset name that 400s.
"""
model = _read_main_model()
if (_read_main_provider() or "").strip().lower() == "moa":
_, agg_model = _resolve_moa_aggregator(model)
return agg_model or ""
return model
def _read_main_api_key_if_same_host(aux_base_url: str) -> str:
"""Return the main api_key only when *aux_base_url* points at the same
host as the main model's base_url.
@@ -2378,6 +2434,7 @@ def set_runtime_main(
provider: str,
model: str,
*,
requested_provider: str = "",
base_url: str = "",
api_key: Any = "",
api_mode: str = "",
@@ -2393,6 +2450,7 @@ def set_runtime_main(
global _RUNTIME_MAIN_AUTH_MODE, _RUNTIME_MAIN_COMPAT_SNAPSHOT
runtime = {
"provider": (provider or "").strip().lower(),
"requested_provider": (requested_provider or "").strip().lower(),
"model": (model or "").strip(),
"base_url": (base_url or "").strip(),
"api_key": (
@@ -2573,7 +2631,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
return None, None
if custom_base.lower().startswith(_CODEX_AUX_BASE_URL.lower()):
return None, None
model = _read_main_model() or "gpt-4o-mini"
model = _read_main_model_for_aux() or "gpt-4o-mini"
logger.debug("Auxiliary client: custom endpoint (%s, api_mode=%s)", model, custom_mode or "chat_completions")
_clean_base, _dq = _extract_url_query_params(custom_base)
_extra = {"default_query": _dq} if _dq else {}
@@ -2865,6 +2923,7 @@ _AUTO_PROVIDER_LABELS = {
}
_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode")
_MAIN_RUNTIME_CONTEXT_FIELDS = _MAIN_RUNTIME_FIELDS + ("requested_provider",)
def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]:
@@ -2887,7 +2946,7 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str,
if not isinstance(main_runtime, dict):
return {}
normalized: Dict[str, Any] = {}
for field in _MAIN_RUNTIME_FIELDS:
for field in _MAIN_RUNTIME_CONTEXT_FIELDS:
value = main_runtime.get(field)
# Preserve a callable api_key (Entra ID bearer provider) unchanged.
if field == "api_key" and callable(value) and not isinstance(value, str):
@@ -2895,9 +2954,10 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str,
continue
if isinstance(value, str) and value.strip():
normalized[field] = value.strip()
provider = normalized.get("provider")
if isinstance(provider, str):
normalized["provider"] = provider.lower()
for identity_field in ("provider", "requested_provider"):
identity = normalized.get(identity_field)
if isinstance(identity, str):
normalized[identity_field] = identity.lower()
return normalized
@@ -3623,6 +3683,7 @@ def _retry_same_provider_sync(
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=retry_base or resolved_base_url,
task=task,
)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
@@ -3682,6 +3743,7 @@ async def _retry_same_provider_async(
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=retry_base or resolved_base_url,
task=task,
)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
@@ -3757,6 +3819,24 @@ def _refresh_provider_credentials(provider: str) -> bool:
return False
_evict_cached_clients(normalized)
return True
if normalized == "vertex":
# Mirrors run_agent.py's _try_refresh_vertex_client_credentials
# for the main conversation loop. Without this branch, an
# auxiliary Vertex client (vision, title generation, reflection,
# context compression, ...) that 401s on its ~1h token expiry
# falls through to the final `return False` below: the stale
# client is never evicted from _client_cache (whose cache key
# ignores the rotating bearer token), so every subsequent
# auxiliary Vertex call keeps 401ing until process restart.
from agent.vertex_adapter import get_vertex_config
token, base_url = get_vertex_config()
if not isinstance(token, str) or not token.strip():
return False
if not isinstance(base_url, str) or not base_url.strip():
return False
_evict_cached_clients(normalized)
return True
except Exception as exc:
logger.debug("Auxiliary provider credential refresh failed for %s: %s", normalized, exc)
return False
@@ -3870,7 +3950,7 @@ def _call_fallback_candidate_sync(
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body, reasoning_config=reasoning_config,
base_url=fb_base)
base_url=fb_base, task=task)
try:
return _validate_llm_response(
fb_client.chat.completions.create(**fb_kwargs), task)
@@ -3887,7 +3967,7 @@ def _call_fallback_candidate_sync(
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
try:
return _validate_llm_response(
retry_client.chat.completions.create(**retry_kwargs), task)
@@ -3936,7 +4016,7 @@ async def _call_fallback_candidate_async(
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body, reasoning_config=reasoning_config,
base_url=fb_base)
base_url=fb_base, task=task)
try:
return _validate_llm_response(
await fb_client.chat.completions.create(**fb_kwargs), task)
@@ -3954,7 +4034,7 @@ async def _call_fallback_candidate_async(
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
try:
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task)
@@ -4041,6 +4121,13 @@ def _try_main_agent_model_fallback(
"""
main_provider = (_read_main_provider() or "").strip()
main_model = (_read_main_model() or "").strip()
if main_provider.lower() == "moa":
# MoA virtual provider: fall back to the preset's aggregator — the
# acting model — instead of the unreachable "moa"/<preset-name> pair.
_agg_provider, _agg_model = _resolve_moa_aggregator(main_model)
if not _agg_provider or not _agg_model:
return None, None, ""
main_provider, main_model = _agg_provider, _agg_model
if not main_provider or not main_model or main_provider.lower() in {"auto", ""}:
return None, None, ""
@@ -4441,26 +4528,17 @@ def _resolve_auto(
# model. Resolve the MoA preset to its aggregator slot and continue Step 1
# with that real provider+model. Mirrors the MoA context-length resolution.
if main_provider == "moa":
try:
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
_preset = resolve_moa_preset(load_config().get("moa") or {}, main_model)
_agg = _preset.get("aggregator") or {}
_agg_provider = str(_agg.get("provider") or "").strip()
_agg_model = str(_agg.get("model") or "").strip()
if _agg_provider and _agg_model and _agg_provider.lower() != "moa":
main_provider = _agg_provider
main_model = _agg_model
# The MoA virtual runtime carries a non-HTTP base_url
# ("moa://local") and a placeholder api_key; they belong to the
# facade, not the aggregator's real provider. Drop them so the
# aggregator resolves through its own provider credentials.
runtime_base_url = ""
runtime_api_key = ""
runtime_api_mode = ""
except Exception:
logger.debug("MoA aux resolution to aggregator failed", exc_info=True)
_agg_provider, _agg_model = _resolve_moa_aggregator(main_model)
if _agg_provider and _agg_model:
main_provider = _agg_provider
main_model = _agg_model
# The MoA virtual runtime carries a non-HTTP base_url
# ("moa://local") and a placeholder api_key; they belong to the
# facade, not the aggregator's real provider. Drop them so the
# aggregator resolves through its own provider credentials.
runtime_base_url = ""
runtime_api_key = ""
runtime_api_mode = ""
if (main_provider and main_model
and main_provider not in {"auto", ""}):
@@ -4715,6 +4793,27 @@ def resolve_provider_client(
# Normalise aliases
provider = _normalize_aux_provider(provider)
# MoA virtual provider chokepoint: "moa" is not a real HTTP provider —
# its acting model is the preset's aggregator slot. The two resolver
# layers above (_resolve_auto, _resolve_task_provider_model) already
# unwrap their own paths, but callers that route here directly (vision
# auto-detect, _try_main_agent_model_fallback, get_available_vision_backends,
# plugin code) would otherwise dead-end in the unknown-provider branch.
# ``model`` carries the preset name for moa calls; when the preset can't
# be resolved we leave the call untouched and let the normal
# missing-provider handling produce its diagnostic.
if provider == "moa":
_agg_provider, _agg_model = _resolve_moa_aggregator(model)
if _agg_provider and _agg_model:
original_provider = _agg_provider.strip().lower()
provider = _normalize_aux_provider(_agg_provider)
model = _agg_model
# The moa:// facade endpoint and placeholder key belong to the
# virtual runtime, not the aggregator's real provider.
if explicit_base_url and str(explicit_base_url).lower().startswith("moa://"):
explicit_base_url = None
explicit_api_key = None
# Universal model-resolution fallback for concrete providers. ``auto`` is
# intentionally excluded: `_resolve_auto(main_runtime=...)` returns the
# model paired with the provider it actually selected. Pre-filling an auto
@@ -4735,6 +4834,10 @@ def resolve_provider_client(
# the load-bearing step for OAuth providers: an xai-oauth user
# with grok-4.3 configured gets grok-4.3 for title generation
# instead of silently dropping to whatever Step-2 fallback (#31845).
# When the main provider is MoA, ``_read_main_model_for_aux()``
# substitutes the preset's aggregator model — the preset NAME is
# never a valid wire model id, so unset aux models default to the
# preset's acting model instead.
#
# Each provider branch below sees a non-empty ``model`` whenever the
# user has *anything* configured — no provider-specific empty-model
@@ -4751,7 +4854,7 @@ def resolve_provider_client(
# return the actual current runtime model when the caller did not explicitly
# request one. (# compression-current-model)
if not model and provider != "auto":
model = _get_aux_model_for_provider(provider) or _read_main_model() or model
model = _get_aux_model_for_provider(provider) or _read_main_model_for_aux() or model
def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
"""Decide if a plain OpenAI client should be wrapped for Responses API.
@@ -5025,7 +5128,7 @@ def resolve_provider_client(
model
or custom_entry.get("model")
or (main_runtime.get("model") if main_runtime else None)
or _read_main_model()
or _read_main_model_for_aux()
or "gpt-4o-mini",
provider,
)
@@ -5260,7 +5363,7 @@ def resolve_provider_client(
final_model = _normalize_resolved_model(
model
or (main_runtime.get("model") if main_runtime else None)
or _read_main_model(),
or _read_main_model_for_aux(),
provider,
)
if provider == "copilot-acp":
@@ -5628,7 +5731,24 @@ def resolve_vision_provider_client(
# 5. Stop
main_provider = str(runtime.get("provider") or _read_main_provider())
main_model = str(runtime.get("model") or _read_main_model())
if main_provider and main_provider not in {"auto", ""}:
if main_provider.strip().lower() == "moa":
# MoA virtual provider: main_model is a preset NAME, and every
# capability probe below (_PROVIDERS_WITHOUT_VISION,
# _main_model_supports_vision, _resolve_provider_vision_default)
# would run against a provider/model pair that doesn't exist on
# any wire. Unwrap to the preset's aggregator slot first so the
# checks and the eventual client target the real acting model.
_agg_provider, _agg_model = _resolve_moa_aggregator(main_model)
if _agg_provider and _agg_model:
main_provider, main_model = _agg_provider, _agg_model
# Drop the moa:// facade endpoint from the runtime view used
# below — it belongs to the virtual provider, not the
# aggregator's real provider.
runtime = dict(runtime)
runtime["base_url"] = ""
runtime["api_key"] = ""
runtime["api_mode"] = ""
if main_provider and main_provider not in {"auto", "", "moa"}:
# A provider-specific vision default wins over the user's chat model:
# static overrides (xiaomi/zai) and catalog-backed discovery (the
# DeepInfra profile hook) both yield a *known* vision-capable model,
@@ -6225,8 +6345,8 @@ def _resolve_task_provider_model(
task: str = None,
provider: str = None,
model: str = None,
base_url: str = None,
api_key: str = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]:
"""Determine provider + model for a call.
@@ -6269,12 +6389,57 @@ def _resolve_task_provider_model(
# which downstream consumers like ContextCompressor accept as the task output.
# The provider-side 'auto' is handled in _resolve_auto() via main_runtime
# fallback, so dropping cfg_model to None here lets that path do its job.
#
# The explicit `model` kwarg needs the identical normalization: MoA slots
# (agent/moa_loop.py's _slot_runtime) forward a preset's `model:` field as
# this explicit argument rather than through auxiliary.<task> config, so a
# user-configured `model: auto` on a MoA reference/aggregator slot reaches
# this function here, not as cfg_model. Only normalizing cfg_model let that
# literal "auto" slip through via `model or cfg_model` below.
if model and model.lower() == "auto":
model = None
if cfg_model and cfg_model.lower() == "auto":
cfg_model = None
resolved_model = model or cfg_model
resolved_api_mode = cfg_api_mode
# MoA virtual provider: an *explicit* `provider: moa` override (either the
# caller-passed `provider` arg or `auxiliary.<task>.provider` in
# config.yaml) reaches this function directly — it never goes through
# _resolve_auto(), which only unwraps the *implicit* "main provider is
# moa" case (#53827). Left as-is, "moa" is returned verbatim and
# resolve_provider_client() looks it up in PROVIDER_REGISTRY (which has
# no "moa" entry — it's not a real HTTP provider), falls to the
# unknown-provider dead end, and call_llm surfaces a nonsensical
# "MOA_API_KEY environment variable" error for a provider that was never
# meant to be reached over the wire. Auxiliary tasks don't need the
# reference fan-out — resolve to the preset's aggregator slot instead,
# exactly like the implicit path does (shared helper: _resolve_moa_aggregator).
def _unwrap_moa_provider(prov: str, mdl: Optional[str]) -> Tuple[str, Optional[str]]:
if prov.strip().lower() != "moa":
return prov, mdl
agg_provider, agg_model = _resolve_moa_aggregator(mdl)
if agg_provider and agg_model:
return agg_provider, agg_model
return prov, mdl
if provider and str(provider).strip().lower() == "moa":
provider, resolved_model = _unwrap_moa_provider(provider, resolved_model)
# The moa:// virtual endpoint (if any explicit base_url/api_key was
# passed alongside provider="moa") belongs to the facade, not the
# aggregator's real provider — drop it so the aggregator resolves
# through its own provider credentials, mirroring _resolve_auto().
if provider and provider.lower() != "moa":
base_url = None
api_key = None
elif cfg_provider and str(cfg_provider).strip().lower() == "moa":
cfg_provider, cfg_model = _unwrap_moa_provider(cfg_provider, resolved_model)
if cfg_provider and cfg_provider.lower() != "moa":
resolved_model = cfg_model
cfg_base_url = None
cfg_api_key = None
# Convenience aliases for direct API-key endpoints that aren't first-class
# providers (e.g. ``provider: openai`` → custom + api.openai.com/v1).
# Applied to both explicit args and config-derived values. When the user
@@ -6631,6 +6796,7 @@ def _build_call_kwargs(
extra_body: Optional[dict] = None,
reasoning_config: Optional[dict] = None,
base_url: Optional[str] = None,
task: Optional[str] = None,
) -> dict:
"""Build kwargs for .chat.completions.create() with model/provider adjustments."""
kwargs: Dict[str, Any] = {
@@ -6685,11 +6851,32 @@ def _build_call_kwargs(
_provider_norm in {"nvidia", "nvidia-nim", "nim", "build-nvidia", "nemotron"}
or base_url_host_matches(_effective_base, "integrate.api.nvidia.com")
)
_is_moa = bool(task) and str(task) == "moa_reference"
# Gemini's native generateContent maps max_tokens → maxOutputTokens and,
# when it is omitted, applies a fixed 65,535-token ceiling rather than
# "the model's full budget" (see gemini_native_adapter.build_gemini_request).
# So an explicit cap is both safe and the ONLY way to honor it here —
# dropping max_tokens silently makes MoA's reference_max_tokens a no-op
# for gemini advisors (they run effectively uncapped).
_is_gemini_native = _provider_norm in {
"gemini", "google", "google-gemini", "google-ai-studio",
}
if not _is_gemini_native and _effective_base:
try:
from agent.gemini_native_adapter import is_native_gemini_base_url
_is_gemini_native = is_native_gemini_base_url(_effective_base)
except Exception:
pass
if (
_is_anthropic_compat_endpoint(provider, _effective_base)
or _is_nvidia_nim
or _is_moa
or _is_gemini_native
):
kwargs["max_tokens"] = max_tokens
# Use auxiliary_max_tokens_param() so models that require
# max_completion_tokens (GPT-5 family, Copilot) get the right
# parameter name instead of a hardcoded max_tokens that 400s.
kwargs.update(auxiliary_max_tokens_param(max_tokens, model=model))
if tools:
# Defensive dedup: providers like Google Vertex, Azure, and Bedrock
@@ -7058,7 +7245,7 @@ def call_llm(
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=_base_info or resolved_base_url)
base_url=_base_info or resolved_base_url, task=task)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
_client_base = str(getattr(client, "base_url", "") or "")
@@ -7674,7 +7861,7 @@ async def async_call_llm(
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=_client_base or resolved_base_url)
base_url=_client_base or resolved_base_url, task=task)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
+61 -11
View File
@@ -433,6 +433,29 @@ def _model_supports_tool_use(model_id: str) -> bool:
return not any(pattern in model_lower for pattern in _NON_TOOL_CALLING_PATTERNS)
# ---------------------------------------------------------------------------
# Prompt-cache capability detection (Converse API cachePoint)
# ---------------------------------------------------------------------------
# Claude on Bedrock already gets prompt caching through the AnthropicBedrock
# SDK path (see is_anthropic_bedrock_model / runtime_provider.py's dual-path
# routing) — it never reaches build_converse_kwargs unless bearer-token auth
# forces the Converse path (#28156). This allowlist covers the Converse API
# itself: sending an unsupported model a cachePoint block raises a
# ValidationException, so — like _model_supports_tool_use but inverted —
# unknown models default to NOT receiving cache markers until confirmed.
# Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html
_CACHE_POINT_PATTERNS = [
"anthropic.claude", # bearer-token fallback path
"amazon.nova",
]
def _model_supports_prompt_cache(model_id: str) -> bool:
"""Return True if the model accepts a Converse API cachePoint block."""
model_lower = model_id.lower()
return any(pattern in model_lower for pattern in _CACHE_POINT_PATTERNS)
def is_anthropic_bedrock_model(model_id: str) -> bool:
"""Return True if the model is an Anthropic Claude model on Bedrock.
@@ -764,14 +787,22 @@ def normalize_converse_response(response: Dict) -> SimpleNamespace:
reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None,
)
# Build usage stats
# Build usage stats. Converse's inputTokens excludes cache read/write
# tokens (unlike OpenAI's prompt_tokens, which includes them) — restore
# the OpenAI-style "total includes cache" convention here so downstream
# normalize_usage() can subtract them back out consistently, and surface
# the Anthropic-named fields it already falls back to for cache reads.
usage_data = response.get("usage", {})
input_tokens = usage_data.get("inputTokens", 0)
cache_read_tokens = usage_data.get("cacheReadInputTokens", 0)
cache_write_tokens = usage_data.get("cacheWriteInputTokens", 0)
output_tokens = usage_data.get("outputTokens", 0)
usage = SimpleNamespace(
prompt_tokens=usage_data.get("inputTokens", 0),
completion_tokens=usage_data.get("outputTokens", 0),
total_tokens=(
usage_data.get("inputTokens", 0) + usage_data.get("outputTokens", 0)
),
prompt_tokens=input_tokens + cache_read_tokens + cache_write_tokens,
completion_tokens=output_tokens,
total_tokens=input_tokens + cache_read_tokens + cache_write_tokens + output_tokens,
cache_read_input_tokens=cache_read_tokens,
cache_creation_input_tokens=cache_write_tokens,
)
finish_reason = _converse_stop_reason_to_openai(stop_reason)
@@ -936,6 +967,8 @@ def stream_converse_with_callbacks(
usage_data = {
"inputTokens": meta_usage.get("inputTokens", 0),
"outputTokens": meta_usage.get("outputTokens", 0),
"cacheReadInputTokens": meta_usage.get("cacheReadInputTokens", 0),
"cacheWriteInputTokens": meta_usage.get("cacheWriteInputTokens", 0),
}
# Flush remaining text
@@ -949,12 +982,16 @@ def stream_converse_with_callbacks(
reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None,
)
input_tokens = usage_data.get("inputTokens", 0)
cache_read_tokens = usage_data.get("cacheReadInputTokens", 0)
cache_write_tokens = usage_data.get("cacheWriteInputTokens", 0)
output_tokens = usage_data.get("outputTokens", 0)
usage = SimpleNamespace(
prompt_tokens=usage_data.get("inputTokens", 0),
completion_tokens=usage_data.get("outputTokens", 0),
total_tokens=(
usage_data.get("inputTokens", 0) + usage_data.get("outputTokens", 0)
),
prompt_tokens=input_tokens + cache_read_tokens + cache_write_tokens,
completion_tokens=output_tokens,
total_tokens=input_tokens + cache_read_tokens + cache_write_tokens + output_tokens,
cache_read_input_tokens=cache_read_tokens,
cache_creation_input_tokens=cache_write_tokens,
)
finish_reason = _converse_stop_reason_to_openai(stop_reason)
@@ -993,6 +1030,7 @@ def build_converse_kwargs(
Converts OpenAI-format inputs to Converse API parameters.
"""
system_prompt, converse_messages = convert_messages_to_converse(messages)
cache_enabled = _model_supports_prompt_cache(model)
kwargs: Dict[str, Any] = {
"modelId": model,
@@ -1003,6 +1041,8 @@ def build_converse_kwargs(
}
if system_prompt:
if cache_enabled:
system_prompt = system_prompt + [{"cachePoint": {"type": "default"}}]
kwargs["system"] = system_prompt
from agent.anthropic_adapter import _forbids_sampling_params
@@ -1026,6 +1066,8 @@ def build_converse_kwargs(
# Strip tools for known non-tool-calling models and warn the user.
# Ref: PR #7920 feedback from @ptlally, pattern from PR #4346.
if _model_supports_tool_use(model):
if cache_enabled:
converse_tools = converse_tools + [{"cachePoint": {"type": "default"}}]
kwargs["toolConfig"] = {"tools": converse_tools}
else:
logger.warning(
@@ -1033,6 +1075,14 @@ def build_converse_kwargs(
"The agent will operate in text-only mode.", model
)
if cache_enabled and len(converse_messages) >= 2:
# Checkpoint everything up to (not including) the newest turn, so the
# marker survives unchanged across requests as only the tail grows —
# mirroring the Anthropic system_and_3 strategy in prompt_caching.py.
content = converse_messages[-2].get("content")
if isinstance(content, list) and content:
content.append({"cachePoint": {"type": "default"}})
if guardrail_config:
kwargs["guardrailConfig"] = guardrail_config
+1
View File
@@ -1712,6 +1712,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
agent._config_context_length = None
agent.model = fb_model
agent.provider = fb_provider
agent.requested_provider = fb_provider
agent.base_url = fb_base_url
agent.api_mode = fb_api_mode
if hasattr(agent, "_transport_cache"):
File diff suppressed because it is too large Load Diff
+94
View File
@@ -53,6 +53,39 @@ def sanitize_memory_context(memory_context: str) -> str:
)
def automatic_compaction_status_message(
engine: Any,
*,
phase: str,
default_message: str,
**context: Any,
) -> str | None:
"""Resolve host-visible status for an automatic compaction event.
Engines can suppress routine automatic status with
``emit_automatic_compaction_status = False`` or customize it by defining
``get_automatic_compaction_status_message(...)``. Empty strings and
``None`` mean "do not emit a lifecycle status".
"""
if not getattr(engine, "emit_automatic_compaction_status", True):
return None
formatter = getattr(engine, "get_automatic_compaction_status_message", None)
if callable(formatter):
message = formatter(
phase=phase,
default_message=default_message,
**context,
)
else:
message = default_message
if message is None:
return None
message = str(message).strip()
return message or None
class ContextEngine(ABC):
"""Base class all context engines must implement."""
@@ -89,6 +122,12 @@ class ContextEngine(ABC):
protect_first_n: int = 3
protect_last_n: int = 6
# User-visible lifecycle status for automatic host-triggered compaction.
# Alternative engines that treat compaction as routine background
# maintenance can set this false to keep successful automatic passes silent;
# warnings, errors, and explicit manual commands should still surface.
emit_automatic_compaction_status: bool = True
# -- Core interface ----------------------------------------------------
@abstractmethod
@@ -107,6 +146,19 @@ class ContextEngine(ABC):
def should_compress(self, prompt_tokens: int = None) -> bool:
"""Return True if compaction should fire this turn."""
def should_compress_info(self, prompt_tokens: int = None) -> "tuple[bool, str | None]":
"""Return ``(should_compress, reason)``.
The base implementation is backward-compatible: engines that only
implement ``should_compress`` get ``(should_compress(prompt_tokens),
None)``. Concrete engines with richer block reasons (e.g. a
summary-LLM cooldown or an anti-thrashing guard) override this to
surface a human-readable reason so callers can warn the user instead
of silently skipping compression. Added for the silent-overflow
warning fix (#62625) so plugin engines don't raise AttributeError.
"""
return self.should_compress(prompt_tokens), None
@abstractmethod
def compress(
self,
@@ -137,6 +189,27 @@ class ContextEngine(ABC):
host filters unsupported optional arguments by signature.
"""
# -- Optional: proactive tool-result prune -----------------------------
def prune_tool_results_only(
self,
messages: List[Dict[str, Any]],
current_tokens: int | None = None,
) -> tuple[List[Dict[str, Any]], int]:
"""Deterministically trim old tool-result payloads without an LLM call.
Runs on a low, cost-oriented trigger independent of ``should_compress``
so large-window engines can reclaim re-sent tool output long before full
compaction would fire. Returns ``(messages, n_pruned)``.
Default is a safe no-op: the list is returned unchanged with ``0``
pruned. Engines that don't implement a cheap prune — and any engine that
predates this hook — inherit this default, so the agent loop's
post-tool-call prune path never raises ``AttributeError`` on them. The
built-in ContextCompressor overrides this with the real implementation.
"""
return messages, 0
# -- Optional: pre-flight check ----------------------------------------
def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:
@@ -156,6 +229,27 @@ class ContextEngine(ABC):
"""
return False
def get_automatic_compaction_status_message(
self,
*,
phase: str,
default_message: str,
**context: Any,
) -> str | None:
"""Return user-visible status for automatic host-triggered compaction.
Return ``None`` to suppress successful automatic lifecycle status for
this compaction event. ``phase`` identifies the host call site (for
example ``"preflight"`` or ``"compress"``). ``context`` contains
best-effort fields such as ``approx_tokens`` and ``threshold_tokens``.
This hook does not control warning/error messages or explicit manual
commands such as ``/compress``.
"""
if not self.emit_automatic_compaction_status:
return None
return default_message
# -- Optional: manual /compress preflight ------------------------------
def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
+291 -18
View File
@@ -42,7 +42,10 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Optional, Tuple
from agent.context_engine import sanitize_memory_context
from agent.context_engine import (
automatic_compaction_status_message,
sanitize_memory_context,
)
from agent.model_metadata import estimate_request_tokens_rough
logger = logging.getLogger(__name__)
@@ -106,6 +109,22 @@ COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE = (
"🗜️ Context reduced to {new_ctx:,} tokens (was {old_ctx:,}), retrying..."
)
# FAILURE-CLASS notice — a deliberate carve-out from routine-compression
# silence (#16775 class): the context is over the compression threshold but
# compression is blocked (summary-LLM cooldown / anti-thrash breaker), so the
# session will keep growing until the hard provider token limit kills it.
# This MUST stay visible on chat gateways. Do NOT add it to
# ROUTINE_COMPRESSION_STATUS_SAMPLES or the gateway noise regex
# (_TELEGRAM_NOISY_STATUS_RE); it is pinned un-swallowed in
# tests/gateway/test_telegram_noise_filter.py::VISIBLE_COMPRESSION_MESSAGES.
CONTEXT_OVERFLOW_BLOCKED_WARNING_TEMPLATE = (
"⚠ Context is over the compression threshold "
"(~{tokens:,} tokens >= {threshold:,}) "
"but compression is currently blocked ({reason}). "
"The model may stop responding. Run /new to start a fresh "
"session or /compress to retry immediately."
)
# Sample-formatted instances of every routine compression status line, for
# behavioral tests that iterate the ACTUAL emitted wording (formatted from the
# same constants the emission sites use) through the gateway noise filter.
@@ -188,6 +207,64 @@ def _cached_prompt_reflects_builtin_memory(agent: Any, cached_prompt: str) -> bo
return True
class CompressionCommitFence:
"""Fence timeout cancellation against post-summary session mutation.
Compression itself is synchronous and may be running in an executor thread.
A caller can stop waiting for the summary, but it cannot kill that thread.
This fence makes the commit boundary deterministic: cancellation either wins
before session mutation starts, or waits until an already-started commit is
fully complete before the caller proceeds.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._cancelled = False
self._commit_started = False
def cancel_before_commit(self) -> bool:
"""Cancel a pending commit, or wait for an active commit to finish.
Returns ``True`` when cancellation won before the commit boundary.
Returns ``False`` when the worker had already entered the boundary; in
that case acquiring this lock waits until all session mutation finishes.
"""
with self._lock:
if self._commit_started:
return False
self._cancelled = True
return True
def try_cancel_before_commit(self) -> Optional[bool]:
"""Non-blocking form of :meth:`cancel_before_commit`.
Returns ``None`` while an active commit owns the fence, allowing an
async caller to yield instead of blocking its event loop.
"""
if not self._lock.acquire(blocking=False):
return None
try:
if self._commit_started:
return False
self._cancelled = True
return True
finally:
self._lock.release()
def begin_commit(self) -> bool:
"""Enter the commit boundary unless cancellation already won."""
self._lock.acquire()
if self._cancelled:
self._lock.release()
return False
self._commit_started = True
return True
def finish_commit(self) -> None:
"""Leave a commit boundary entered by :meth:`begin_commit`."""
self._lock.release()
def _lock_api_is_absent_on_session_db(lock_db: Any) -> bool:
"""Whether the live in-memory SessionDB class structurally predates locks.
@@ -216,6 +293,7 @@ def _refresh_persisted_compression_guards(compressor: Any) -> None:
method_calls = (
("get_active_compression_failure_cooldown", {"refresh": True}),
("_load_fallback_compression_streak", {}),
("_load_ineffective_compression_count", {}),
)
for method_name, kwargs in method_calls:
method = getattr(type(compressor), method_name, None)
@@ -277,6 +355,24 @@ def _emit_compression_attempt_telemetry(
logger.debug("failed to emit compression attempt telemetry: %s", exc)
def compression_skipped_due_to_lock(agent: Any) -> bool:
"""Type-pinned read of the #69870 lock-skip signal.
``agent._compression_skipped_due_to_lock`` is set by ``compress_context``
when a compression pass no-ops because another path holds the per-session
compression lock (holder string when the holder was confirmed, ``True``
otherwise) and cleared to ``None`` at the entry of every call.
The read MUST be type-pinned (``is True or isinstance(x, str)``), never
bare truthiness: MagicMock test-double agents auto-create truthy
attributes, and a bare ``if getattr(agent, ...)`` would hijack every
mocked agent in sibling suites into the lock-skip branch (the
#69870 × #69840 type-ahead incident).
"""
_sig = getattr(agent, "_compression_skipped_due_to_lock", None)
return _sig is True or isinstance(_sig, str)
def _compression_lock_holder(agent: Any) -> str:
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
@@ -730,7 +826,11 @@ def replay_compression_warning(agent: Any) -> None:
pass
def conversation_history_after_compression(agent: Any, messages: list) -> Optional[list]:
def conversation_history_after_compression(
agent: Any,
messages: list,
previous_history: Optional[list] = None,
) -> Optional[list]:
"""Return the correct flush baseline after a compression boundary.
Legacy compression rotates to a fresh child session. That child has not
@@ -747,7 +847,19 @@ def conversation_history_after_compression(agent: Any, messages: list) -> Option
A shallow copy is intentional: it captures the current compacted dict
identities as history while allowing later same-turn appends to remain new.
An aborted or no-op attempt after an earlier in-place compaction must retain
the pre-attempt baseline. Treating all current messages as persisted would
drop any later, unflushed turns on restart; clearing the baseline would
append the already-persisted compacted rows a second time.
"""
if bool(getattr(agent, "_last_compression_attempt_recorded", False)):
attempt_in_place = getattr(agent, "_last_compression_attempt_in_place", None)
if attempt_in_place is True:
return list(messages)
if attempt_in_place is False:
return None
return previous_history
if bool(getattr(agent, "_last_compaction_in_place", False)):
return list(messages)
return None
@@ -806,6 +918,37 @@ def _is_real_user_message(message: Any) -> bool:
return not ContextCompressor._is_synthetic_compression_user_turn(message)
def _strip_stale_todo_snapshot(content: Any) -> Any:
"""Remove a previously merged todo-snapshot block from message content.
Snapshot merges (see the injection site in ``compress_context``) always
append the block at the end of the trailing user turn, so a surviving
header marks stale todo state from an earlier compaction boundary.
Stripping before re-injection keeps repeated boundaries from
accumulating outdated snapshots (#26981).
"""
from tools.todo_tool import TODO_INJECTION_HEADER
if isinstance(content, str):
idx = content.find(TODO_INJECTION_HEADER)
if idx == -1:
return content
return content[:idx].rstrip()
if isinstance(content, list):
return [
part
for part in content
if not (
isinstance(part, dict)
and part.get("type") == "text"
and str(part.get("text") or "")
.lstrip()
.startswith(TODO_INJECTION_HEADER)
)
]
return content
def _merge_anchor_into_user_message(target: dict, anchor: dict) -> None:
"""Fold the human anchor into an existing user-role scaffolding turn.
@@ -975,6 +1118,7 @@ def compress_context(
focus_topic: Optional[str] = None,
force: bool = False,
defer_context_engine_notification: bool = False,
commit_fence: Optional[CompressionCommitFence] = None,
) -> Tuple[list, str]:
"""Compress conversation context and split the session in SQLite.
@@ -994,6 +1138,9 @@ def compress_context(
callers use the default ``False``.
defer_context_engine_notification: Delay the existing context-engine
hook until a manual host commits its outer history transaction.
commit_fence: Optional cooperative fence for executor callers that
may time out. It prevents a late worker from mutating session state
after its caller has moved on.
Returns:
``(compressed_messages, new_system_prompt)`` tuple. When
@@ -1008,6 +1155,21 @@ def compress_context(
):
raise RuntimeError("a compression notification is already pending")
# ``conversation_history_after_compression()`` needs the latest attempt's
# outcome, while ``_last_compaction_in_place`` remains the run-level signal
# read by gateway callers. ``None`` means this attempt aborted or made no
# boundary, so the previous flush baseline remains authoritative.
agent._last_compression_attempt_recorded = True
agent._last_compression_attempt_in_place = None
# Clear the lock-skip signal at the VERY TOP, before the codex route and
# the breaker gates below can early-return (per-attempt state rule,
# #58630/#69853). A stale ``True``/holder value from a prior lock-skip
# must never make a later breaker/codex no-op look like lock contention
# to the automatic-path consumers (compression_deferred, #49874) — the
# second clear before lock acquisition below stays for the same reason
# it was added in #69870 and is simply idempotent now.
agent._compression_skipped_due_to_lock = None
_attempt_started_at = time.monotonic()
_attempt_id = uuid.uuid4().hex
_trigger_source = "manual" if force else "auto"
@@ -1030,14 +1192,26 @@ def compress_context(
# the app server does not expose its native summary prompt, so there is no
# truthful injection point for ``on_pre_compress()`` return text here.
if getattr(agent, "api_mode", None) == "codex_app_server":
return _compress_context_via_codex_app_server(
agent,
messages,
system_message,
approx_tokens=approx_tokens,
task_id=task_id,
force=force,
)
_codex_fence_entered = False
if commit_fence is not None:
_codex_fence_entered = commit_fence.begin_commit()
if not _codex_fence_entered:
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
try:
return _compress_context_via_codex_app_server(
agent,
messages,
system_message,
approx_tokens=approx_tokens,
task_id=task_id,
force=force,
)
finally:
if _codex_fence_entered:
commit_fence.finish_commit()
# Every automatic entrypoint must honor compressor-owned cooldown and
# breaker state. Gateway hygiene constructs a fresh AIAgent, so the
@@ -1089,7 +1263,20 @@ def compress_context(
f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model,
focus_topic,
)
agent._emit_status(COMPACTION_STATUS)
_compaction_status = COMPACTION_STATUS
if not force:
_compaction_status = automatic_compaction_status_message(
agent.context_compressor,
phase="compress",
default_message=_compaction_status,
approx_tokens=approx_tokens,
message_count=_pre_msg_count,
model=agent.model,
focus_topic=focus_topic,
)
_compaction_status_emitted = bool(_compaction_status)
if _compaction_status:
agent._emit_status(_compaction_status)
_compaction_done_emitted = False
def _complete_compaction_lifecycle() -> None:
@@ -1097,7 +1284,11 @@ def compress_context(
if _compaction_done_emitted:
return
_compaction_done_emitted = True
_emit_compaction_done(agent)
# A suppressed start (quiet context engine) opened no visible
# compaction phase — emit no terminal edge either. Failure warnings
# go through agent._emit_warning and are never suppressed here.
if _compaction_status_emitted:
_emit_compaction_done(agent)
# ── Compression lock ────────────────────────────────────────────────
# Atomic, state.db-backed lock per session_id. Without this, two
@@ -1134,6 +1325,12 @@ def compress_context(
_try_acquire_lock = None
_lock_lookup_error: Optional[Exception] = None
_legacy_session_db_without_lock_api = False
# Clear any stale lock-skip signal from a prior call so this call's
# outcome alone determines what callers see. Without this an
# auto-compress lock-skip followed by a successful manual /compress
# would falsely report "Compression already in progress" and discard
# the compression results.
agent._compression_skipped_due_to_lock = None
if _lock_db is not None:
try:
_legacy_session_db_without_lock_api = _lock_api_is_absent_on_session_db(
@@ -1221,6 +1418,11 @@ def compress_context(
_lock_sid, existing,
)
_lock_holder = None # don't release a lock we don't own
# Signal to callers that this no-op is due to a concurrent lock,
# not a genuine "nothing to compress" or aux-model failure.
# Manual /compress callers can surface a clear status message
# instead of the misleading "No changes from compression" text.
agent._compression_skipped_due_to_lock = existing or True
# Surface to the user once — quiet for downstream auto-compress loops
if getattr(agent, "_last_compression_lock_warning_sid", None) != _lock_sid:
agent._last_compression_lock_warning_sid = _lock_sid
@@ -1394,6 +1596,7 @@ def compress_context(
if _activity_heartbeat is not None:
_activity_heartbeat.stop("context compression completed")
_commit_fence_entered = False
try:
# Capture boundary quality before session-rotation callbacks run. Built-in
# and plugin lifecycle hooks may reset per-session compressor fields while
@@ -1481,6 +1684,28 @@ def compress_context(
_release_lock()
return messages, _existing_sp
if commit_fence is not None:
_commit_fence_entered = commit_fence.begin_commit()
if not _commit_fence_entered:
logger.info(
"Compression commit cancelled before session mutation "
"(session=%s).",
agent.session_id or "none",
)
agent._last_compaction_in_place = False
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class="commit_fence_cancelled",
)
_release_lock()
return messages, _existing_sp
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
if summary_error:
if getattr(agent, "_last_compression_summary_warning", None) != summary_error:
@@ -1509,11 +1734,54 @@ def compress_context(
todo_snapshot = agent._todo_store.format_for_injection()
if todo_snapshot:
compressed.append({
"role": "user",
"content": todo_snapshot,
"_todo_snapshot_synthetic": True,
})
# Fold the snapshot into a trailing REAL user message so
# compression never introduces a synthetic user/user pair. Any
# snapshot merged at an earlier boundary is stripped first so
# repeated compactions refresh rather than accumulate todo state
# (#26981). Scaffolding tails (continuation marker, summary
# handoff, a bare stale snapshot row) must never absorb the
# snapshot: merging would upgrade them to "real user" evidence
# and break zero-user provenance (#69292), so those keep the
# flagged standalone append and the real-user preservation pass
# continues to see todo scaffolding, not human intent.
from agent.context_compressor import _append_text_to_content
merged = False
_tail = (
compressed[-1]
if compressed and isinstance(compressed[-1], dict)
else None
)
if _tail is not None and _tail.get("role") == "user":
_stripped = _strip_stale_todo_snapshot(_tail.get("content"))
_probe = {
key: value for key, value in _tail.items() if key != "content"
}
_probe["content"] = _stripped
if _is_real_user_message(_probe):
_snapshot_text = (
f"\n\n{todo_snapshot}"
if isinstance(_stripped, str) and _stripped
else todo_snapshot
)
_tail["content"] = _append_text_to_content(
_stripped, _snapshot_text
)
merged = True
elif _stripped != _tail.get("content") and not _message_text(
{"role": "user", "content": _stripped}
).strip():
# The tail was nothing but an earlier snapshot row —
# refresh it in place instead of stacking a duplicate.
_tail["content"] = todo_snapshot
_tail["_todo_snapshot_synthetic"] = True
merged = True
if not merged:
compressed.append({
"role": "user",
"content": todo_snapshot,
"_todo_snapshot_synthetic": True,
})
_ensure_compressed_has_user_turn(messages, compressed)
cached_system_prompt = agent._cached_system_prompt
@@ -1823,6 +2091,7 @@ def compress_context(
# via a rotation-independent flag. The gateway uses this — NOT an
# id-change diff — to re-baseline transcript handling (history_offset=0 +
# rewrite on the same id) when compaction happened in place. See #38763.
agent._last_compression_attempt_in_place = compacted_in_place
agent._last_compaction_in_place = compacted_in_place
# Keep the post-compression rough estimate for diagnostics, but do not
@@ -1888,7 +2157,11 @@ def compress_context(
# file dedup) ran. A concurrent path that wakes up the moment we
# release will see the NEW session_id in state.db / SessionEntry and
# acquire on that — no race against our just-finished work.
_release_lock()
try:
_release_lock()
finally:
if _commit_fence_entered:
commit_fence.finish_commit()
def _compress_context_via_codex_app_server(
+292 -38
View File
@@ -34,8 +34,10 @@ from agent.conversation_compression import (
COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE,
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE,
PRE_API_COMPRESSION_STATUS_TEMPLATE,
compression_skipped_due_to_lock,
conversation_history_after_compression,
)
from agent.context_engine import automatic_compaction_status_message
from agent.display import KawaiiSpinner
from agent.error_classifier import FailoverReason, classify_api_error
from agent.iteration_budget import IterationBudget
@@ -639,6 +641,55 @@ def _content_policy_blocked_result(
}
def _compression_deferred_result(
agent,
messages: List[Dict],
api_call_count: int,
) -> Dict[str, Any]:
"""Build the soft turn result for a lock-contended compression defer.
Another path (a sibling turn, a background review fork, a manual
``/compress``) holds this session's compression lock, so every
compression pass this turn no-oped and the request still does not fit.
This is a TEMPORARY condition — the lock winner is actively shrinking
the same session — so the turn must end as a soft defer
(``compression_deferred``), never as ``compression_exhausted``: the
gateway auto-resets (wipes) the session on exhaustion (#9893/#35809),
which would destroy a session that the concurrent compressor is about
to make healthy again.
``failed`` stays False so the gateway persists the user turn (transient
branch) and retry-next-message semantics apply.
"""
holder = getattr(agent, "_compression_skipped_due_to_lock", None)
logger.info(
"turn deferred: compression lock held by another path "
"(session=%s holder=%s) — not counting as compression exhaustion",
agent.session_id or "none",
holder if isinstance(holder, str) else "unconfirmed",
)
try:
agent._flush_status_buffer()
except Exception:
pass
_final = (
"Context compression is already running for this session. "
"Please retry in a moment — your next message will be processed "
"once the concurrent compression finishes."
)
return {
"final_response": _final,
"messages": messages,
"completed": False,
"api_calls": api_call_count,
"error": _final,
"partial": True,
"failed": False,
"compression_deferred": True,
"session_id": agent.session_id,
}
def _sync_failover_system_message(agent, api_messages, active_system_prompt):
"""Refresh the in-flight system message after a provider failover.
@@ -710,6 +761,13 @@ def run_conversation(
except Exception:
pass
# The gateway caches agents across user turns. Compression state is
# per-turn: carrying a prior in-place boundary forward would make a later
# uncompressed result look like a compacted transcript to gateway writers.
agent._last_compaction_in_place = False
agent._last_compression_attempt_recorded = False
agent._last_compression_attempt_in_place = None
# ── Per-turn setup (the prologue) ──
# All once-per-turn setup — stdio guarding, retry-counter resets, user
# message sanitization, todo/nudge hydration, system-prompt restore-or-
@@ -981,6 +1039,13 @@ def run_conversation(
# outgoing copy.
_api_content = api_msg.pop("api_content", None)
# Display-only timeline metadata. Never a provider field — strip
# from every outgoing copy so strict OpenAI-compatible backends
# don't reject the request after a model switch or resumed typed
# event row enters the live history.
api_msg.pop("display_kind", None)
api_msg.pop("display_metadata", None)
# Inject ephemeral context into the current turn's user message.
# Sources: memory manager prefetch + plugin pre_llm_call hooks
# with target="user_message" (the default). Both are
@@ -1084,7 +1149,7 @@ def run_conversation(
aggregator=moa_config.get("aggregator") or {},
temperature=_preset_temperature(moa_config, "reference_temperature"),
aggregator_temperature=_preset_temperature(moa_config, "aggregator_temperature"),
max_tokens=moa_config.get("reference_max_tokens"),
reference_max_tokens=moa_config.get("reference_max_tokens"),
)
if _moa_context:
for _msg in reversed(api_messages):
@@ -1300,6 +1365,15 @@ def run_conversation(
if _moa_prepared_request is not None:
pending_moa_prepared_request = _moa_prepared_request
compression_attempts += 1
# Compression is actually running (block cleared / was never
# blocked) — reset the blocked-overflow warning dedup so a future
# blocked-over-threshold turn can warn again. Mirrors the
# turn-context preflight reset (silent-overflow fix #62625).
# getattr guard: test doubles built via object.__new__ lack the
# method (gateway test-double pitfall) — treat absence as no-op.
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
if callable(_clear_warn):
_clear_warn()
logger.info(
"Pre-API compression: ~%s request tokens >= %s threshold "
"(context=%s, attempt=%s/%s)",
@@ -1310,42 +1384,98 @@ def run_conversation(
compression_attempts,
max_compression_attempts,
)
agent._emit_status(
PRE_API_COMPRESSION_STATUS_TEMPLATE.format(
_pre_api_status = automatic_compaction_status_message(
_compressor,
phase="pre_api",
default_message=PRE_API_COMPRESSION_STATUS_TEMPLATE.format(
tokens=request_pressure_tokens
)
),
approx_tokens=request_pressure_tokens,
threshold_tokens=int(
getattr(_compressor, "threshold_tokens", 0) or 0
),
context_length=int(
getattr(_compressor, "context_length", 0) or 0
),
model=agent.model,
attempt=compression_attempts,
max_attempts=max_compression_attempts,
)
if _pre_api_status:
agent._emit_status(_pre_api_status)
_last_preflight_pressure = request_pressure_tokens
_pre_api_input = messages
messages, active_system_prompt = agent._compress_context(
messages,
system_message,
approx_tokens=request_pressure_tokens,
task_id=effective_task_id,
)
# Reset retry/empty-response state so the compacted request
# gets a fresh chance instead of inheriting stale recovery
# counters from the pre-compaction history.
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
# Re-baseline the flush cursor for the compaction mode that just
# ran. Legacy session-rotation returns None (the child session has
# not seen the compacted transcript, so the next flush writes it
# whole); in-place compaction returns list(messages) because the
# compacted rows are already persisted under the same session id —
# leaving None there would re-append them, doubling the active
# context and retriggering compression. Mirrors the post-response
# and preflight compaction sites; see
# conversation_history_after_compression().
conversation_history = conversation_history_after_compression(
agent, messages
)
api_call_count -= 1
agent._api_call_count = api_call_count
agent.iteration_budget.refund()
continue
if messages is _pre_api_input and compression_skipped_due_to_lock(agent):
# #69870 lock-skip: another path holds this session's
# compression lock, so this pass no-oped. That is a temporary
# DEFER, not evidence about compressibility — refund the
# attempt (it must not burn the shared overflow-recovery
# budget toward compression_exhausted → gateway auto-reset,
# #9893/#35809) and leave the insufficient-progress blocker
# unarmed. Proceed with the current request: if it truly does
# not fit, the provider's 413/overflow handler returns the
# soft compression_deferred result with that stronger signal.
compression_attempts -= 1
_last_preflight_pressure = None
if pending_moa_prepared_request is _moa_prepared_request:
pending_moa_prepared_request = None
else:
# Reset retry/empty-response state so the compacted request
# gets a fresh chance instead of inheriting stale recovery
# counters from the pre-compaction history.
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
# Re-baseline the flush cursor for the compaction mode that just
# ran. Legacy session-rotation returns None (the child session has
# not seen the compacted transcript, so the next flush writes it
# whole); in-place compaction returns list(messages) because the
# compacted rows are already persisted under the same session id —
# leaving None there would re-append them, doubling the active
# context and retriggering compression. Mirrors the post-response
# and preflight compaction sites; see
# conversation_history_after_compression().
conversation_history = conversation_history_after_compression(
agent, messages, conversation_history
)
api_call_count -= 1
agent._api_call_count = api_call_count
agent.iteration_budget.refund()
continue
elif (
agent.compression_enabled
and len(messages) > 1
and compression_attempts < max_compression_attempts
and not _defer_preflight(request_pressure_tokens)
and _compression_cooldown
):
# Blocked by the summary-LLM cooldown. Surface a deduped warning
# (only when actually over threshold — should_compress_info
# returns a None reason below threshold) so the user isn't left
# with a silently growing context. Mirrors the turn-context
# preflight and the loop-compaction guards (silent-overflow fix
# #62625).
_block_reason = None
try:
_block_reason = _compressor.should_compress_info(
request_pressure_tokens
)[1]
except Exception:
_block_reason = None
if _block_reason:
agent._warn_context_overflow_blocked(
_block_reason,
request_pressure_tokens,
int(getattr(_compressor, "threshold_tokens", 0) or 0),
)
# Thinking spinner for quiet mode (animated during API call)
thinking_spinner = None
@@ -1935,6 +2065,8 @@ def run_conversation(
)
continue # Retry the API call
agent._turn_received_provider_response = True
# Check finish_reason before proceeding
if agent.api_mode == "codex_responses":
status = getattr(response, "status", None)
@@ -2343,13 +2475,17 @@ def run_conversation(
force=True,
)
agent._cleanup_task_resources(effective_task_id)
agent._persist_session(messages, conversation_history)
_final_response = (
"Stream repeatedly dropped mid tool-call (network); "
"the tool was not executed"
if _is_stub_stall
else "Response truncated due to output length limit"
)
# Prior successful tool batches (or injected tool
# errors) can leave a tool-result tail; this path
# never reaches finalize_turn (#48879 class).
close_interrupted_tool_sequence(messages, _final_response)
agent._persist_session(messages, conversation_history)
return {
"final_response": _final_response,
"messages": messages,
@@ -3523,7 +3659,7 @@ def run_conversation(
task_id=effective_task_id,
)
conversation_history = conversation_history_after_compression(
agent, messages
agent, messages, conversation_history
)
if len(messages) < original_len or old_ctx > _reduced_ctx:
agent._buffer_status(
@@ -3773,12 +3909,25 @@ def run_conversation(
original_len = len(messages)
original_tokens = estimate_messages_tokens_rough(messages)
_overflow_input = messages
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=approx_tokens,
task_id=effective_task_id,
)
if messages is _overflow_input and compression_skipped_due_to_lock(agent):
# #69870 lock-skip: the provider proved the request
# does not fit, but this compression pass no-oped only
# because another path holds the session's compression
# lock. Temporary defer, not exhaustion — refund the
# attempt and end the turn softly so the gateway does
# NOT auto-reset the session (#9893/#35809).
compression_attempts -= 1
agent._persist_session(messages, conversation_history)
return _compression_deferred_result(
agent, messages, api_call_count
)
conversation_history = conversation_history_after_compression(
agent, messages
agent, messages, conversation_history
)
# Re-estimate tokens after compression. Same-message-count
@@ -4014,12 +4163,25 @@ def run_conversation(
original_len = len(messages)
original_tokens = estimate_messages_tokens_rough(messages)
_overflow_input = messages
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=approx_tokens,
task_id=effective_task_id,
)
if messages is _overflow_input and compression_skipped_due_to_lock(agent):
# #69870 lock-skip: the provider proved the request
# does not fit, but this compression pass no-oped only
# because another path holds the session's compression
# lock. Temporary defer, not exhaustion — refund the
# attempt and end the turn softly so the gateway does
# NOT auto-reset the session (#9893/#35809).
compression_attempts -= 1
agent._persist_session(messages, conversation_history)
return _compression_deferred_result(
agent, messages, api_call_count
)
conversation_history = conversation_history_after_compression(
agent, messages
agent, messages, conversation_history
)
# Re-estimate tokens after compression. Same-message-count
@@ -5022,8 +5184,13 @@ def run_conversation(
agent._flush_status_buffer()
agent._vprint(f"{agent.log_prefix}❌ Max retries (3) for invalid tool calls exceeded. Stopping as partial.", force=True)
agent._invalid_tool_retries = 0
agent._persist_session(messages, conversation_history)
_final_response = f"Model generated invalid tool call: {invalid_preview}"
# Prior <3 retries (or an earlier successful tool batch)
# leave a tool-result tail. Closing it here matches
# interrupt aborts (#48879 / #52592) so the next user
# turn is not tool→user for strict providers.
close_interrupted_tool_sequence(messages, _final_response)
agent._persist_session(messages, conversation_history)
return {
"final_response": _final_response,
"messages": messages,
@@ -5103,14 +5270,18 @@ def run_conversation(
)
agent._invalid_json_retries = 0
agent._cleanup_task_resources(effective_task_id)
_final_response = "Response truncated due to output length limit"
# Same tool-tail close as interrupt / invalid-tool
# exhaustion — this path never reaches finalize_turn.
close_interrupted_tool_sequence(messages, _final_response)
agent._persist_session(messages, conversation_history)
return {
"final_response": "Response truncated due to output length limit",
"final_response": _final_response,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"partial": True,
"error": "Response truncated due to output length limit",
"error": _final_response,
}
# Track retries for invalid JSON arguments
@@ -5401,15 +5572,98 @@ def run_conversation(
and _compressor.should_compress(_real_tokens)
):
compression_attempts += 1
# Compression is actually running (block cleared / was
# never blocked) — reset the blocked-overflow warning
# dedup so a future blocked-over-threshold turn can warn
# again (silent-overflow fix #62625).
# getattr guard: test doubles built via object.__new__ lack the
# method (gateway test-double pitfall) — treat absence as no-op.
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
if callable(_clear_warn):
_clear_warn()
agent._safe_print(" ⟳ compacting context…")
_post_tool_input = messages
messages, active_system_prompt = agent._compress_context(
messages, system_message,
approx_tokens=agent.context_compressor.last_prompt_tokens,
task_id=effective_task_id,
)
conversation_history = conversation_history_after_compression(
agent, messages
)
if (
messages is _post_tool_input
and compression_skipped_due_to_lock(agent)
):
# #69870 lock-skip: this pass no-oped because another
# path holds the session's compression lock — a
# temporary defer, not evidence about compressibility.
# Refund the attempt so a lock-loser tool loop does not
# burn the shared per-turn budget toward
# compression_exhausted (#9893/#35809).
compression_attempts -= 1
else:
conversation_history = conversation_history_after_compression(
agent, messages, conversation_history
)
elif agent.compression_enabled:
# Over threshold but compression is blocked (summary-LLM
# cooldown or anti-thrashing). Surface a deduped warning so
# the user isn't left with a silently growing context that
# eventually hits the hard provider limit. Mirrors the
# turn-context preflight guard (silent-overflow fix #62625).
_block_reason = None
_info = getattr(_compressor, "should_compress_info", None)
if _info is not None:
try:
_block_reason = _info(_real_tokens)[1]
except Exception:
_block_reason = None
if _block_reason:
agent._warn_context_overflow_blocked(
_block_reason,
_real_tokens,
int(getattr(_compressor, "threshold_tokens", 0) or 0),
)
# Proactive tool-result prune: reclaim re-sent history on
# large-window models long before should_compress() (≈50% of
# the window) would ever fire. Deterministic, no LLM call;
# protects the recent tail. No-op unless proactive_prune_tokens
# is configured and _real_tokens is above it — and even then
# the prune only commits when it reclaims at least
# proactive_prune_min_reclaim_tokens, so prompt-cache breaks
# stay episodic like compression's (the one sanctioned cache
# break) instead of firing every tool iteration. See
# ContextCompressor.prune_tool_results_only.
# getattr guard: plugin context engines predating the hook and
# minimal test doubles (SimpleNamespace compressors) lack the
# method — treat absence as a no-op.
_prune = getattr(_compressor, "prune_tool_results_only", None)
if callable(_prune):
try:
_pruned_msgs, _pruned_n = _prune(
messages, current_tokens=_real_tokens
)
except Exception:
logger.debug(
"proactive tool-result prune failed; skipping",
exc_info=True,
)
_pruned_msgs, _pruned_n = messages, 0
# Standard no-op caller contract: only commit when the
# engine returned a NEW list object with a non-zero count.
if _pruned_n and _pruned_msgs is not messages:
# Do NOT rebuild conversation_history here. Unlike the
# compression branch, the prune neither rotates the session
# nor calls archive_and_compact(), so there is no new
# persistence baseline to establish. _prune_old_tool_results
# returns per-message copies that preserve the
# _DB_PERSISTED_MARKER, so the marker-based flush dedup (see
# _flush_messages_to_session_db) already prevents both
# duplicate writes and dropped rows. Calling
# conversation_history_after_compression (a compaction-only
# helper keyed on the _last_compaction_in_place flag) would be
# a no-op at best, and on a stale in-place flag could seed
# this turn's fresh, not-yet-persisted rows into history_ids
# and skip writing them.
messages = _pruned_msgs
# Save session log incrementally (so progress is visible even if interrupted)
agent._session_messages = messages
+5
View File
@@ -503,6 +503,10 @@ class CopilotACPClient:
def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]:
try:
# Hide the console the CLI child would otherwise flash on Windows
# (#56747). Hide-only — stdio pipes stay intact for the ACP wire.
from hermes_cli._subprocess_compat import windows_hide_flags
proc = subprocess.Popen(
[self._acp_command] + self._acp_args,
stdin=subprocess.PIPE,
@@ -512,6 +516,7 @@ class CopilotACPClient:
bufsize=1,
cwd=self._acp_cwd,
env=_build_subprocess_env(),
creationflags=windows_hide_flags(),
)
except FileNotFoundError as exc:
raise RuntimeError(
+131 -72
View File
@@ -596,20 +596,32 @@ class CredentialPool:
self._last_no_entries_log_at: Optional[float] = None
def has_credentials(self) -> bool:
return bool(self._entries)
with self._lock:
return bool(self._entries)
def has_available(self) -> bool:
"""True if at least one entry is not currently in exhaustion cooldown."""
return bool(self._available_entries())
# ``_available_entries`` is not read-only: it prunes aged-out DEAD
# manual entries (rebinding ``self._entries``) and persists. It must
# run under ``self._lock`` like every other caller (``select`` etc.),
# otherwise a status probe here can race a concurrent ``select`` /
# rotation and tear ``self._entries`` or double-write auth.json.
with self._lock:
return bool(self._available_entries())
def entries(self) -> List[PooledCredential]:
return list(self._entries)
with self._lock:
return list(self._entries)
def current(self) -> Optional[PooledCredential]:
def _current_unlocked(self) -> Optional[PooledCredential]:
if not self._current_id:
return None
return next((entry for entry in self._entries if entry.id == self._current_id), None)
def current(self) -> Optional[PooledCredential]:
with self._lock:
return self._current_unlocked()
def _replace_entry(self, old: PooledCredential, new: PooledCredential) -> None:
"""Swap an entry in-place by id, preserving sort order."""
for idx, entry in enumerate(self._entries):
@@ -652,6 +664,8 @@ class CredentialPool:
entry: PooledCredential,
status_code: Optional[int],
error_context: Optional[Dict[str, Any]] = None,
*,
persist: bool = True,
) -> PooledCredential:
normalized_error = _normalize_error_context(error_context)
# Permanent OAuth failures (token_invalidated, token_revoked, etc.)
@@ -675,7 +689,8 @@ class CredentialPool:
last_error_reset_at=normalized_error.get("reset_at"),
)
self._replace_entry(entry, updated)
self._persist()
if persist:
self._persist()
return updated
def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -> PooledCredential:
@@ -1726,18 +1741,21 @@ class CredentialPool:
self._entries = [replace(candidate, priority=idx) for idx, candidate in enumerate(rotated)]
self._persist()
self._current_id = entry.id
return self.current() or entry
return self._current_unlocked() or entry
entry = available[0]
self._current_id = entry.id
return entry
def peek(self) -> Optional[PooledCredential]:
current = self.current()
if current is not None:
return current
available = self._available_entries()
return available[0] if available else None
# Single lock acquisition for the whole read; call the unlocked
# helpers so we don't re-enter the non-reentrant ``self._lock``.
with self._lock:
current = self._current_unlocked()
if current is not None:
return current
available = self._available_entries()
return available[0] if available else None
def mark_exhausted_and_rotate(
self,
@@ -1757,12 +1775,49 @@ class CredentialPool:
(e for e in self._entries if e.runtime_api_key == api_key_hint),
None,
)
if entry is None:
# The failed key is identifiable but matches no entry
# (rotated away, or a wrapper whose runtime key differs).
# Falling through to current()/_select_unlocked() would
# mark an INNOCENT healthy key exhausted for the full
# cooldown TTL. Don't guess — just hand back a fresh
# selection so the caller can retry.
logger.info(
"credential pool: failed key hint matched no %s entry; "
"rotating without marking any credential exhausted",
self.provider,
)
self._current_id = None
return self._select_unlocked()
if entry is None:
entry = self.current() or self._select_unlocked()
entry = self._current_unlocked() or self._select_unlocked()
if entry is None:
return None
_label = entry.label or entry.id[:8]
self._mark_exhausted(entry, status_code, error_context)
# A 402/429/401 is an API-key–level failure: the account is out of
# balance, rate-limited, or its key is rejected. The same key can
# back more than one pool entry (e.g. an explicit pool entry plus a
# ``model_config`` entry auto-seeded from ``model.api_key`` — both
# carry the identical ``runtime_api_key``). Marking only the first
# match leaves the sibling entries OK, so ``_select_unlocked()``
# keeps handing back the same depleted key and rotation never
# converges — the caller ``continue``s forever until the client
# disconnects (a ~2.5min hang with no error surfaced to the user).
# Mark every entry sharing the failed key so the pool can reach the
# "no available entries" state and let the error propagate.
if api_key_hint:
siblings_marked = False
for sibling in self._entries:
if sibling.id == entry.id:
continue
if sibling.runtime_api_key == api_key_hint:
self._mark_exhausted(
sibling, status_code, error_context, persist=False
)
siblings_marked = True
if siblings_marked:
self._persist()
# Re-read the updated entry to log the correct terminal state.
updated_entry = next(
(e for e in self._entries if e.id == entry.id), entry,
@@ -1852,14 +1907,14 @@ class CredentialPool:
None,
)
else:
entry = self.current() or self._select_unlocked(refresh=False)
entry = self._current_unlocked() or self._select_unlocked(refresh=False)
if entry is None:
return None
self._current_id = entry.id
return self._try_refresh_current_unlocked()
def _try_refresh_current_unlocked(self) -> Optional[PooledCredential]:
entry = self.current()
entry = self._current_unlocked()
if entry is None:
return None
refreshed = self._refresh_entry(entry, force=True)
@@ -1868,76 +1923,80 @@ class CredentialPool:
return refreshed
def reset_statuses(self) -> int:
count = 0
new_entries = []
for entry in self._entries:
if entry.last_status or entry.last_status_at or entry.last_error_code:
new_entries.append(
replace(
entry,
last_status=None,
last_status_at=None,
last_error_code=None,
last_error_reason=None,
last_error_message=None,
last_error_reset_at=None,
with self._lock:
count = 0
new_entries = []
for entry in self._entries:
if entry.last_status or entry.last_status_at or entry.last_error_code:
new_entries.append(
replace(
entry,
last_status=None,
last_status_at=None,
last_error_code=None,
last_error_reason=None,
last_error_message=None,
last_error_reset_at=None,
)
)
)
count += 1
else:
new_entries.append(entry)
if count:
self._entries = new_entries
self._persist()
return count
count += 1
else:
new_entries.append(entry)
if count:
self._entries = new_entries
self._persist()
return count
def remove_index(self, index: int) -> Optional[PooledCredential]:
if index < 1 or index > len(self._entries):
return None
removed = self._entries.pop(index - 1)
self._entries = [
replace(entry, priority=new_priority)
for new_priority, entry in enumerate(self._entries)
]
write_credential_pool(
self.provider,
[entry.to_dict() for entry in self._entries],
removed_ids=[removed.id],
)
if self._current_id == removed.id:
self._current_id = None
return removed
with self._lock:
if index < 1 or index > len(self._entries):
return None
removed = self._entries.pop(index - 1)
self._entries = [
replace(entry, priority=new_priority)
for new_priority, entry in enumerate(self._entries)
]
write_credential_pool(
self.provider,
[entry.to_dict() for entry in self._entries],
removed_ids=[removed.id],
)
if self._current_id == removed.id:
self._current_id = None
return removed
def resolve_target(self, target: Any) -> Tuple[Optional[int], Optional[PooledCredential], Optional[str]]:
raw = str(target or "").strip()
if not raw:
return None, None, "No credential target provided."
for idx, entry in enumerate(self._entries, start=1):
if entry.id == raw:
return idx, entry, None
with self._lock:
for idx, entry in enumerate(self._entries, start=1):
if entry.id == raw:
return idx, entry, None
label_matches = [
(idx, entry)
for idx, entry in enumerate(self._entries, start=1)
if entry.label.strip().lower() == raw.lower()
]
if len(label_matches) == 1:
return label_matches[0][0], label_matches[0][1], None
if len(label_matches) > 1:
return None, None, f'Ambiguous credential label "{raw}". Use the numeric index or entry id instead.'
if raw.isdigit():
index = int(raw)
if 1 <= index <= len(self._entries):
return index, self._entries[index - 1], None
return None, None, f"No credential #{index}."
return None, None, f'No credential matching "{raw}".'
label_matches = [
(idx, entry)
for idx, entry in enumerate(self._entries, start=1)
if entry.label.strip().lower() == raw.lower()
]
if len(label_matches) == 1:
return label_matches[0][0], label_matches[0][1], None
if len(label_matches) > 1:
return None, None, f'Ambiguous credential label "{raw}". Use the numeric index or entry id instead.'
if raw.isdigit():
index = int(raw)
if 1 <= index <= len(self._entries):
return index, self._entries[index - 1], None
return None, None, f"No credential #{index}."
return None, None, f'No credential matching "{raw}".'
def add_entry(self, entry: PooledCredential) -> PooledCredential:
entry = replace(entry, priority=_next_priority(self._entries))
self._entries.append(entry)
self._persist()
return entry
with self._lock:
entry = replace(entry, priority=_next_priority(self._entries))
self._entries.append(entry)
self._persist()
return entry
def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool:
+11 -2
View File
@@ -316,12 +316,21 @@ def evaluate_credits_notices(
active.discard(CREDITS_USAGE_KEY)
if target_band is not None:
# Belt-and-suspenders: a producer could set subscription_limit_micros
# without subscription_limit_usd. Render "$? cap" rather than "$None cap".
# without subscription_limit_usd. Render "$?" rather than "$None".
_cap_usd = state.subscription_limit_usd or "?"
_level = current_band[1] # type: ignore[index] (current_band set when target_band set)
# Report absolute dollars used, not a bare "N% used": the percentage is
# only meaningful against a Nous subscription cap (no cap → never fires),
# so dollars are clearer and don't imply a universal %. Used = cap −
# remaining (micros, money-safe), clamped to [0, cap]. Re-emits on band
# change (50 → 75 → 90), not every turn — a snapshot, not a live ticker.
_lim = state.subscription_limit_micros or 0
_used_micros = max(0, min(_lim, _lim - state.subscription_micros))
_used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?"
_glyph = "⚠" if _level == "warn" else "•"
to_show.append(
AgentNotice(
text=f"{'⚠' if _level == 'warn' else '•'} Credits {target_band}% used · ${_cap_usd} cap",
text=f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap",
level=_level,
kind=CREDITS_NOTICE_KIND,
key=CREDITS_USAGE_KEY,
+85
View File
@@ -0,0 +1,85 @@
"""Context-local state for delegate_task child execution.
The parent Hermes process may itself be a Kanban dispatcher worker with
HERMES_KANBAN_* variables in process env. delegate_task children run inside the
same Python process, but they are not dispatcher-owned Kanban workers. This
module lets code paths that resolve tool schemas or spawn subprocesses fail
closed for delegated children without mutating global os.environ for the parent.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Iterator, Mapping, MutableMapping
_DELEGATED_CHILD_CONTEXT: ContextVar[bool] = ContextVar(
"hermes_delegated_child_context",
default=False,
)
DELEGATED_CHILD_ENV_MARKER = "HERMES_DELEGATED_CHILD_CONTEXT"
KANBAN_ENV_KEYS: tuple[str, ...] = (
"HERMES_KANBAN_TASK",
"HERMES_KANBAN_RUN_ID",
"HERMES_KANBAN_WORKSPACE",
"HERMES_KANBAN_WORKSPACES_ROOT",
"HERMES_KANBAN_CLAIM_LOCK",
"HERMES_KANBAN_BOARD",
"HERMES_KANBAN_DB",
)
@contextmanager
def delegated_child_context() -> Iterator[None]:
"""Mark the current execution context as a delegate_task child."""
token = _DELEGATED_CHILD_CONTEXT.set(True)
try:
yield
finally:
_DELEGATED_CHILD_CONTEXT.reset(token)
def is_delegated_child_context() -> bool:
"""Return True while code is running for a delegate_task child."""
return bool(_DELEGATED_CHILD_CONTEXT.get())
def is_delegated_child_process_context() -> bool:
"""Return True in this process or a subprocess spawned by a child."""
import os
return bool(_DELEGATED_CHILD_CONTEXT.get()) or bool(
os.environ.get(DELEGATED_CHILD_ENV_MARKER)
)
def scrub_kanban_env(env: Mapping[str, str] | MutableMapping[str, str]) -> dict[str, str]:
"""Return *env* with dispatcher-only Kanban variables removed."""
cleaned = dict(env)
for key in KANBAN_ENV_KEYS:
cleaned.pop(key, None)
cleaned[DELEGATED_CHILD_ENV_MARKER] = "1"
return cleaned
def delegated_child_subprocess_env(
env: Mapping[str, str] | MutableMapping[str, str] | None = None,
) -> dict[str, str] | None:
"""Return an env override only when delegated-child lineage must cross fork.
Most subprocess call sites historically used ``env=None`` to inherit the
process environment. In a ``delegate_task`` child, inheriting as-is leaks
parent dispatcher ``HERMES_KANBAN_*`` vars while losing the ContextVar in
the new process. This helper preserves normal ``env=None`` semantics for
non-delegated calls, and only materializes a scrubbed env when the lineage
marker must be propagated across a child-process boundary.
"""
if not is_delegated_child_process_context():
return None if env is None else dict(env)
if env is None:
import os
env = os.environ
return scrub_kanban_env(env)
+19
View File
@@ -159,6 +159,14 @@ _RATE_LIMIT_PATTERNS = [
"throttlingexception",
"too many concurrent requests",
"servicequotaexceededexception",
# Generic throttle prefix — Bedrock (and some proxies) surface throttling
# as "Throttling error: Too many tokens, please wait before trying
# again." Without this entry the message falls through to the
# context-overflow list (which contains "too many tokens") and the retry
# loop compresses a healthy session instead of backing off. Matched
# BEFORE _CONTEXT_OVERFLOW_PATTERNS in the message-only path, so the
# throttle wins. (port of anomalyco/opencode#37848's exclusion guard)
"throttling",
]
# Patterns that indicate provider-side overload, NOT a per-credential rate
@@ -212,6 +220,12 @@ _PAYLOAD_TOO_LARGE_PATTERNS = [
"request entity too large",
"payload too large",
"error code: 413",
# Anthropic's structured 413 error type. Normally arrives with an HTTP
# 413 status (handled by the status path), but aggregators/proxies can
# re-wrap it into a plain message with no status attribute — route it to
# the same compression recovery. (port of anomalyco/opencode#37848)
"request_too_large",
"request exceeds the maximum size",
]
# Image-size patterns. Matched against 400 bodies (not 413) because most
@@ -298,6 +312,10 @@ _CONTEXT_OVERFLOW_PATTERNS = [
"max input token",
"input token",
"exceeds the maximum number of input tokens",
# Together/Fireworks-style: "Input length 131393 exceeds the maximum
# allowed input length of 131040 tokens." No other pattern in this list
# matches that wording. (port of anomalyco/opencode#37848)
"maximum allowed input length",
]
# Model not found patterns
@@ -413,6 +431,7 @@ _CONTENT_POLICY_BLOCKED_PATTERNS = [
_AUTH_PATTERNS = [
"invalid api key",
"invalid_api_key",
"gateway_auth_failed",
"authentication",
"unauthorized",
"forbidden",
+89 -35
View File
@@ -181,6 +181,8 @@ def _supports_vision_override(
cfg: Optional[Dict[str, Any]],
provider: str,
model: str,
*,
requested_provider: str = "",
) -> Optional[bool]:
"""Resolve user-declared vision capability from config.yaml.
@@ -188,9 +190,14 @@ def _supports_vision_override(
1. ``model.supports_vision`` (top-level shortcut for the active model)
2. ``providers.<provider>.models.<model>.supports_vision``
(named custom providers — ``provider`` may be the runtime-resolved
value ``"custom"`` and/or the user-declared name under
``model.provider``; both are tried. For ``custom:<name>`` syntax,
the stripped ``<name>`` is also tried as a provider key.)
value ``"custom"``, the runtime's originally requested provider,
and/or the user-declared name under ``model.provider``; all are
tried. For ``custom:<name>`` syntax, the stripped ``<name>`` is also
tried as a provider key.)
2b. ``custom_providers`` (legacy list form) ``.models.<model>``
Under (2) and (2b), the per-model capability key may be written as
either ``supports_vision`` or the shorter ``vision`` alias; both work.
Returns None when no override is set, so the caller falls through to
models.dev. Returns False explicitly only when the user wrote a
@@ -210,23 +217,30 @@ def _supports_vision_override(
# get rewritten to provider="custom" at runtime
# (hermes_cli/runtime_provider.py:_resolve_named_custom_runtime), so the
# config still holds the user-declared name under model.provider. Try
# both as candidate provider keys, plus the stripped suffix from
# "custom:<name>" (where <name> is the key under providers:).
# both as candidate provider keys. Either identity may use the
# "custom:<name>" form while providers: is keyed by bare <name>.
config_provider = str(model_cfg.get("provider") or "").strip()
# Extract the stripped name from "custom:<name>" if present
stripped_suffix = ""
if config_provider.startswith("custom:"):
stripped_suffix = config_provider[len("custom:"):]
provider_candidates: List[str] = []
for candidate in (requested_provider, provider, config_provider):
if not candidate:
continue
provider_candidates.append(candidate)
if candidate.startswith("custom:"):
stripped_candidate = candidate[len("custom:"):]
if stripped_candidate:
provider_candidates.append(stripped_candidate)
providers_raw = cfg.get("providers")
providers_cfg: Dict[str, Any] = providers_raw if isinstance(providers_raw, dict) else {}
for p in dict.fromkeys(filter(None, (provider, config_provider, stripped_suffix))):
for p in dict.fromkeys(provider_candidates):
entry_raw = providers_cfg.get(p)
entry: Dict[str, Any] = entry_raw if isinstance(entry_raw, dict) else {}
models_raw = entry.get("models")
models_cfg: Dict[str, Any] = models_raw if isinstance(models_raw, dict) else {}
per_model_raw = models_cfg.get(model)
per_model: Dict[str, Any] = per_model_raw if isinstance(per_model_raw, dict) else {}
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
coerced = _coerce_capability_bool(
per_model.get("supports_vision", per_model.get("vision"))
)
if coerced is not None:
return coerced
@@ -235,28 +249,26 @@ def _supports_vision_override(
# may appear as the raw name or "custom:<name>" at runtime).
custom_providers = cfg.get("custom_providers")
if isinstance(custom_providers, list):
# Build candidate names: the provider value and the config provider
# value, both raw and with "custom:" prefix stripped/added.
candidate_names: set = set()
for p in filter(None, (provider, config_provider)):
candidate_names.add(p)
if p.startswith("custom:"):
candidate_names.add(p[len("custom:"):])
else:
candidate_names.add(f"custom:{p}")
for entry_raw in custom_providers:
if not isinstance(entry_raw, dict):
continue
entry_name = str(entry_raw.get("name") or "").strip()
if entry_name not in candidate_names:
continue
models_raw = entry_raw.get("models")
models_cfg = models_raw if isinstance(models_raw, dict) else {}
per_model_raw = models_cfg.get(model)
per_model = per_model_raw if isinstance(per_model_raw, dict) else {}
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
if coerced is not None:
return coerced
# Candidate priority matters when the CLI-selected provider differs
# from model.provider. Walk identities first, then config entries, so
# list order cannot let the persisted default shadow the live route.
for candidate in dict.fromkeys(provider_candidates):
candidate_name = candidate.strip().lower()
for entry_raw in custom_providers:
if not isinstance(entry_raw, dict):
continue
entry_name = str(entry_raw.get("name") or "").strip().lower()
if entry_name != candidate_name:
continue
models_raw = entry_raw.get("models")
models_cfg = models_raw if isinstance(models_raw, dict) else {}
per_model_raw = models_cfg.get(model)
per_model = per_model_raw if isinstance(per_model_raw, dict) else {}
coerced = _coerce_capability_bool(
per_model.get("supports_vision", per_model.get("vision"))
)
if coerced is not None:
return coerced
return None
@@ -376,6 +388,8 @@ def _lookup_supports_vision(
provider: str,
model: str,
cfg: Optional[Dict[str, Any]] = None,
*,
requested_provider: str = "",
) -> Optional[bool]:
"""Return True/False if we can resolve caps, None if unknown.
@@ -383,7 +397,34 @@ def _lookup_supports_vision(
(so custom/local models declared as vision-capable don't fall through to
text routing in ``auto`` mode), then falls back to models.dev.
"""
override = _supports_vision_override(cfg, provider, model)
# Named custom providers are canonicalized to ``provider="custom"`` by
# runtime resolution. The original CLI/config name is carried in the
# context-local main runtime so capability lookup can still select the
# exact custom_providers entry. Require an exact provider+model match:
# background/auxiliary lookups must never borrow another turn's identity.
if not requested_provider:
try:
from agent.auxiliary_client import _runtime_main_value
runtime_provider = str(
_runtime_main_value("provider") or ""
).strip().lower()
runtime_model = str(_runtime_main_value("model") or "").strip()
lookup_provider = str(provider or "").strip().lower()
lookup_model = str(model or "").strip()
if runtime_provider == lookup_provider and runtime_model == lookup_model:
requested_provider = str(
_runtime_main_value("requested_provider") or ""
).strip()
except Exception:
pass
override = _supports_vision_override(
cfg,
provider,
model,
requested_provider=requested_provider,
)
if override is not None:
return override
if not provider or not model:
@@ -421,6 +462,8 @@ def decide_image_input_mode(
provider: str,
model: str,
cfg: Optional[Dict[str, Any]],
*,
requested_provider: str = "",
) -> str:
"""Return ``"native"`` or ``"text"`` for the given turn.
@@ -428,6 +471,7 @@ def decide_image_input_mode(
provider: active inference provider ID (e.g. ``"anthropic"``, ``"openrouter"``).
model: active model slug as it would be sent to the provider.
cfg: loaded config.yaml dict, or None. When None, behaves as auto.
requested_provider: provider identity before runtime canonicalization.
"""
mode_cfg = "auto"
if isinstance(cfg, dict):
@@ -444,7 +488,17 @@ def decide_image_input_mode(
# explicit auxiliary.vision config acts as a *fallback* for text-only
# main models — it should not preempt native vision on a model that
# can natively inspect the pixels (issue #29135).
supports = _lookup_supports_vision(provider, model, cfg)
if requested_provider:
supports = _lookup_supports_vision(
provider,
model,
cfg,
requested_provider=requested_provider,
)
else:
# Keep the long-standing three-argument call contract for callers and
# tests that replace the capability lookup hook.
supports = _lookup_supports_vision(provider, model, cfg)
if supports is True:
return "native"
if _explicit_aux_vision_override(cfg):
+30
View File
@@ -7,6 +7,36 @@ from typing import Any, Sequence
from agent.redact import redact_sensitive_text
def describe_compression_lock_skip(lock_signal: Any) -> str:
"""User-facing text for a manual /compress skipped by the compression lock.
``lock_signal`` is ``agent._compression_skipped_due_to_lock`` (or the
``holder`` carried by the TUI's ``CompressionLockHeld``): a descriptive
holder string when another compressor CONFIRMED holds the lock, or
``True``/``None`` when acquisition failed without a confirmed holder
(``hermes_state.try_acquire_compression_lock`` catches ``sqlite3.Error``
internally and returns ``False``, so a failed acquire is NOT proof that
another compression is running). The two cases must be worded
differently: claiming "already in progress" on an unconfirmed failure
misdirects the user when the real problem is a broken lock subsystem.
"""
holder = (
lock_signal
if isinstance(lock_signal, str) and lock_signal.strip()
else None
)
if holder:
return (
f"⏳ Compression already in progress for this session "
f"(holder: {holder}). Please wait for it to finish."
)
return (
"⏳ Compression skipped: could not acquire this session's "
"compression lock. Another compression may still be running, or "
"the lock check failed — try again shortly."
)
def summarize_manual_compression(
before_messages: Sequence[dict[str, Any]],
after_messages: Sequence[dict[str, Any]],
+52 -18
View File
@@ -212,11 +212,30 @@ def _slot_runtime(slot: dict[str, Any]) -> dict[str, Any]:
out["api_key"] = rt["api_key"]
if rt.get("api_mode"):
out["api_mode"] = rt["api_mode"]
request_overrides = rt.get("request_overrides")
if isinstance(request_overrides, dict):
extra_body = request_overrides.get("extra_body")
if isinstance(extra_body, dict) and extra_body:
out["extra_body"] = dict(extra_body)
except Exception as exc: # pragma: no cover - defensive
logger.debug("MoA slot runtime resolution failed for %s: %s", _slot_label(slot), exc)
return out
def _merge_slot_extra_body(
slot_extra_body: Any,
caller_extra_body: Any,
) -> Any:
"""Merge slot defaults with a caller override for ``call_llm``."""
if isinstance(slot_extra_body, dict) and slot_extra_body:
if isinstance(caller_extra_body, dict):
return {**slot_extra_body, **caller_extra_body}
if caller_extra_body:
return caller_extra_body
return dict(slot_extra_body)
return caller_extra_body
def _maybe_apply_moa_cache_control(
messages: list[dict[str, Any]],
runtime: dict[str, Any],
@@ -262,7 +281,7 @@ def _maybe_apply_moa_cache_control(
def _run_reference(
slot: dict[str, str],
slot: dict[str, Any],
ref_messages: list[dict[str, Any]],
*,
temperature: float | None = None,
@@ -314,11 +333,16 @@ def _run_reference(
# (their caching is automatic; markers are ignored harmlessly, but we
# only decorate when the policy says the route honors them).
messages = _maybe_apply_moa_cache_control(messages, runtime)
# Per-slot max_tokens takes precedence over the preset-level
# reference_max_tokens passed in by the caller. This lets each
# reference model have its own output cap independently.
_slot_max_tokens: int | None = slot.get("max_tokens")
_effective_max_tokens = _slot_max_tokens if _slot_max_tokens is not None else max_tokens
response = call_llm(
task="moa_reference",
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
max_tokens=_effective_max_tokens,
reasoning_config=_slot_reasoning_config(slot),
**runtime,
)
@@ -379,7 +403,7 @@ def _run_reference(
def _run_references_parallel(
reference_models: list[dict[str, str]],
reference_models: list[dict[str, Any]],
ref_messages: list[dict[str, Any]],
*,
temperature: float | None = None,
@@ -664,27 +688,30 @@ def aggregate_moa_context(
*,
user_prompt: str,
api_messages: list[dict[str, Any]],
reference_models: list[dict[str, str]],
aggregator: dict[str, str],
reference_models: list[dict[str, Any]],
aggregator: dict[str, Any],
temperature: float | None = None,
aggregator_temperature: float | None = None,
max_tokens: int | None = None,
reference_max_tokens: int | None = None,
) -> str:
"""Run configured reference models and synthesize their advice.
Failures are returned as model-specific notes instead of aborting the normal
agent loop; the main model can still act with partial context.
``max_tokens`` is ``None`` by default: MoA does not cap reference or
aggregator output, so each model uses its own maximum. ``call_llm`` omits
the parameter entirely when it is ``None`` (see its docstring), which also
sidesteps providers that reject ``max_tokens`` outright. A hardcoded cap
here previously truncated long aggregator syntheses.
``reference_max_tokens`` applies ONLY to the reference fan-out — the
aggregator's own synthesis call is never capped, so it always uses its
model's own maximum. ``call_llm`` omits the parameter entirely when it
is ``None`` (see its docstring), which also sidesteps providers that
reject ``max_tokens`` outright. A hardcoded cap on the aggregator call
previously truncated long aggregator syntheses (#53580) — passing
``reference_max_tokens`` to both calls here would silently reintroduce
that regression.
``temperature`` / ``aggregator_temperature`` are ``None`` by default:
like max_tokens, ``call_llm`` omits temperature when None so the
provider default applies — matching single-model agent behavior. Presets
may still pin explicit values.
like ``reference_max_tokens``, ``call_llm`` omits temperature when None
so the provider default applies — matching single-model agent behavior.
Presets may still pin explicit values.
"""
reference_outputs: list[tuple[str, str, Any]] = []
ref_messages = _reference_messages(api_messages)
@@ -692,7 +719,7 @@ def aggregate_moa_context(
reference_models,
ref_messages,
temperature=temperature,
max_tokens=max_tokens,
max_tokens=reference_max_tokens,
)
joined = "\n\n".join(
@@ -729,7 +756,6 @@ def aggregate_moa_context(
task="moa_aggregator",
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=max_tokens,
reasoning_config=_aggregator_reasoning_config(aggregator),
**agg_runtime,
)
@@ -976,18 +1002,26 @@ class MoAChatCompletions:
# actually governs the aggregator stream, not just call_llm's default.
if api_kwargs.get("timeout") is not None:
stream_kwargs["timeout"] = api_kwargs["timeout"]
agg_runtime = _slot_runtime(aggregator)
# _slot_runtime may carry the provider's request_overrides.extra_body;
# pop it and merge with the caller's extra_body (caller wins) so the
# explicit kwarg below never collides with **agg_runtime.
agg_extra_body = _merge_slot_extra_body(
agg_runtime.pop("extra_body", None),
extra_body,
)
_agg_response = call_llm(
task="moa_aggregator",
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=max_tokens,
tools=tools,
extra_body=extra_body,
extra_body=agg_extra_body,
# Prepared requests must retain the acting aggregator's reasoning
# policy exactly as the direct create() path does (#64187).
reasoning_config=_aggregator_reasoning_config(aggregator),
**stream_kwargs,
**_slot_runtime(aggregator),
**agg_runtime,
)
# Non-streaming path (quiet mode / eval / subagents): the aggregator
# output is available inline, so capture it into the pending trace now.
+11 -3
View File
@@ -2201,11 +2201,18 @@ def get_model_context_length(
# acting context, so they're ignored here.
if (provider or "").strip().lower() == "moa":
try:
from hermes_cli.config import load_config
from hermes_cli.config import (
get_compatible_custom_providers,
load_config,
)
from hermes_cli.moa_config import resolve_moa_preset
from hermes_cli.runtime_provider import resolve_runtime_provider
preset = resolve_moa_preset(load_config().get("moa") or {}, model)
config = load_config()
effective_custom_providers = custom_providers
if effective_custom_providers is None:
effective_custom_providers = get_compatible_custom_providers(config)
preset = resolve_moa_preset(config.get("moa") or {}, model)
agg = preset.get("aggregator") or {}
agg_provider = str(agg.get("provider") or "").strip()
agg_model = str(agg.get("model") or "").strip()
@@ -2215,7 +2222,8 @@ def get_model_context_length(
agg_model,
base_url=rt.get("base_url", "") or "",
api_key=rt.get("api_key", "") or "",
provider=agg_provider,
provider=rt.get("provider") or agg_provider,
custom_providers=effective_custom_providers,
)
except Exception:
logger.debug("MoA aggregator context-length resolution failed", exc_info=True)
+15 -2
View File
@@ -192,7 +192,13 @@ SKILLS_GUIDANCE = (
"skill with skill_manage so you can reuse it next time.\n"
"When using a skill and finding it outdated, incomplete, or wrong, "
"patch it immediately with skill_manage(action='patch') — don't wait to be asked. "
"Skills that aren't maintained become liabilities."
"Skills that aren't maintained become liabilities.\n"
"\n"
"## Skill Safety Rule\n"
"1. **UNAVAILABLE** — If a skill placeholder contains `[SKILL_PRUNED]`, the skill content was lost in compression and is inaccessible.\n"
"2. **RELOAD** — Before performing any action that depends on a skill, re-check its content with `skill_view(name='...')` if it shows `[SKILL_PRUNED]`.\n"
"3. **WAIT** — If a skill is loading or was just pruned, wait for the reload confirmation before proceeding.\n"
"4. **DEDUP** — After reloading a pruned skill, **ignore any remaining `[SKILL_PRUNED]` markers for that same skill** — they are historical artifacts from previous compactions and do not need further action."
)
KANBAN_GUIDANCE = (
@@ -884,7 +890,14 @@ PLATFORM_HINTS = {
"You're responding through an API server. The rendering layer is unknown — "
"assume plain text. No markdown formatting (no asterisks, bullets, headers, "
"code fences). Treat this like a conversation, not a document. Keep responses "
"brief and natural."
"brief and natural. "
"File/media delivery: images referenced as MEDIA:/absolute/path tags "
"(.png/.jpg/.jpeg/.gif/.webp/.bmp, up to 5MB) are inlined as base64 data "
"URLs in responses on the chat, completions, and responses endpoints. "
"Non-image files are NOT intercepted anywhere, and the runs endpoint "
"intercepts nothing — a MEDIA: tag there renders as literal text exposing "
"a raw host filesystem path. For those cases, state the plain file path "
"in your response text instead of a MEDIA: tag."
),
"webui": (
"You are in the Hermes WebUI, a browser-based chat interface. "
+5
View File
@@ -127,6 +127,10 @@ class CodexAppServerClient:
# Codex emits tracing to stderr; default WARN keeps it quiet for users.
spawn_env.setdefault("RUST_LOG", "warn")
# Hide the console the codex child would otherwise flash on Windows
# (#56747). Hide-only — stdio pipes stay intact for the app-server wire.
from hermes_cli._subprocess_compat import windows_hide_flags
self._proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
@@ -134,6 +138,7 @@ class CodexAppServerClient:
stderr=subprocess.PIPE,
bufsize=0,
env=spawn_env,
creationflags=windows_hide_flags(),
)
self._next_id = 1
self._pending: dict[int, _Pending] = {}
+187 -9
View File
@@ -34,8 +34,10 @@ from typing import Any, Dict, List, Mapping, Optional
from agent.conversation_compression import (
IDLE_COMPACTION_STATUS_TEMPLATE,
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE,
compression_skipped_due_to_lock,
conversation_history_after_compression,
)
from agent.context_engine import automatic_compaction_status_message
from agent.iteration_budget import IterationBudget
from agent.memory_manager import build_memory_context_block
from agent.model_metadata import (
@@ -375,6 +377,7 @@ def build_turn_context(
set_runtime_main(
getattr(agent, "provider", "") or "",
getattr(agent, "model", "") or "",
requested_provider=getattr(agent, "requested_provider", "") or "",
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
@@ -662,11 +665,18 @@ def build_turn_context(
f"{_idle_floor:,}",
agent.session_id or "none",
)
agent._emit_status(
IDLE_COMPACTION_STATUS_TEMPLATE.format(
_idle_status = automatic_compaction_status_message(
_compressor,
phase="idle",
default_message=IDLE_COMPACTION_STATUS_TEMPLATE.format(
idle_seconds=int(_idle_gap), tokens=_idle_tokens
)
),
approx_tokens=_idle_tokens,
idle_seconds=int(_idle_gap),
model=agent.model,
)
if _idle_status:
agent._emit_status(_idle_status)
_idle_input = messages
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_idle_tokens,
@@ -680,7 +690,7 @@ def build_turn_context(
# untouched.
if messages is not _idle_input:
conversation_history = conversation_history_after_compression(
agent, messages
agent, messages, conversation_history
)
# Compaction rebuilt the list, so the index of this turn's
# just-appended user message is stale — re-anchor it the
@@ -696,6 +706,8 @@ def build_turn_context(
# issue #27405 (a few very large messages slipping past the count gate).
_preflight_compressed = False
_preflight_compression_blocked = False
agent._turn_received_provider_response = False
agent._turn_preflight_display_snapshot = None
if agent.compression_enabled and _should_run_preflight_estimate(
messages,
agent.context_compressor.protect_first_n,
@@ -708,6 +720,21 @@ def build_turn_context(
tools=agent.tools or None,
)
_compressor = agent.context_compressor
# getattr guard: minimal compressor doubles (SimpleNamespace in the
# engine-preflight tests) and plugin context engines lack this
# ContextCompressor-only method — absence means no snapshot, and the
# finalizer's rollback stays disarmed for the turn (display-only).
_snapshot_fn = getattr(
_compressor, "snapshot_preflight_display_tokens", None
)
if callable(_snapshot_fn):
_snapshot_val = _snapshot_fn()
# Type pin: MagicMock compressors return truthy Mock objects —
# only a real int snapshot may arm the interrupted-turn rollback.
if isinstance(_snapshot_val, int) and not isinstance(
_snapshot_val, bool
):
agent._turn_preflight_display_snapshot = _snapshot_val
_defer_preflight = getattr(
_compressor,
"should_defer_preflight_to_real_usage",
@@ -741,6 +768,8 @@ def build_turn_context(
lambda: None,
)()
_should_compress_now = False
_compress_block_reason = None
if _preflight_deferred:
logger.info(
"Skipping preflight compression: rough estimate ~%s >= %s, "
@@ -756,14 +785,42 @@ def build_turn_context(
int(_compression_cooldown.get("remaining_seconds", 0.0)),
agent.session_id or "none",
)
if _preflight_tokens >= _compressor.threshold_tokens:
# Context is over threshold but compression is blocked by the
# summary-LLM cooldown — surface a warning (see block below).
_cooldown_secs = _compression_cooldown.get("remaining_seconds", 0.0)
_compress_block_reason = f"cooldown:{_cooldown_secs:.0f}"
elif _codex_native_auto:
logger.info(
"Skipping Hermes preflight compression for codex app-server "
"(mode=%s); Hermes will not start thread compaction here.",
getattr(agent, "codex_app_server_auto_compaction", "native"),
)
elif _compressor.should_compress(_preflight_tokens):
else:
_should_compress_now = _compressor.should_compress(_preflight_tokens)
if not _should_compress_now:
# Context is over threshold but compression is blocked
# (summary-LLM cooldown or anti-thrashing). Ask should_compress_info
# for the human-readable reason so we can surface a warning below.
# getattr guard: minimal compressor doubles (SimpleNamespace in
# the engine-preflight tests) and older plugin engines lack the
# method — absence means no block reason, no warning.
_info = getattr(_compressor, "should_compress_info", None)
if callable(_info):
try:
_compress_block_reason = _info(_preflight_tokens)[1]
except Exception:
_compress_block_reason = None
if _should_compress_now:
_preflight_compressed = True
# Compression is actually running (block cleared / was never
# blocked) — reset the dedup so a future blocked-over-threshold
# turn can warn again. Real session boundary.
# getattr guard: test doubles built via object.__new__ lack the
# method (gateway test-double pitfall) — treat absence as no-op.
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
if callable(_clear_warn):
_clear_warn()
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}",
@@ -771,12 +828,20 @@ def build_turn_context(
agent.model,
f"{_compressor.context_length:,}",
)
agent._emit_status(
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(
_preflight_status = automatic_compaction_status_message(
_compressor,
phase="preflight",
default_message=PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(
tokens=_preflight_tokens,
threshold=_compressor.threshold_tokens,
)
),
approx_tokens=_preflight_tokens,
threshold_tokens=_compressor.threshold_tokens,
context_length=_compressor.context_length,
model=agent.model,
)
if _preflight_status:
agent._emit_status(_preflight_status)
# Preflight passes honor the same configured per-turn cap
# (compression.max_attempts) as the loop's compression sites;
# default 3 preserves the prior hardcoded behavior.
@@ -786,10 +851,29 @@ def build_turn_context(
for _pass in range(_max_preflight_passes):
_orig_len = len(messages)
_orig_tokens = _preflight_tokens
_preflight_input = messages
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_preflight_tokens,
task_id=effective_task_id,
)
if (
messages is _preflight_input
and compression_skipped_due_to_lock(agent)
):
# #69870 lock-skip: another path holds this session's
# compression lock, so the pass no-oped. That is a
# temporary DEFER, not proof the transcript cannot
# compress — do NOT arm the insufficient-progress
# blocker (the loop's error handlers must keep their
# provider-proven retry budget) and stop preflight
# passes for this turn; the lock winner is shrinking
# the same session concurrently.
logger.info(
"Preflight compression deferred: compression lock "
"held by another path (session %s)",
agent.session_id or "none",
)
break
# Re-estimate now so size-only compression (same row count,
# lower token count — e.g. summarising tool outputs) is
# recognised as progress instead of being misread as
@@ -805,7 +889,7 @@ def build_turn_context(
_preflight_compression_blocked = True
break # Cannot compress further: neither rows nor tokens moved
conversation_history = conversation_history_after_compression(
agent, messages
agent, messages, conversation_history
)
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
@@ -827,6 +911,100 @@ def build_turn_context(
f"{_preflight_tokens:,}",
)
break
elif _compress_block_reason:
# Context is already over the compression threshold, but compression
# is blocked (summary LLM cooldown or anti-thrashing). Without a
# signal the session keeps growing until the model silently stops
# answering — the conversation hits the hard provider token limit
# with no explanation. Surface a deduped warning so the user can
# take action (/new or /compress) instead of hitting a silent hang.
agent._warn_context_overflow_blocked(
_compress_block_reason,
_preflight_tokens,
_compressor.threshold_tokens,
)
else:
# Sub-threshold and unblocked — allow the overflow warning to fire
# again next time the context is over threshold but blocked.
# getattr guard: test doubles built via object.__new__ lack the
# method (gateway test-double pitfall) — treat absence as no-op.
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
if callable(_clear_warn):
_clear_warn()
# Engine maintenance only when NO skip-branch fired: a failure
# cooldown, deferred estimate, or codex-native route must keep
# the engine hook un-consulted (#20316 contract — the cooldown
# exists precisely because compression recently failed).
if _compression_cooldown or _preflight_deferred or _codex_native_auto:
_engine_preflight = None
else:
_engine_preflight = getattr(
_compressor, "should_compress_preflight", None
)
# ── Engine-driven sub-threshold preflight maintenance (#20316) ──
# None of the threshold-path branches fired (not deferred, no
# failure cooldown, not codex-native, and should_compress() said
# the request is under pressure). Context engines that override
# ``should_compress_preflight()`` (e.g. LCM-style incremental
# leaf-chunk compaction) can still request deferred maintenance
# below the token threshold. The default
# ``ContextEngine.should_compress_preflight()`` returns False, so
# the built-in ``ContextCompressor`` path is byte-identical.
#
# Attempt-cap integration: the engine gets exactly ONE
# ``compress()`` pass per turn. It is mutually exclusive with the
# threshold multi-pass loop above (if/elif), so turn-start
# preflight passes stay bounded by the resolved
# ``compression.max_attempts`` cap (floor 1) in every case.
#
# No-op-blocking integration: a sub-threshold engine pass that
# no-ops says nothing about over-threshold compressibility, so it
# must neither set nor clear ``_preflight_compression_blocked``
# (#64382) — and being in the ``else`` arm it can never run after
# the threshold loop has proven a retry ineffective.
# (resolved above, gated on no skip-branch having fired)
_wants_engine_preflight = False
if callable(_engine_preflight):
try:
_wants_engine_preflight = bool(_engine_preflight(messages))
except Exception as _preflight_exc:
# A buggy engine must never break an otherwise-healthy
# turn: swallow at debug level and skip maintenance.
logger.debug(
"should_compress_preflight raised %s; skipping "
"engine-driven preflight maintenance",
_preflight_exc,
)
_wants_engine_preflight = False
if _wants_engine_preflight:
logger.info(
"Engine-driven preflight maintenance: %s requested "
"compress() at ~%s tokens (below %s threshold)",
getattr(_compressor, "name", type(_compressor).__name__),
f"{_preflight_tokens:,}",
f"{getattr(_compressor, 'threshold_tokens', 0):,}",
)
_engine_input = messages
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_preflight_tokens,
task_id=effective_task_id,
)
# ``_compress_context`` returns the INPUT list object on every
# skip path (per-session lock held elsewhere, cooldown,
# anti-thrash breaker, codex-native routing) and an engine may
# legitimately no-op. Only re-baseline the flush history and
# re-anchor the user row after a REAL compaction — a skip must
# leave the turn's bookkeeping untouched.
if messages is not _engine_input:
_preflight_compressed = True
conversation_history = conversation_history_after_compression(
agent, messages
)
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
if _preflight_compressed:
# Compression rebuilt the list (tail messages are fresh compaction
+33
View File
@@ -201,6 +201,36 @@ def finalize_turn(
)
)
# Preflight can seed the display count before the provider receives the
# request. Roll that estimate back only when an interrupt wins the race
# before any successful provider response. Compaction state remains owned
# by the real-usage/post-compaction path, including its ``-1`` sentinel.
# Guard rules (test-double density on this path is high):
# - snapshot is type-pinned to a real int — MagicMock agents auto-create
# truthy Mock attributes that must never arm the rollback;
# - the received-response flag is pinned to ``is not True`` — its real
# domain is True/False, and only a literal True means a provider
# response completed;
# - the compressor method gets a getattr+callable guard — SimpleNamespace
# compressor doubles and plugin context engines lack it.
_preflight_snapshot = getattr(
agent, "_turn_preflight_display_snapshot", None
)
if (
interrupted is True
and isinstance(_preflight_snapshot, int)
and not isinstance(_preflight_snapshot, bool)
and getattr(agent, "_turn_received_provider_response", False) is not True
and getattr(agent, "context_compressor", None) is not None
):
_rollback_fn = getattr(
agent.context_compressor,
"rollback_interrupted_preflight_display_tokens",
None,
)
if callable(_rollback_fn):
_rollback_fn(_preflight_snapshot)
# Post-loop cleanup must never lose the response. Trajectory save,
# resource teardown, and session persistence all touch fallible
# surfaces — file I/O / JSON serialization (_save_trajectory), remote
@@ -625,4 +655,7 @@ def finalize_turn(
except Exception as exc:
logger.warning("on_session_end hook failed: %s", exc)
agent._turn_preflight_display_snapshot = None
agent._turn_received_provider_response = False
return result
+3 -1
View File
@@ -60,10 +60,12 @@ def _db_path() -> Path:
def _connect() -> sqlite3.Connection:
from hermes_state import apply_wal_with_fallback
path = _db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.execute("PRAGMA journal_mode=WAL")
apply_wal_with_fallback(conn, db_label="verification_evidence.db")
conn.execute("PRAGMA busy_timeout=5000")
conn.row_factory = sqlite3.Row
_ensure_schema(conn)
+1 -1
View File
@@ -13,7 +13,7 @@
"tauri:build": "tauri build",
"tauri:build:debug": "tauri build --debug",
"typecheck": "tsc -p . --noEmit",
"check": "npm run typecheck",
"check": "npm run typecheck && npm run lint",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"fix": "npm run lint:fix"
@@ -0,0 +1,147 @@
/**
* E2E regression: desktop resume must hide agent-only transcript rows.
*
* Compaction handoffs are active user rows because the model needs them for
* context continuity. They are not authored chat content, so the desktop
* transcript must never display them after a real compressor-generated resume.
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
import { expect, test } from './test'
import {
type MockBackendFixture,
buildAppEnv,
createSandbox,
launchDesktop,
waitForAppReady,
writeEnvFile,
writeMockProviderConfig,
} from './fixtures'
import {
MOCK_REPLY,
startMockServer,
VERIFICATION_STOP_TEXT,
VERIFICATION_STOP_TRIGGER,
} from './mock-server'
import { RealSessionBuilder } from './real-session-builder'
const SESSION_TITLE = 'E2E Hidden History Messages'
const VISIBLE_USER_TEXT = 'E2E_VISIBLE_USER_HISTORY'
const VISIBLE_POST_COMPACTION_TEXT = 'E2E_VISIBLE_POST_COMPACTION_HISTORY'
const COMPACTION_TRIGGER_PADDING = ' force real context compression'.repeat(600)
async function setupSeededMockBackend(): Promise<MockBackendFixture> {
const mock = await startMockServer()
const sandbox = createSandbox('hidden-history')
writeMockProviderConfig(sandbox.hermesHome, mock.url)
fs.appendFileSync(
path.join(sandbox.hermesHome, 'config.yaml'),
'\ncompression:\n threshold_tokens: 1\n',
'utf8',
)
writeEnvFile(sandbox.hermesHome)
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
try {
await builder.createSession({
title: SESSION_TITLE,
turns: [
`${VISIBLE_USER_TEXT}${COMPACTION_TRIGGER_PADDING}`,
VISIBLE_POST_COMPACTION_TEXT,
],
})
} finally {
await builder.close()
}
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
return {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
}
test('resume hides real context-compaction handoffs', async ({}, testInfo) => {
const fixture = await setupSeededMockBackend()
try {
const { page } = fixture
await waitForAppReady(fixture, 120_000)
const sessionRow = page
.locator('[data-slot="sidebar"] button')
.filter({ hasText: SESSION_TITLE })
.first()
await sessionRow.click()
const transcript = page.locator('[data-slot="aui_thread-viewport"]')
await expect(transcript).toContainText(VISIBLE_USER_TEXT)
await expect(transcript).toContainText(VISIBLE_POST_COMPACTION_TEXT)
await expect(transcript).toContainText(MOCK_REPLY)
await expect(transcript).not.toContainText('[CONTEXT COMPACTION — REFERENCE ONLY]')
await page.screenshot({ path: testInfo.outputPath('hidden-history-resume.png') })
} finally {
await fixture.cleanup()
}
})
test('live verify-on-stop continuations stay out of the transcript', async ({}, testInfo) => {
const sandbox = createSandbox('live-verification-nudge')
const projectRoot = path.join(sandbox.root, 'project')
const changedFile = path.join(projectRoot, 'e2e-verification-target.py')
fs.mkdirSync(projectRoot)
fs.writeFileSync(
path.join(projectRoot, 'pyproject.toml'),
'[project]\nname = "e2e-verification-project"\nversion = "0.0.0"\n',
'utf8',
)
const mock = await startMockServer({ verificationWritePath: changedFile })
writeMockProviderConfig(sandbox.hermesHome, mock.url)
fs.appendFileSync(path.join(sandbox.hermesHome, 'config.yaml'), '\nagent:\n verify_on_stop: true\n', 'utf8')
writeEnvFile(sandbox.hermesHome)
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
const fixture: MockBackendFixture = {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
try {
await waitForAppReady(fixture, 120_000)
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(VERIFICATION_STOP_TRIGGER)
await page.keyboard.press('Enter')
const transcript = page.locator('[data-slot="aui_thread-viewport"]')
await expect(transcript).toContainText(VERIFICATION_STOP_TEXT, { timeout: 60_000 })
await expect.poll(
() => mock.receivedPrompts.some(prompt => prompt.includes('[System: You edited code in this turn')),
{ timeout: 30_000 },
).toBe(true)
expect(fs.existsSync(changedFile), 'The scripted write_file call should edit only the sandbox project').toBe(true)
await expect(transcript).not.toContainText('[System: You edited code in this turn')
await page.screenshot({ path: testInfo.outputPath('live-verification-nudge.png') })
} finally {
await fixture.cleanup()
}
})
+15 -11
View File
@@ -1,4 +1,3 @@
import { spawnSync } from 'node:child_process'
import * as path from 'node:path'
import { type TestInfo } from '@playwright/test'
@@ -15,13 +14,16 @@ import {
writeMockProviderConfig,
} from './fixtures'
import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server'
import { RealSessionBuilder } from './real-session-builder'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const SEED_SCRIPT = path.resolve(import.meta.dirname, 'scripts', 'seed_large_session.py')
const SESSION_TITLE = 'E2E large persisted session'
const EXPECTED_TEXT = 'E2E persisted user message 52'
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
const HISTORY_TURNS = Array.from(
{ length: 27 },
(_, index) => `E2E persisted user message ${index * 2}: audit the compatibility matrix`,
)
interface SeededFixture {
app: ElectronApplication
@@ -43,13 +45,11 @@ async function setupSeededDesktop(mockServer?: MockServerOptions): Promise<Seede
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
const seeded = spawnSync('python3', [SEED_SCRIPT, path.join(sandbox.hermesHome, 'state.db')], {
cwd: REPO_ROOT,
encoding: 'utf8',
env: { ...process.env, PYTHONPATH: REPO_ROOT },
})
if (seeded.status !== 0) {
throw new Error(`large-session seed failed:\n${seeded.stdout}\n${seeded.stderr}`)
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
try {
await builder.createSession({ title: SESSION_TITLE, turns: HISTORY_TURNS })
} finally {
await builder.close()
}
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
@@ -210,6 +210,7 @@ test.describe('large session resume', () => {
await waitForAppReady(fixture, 120_000)
await openSeededSession(fixture.page)
const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY)
await submitPrompt(fixture.page, BACKGROUND_PROMPT)
await fixture.mock.waitForHeldStream()
await openNewSession(fixture.page)
@@ -229,7 +230,10 @@ test.describe('large session resume', () => {
await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false })
expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1)
expect(await textNodeOccurrences(fixture.page, MOCK_REPLY), 'the completed assistant reply should appear once').toBe(1)
expect(
await textNodeOccurrences(fixture.page, MOCK_REPLY),
'the completed assistant reply should add exactly one transcript row',
).toBe(initialMockReplyCount + 1)
})
}
})
+49 -2
View File
@@ -23,8 +23,10 @@ export const MOCK_REPLY = 'Hello from the mock inference server! The full boot c
export interface MockServerOptions {
/** Pause the matching stream after its first token for session-switch E2E coverage. */
holdFirstStreamForPrompt?: string
/** Pause the first completion whose request JSON contains this text. */
holdFirstCompletionContaining?: string
/** Pause the first completion whose request JSON contains this text. */
holdFirstCompletionContaining?: string
/** Absolute sandbox path written by the verify-on-stop scripted tool call. */
verificationWritePath?: string
}
export interface MockServer {
@@ -104,6 +106,9 @@ let _queueStopIndex = 0
/** Per-server counter for the correction/session-switch script. */
let _correctionSwitchIndex = 0
/** Per-server counter for the verify-on-stop script. */
let _verificationStopIndex = 0
/** User messages received by the mock, for E2E assertions on real submits. */
const _receivedUserTexts: string[] = []
@@ -114,6 +119,7 @@ function resetScriptIndex(): void {
_sidebarCrossIndex = 0
_queueStopIndex = 0
_correctionSwitchIndex = 0
_verificationStopIndex = 0
_receivedUserTexts.length = 0
}
@@ -214,6 +220,32 @@ const CORRECTION_SWITCH_SCRIPT: ScriptedTurn[] = [
export const CORRECTION_SWITCH_TRIGGER = 'E2E_CORRECTION_SWITCH_TRIGGER'
/**
* Drives a real code edit followed by two finish attempts. Hermes should add
* its synthetic verify-on-stop continuation after each finish attempt until
* the bounded verifier gives up. The mock's request capture proves the nudge
* reached the model; desktop must never render it as chat content.
*/
function verificationStopScript(writePath: string): ScriptedTurn[] {
return [
{
text: 'I will make the requested code change.',
toolCalls: [{
name: 'write_file',
args: {
path: writePath,
content: 'def changed_by_e2e():\n return "changed"\n',
},
}],
},
{ text: 'The code edit is complete.' },
{ text: 'I cannot provide fresh verification evidence for that edit.' },
]
}
export const VERIFICATION_STOP_TRIGGER = 'E2E_VERIFY_ON_STOP_TRIGGER'
export const VERIFICATION_STOP_TEXT = 'I cannot provide fresh verification evidence for that edit.'
/**
* A marker that makes the mock emit a real blocking clarify tool call. Tests
* use it to hold a turn open while exercising busy-composer interactions.
@@ -340,6 +372,9 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
const isSidebarTrigger = userText.includes('E2E_SIDEBAR_TRIGGER')
const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS')
const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER')
const isVerificationStopTrigger = messages.some(
message => typeof message?.content === 'string' && message.content.includes(VERIFICATION_STOP_TRIGGER),
)
const isCorrectionSwitchTrigger = messages.some(
message => typeof message?.content === 'string' && message.content.includes(CORRECTION_SWITCH_TRIGGER),
)
@@ -364,6 +399,18 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
return
}
if (isVerificationStopTrigger) {
const script = verificationStopScript(options.verificationWritePath ?? 'e2e-verification-target.py')
const turn = script[_verificationStopIndex] ?? script[script.length - 1]
_verificationStopIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isCorrectionSwitchTrigger) {
const turn = CORRECTION_SWITCH_SCRIPT[_correctionSwitchIndex] ?? CORRECTION_SWITCH_SCRIPT[CORRECTION_SWITCH_SCRIPT.length - 1]
_correctionSwitchIndex++
+10 -9
View File
@@ -26,12 +26,18 @@ async function send(page: Page, text: string): Promise<void> {
async function steer(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
await composer.click()
await composer.type(text, { delay: 5 })
await expect(primary).toHaveAttribute('aria-label', /Steer/)
await primary.click()
await page.keyboard.press('Enter')
}
async function queue(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Control+Enter')
}
async function transcriptMessageOrder(page: Page): Promise<string[]> {
@@ -72,15 +78,10 @@ test.describe('queued prompt turn boundary', () => {
test('submits a queued prompt only after the active turn completes', async () => {
const { mock, page } = fixture!
const composer = page.locator('[contenteditable="true"]').first()
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
await send(page, ACTIVE_PROMPT)
await mock.waitForHeldStream()
await composer.click()
await composer.type(QUEUED_PROMPT, { delay: 5 })
await queue.click()
await queue(page, QUEUED_PROMPT)
await expect(page.getByText('1 Queued')).toBeVisible()
// The mock keeps the active SSE stream open, so a queued prompt has no
+226
View File
@@ -0,0 +1,226 @@
import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
import * as path from 'node:path'
import { createInterface } from 'node:readline'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const DEFAULT_TIMEOUT_MS = 60_000
interface JsonRpcError {
code?: number
message?: string
}
interface JsonRpcFrame {
error?: JsonRpcError
id?: number
method?: string
params?: {
payload?: unknown
session_id?: string
type?: string
}
result?: unknown
}
interface CreatedSession {
session_id: string
stored_session_id: string
}
export interface RealSessionSpec {
/** Human-visible sidebar title, persisted by the first completed turn. */
title: string
/** Each item becomes one real user prompt followed by the mock provider's reply. */
turns: readonly string[]
}
export interface RealSession {
/** Runtime-only TUI session id, valid only while the builder process is alive. */
runtimeId: string
/** Durable SessionDB id that desktop resumes after the builder exits. */
sessionId: string
}
/**
* Creates durable desktop session history through the real TUI gateway and
* AIAgent loop, using the E2E mock provider configured in `hermesHome`.
*
* This intentionally uses the shipped stdio JSON-RPC transport instead of
* importing SessionDB or launching Electron. The desktop's WebSocket backend
* dispatches the same `tui_gateway.server` methods.
*/
export class RealSessionBuilder {
private readonly child: ChildProcessWithoutNullStreams
private nextRequestId = 0
private readonly pending = new Map<number, { reject: (reason: Error) => void; resolve: (value: unknown) => void }>()
private readonly events: JsonRpcFrame[] = []
private readonly eventWaiters: Array<{
predicate: (frame: JsonRpcFrame) => boolean
reject: (reason: Error) => void
resolve: (frame: JsonRpcFrame) => void
}> = []
private readonly stderr: string[] = []
private closed = false
private constructor(hermesHome: string) {
this.child = spawn('uv', ['run', '--active', '--no-sync', 'python', '-m', 'tui_gateway.entry'], {
cwd: REPO_ROOT,
env: {
...process.env,
HERMES_HOME: hermesHome,
PYTHONPATH: REPO_ROOT,
},
stdio: 'pipe',
})
createInterface({ input: this.child.stdout }).on('line', line => this.handleLine(line))
createInterface({ input: this.child.stderr }).on('line', line => {
this.stderr.push(line)
if (this.stderr.length > 80) this.stderr.shift()
})
this.child.once('error', error => this.failAll(new Error(`real-session gateway failed to start: ${error.message}`)))
this.child.once('exit', (code, signal) => {
if (!this.closed) {
this.failAll(new Error(`real-session gateway exited unexpectedly (${signal ?? code ?? 'unknown'}):\n${this.stderr.join('\n')}`))
}
})
}
static async start(hermesHome: string): Promise<RealSessionBuilder> {
const builder = new RealSessionBuilder(hermesHome)
await builder.waitForEvent(frame => frame.params?.type === 'gateway.ready')
return builder
}
async createSession(spec: RealSessionSpec): Promise<RealSession> {
if (spec.turns.length === 0) {
throw new Error('RealSessionBuilder requires at least one turn so the real agent creates a durable session row')
}
const created = await this.request<CreatedSession>('session.create', {
cols: 120,
cwd: REPO_ROOT,
source: 'desktop',
title: spec.title,
})
const runtimeId = requireString(created, 'session_id')
const sessionId = requireString(created, 'stored_session_id')
for (const text of spec.turns) {
const completion = this.waitForEvent(
frame => frame.params?.type === 'message.complete' && frame.params.session_id === runtimeId,
)
await this.request('prompt.submit', { session_id: runtimeId, text })
const frame = await completion
const status = readString(frame.params?.payload, 'status')
if (status !== 'complete') {
throw new Error(`real session turn failed with status ${status ?? 'unknown'}: ${JSON.stringify(frame.params?.payload)}`)
}
}
await this.request('session.close', { session_id: runtimeId })
return { runtimeId, sessionId }
}
async close(): Promise<void> {
if (this.closed) return
this.closed = true
this.child.stdin.end()
await new Promise<void>(resolve => {
const timeout = setTimeout(() => {
this.child.kill('SIGTERM')
resolve()
}, 5_000)
this.child.once('exit', () => {
clearTimeout(timeout)
resolve()
})
})
}
private request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {
const id = ++this.nextRequestId
return this.withTimeout(new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: value => resolve(value as T), reject })
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, error => {
if (error) {
this.pending.delete(id)
reject(error)
}
})
}), `request ${method}`)
}
private waitForEvent(predicate: (frame: JsonRpcFrame) => boolean): Promise<JsonRpcFrame> {
const index = this.events.findIndex(predicate)
if (index >= 0) {
return Promise.resolve(this.events.splice(index, 1)[0])
}
return this.withTimeout(new Promise<JsonRpcFrame>((resolve, reject) => {
this.eventWaiters.push({ predicate, resolve, reject })
}), 'gateway event')
}
private handleLine(line: string): void {
let frame: JsonRpcFrame
try {
frame = JSON.parse(line) as JsonRpcFrame
} catch {
return
}
if (typeof frame.id === 'number') {
const pending = this.pending.get(frame.id)
if (!pending) return
this.pending.delete(frame.id)
if (frame.error) {
pending.reject(new Error(`JSON-RPC error ${frame.error.code ?? 'unknown'}: ${frame.error.message ?? 'unknown error'}`))
} else {
pending.resolve(frame.result)
}
return
}
if (frame.method !== 'event') return
const waiter = this.eventWaiters.find(candidate => candidate.predicate(frame))
if (!waiter) {
this.events.push(frame)
return
}
this.eventWaiters.splice(this.eventWaiters.indexOf(waiter), 1)
waiter.resolve(frame)
}
private withTimeout<T>(promise: Promise<T>, operation: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timed out after ${DEFAULT_TIMEOUT_MS / 1000}s waiting for ${operation}:\n${this.stderr.join('\n')}`)), DEFAULT_TIMEOUT_MS)
promise.then(value => {
clearTimeout(timer)
resolve(value)
}, error => {
clearTimeout(timer)
reject(error)
})
})
}
private failAll(error: Error): void {
for (const pending of this.pending.values()) pending.reject(error)
this.pending.clear()
for (const waiter of this.eventWaiters) waiter.reject(error)
this.eventWaiters.length = 0
}
}
function readString(value: unknown, key: string): string | undefined {
if (!value || typeof value !== 'object') return undefined
const candidate = (value as Record<string, unknown>)[key]
return typeof candidate === 'string' ? candidate : undefined
}
function requireString(value: unknown, key: string): string {
const candidate = readString(value, key)
if (!candidate) throw new Error(`Gateway response omitted required ${key}: ${JSON.stringify(value)}`)
return candidate
}
@@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""Seed a deterministic, tool-free large session into an isolated state.db."""
import sys
from pathlib import Path
repo_root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(repo_root))
from hermes_state import SessionDB # noqa: E402
SESSION_ID = "e2e-large-session"
SESSION_TITLE = "E2E large persisted session"
def main() -> None:
if len(sys.argv) != 2:
raise SystemExit(f"usage: {sys.argv[0]} <state.db>")
messages = []
for index in range(53):
role = "user" if index % 2 == 0 else "assistant"
content = (
f"E2E persisted user message {index}: audit the compatibility matrix"
if role == "user"
else f"E2E persisted assistant reply {index}: recorded the audit result"
)
messages.append({"role": role, "content": content, "timestamp": 1_700_000_000 + index})
database = SessionDB(db_path=Path(sys.argv[1]))
result = database.import_sessions(
[
{
"id": SESSION_ID,
"source": "desktop",
"model": "mock-model",
"started_at": 1_700_000_000,
"title": SESSION_TITLE,
"cwd": str(repo_root),
"system_prompt": "",
"messages": messages,
}
]
)
database.close()
if not result.get("ok") or result.get("imported") != 1:
raise SystemExit(f"failed to seed large session: {result}")
if __name__ == "__main__":
main()
@@ -1,56 +0,0 @@
#!/usr/bin/env python3
"""Seed a Hermes state.db with a session exported from a real conversation.
Usage: seed_session_db.py <state_db_path> <fixture_json_path>
Creates the database with the full SessionDB schema (if it doesn't exist)
and imports the session from the JSON fixture. Uses the real
SessionDB.import_sessions() so the data shape matches what the desktop
backend expects.
"""
import json
import sys
from pathlib import Path
# Add the repo root to sys.path so we can import hermes_state.
# The script is invoked from apps/desktop/e2e/ — repo root is ../../..
repo_root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(repo_root))
from hermes_state import SessionDB # noqa: E402
def main():
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <state_db_path> <fixture_json_path>", file=sys.stderr)
sys.exit(1)
db_path = Path(sys.argv[1])
fixture_path = Path(sys.argv[2])
db_path.parent.mkdir(parents=True, exist_ok=True)
with open(fixture_path, "r", encoding="utf-8") as f:
session_data = json.load(f)
db = SessionDB(db_path=db_path)
result = db.import_sessions([session_data])
if not result.get("ok"):
print(f"Import failed: {result}", file=sys.stderr)
sys.exit(1)
imported = result.get("imported", 0)
skipped = result.get("skipped", 0)
errors = result.get("errors", [])
if errors:
print(f"Import had errors: {errors}", file=sys.stderr)
sys.exit(1)
print(f"Seeded {imported} session(s), skipped {skipped} → {db_path}")
db.close()
if __name__ == "__main__":
main()
+19 -69
View File
@@ -28,11 +28,6 @@
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { spawnSync } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { expect, test } from './test'
import {
@@ -45,12 +40,9 @@ import {
launchDesktop,
} from './fixtures'
import { startMockServer } from './mock-server'
import { RealSessionBuilder } from './real-session-builder'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const SEED_SCRIPT = path.join(DESKTOP_ROOT, 'e2e', 'scripts', 'seed_session_db.py')
const SESSION_TITLE = 'E2E Warm Resume Jitter Test'
const SESSION_ID = 'e2e-warm-resume-session'
/** 32 messages (16 user/assistant pairs) — enough DOM churn for detection. */
const MESSAGE_COUNT = 32
/** Seeded PRNG so the generated content is deterministic across runs. */
@@ -82,54 +74,27 @@ function gibberish(rng: () => number): string {
const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED))
/**
* Generate a session fixture with MESSAGE_COUNT messages (user/assistant
* pairs) of seeded gibberish — just role + content, enough for SessionDB
* to import and the transcript to render. Written to a temp file for the
* seed script.
* Generate the user turns for a real session. The mock provider produces the
* assistant side of each pair through the normal AIAgent persistence path.
*/
function generateSessionFixture(fixturePath: string): void {
function generateSessionTurns(): string[] {
const rng = mulberry32(RNG_SEED)
const messages: Array<{ role: string; content: string }> = []
const turns: string[] = []
for (let i = 0; i < MESSAGE_COUNT / 2; i++) {
messages.push({ role: 'user', content: gibberish(rng) })
messages.push({ role: 'assistant', content: gibberish(rng) })
turns.push(gibberish(rng))
gibberish(rng)
}
const session = {
id: SESSION_ID,
source: 'cli',
model: 'mock-model',
system_prompt: '',
started_at: 1721692800.0,
message_count: MESSAGE_COUNT,
title: SESSION_TITLE,
cwd: '/tmp',
archived: 0,
rewind_count: 0,
compression_fallback_streak: 0,
messages,
}
fs.writeFileSync(fixturePath, JSON.stringify(session), 'utf8')
}
/** Resolve the python binary from the nix devshell (falls back to python3). */
function findPython(): string {
const result = spawnSync('which', ['python'], { encoding: 'utf8' })
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.trim()
}
return 'python3'
return turns
}
/**
* Set up a mock-backend sandbox with a pre-seeded session in state.db.
* Set up a mock-backend sandbox with a real persisted session in state.db.
*
* Unlike the shared `setupMockBackend()`, this variant seeds the DB
* BEFORE launching the app so the session appears in the sidebar on first
* load — exercising the real `resumeSession()` cold path without needing
* to send a message first.
* Unlike the shared `setupMockBackend()`, this variant creates the session
* through the real stdio gateway before launching desktop so the session is
* visible in the sidebar on first load.
*/
async function setupSeededMockBackend(): Promise<MockBackendFixture> {
// 1. Start mock server
@@ -140,28 +105,13 @@ async function setupSeededMockBackend(): Promise<MockBackendFixture> {
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
// 3. Pre-seed state.db: generate a fixture JSON to a temp file, then
// run the seed script to import it into state.db BEFORE launching.
const stateDbPath = path.join(sandbox.hermesHome, 'state.db')
const fixturePath = path.join(os.tmpdir(), `hermes-e2e-warm-resume-${Date.now()}.json`)
generateSessionFixture(fixturePath)
const python = findPython()
const seedResult = spawnSync(
python,
[SEED_SCRIPT, stateDbPath, fixturePath],
{
cwd: REPO_ROOT,
env: { ...process.env, PYTHONPATH: REPO_ROOT },
encoding: 'utf8',
timeout: 30_000,
},
)
fs.unlinkSync(fixturePath)
if (seedResult.status !== 0) {
throw new Error(
`Failed to seed state.db:\nstdout: ${seedResult.stdout}\nstderr: ${seedResult.stderr}`,
)
// 3. Produce all 16 user/assistant pairs through the real TUI gateway,
// AIAgent, mock provider, and SessionDB persistence path before desktop starts.
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
try {
await builder.createSession({ title: SESSION_TITLE, turns: generateSessionTurns() })
} finally {
await builder.close()
}
// 4. Build env + launch
+5 -1
View File
@@ -54,7 +54,11 @@
"test:desktop:platforms": "vitest run --project electron",
"test": "vitest run",
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
"check": "npm run typecheck && npm run test && npm run test:desktop:all",
"check:test:desktop:platforms": "npm run test:desktop:platforms",
"check:test:ui": "npm run test:ui",
"check:test:desktop:all": "npm run test:desktop:all",
"check:lint": "npm run typecheck && npm run lint",
"check": "npm run check:lint && npm run check:test:ui && npm run test:desktop:platforms && npm run test:desktop:all",
"test:e2e": "npm run build && playwright test e2e/",
"test:e2e:visual": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
"test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots"
@@ -65,15 +65,15 @@ describe('ComposerControls shortcut tooltips', () => {
await expectShortcutTooltip('Send', '↵')
})
it('shows Ctrl+Enter for Steer', async () => {
it('shows Enter for Steer', async () => {
renderControls({ busy: true, busyAction: 'steer' })
await expectShortcutTooltip('Steer the current run', 'Ctrl+↵')
await expectShortcutTooltip('Steer the current run', '↵')
})
it('shows Enter for Queue', async () => {
it('shows Ctrl+Enter for Queue', async () => {
renderControls({ busy: true, busyAction: 'queue' })
await expectShortcutTooltip('Queue message', '↵')
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
})
})
@@ -81,7 +81,7 @@ export function ComposerControls({
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
{busyAction === 'steer' ? (
<Tip label={<TipKeybindLabel actionId="composer.send" text={c.queueMessage} />}>
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
<Button
aria-label={c.queueMessage}
className={GHOST_ICON_BTN}
@@ -116,7 +116,13 @@ export function ComposerControls({
label={
busy ? (
<TipKeybindLabel
actionId={busyAction === 'steer' ? 'composer.steer' : 'composer.send'}
actionId={
busyAction === 'steer'
? 'composer.steer'
: busyAction === 'queue'
? 'composer.queue'
: 'composer.send'
}
text={busyLabel}
/>
) : (
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { resetBrowseState } from '@/store/composer-input-history'
import { notifyError } from '@/store/notifications'
@@ -60,6 +60,7 @@ export function useComposerVoice({
onTranscribeAudio
})
/** Auto-speak selector: the latest unspoken reply only — a backlog collapses to the newest. */
const pendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
@@ -81,6 +82,13 @@ export function useComposerVoice({
}
}
/**
* Voice-conversation selector: every unspoken assistant bubble of the turn,
* in order — narration interims AND the final answer, not just whichever
* bubble happens to be last. See `collectUnspokenTurnSpeech`.
*/
const pendingTurnResponse = () => collectUnspokenTurnSpeech($messages.get(), lastSpokenIdRef.current)
const consumePendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
@@ -108,7 +116,7 @@ export function useComposerVoice({
onFatalError: () => setVoiceConversationActive(false),
onSubmit: submitVoiceTurn,
onTranscribeAudio,
pendingResponse
pendingResponse: pendingTurnResponse
})
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
+13 -5
View File
@@ -587,14 +587,22 @@ export function ChatBar({
return
}
// Cmd/Ctrl+Enter is reserved for steering the live run — never a send.
// Steer when there's a steerable draft, otherwise swallow it so it can't
// surprise-send. (Plain Enter still queues while busy / sends when idle.)
// Cmd/Ctrl+Enter queues a follow-up while a turn runs. Plain Enter steers
// a text-only draft, so both live-turn actions stay reachable by keyboard.
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.shiftKey) {
event.preventDefault()
if (canSteer) {
steerDraft()
if (busy && !disabled) {
// As with plain Enter, source the just-typed content from the DOM so a
// fast keypress cannot queue a stale draft.
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
if (editorText !== draftRef.current) {
draftRef.current = editorText
setComposerText(editorText)
}
queueDraft()
}
return
+1 -1
View File
@@ -21,8 +21,8 @@ import { useEffect, useMemo, useRef } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { useModelControls } from '@/app/session/hooks/use-model-controls'
import { resolveStoredSession } from '@/app/session/hooks/use-session-actions/utils'
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
import { resolveStoredSession } from '@/app/session/hooks/use-session-actions/utils'
import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
@@ -0,0 +1,128 @@
// Dev-only: drive the credit-notice UX without a backend that can hit real
// usage bands. Each trigger emits ONE synthetic `notification.show` /
// `notification.clear` gateway event through the real fan-out
// (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in
// `use-message-stream/gateway-event.ts` — toast render, key-replacement
// escalation, TTL self-dismiss, native OS notification, and billing re-poll.
//
// Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none
// of this ships in a production build.
import type { GatewayEvent } from '@hermes/shared'
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
import { registry } from '@/contrib/registry'
import { CreditCard } from '@/lib/icons'
import { emitLocalGatewayEvent } from '@/store/gateway'
import { $activeSessionId } from '@/store/session'
interface NoticeStep {
key: string
level: string
kind: 'sticky' | 'ttl'
text: string
ttl_ms?: number
}
// Walks the same lifecycle the Nous credits tracker drives: usage escalates in
// place (50→75→90, one key), then grant-spent, then the depleted/restored pair.
// Wraps around. These are all separate SHOW steps; the stepper auto-clears the
// previous notice when the key changes, so the demo shows one toast at a time
// (real usage CAN stack these, but that's noise when you're eyeballing a single
// transition). The same-key usage steps still demonstrate in-place escalation.
const STEPS: readonly NoticeStep[] = [
{ key: 'credits.usage', kind: 'sticky', level: 'info', text: "• You've used $110.00 of your $220.00 cap" },
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $165.00 of your $220.00 cap" },
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $198.00 of your $220.00 cap" },
{ key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $12.00 top-up left' },
{ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
{ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 }
]
let cursor = 0
let lastShownKey: null | string = null
function clearNotice(key: string): void {
emitLocalGatewayEvent({
payload: { key },
session_id: $activeSessionId.get() ?? '',
type: 'notification.clear'
} as GatewayEvent)
}
function showNotice(step: NoticeStep): void {
emitLocalGatewayEvent({
payload: {
id: `${step.key}:${Date.now()}`,
key: step.key,
kind: step.kind,
level: step.level,
text: step.text,
ttl_ms: step.ttl_ms ?? null
},
session_id: $activeSessionId.get() ?? '',
type: 'notification.show'
} as GatewayEvent)
}
/** Fire the next notice in the scripted sequence, wrapping at the end. */
export function stepCreditsNoticeDemo(): void {
const step = STEPS[cursor % STEPS.length]
// One toast at a time: retire the previous notice when we move to a new key.
// (Same-key steps skip this, so the usage line still escalates in place.)
if (lastShownKey && lastShownKey !== step.key) {
clearNotice(lastShownKey)
}
showNotice(step)
lastShownKey = step.key
cursor += 1
}
// The hotkey: Ctrl+Shift+C on every platform (Ctrl, not Cmd, so it can't clash
// with a system Cmd chord and works the same on Windows/Linux). Matched on
// `code` so it's keyboard-layout independent, and captured before the composer
// so a focused input can't swallow it.
function isTriggerChord(e: KeyboardEvent): boolean {
return e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey && e.code === 'KeyC'
}
/**
* Install the dev trigger: a capture-phase hotkey (Ctrl+Shift+C), a ⌘K palette
* entry, and a `window.__creditsDemo()` console hook. Returns a disposer.
*/
export function installCreditsNoticeDemo(): () => void {
const onKeyDown = (e: KeyboardEvent) => {
if (!isTriggerChord(e)) {
return
}
e.preventDefault()
e.stopPropagation()
stepCreditsNoticeDemo()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
;(window as unknown as { __creditsDemo?: () => void }).__creditsDemo = stepCreditsNoticeDemo
const disposePalette = registry.register({
id: 'dev.creditsNotice',
area: PALETTE_AREA,
data: {
id: 'dev.creditsNotice',
icon: CreditCard,
keywords: ['credits', 'notice', 'toast', 'billing', 'dev', 'demo'],
label: 'Dev: cycle credit notices',
run: stepCreditsNoticeDemo
} satisfies PaletteContribution
})
console.info('[dev] credit-notice demo ready — press Ctrl+Shift+C, or run window.__creditsDemo()')
return () => {
window.removeEventListener('keydown', onKeyDown, { capture: true })
disposePalette()
delete (window as unknown as { __creditsDemo?: () => void }).__creditsDemo
}
}
@@ -3,7 +3,15 @@ import { useEffect, useRef } from 'react'
import { closeActiveTab } from '@/app/chat/close-tab'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { respondToApprovalAction } from '@/store/native-notifications'
import { getRememberedRoute, getRememberedSessionId, setRememberedRoute, setRememberedSessionId } from '@/store/session'
import { $activeGatewayProfile } from '@/store/profile'
import {
$sessions,
getRememberedRoute,
getRememberedSessionId,
rememberedSessionProfile,
setRememberedRoute,
setRememberedSessionId
} from '@/store/session'
import { onSessionsChanged } from '@/store/session-sync'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
import { isSecondaryWindow } from '@/store/windows'
@@ -63,7 +71,10 @@ export function useDesktopIntegrations({
// you don't want to boot into a modal.
useEffect(() => {
if (routedSessionId) {
setRememberedSessionId(routedSessionId)
setRememberedSessionId(
routedSessionId,
rememberedSessionProfile($sessions.get(), routedSessionId, $activeGatewayProfile.get())
)
}
if (!isOverlayView(appViewForPath(locationPathname))) {
@@ -92,7 +103,7 @@ export function useDesktopIntegrations({
return
}
const last = getRememberedSessionId()
const last = getRememberedSessionId($activeGatewayProfile.get())
if (last) {
navigate(sessionRoute(last), { replace: true })
@@ -100,8 +111,14 @@ export function useDesktopIntegrations({
}, [locationPathname, navigate])
useEffect(() => {
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
setRememberedSessionId(null)
if (!resumeExhaustedSessionId) {
return
}
const owner = rememberedSessionProfile($sessions.get(), resumeExhaustedSessionId, $activeGatewayProfile.get())
if (getRememberedSessionId(owner) === resumeExhaustedSessionId) {
setRememberedSessionId(null, owner)
}
}, [resumeExhaustedSessionId])
@@ -155,8 +172,8 @@ export function useDesktopIntegrations({
// OS-standard window close, esp. secondary windows). The Win/Linux keyboard
// path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() =>
void closeActiveTab(id => navigate(sessionRoute(id)))
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(
() => void closeActiveTab(id => navigate(sessionRoute(id)))
)
return () => unsubscribe?.()
@@ -0,0 +1,100 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as HermesModule from '@/hermes'
import { setSessions } from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'
import { useSessionTileDelegate } from './use-session-tile-delegate'
vi.mock('@/hermes', async importActual => ({
...(await importActual<typeof HermesModule>()),
getSessionMessages: vi.fn(async () => ({ messages: [], session_id: '' }))
}))
const { getSessionMessages } = await import('@/hermes')
const row = (over: Partial<SessionInfo>): SessionInfo =>
({
ended_at: null,
id: 'live',
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 1,
model: null,
output_tokens: 0,
preview: null,
profile: 'default',
source: null,
started_at: 0,
title: null,
...over
}) as SessionInfo
function renderTile(requestGateway: ReturnType<typeof vi.fn>) {
renderHook(() =>
useSessionTileDelegate({
archiveSession: vi.fn(async () => undefined),
branchStoredSession: vi.fn(async () => undefined),
executeSlashCommand: vi.fn(async () => undefined) as never,
removeSession: vi.fn(async () => undefined),
requestGateway: requestGateway as never,
runtimeIdByStoredSessionIdRef: { current: new Map() },
sessionStateByRuntimeIdRef: { current: new Map() },
updateSessionState: vi.fn()
})
)
}
describe('useSessionTileDelegate resumeTile', () => {
beforeEach(() => {
setSessions([])
vi.mocked(getSessionMessages).mockClear()
})
afterEach(() => {
setSessions([])
})
it('carries the owning profile into a cold tile resume so it cannot fork profiles', async () => {
// A tile opens a session owned by another profile. Resuming without the
// profile lets the gateway fall back to the launch-profile DB and clone the
// conversation into the wrong profile (#67603). The owning profile must ride
// both the transcript prefetch and the resume RPC.
setSessions([row({ id: 'stored-x', profile: 'ai-engineer' })])
const requestGateway = vi.fn(async (method: string) =>
method === 'session.resume' ? ({ session_id: 'runtime-1' } as never) : ({} as never)
)
renderTile(requestGateway)
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-x')
expect(runtimeId).toBe('runtime-1')
expect(getSessionMessages).toHaveBeenCalledWith('stored-x', 'ai-engineer')
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
session_id: 'stored-x',
cols: 96,
profile: 'ai-engineer'
})
})
it('resolves and carries a default-profile session explicitly', async () => {
setSessions([row({ id: 'stored-y', profile: 'default' })])
const requestGateway = vi.fn(async (method: string) =>
method === 'session.resume' ? ({ session_id: 'runtime-2' } as never) : ({} as never)
)
renderTile(requestGateway)
await sessionTileDelegate()!.resumeTile('stored-y')
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
session_id: 'stored-y',
cols: 96,
profile: 'default'
})
})
})
@@ -6,6 +6,7 @@ import { publishSessionState, setSessionTileDelegate } from '@/store/session-sta
import type { SessionResumeResponse } from '@/types/hermes'
import type { usePromptActions } from '../../session/hooks/use-prompt-actions'
import { resolveSessionProfile } from '../../session/hooks/use-session-actions/utils'
import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache'
import type { GatewayRequester } from '../types'
@@ -66,9 +67,20 @@ export function useSessionTileDelegate({
return existing
}
// Resolve the owning profile before binding a runtime. A tile can open a
// session from any profile, not just the active one; resuming (or
// reading messages) without a profile lets the gateway fall back to the
// launch-profile DB and fork the conversation into the wrong profile —
// the same cross-profile bleed the recovery resumes had (#67603).
const profile = await resolveSessionProfile(storedSessionId)
const [prefetch, resumed] = await Promise.all([
getSessionMessages(storedSessionId).catch(() => null),
requestGateway<SessionResumeResponse>('session.resume', { session_id: storedSessionId, cols: 96 })
getSessionMessages(storedSessionId, profile).catch(() => null),
requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
...(profile ? { profile } : {})
})
])
const runtimeId = resumed?.session_id
+18 -1
View File
@@ -14,12 +14,12 @@ import { type CSSProperties, lazy, type ReactNode, Suspense, useCallback, useEff
import { useLocation, useNavigate } from 'react-router-dom'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { $newSessionTabAction } from '@/components/pane-shell/tree/store'
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { NotificationStack } from '@/components/notifications'
import { DesktopOnboardingOverlay } from '@/components/onboarding'
import { $newSessionTabAction } from '@/components/pane-shell/tree/store'
import { FloatingPet } from '@/components/pet/floating-pet'
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
import { emitGatewayEvent } from '@/contrib/events'
@@ -272,6 +272,23 @@ export function ContribWiring({ children }: { children: ReactNode }) {
return () => window.removeEventListener('hermes:open-keybinds', onOpenKeybinds)
}, [navigate])
// Dev-only: install the credit-notice demo trigger (Ctrl+Shift+C / ⌘K palette
// / window.__creditsDemo). Dynamic import inside the DEV guard so the module
// is dropped from production builds.
useEffect(() => {
if (!import.meta.env.DEV) {
return
}
let dispose: (() => void) | undefined
void import('./dev/credits-notice-demo').then(m => {
dispose = m.installCreditsNoticeDemo()
})
return () => dispose?.()
}, [])
// Post-turn rehydrate from stored history (same behavior as DesktopController,
// including finished-todos restoration).
const hydrateFromStoredSession = useCallback(
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
type GatewaySurvivor,
stashGatewaySurvivor,
survivorIsStale,
takeGatewaySurvivor
} from './gateway-hmr-survivor'
// A minimal stand-in for HermesGateway: the survivor cache only reads
// `connectionState`. Cast through unknown so we don't drag the whole class in.
function fakeGateway(state: 'idle' | 'connecting' | 'open' | 'closed' | 'error') {
return { connectionState: state } as unknown as GatewaySurvivor['gateway']
}
function makeSurvivor(state: Parameters<typeof fakeGateway>[0]): GatewaySurvivor {
return { gateway: fakeGateway(state), profile: 'default', connection: null }
}
afterEach(() => {
// Drain any survivor a test parked but didn't take, so cases don't leak across
// the shared globalThis slot.
takeGatewaySurvivor()
})
describe('gateway HMR survivor cache', () => {
it('returns null when nothing is parked', () => {
expect(takeGatewaySurvivor()).toBeNull()
})
it('round-trips a parked survivor', () => {
const survivor = makeSurvivor('open')
stashGatewaySurvivor(survivor)
expect(takeGatewaySurvivor()).toBe(survivor)
})
it('is single-shot — a second take returns null', () => {
stashGatewaySurvivor(makeSurvivor('open'))
expect(takeGatewaySurvivor()).not.toBeNull()
expect(takeGatewaySurvivor()).toBeNull()
})
it('persists across re-imports via the globalThis slot', async () => {
const survivor = makeSurvivor('open')
stashGatewaySurvivor(survivor)
// A fresh import of the module (simulating an HMR module swap) must resolve
// the same underlying store, not a reset one.
const reimported = await import('./gateway-hmr-survivor')
expect(reimported.takeGatewaySurvivor()).toBe(survivor)
})
it('treats open / connecting sockets as adoptable', () => {
expect(survivorIsStale(makeSurvivor('open'))).toBe(false)
expect(survivorIsStale(makeSurvivor('connecting'))).toBe(false)
})
it('treats closed / error / idle sockets as stale', () => {
expect(survivorIsStale(makeSurvivor('closed'))).toBe(true)
expect(survivorIsStale(makeSurvivor('error'))).toBe(true)
expect(survivorIsStale(makeSurvivor('idle'))).toBe(true)
})
it('returns a stale survivor from take() so the caller can close it', () => {
const dead = makeSurvivor('closed')
stashGatewaySurvivor(dead)
const taken = takeGatewaySurvivor()
expect(taken).toBe(dead)
expect(taken && survivorIsStale(taken)).toBe(true)
})
})
@@ -0,0 +1,56 @@
// Keep the live primary gateway socket alive across Vite HMR so editing UI never
// drops the agent session. Stash on dispose, re-adopt on remount. globalThis +
// self-accept so this module's own reload doesn't reset the cache. Prod strips
// import.meta.hot → byte-for-byte unchanged live unmount.
import type { HermesConnection } from '@/global'
import type { HermesGateway } from '@/hermes'
export interface GatewaySurvivor {
gateway: HermesGateway
profile: string
connection: HermesConnection | null
}
// One slot on globalThis, keyed by a process-stable Symbol so repeated imports
// (across hot reloads) resolve the exact same store.
const SURVIVOR_KEY = Symbol.for('hermes.desktop.gatewaySurvivor')
interface SurvivorGlobal {
[SURVIVOR_KEY]?: GatewaySurvivor | null
}
function slot(): SurvivorGlobal {
return globalThis as unknown as SurvivorGlobal
}
/** Park the live gateway so the next module instance can re-adopt it. */
export function stashGatewaySurvivor(survivor: GatewaySurvivor): void {
slot()[SURVIVOR_KEY] = survivor
}
/**
* Take the parked gateway, if any. Single-shot: the slot is cleared on read.
* The caller decides whether to adopt or discard it via `survivorIsStale` — a
* socket that died while parked (e.g. backend restart between edits) is still
* returned so the caller can close it and boot fresh.
*/
export function takeGatewaySurvivor(): GatewaySurvivor | null {
const store = slot()
const survivor = store[SURVIVOR_KEY] ?? null
store[SURVIVOR_KEY] = null
return survivor
}
/** A parked survivor whose socket is no longer open — caller should discard it. */
export function survivorIsStale(survivor: GatewaySurvivor): boolean {
const state = survivor.gateway.connectionState
return state !== 'open' && state !== 'connecting'
}
// Self-accept so editing THIS module doesn't blow away the cache it manages.
if (import.meta.hot) {
import.meta.hot.accept()
}
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $desktopBoot } from '@/store/boot'
import { $gatewayState } from '@/store/session'
import { takeGatewaySurvivor } from './gateway-hmr-survivor'
import { useGatewayBoot } from './use-gateway-boot'
// End-to-end-ish repro of the "remote VPS → stuck on CONNECTING, no Settings"
@@ -131,6 +132,17 @@ function Harness({
const originalWebSocket = globalThis.WebSocket
beforeEach(() => {
// Drop any parked gateway left by a prior file/case (globalThis slot).
const leftover = takeGatewaySurvivor()
if (leftover) {
try {
leftover.gateway.close()
} catch {
// ignore
}
}
vi.useFakeTimers()
FakeWebSocket.mode = 'open'
FakeWebSocket.instances = []
@@ -152,6 +164,19 @@ beforeEach(() => {
afterEach(() => {
cleanup()
// Vitest keeps import.meta.hot truthy, so the boot effect's cleanup parks an
// open gateway instead of tearing it down (the real HMR path). Drain + close
// that survivor so the next test boots a fresh socket instead of adoptBoot().
const survivor = takeGatewaySurvivor()
if (survivor) {
try {
survivor.gateway.close()
} catch {
// ignore
}
}
vi.useRealTimers()
;(globalThis as { WebSocket: unknown }).WebSocket = originalWebSocket
delete (window as { hermesDesktop?: unknown }).hermesDesktop
@@ -40,6 +40,8 @@ import {
import { $attentionSessionIds, $workingSessionIds, resetTileRuntimeBindings } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
import { stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor'
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
// raise a recoverable boot error. Otherwise a dropped remote gateway loops the
// backoff forever behind the fullscreen CONNECTING overlay with no way to reach
@@ -337,9 +339,28 @@ export function useGatewayBoot({
progress: 6
})
const gateway = new HermesGateway()
// HMR adoption: in a dev hot update, the previous effect instance parked its
// still-open socket instead of closing it (see the cleanup below). Re-adopt
// it so an edit doesn't drop the live agent session. A stale (closed) parked
// socket is discarded and we boot fresh. No-op in production: import.meta.hot
// is undefined there, so this folds to `null` and the whole survivor module
// dead-code-eliminates out of the bundle.
const survivor = import.meta.hot ? takeGatewaySurvivor() : null
const adoptedFromHmr = Boolean(survivor && !survivorIsStale(survivor))
if (survivor && !adoptedFromHmr) {
// Parked socket died between edits (e.g. backend restart) — release it.
try {
survivor.gateway.close()
} catch {
// ignore
}
}
const gateway = adoptedFromHmr ? survivor!.gateway : new HermesGateway()
callbacksRef.current.onGatewayReady(gateway)
setPrimaryGateway(gateway, normalizeProfileKey($activeGatewayProfile.get()))
setPrimaryGateway(gateway, survivor?.profile ?? normalizeProfileKey($activeGatewayProfile.get()))
// Secondary (background-profile) sockets funnel into the same handler.
configureGatewayRegistry({ onEvent: event => callbacksRef.current.handleGatewayEvent(event) })
@@ -513,7 +534,42 @@ export function useGatewayBoot({
}
}
void boot()
// Adopt the parked socket without re-running the full boot handshake: the
// socket is already open, the backend session is untouched, and we already
// know the profile. We only re-publish the connection, re-sync config +
// sessions (cheap, and the backend may have moved on between edits), and
// dismiss any boot overlay. This is what keeps a live, mid-stream session
// intact across an HMR update.
async function adoptBoot() {
bootCompleted = true
completeDesktopBoot()
if (survivor?.connection) {
publish(survivor.connection)
}
const profile = survivor?.profile ?? $activeGatewayProfile.get()
$activeGatewayProfile.set(profile)
void ensureGatewayForProfile(profile)
// Mirror the current (already-open) socket state into the composer so the
// input doesn't sit disabled after the swap.
reportPrimaryGatewayState(gateway.connectionState)
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
if (cancelled) {
return
}
await callbacksRef.current.refreshSessions().catch(() => undefined)
}
if (adoptedFromHmr) {
void adoptBoot()
} else {
void boot()
}
return () => {
cancelled = true
@@ -532,6 +588,24 @@ export function useGatewayBoot({
offExit()
offWindowState?.()
offBootProgress()
// HMR teardown vs. real unmount. On a hot update we must NOT close the
// socket — that's the whole bug. Detach this instance's listeners (their
// closures capture the disposed module), park the still-open gateway, and
// let the freshly loaded effect re-adopt it. Secondaries are owned by the
// gateway store (HMR-stable module state), so they survive untouched.
// Production: import.meta.hot is undefined, so this branch never runs and
// the original destructive teardown below is byte-for-byte preserved.
if (import.meta.hot && gateway.connectionState === 'open') {
stashGatewaySurvivor({
gateway,
profile: survivor?.profile ?? $activeGatewayProfile.get(),
connection: $connection.get()
})
return
}
closeSecondaryGateways()
gateway.close()
publish(null)
@@ -66,6 +66,13 @@ export function OverlayView({
'p-[calc(var(--titlebar-height)+0.625rem)]',
'sm:p-[calc(var(--titlebar-height)+0.875rem)]'
)}
// Every OverlayView-based overlay (settings, command-center, agents, cron,
// profiles, star map, …) covers the chat while the composer stays mounted
// beneath it. This marker tells `composerFocusBlockedBySurface` to stand
// the global type-to-focus / soft `/` / Enter down, so keystrokes don't
// leak into the hidden composer (and the overlay's own bare-key shortcuts,
// e.g. star map's Space, keep working).
data-overlay-surface=""
onClick={event => {
if (event.target === event.currentTarget) {
closeOverlay()
@@ -0,0 +1,116 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { type MutableRefObject, useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { clearClarifyRequest } from '@/store/clarify'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
// A `clarify.request` must leave an answerable inline row even when the
// `tool.start` that normally mounts it was missed (stream reconnect /
// hydration race). Without it the sidebar says "needs input" but the
// transcript has nowhere to render the choices, so the agent blocks forever.
const SID = 'session-1'
let handleEvent: ((event: RpcEvent) => void) | null = null
let stateRef: MutableRefObject<Map<string, ClientSessionState>> | null = null
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
stateRef = sessionStateByRuntimeIdRef
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
const clarifyRequest = (payload: Record<string, unknown>) =>
act(() => handleEvent!({ payload, session_id: SID, type: 'clarify.request' }))
const toolStart = (payload: Record<string, unknown>) =>
act(() => handleEvent!({ payload, session_id: SID, type: 'tool.start' }))
function clarifyParts() {
const messages = stateRef?.current.get(SID)?.messages ?? []
return messages.flatMap(m => m.parts).filter(p => p.type === 'tool-call' && p.toolName === 'clarify')
}
describe('clarify.request stream hydration', () => {
beforeEach(() => {
handleEvent = null
stateRef = null
clearClarifyRequest()
})
afterEach(() => {
cleanup()
clearClarifyRequest()
vi.restoreAllMocks()
})
it('mounts an answerable clarify row when the tool.start row was missed', async () => {
await mountStream()
clarifyRequest({ choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-1' })
const parts = clarifyParts()
expect(parts).toHaveLength(1)
expect(parts[0].type === 'tool-call' && parts[0].toolCallId).toBe('req-1')
expect(parts[0].type === 'tool-call' && parts[0].args).toMatchObject({
choices: ['yes', 'no'],
question: 'Ship it?'
})
})
it('merges with the real tool.start row even though its id differs from the request id', async () => {
await mountStream()
// Reality: tool.start carries the model's tool_call_id, clarify.request a
// separately-generated request_id. They must still collapse to ONE card
// (correlated by question), not two.
toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-abc' })
clarifyRequest({ choices: ['a'], question: 'Pick', request_id: 'req-2' })
expect(clarifyParts()).toHaveLength(1)
})
it('does not duplicate when clarify.request arrives before the tool.start row', async () => {
await mountStream()
clarifyRequest({ choices: ['a'], question: 'Pick', request_id: 'req-3' })
toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-xyz' })
expect(clarifyParts()).toHaveLength(1)
})
})
@@ -15,15 +15,16 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { requestDesktopOnboarding, requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
import { revealDesktopPane } from '@/store/pane-focus'
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
@@ -407,9 +408,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
if (typeof payload?.credential_warning === 'string' && payload.credential_warning) {
requestDesktopOnboarding(payload.credential_warning)
}
requestDesktopOnboardingForCredentialWarning(payload?.credential_warning)
if (apply) {
reportInstallMethodWarning(payload?.install_warning)
@@ -677,21 +676,36 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
// over; the inline ClarifyTool reads the active session's entry.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
const question = typeof payload?.question === 'string' ? payload.question : ''
const rawChoices = payload?.choices
const choices = normalizeChoices(rawChoices)
if (requestId && question) {
if (rawChoices != null && choices.length === 0) {
warnDroppedChoices('gateway', question, rawChoices)
}
setClarifyRequest({
requestId,
question,
choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null,
choices: choices.length > 0 ? choices : null,
sessionId: sessionId ?? null
})
// The transcript only renders the active session, so a background
// clarify is otherwise invisible (the row just keeps spinning like
// it's working). Flag the session so the sidebar shows a persistent
// "needs input" indicator on its row — works for the active session
// too, and survives alt-tab / window blur (unlike a toast).
if (sessionId) {
// `clarify.request` is the blocking event the Python side waits on,
// while the inline UI normally mounts from the earlier `tool.start`
// row. If that row was missed (stream reconnect / hydration race) the
// sidebar still says "needs input" but there is nowhere to render the
// choices. Upsert a stable pending clarify tool row from the request
// itself so the prompt stays answerable; a real tool.start/complete
// with the same request id merges rather than duplicates.
upsertToolCall(sessionId, { args: { choices, question }, name: 'clarify', tool_id: requestId }, 'running')
// The transcript only renders the active session, so a background
// clarify is otherwise invisible (the row just keeps spinning like
// it's working). Flag the session so the sidebar shows a persistent
// "needs input" indicator on its row — works for the active session
// too, and survives alt-tab / window blur (unlike a toast).
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
@@ -852,6 +866,37 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
]
}))
}
} else if (event.type === 'notification.show') {
// Driver-agnostic agent notice (credits usage/grant/depleted/restored
// from `agent/credits_tracker.py`). The Ink TUI renders these in its
// status bar; the desktop renders them as toasts. The notice key doubles
// as the toast id, so the escalating 50→75→90 credits line replaces in
// place instead of stacking. Account-wide signal — shown regardless of
// which session is focused.
const notice = event.payload as AgentNoticePayload | undefined
showAgentNotice(notice)
// The urgent pair (access paused / restored) also breaks through as a
// native OS notification when Hermes is backgrounded; dispatch is gated
// by the user's notification prefs + backgrounded check.
const native = nativeNoticeInput(notice, translateNow('notifications.native.creditsTitle'))
if (native) {
dispatchNativeNotification(native)
}
// A credits crossing moves the account balance. Settings → Billing polls
// `billing.state` every 30s; nudge it so the page reflects the crossing
// immediately instead of up to 30s late.
if (notice?.key?.startsWith('credits.')) {
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
}
} else if (event.type === 'notification.clear') {
// Key-matched dismissal (e.g. credits restored clears the depleted
// notice). notify() keys the toast by the notice key, so this maps
// straight to dismissNotification(key).
clearAgentNotice((event.payload as AgentNoticePayload | undefined)?.key)
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
@@ -501,27 +501,36 @@ export function useMessageStream({
const existing = prev[index]
const existingText = chatMessageText(existing).trim()
// The last assistant row is a sealed interim (a tool-call turn or a
// verify-on-stop candidate — `message.interim` fires for BOTH, see
// tui_gateway `_load_interim_assistant_messages`). When the final
// completion is the SAME turn's reply, settle it onto that interim
// instead of appending a second bubble. Continuity, not exact
// equality: streaming can drop characters and the final may add a
// trailing delta, so treat prefix-either-way as the same message.
// (mergeFinalAssistantText, via completeMessage, does the real
// text merge — replaces the interim's text with the full final.)
const finalContinuesInterim = Boolean(
existing.interim &&
finalText &&
existingText &&
(finalText === existingText || finalText.startsWith(existingText) || existingText.startsWith(finalText))
)
if (existing.pending || (!interimBoundaryPending && finalText && existingText === finalText)) {
nextMessages = prev.map((message, messageIndex) =>
messageIndex === index ? completeMessage(message) : message
)
} else if (
interimBoundaryPending &&
responsePreviewed &&
finalText &&
existingText &&
finalText.startsWith(existingText)
) {
// The verification candidate was published provisionally as an
// interim message and then reused as the terminal response
// (continuation-budget fallback). Settle the interim in place
// instead of creating a duplicate — the DB has one row, so the
// live UI must agree. (#65919 review: duplicate-message blocker)
//
// Prefix match (not exact equality): the final response may be
// the streamed text plus a trailing delta. mergeFinalAssistantText
// (called via completeMessage) handles the actual merge — it
// strips the old text parts and appends the full final text.
} else if (interimBoundaryPending && (responsePreviewed || finalContinuesInterim)) {
// Settle the interim in place instead of creating a duplicate —
// the DB has one row, so the live UI must agree. Previously this
// was gated on `responsePreviewed` alone, so a NON-previewed
// tool-call turn whose final matched its sealed interim appended a
// second bubble (the "renders twice: partial first copy + clean
// final" bug, #63679). `finalContinuesInterim` closes that gap
// for ordinary tool-call turns while `responsePreviewed` still
// covers the verify-on-stop continuation-budget case even when the
// final text was rewritten and no longer shares a prefix.
nextMessages = prev.map((message, messageIndex) =>
messageIndex === index ? completeMessage(message) : message
)
@@ -186,18 +186,51 @@ describe('useMessageStream interim text sealing', () => {
expect(getState().interimBoundaryPending).toBe(true)
})
it('keeps an identical final completion distinct from an interim reply without response_previewed', async () => {
it('settles an identical final onto a non-previewed interim (tool-call turn) instead of duplicating (#63679)', async () => {
await mountStream()
await start()
// A plain tool-call turn: the streamed text is sealed as an interim at the
// tool boundary (no response_previewed — that flag is only for verify-on-
// stop). The final completion is the SAME turn's reply. It must settle onto
// the interim, not append a second bubble — the DB has one row. This is the
// "renders twice" bug: partial streamed copy + clean final copy side by side.
await interim('same reply')
await complete('same reply')
// Without response_previewed, the interim and terminal replies are
// distinct messages — the gateway didn't signal that the final reuses
// the provisional candidate.
const texts = assistantMessages()
expect(texts.filter(t => t === 'same reply')).toHaveLength(2)
expect(texts.filter(t => t === 'same reply')).toHaveLength(1)
})
it('settles a prefix-extended final onto a non-previewed interim (streamed + trailing delta)', async () => {
await mountStream()
await start()
// The stream dropped/settled early at the tool boundary; the final adds a
// trailing delta. Same turn — one bubble with the full final text.
await delta('partial')
await interim('partial')
await complete('partial answer continued')
const texts = assistantMessages()
expect(texts.filter(t => t.includes('partial'))).toHaveLength(1)
expect(texts[0]).toBe('partial answer continued')
})
it('appends a genuinely different final as its own bubble (two real assistant segments)', async () => {
await mountStream()
await start()
// The interim is one segment (pre-tool commentary); the final is different
// content, not a continuation of it. These are two real messages and must
// both render — the fix must not over-collapse distinct replies.
await interim('let me check the files')
await complete('the answer is 42')
const texts = assistantMessages()
expect(texts).toContain('let me check the files')
expect(texts).toContain('the answer is 42')
expect(texts).toHaveLength(2)
})
it('settles an identical final completion onto the interim when response_previewed', async () => {
@@ -3,6 +3,7 @@ import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getSession } from '@/hermes'
import { textPart } from '@/lib/chat-messages'
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
import { $notifications, clearNotifications } from '@/store/notifications'
@@ -25,6 +26,7 @@ import { uploadComposerAttachment, usePromptActions } from '.'
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
getSession: vi.fn(),
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000,
setApiRequestProfile: vi.fn(),
transcribeAudio: vi.fn()
@@ -1834,6 +1836,99 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})
// #67603 (second symptom): a recovery resume must re-register on the session's
// OWNING profile. Resuming on whichever profile is live forks the conversation
// into the wrong profile's DB — the session then appears under both profiles.
it('carries the owning profile from the cache into the recovery resume', async () => {
setSessions(() => [sessionInfo({ id: STORED_SESSION_ID, profile: 'work' })])
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'prompt.submit') {
submitAttempts += 1
if (submitAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
expect(await handle!.submitText('message after wake')).toBe(true)
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' })
setSessions(() => [])
})
// The session lives on another profile and is outside the paginated sidebar
// cache: resolve it by id across profiles rather than resuming profile-blind.
it('resolves the owning profile across profiles when the session is not cached', async () => {
// module-factory vi.fn is not reset by restoreAllMocks — reset explicitly in
// the finally below so this resolved value never leaks into sibling tests.
setSessions(() => [])
vi.mocked(getSession).mockResolvedValue(sessionInfo({ id: STORED_SESSION_ID, profile: 'work' }))
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'prompt.submit') {
submitAttempts += 1
if (submitAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
expect(await handle!.submitText('message after wake')).toBe(true)
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' })
vi.mocked(getSession).mockReset()
setSessions(() => [])
})
it('background queue resume uses the queued stored id and leaves foreground runtime selected', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
@@ -43,6 +43,7 @@ import type {
ImageAttachResponse,
SessionRedirectResponse
} from '../../../types'
import { resolveSessionProfile } from '../use-session-actions/utils'
import {
applyBranchVisibility,
@@ -601,9 +602,12 @@ export function usePromptActions({
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
try {
const resumeProfile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current,
source: 'desktop'
source: 'desktop',
...(resumeProfile ? { profile: resumeProfile } : {})
})
const recoveredId = resumed?.session_id
@@ -700,9 +704,12 @@ export function usePromptActions({
// correction right after a reconnect isn't lost to the race.
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
try {
const resumeProfile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current,
source: 'desktop'
source: 'desktop',
...(resumeProfile ? { profile: resumeProfile } : {})
})
const recoveredId = resumed?.session_id
@@ -24,6 +24,7 @@ import { setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import type { ClientSessionState } from '../../../types'
import { sessionContextDrift } from '../session-context-drift'
import { resolveSessionProfile } from '../use-session-actions/utils'
import {
_submitInFlight,
@@ -384,9 +385,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// background queue drain only has the durable id). Continue that target
// conversation; only a genuine new-chat draft may create a new session.
try {
// Re-register on the session's OWNING profile — resuming on whichever
// profile is live would fork the conversation into the wrong DB (#67603).
const resumeProfile = await resolveSessionProfile(targetStoredSessionId)
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: targetStoredSessionId,
source: 'desktop'
source: 'desktop',
...(resumeProfile ? { profile: resumeProfile } : {})
})
const resumeDrift = sessionDriftReason()
@@ -528,9 +534,12 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// backend loop (#55578 symptom d) rejects the submit even though
// the stored session is fine — resume + retry instead of erroring
// out and losing the session binding.
const resumeProfile = await resolveSessionProfile(recoverStoredSessionId)
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: recoverStoredSessionId,
source: 'desktop'
source: 'desktop',
...(resumeProfile ? { profile: resumeProfile } : {})
})
const resumeRetryDrift = sessionDriftReason()
@@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages, type SessionInfo } from '@/hermes'
import { getSession, getSessionMessages, type SessionInfo } from '@/hermes'
import { createClientSessionState } from '@/lib/chat-runtime'
import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile'
@@ -43,6 +43,7 @@ import { useSessionActions } from './use-session-actions'
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
deleteSession: vi.fn(),
getSession: vi.fn(),
getSessionMessages: vi.fn(),
listAllProfileSessions: vi.fn(),
setApiRequestProfile: vi.fn(),
@@ -1088,6 +1089,40 @@ describe('branchStoredSession desktop source tagging', () => {
source: 'desktop'
})
})
// #67603: right-clicking a session outside the paginated sidebar window is a
// cache miss. Resolve its owning profile (cache → active → cross-profile) and
// swap to it before reading the transcript / creating the branch, so the fork
// is not created on whichever profile happens to be live.
it('resolves and swaps to the parent profile when the branched session is not cached', async () => {
setSessions([])
vi.mocked(getSession).mockResolvedValue(storedSession({ id: 'stored-parent', message_count: 1, profile: 'work' }))
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
} as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.create') {
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}
return {} as never
})
let branchStoredSession: ((storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) | null =
null
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
await waitFor(() => expect(branchStoredSession).not.toBeNull())
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
expect(ensureGatewayProfile).toHaveBeenCalledWith('work')
expect(getSessionMessages).toHaveBeenCalledWith('stored-parent', 'work')
vi.mocked(getSession).mockReset()
})
})
// ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ─────────
@@ -1177,7 +1177,13 @@ export function useSessionActions({
async (storedSessionId: string, sessionProfile?: string | null): Promise<boolean> => {
clearNotifications()
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
// Right-clicking a session outside the paginated sidebar window is a cache
// miss: resolve it (cache → active backend → cross-profile) so the branch
// is created on the parent's OWNING profile, not whichever is live (#67603).
const stored =
$sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ??
(sessionProfile ? undefined : await resolveStoredSession(storedSessionId))
const profile = sessionProfile ?? stored?.profile
try {
@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
import { $desktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile } from '@/store/profile'
import type { SessionInfo } from '@/types/hermes'
@@ -38,6 +39,32 @@ describe('applyRuntimeInfo approval mode', () => {
})
})
const initialOnboardingState = $desktopOnboarding.get()
describe('applyRuntimeInfo credential warnings', () => {
beforeEach(() => {
$desktopOnboarding.set({ ...initialOnboardingState, reason: null, requested: false })
})
afterEach(() => {
$desktopOnboarding.set(initialOnboardingState)
})
it('requests setup for the exact empty-key warning returned by the server', () => {
const warning = "No API key configured for provider 'openrouter'. First message will fail."
applyRuntimeInfo({ credential_warning: warning })
expect($desktopOnboarding.get()).toMatchObject({ reason: warning, requested: true })
})
it('ignores an auxiliary-provider warning', () => {
applyRuntimeInfo({ credential_warning: 'OPENROUTER_API_KEY not set' })
expect($desktopOnboarding.get()).toMatchObject({ reason: null, requested: false })
})
})
describe('isSessionGoneError', () => {
it('is true for 404 / session-not-found, false otherwise', () => {
expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true)
@@ -361,6 +388,58 @@ describe('preserveLocalPendingTurnMessages', () => {
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache)).toBe(compressedAuthority)
})
// #67603: the gateway persists model-switch / personality notices as role=user
// ([System: …], tui_gateway/server.py). A single trailing marker is already
// handled by the latestAuthoritativeUser guard above, but TWO switches around
// one turn put a marker BEFORE the committed prompt (shifting its ordinal) and
// another AFTER it (so the prompt is no longer the last user row, so the text
// guard can't rescue it). Naive ordinal pairing then pairs the optimistic row
// against a marker, treats it as uncommitted, and re-appends it — the
// duplicated user bubble stacked at the bottom of the chat.
it('does not duplicate the optimistic prompt when markers bracket it (two model switches)', () => {
const marker = (name: string) => `[System: The active model for this chat has changed to ${name}.]`
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'second question')
]
const next = [
msg('s1-user', 'user', 'first'),
msg('s2-assistant', 'assistant', 'first answer'),
msg('s3-marker', 'user', marker('k2')),
msg('s4-user', 'user', 'second question'),
msg('s5-assistant', 'assistant', 'second answer'),
msg('s6-marker', 'user', marker('k3'))
]
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
})
it('still keeps a genuinely uncommitted optimistic turn when a marker is present', () => {
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'new question')
]
// The marker is persisted but the new prompt has not committed yet — the
// optimistic row must survive (marker exclusion must not over-correct).
const next = [
msg('1-user-stored', 'user', 'first'),
msg('2-assistant-stored', 'assistant', 'first answer'),
msg('3-marker-stored', 'user', '[System: The active model for this chat has changed to k3.]')
]
expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
'1-user-stored',
'2-assistant-stored',
'3-marker-stored',
'user-optimistic'
])
})
})
describe('appendLiveSessionProjection', () => {
@@ -3,7 +3,7 @@ import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import {
$currentCwd,
@@ -240,7 +240,17 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
* history, not in-flight work. The latest authoritative user confirms whether
* that tail has persisted; any authoritative assistant at the same ordinal
* supersedes the local stream.
*
* Gateway bookkeeping markers (the model-switch / personality notices written
* by tui_gateway/server.py) are persisted as role=user but are not user turns.
* They must not take part in ordinal pairing on either side: a stored marker
* between two real user turns shifts every later user ordinal, so the optimistic
* row misses its committed copy and is appended a second time at the end of the
* transcript — the duplicated user bubble of #67603.
*/
const isGatewaySystemMarker = (message: ChatMessage): boolean =>
message.role === 'user' && chatMessageText(message).trimStart().startsWith('[System:')
export function preserveLocalPendingTurnMessages(
nextMessages: ChatMessage[],
previousMessages: ChatMessage[]
@@ -253,6 +263,10 @@ export function preserveLocalPendingTurnMessages(
const nextRoleCounts = new Map<ChatMessage['role'], number>()
for (const message of nextMessages) {
if (isGatewaySystemMarker(message)) {
continue
}
const ordinal = nextRoleCounts.get(message.role) ?? 0
nextRoleCounts.set(message.role, ordinal + 1)
nextByRoleOrdinal.set(`${message.role}:${ordinal}`, message)
@@ -269,6 +283,10 @@ export function preserveLocalPendingTurnMessages(
const preserved: ChatMessage[] = []
for (const message of previousMessages) {
if (isGatewaySystemMarker(message)) {
continue
}
const ordinal = previousRoleCounts.get(message.role) ?? 0
previousRoleCounts.set(message.role, ordinal + 1)
@@ -497,6 +515,28 @@ export async function resolveStoredSession(storedSessionId: string): Promise<Ses
return undefined
}
/**
* The profile that owns a stored session, resolved through the same
* cache → active-backend → cross-profile ladder as `resolveStoredSession`.
*
* Recovery `session.resume` calls (stale runtime id, session-not-found, wedged
* loop) must re-register the conversation on ITS backend, not on whichever
* profile happens to be live. Omitting the profile lets the gateway fall back to
* the launch-profile DB (tui_gateway/server.py), which is how a session bleeds
* from one profile into another (#67603, second symptom). A cache-only lookup
* misses any session outside the paginated sidebar window, so route through the
* resolver, which probes uncached ids across profiles.
*/
export async function resolveSessionProfile(storedSessionId: null | string): Promise<string | undefined> {
if (!storedSessionId) {
return undefined
}
const profile = (await resolveStoredSession(storedSessionId))?.profile?.trim()
return profile || undefined
}
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
@@ -517,9 +557,7 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
reconcileApprovalModeForProfile($activeGatewayProfile.get(), info.approval_mode)
}
if (info.credential_warning) {
requestDesktopOnboarding(info.credential_warning)
}
requestDesktopOnboardingForCredentialWarning(info.credential_warning)
reportInstallMethodWarning(info.install_warning)
@@ -188,7 +188,9 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
const tombstones = $removedSessionIds.get()
const incoming = tombstones.size
? recents.sessions.filter(s => !tombstones.has(s.id) && !(s._lineage_root_id && tombstones.has(s._lineage_root_id)))
? recents.sessions.filter(
s => !tombstones.has(s.id) && !(s._lineage_root_id && tombstones.has(s._lineage_root_id))
)
: recents.sessions
// Signature-gate the swap (same pattern as cron/messaging): a refresh
@@ -11,15 +11,22 @@ export const EMPTY_BILLING_VALUE = '—'
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com'
// Billing polls on its own while the page is mounted (react-query only ticks an
// active observer), so the view stays live without a manual refresh control —
// matching every other data view in the app. It pauses when the window is
// backgrounded (refetchIntervalInBackground defaults to false).
// The billing endpoint is the authoritative source of truth for balance / cap /
// plan — the inference `x-nous-credits-*` headers are best-effort and can drift
// out of sync (notably in team/org accounts where another member's spend moves
// the shared balance without ever touching THIS client's headers). So the page
// never trusts a cache: `staleTime: 0` + `refetchOnMount: 'always'` force a
// fresh fetch every time it opens or regains focus, and it keeps polling every
// 30s while mounted (react-query only ticks an active observer; it pauses when
// the window is backgrounded — refetchIntervalInBackground defaults to false).
// A `credits.*` notice crossing additionally invalidates ['billing','state'] to
// pull the change in immediately rather than waiting for the next poll tick.
const BILLING_QUERY_OPTIONS = {
refetchInterval: 30_000,
refetchOnMount: 'always',
refetchOnWindowFocus: true,
retry: false,
staleTime: 30_000
staleTime: 0
} as const
export interface BillingSummaryItemView {
@@ -1,15 +1,28 @@
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { onComposerInsertRequest } from '@/app/chat/composer/focus'
import { I18nProvider } from '@/i18n'
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { $activeSessionId } from '@/store/session'
import { ClarifyTool, readClarifyResult } from './clarify-tool'
// The live pending card only renders while its message is running. Force that so
// keyboard-navigation tests can exercise ClarifyToolPending directly.
vi.mock('@assistant-ui/react', () => ({
useAuiState: () => true
}))
afterEach(() => {
cleanup()
clearClarifyRequest()
$activeSessionId.set(null)
$gateway.set(null)
vi.clearAllMocks()
})
function renderClarify(ui: ReactNode) {
@@ -40,6 +53,40 @@ function settledClarifyProps(
}
}
function liveClarifyProps(choices = ['staging', 'production']): ToolCallMessagePartProps {
const args = { choices, question: 'Which deployment target?' }
return {
addResult: vi.fn(),
args,
argsText: JSON.stringify(args),
isError: false,
respondToApproval: vi.fn(),
result: undefined,
resume: vi.fn(),
status: { type: 'running' },
toolCallId: 'clarify-live',
toolName: 'clarify',
type: 'tool-call'
}
}
function renderLiveClarify() {
const request = vi.fn().mockResolvedValue({ ok: true })
$activeSessionId.set('session-1')
$gateway.set({ request } as never)
setClarifyRequest({
choices: ['staging', 'production'],
question: 'Which deployment target?',
requestId: 'request-1',
sessionId: 'session-1'
})
renderClarify(<ClarifyTool {...liveClarifyProps()} />)
return request
}
describe('readClarifyResult', () => {
it('reads question + user_response from the tool JSON payload', () => {
expect(
@@ -182,3 +229,112 @@ describe('ClarifyTool settled view', () => {
expect(document.querySelector('[data-clarify-late-choices]')).toBeNull()
})
})
describe('ClarifyTool keyboard navigation', () => {
it('cycles through choices and Other with the arrow keys', () => {
renderLiveClarify()
const staging = screen.getByRole('button', { name: /staging/ })
const production = screen.getByRole('button', { name: /production/ })
const other = screen.getByPlaceholderText(/Other/)
expect(staging.getAttribute('data-highlighted')).toBe('true')
expect(staging.getAttribute('aria-current')).toBe('true')
expect(staging.getAttribute('aria-keyshortcuts')).toBe('A 1')
fireEvent.keyDown(window, { key: 'ArrowDown' })
expect(production.getAttribute('data-highlighted')).toBe('true')
fireEvent.keyDown(window, { key: 'ArrowDown' })
expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true')
expect(other.getAttribute('aria-current')).toBe('true')
expect(other.getAttribute('aria-keyshortcuts')).toBe('C 3')
fireEvent.keyDown(window, { key: 'ArrowDown' })
expect(staging.getAttribute('data-highlighted')).toBe('true')
fireEvent.keyDown(window, { key: 'ArrowUp' })
expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true')
})
it('selects by number and confirms the answer with Enter', async () => {
const request = renderLiveClarify()
fireEvent.keyDown(window, { key: '2' })
fireEvent.keyDown(window, { key: 'Enter' })
await waitFor(() => {
expect(request).toHaveBeenCalledWith('clarify.respond', {
answer: 'production',
request_id: 'request-1'
})
})
})
it('focuses Other when its number is pressed and leaves typing keys alone', () => {
renderLiveClarify()
const other = screen.getByPlaceholderText(/Other/)
fireEvent.keyDown(window, { key: '3' })
expect(document.activeElement).toBe(other)
fireEvent.change(other, { target: { value: 'canary' } })
fireEvent.keyDown(window, { key: 'ArrowUp' })
expect(document.activeElement).toBe(other)
expect((other as HTMLTextAreaElement).value).toBe('canary')
})
it('does not intercept keyboard events while an action button has focus', () => {
const request = renderLiveClarify()
const skip = screen.getByRole('button', { name: 'Skip' })
skip.focus()
expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true)
expect(fireEvent.keyDown(window, { key: 'ArrowDown' })).toBe(true)
expect(request).not.toHaveBeenCalled()
})
})
describe('ClarifyTool pending marker', () => {
it('marks a live choices card so type-to-focus yields its shortcut keys', () => {
renderLiveClarify()
// The marker is what `composerFocusBlockedBySurface` keys off of, so the
// global type-to-focus listener stands down and A/B/C… + 1-9 + Enter reach
// the card instead of the composer.
expect(document.querySelector('[data-clarify-choices]')).toBeTruthy()
})
it('does not mark a free-text (no-choice) pending card', () => {
$activeSessionId.set('session-1')
$gateway.set({ request: vi.fn().mockResolvedValue({ ok: true }) } as never)
setClarifyRequest({
choices: null,
question: 'Anything else?',
requestId: 'request-1',
sessionId: 'session-1'
})
const args = { question: 'Anything else?' }
renderClarify(
<ClarifyTool
addResult={vi.fn()}
args={args}
argsText={JSON.stringify(args)}
isError={false}
respondToApproval={vi.fn()}
result={undefined}
resume={vi.fn()}
status={{ type: 'running' }}
toolCallId="clarify-free"
toolName="clarify"
type="tool-call"
/>
)
// No shortcuts → nothing to protect → composer type-to-focus stays live.
expect(document.querySelector('[data-clarify-choices]')).toBeNull()
})
})
@@ -24,7 +24,7 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { clearClarifyRequest, sessionClarifyRequest } from '@/store/clarify'
import { clearClarifyRequest, normalizeChoices, sessionClarifyRequest, warnDroppedChoices } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
@@ -54,11 +54,18 @@ function stringField(row: Record<string, unknown>, ...keys: string[]): string |
function readClarifyArgs(args: unknown): ClarifyArgs {
const row = parseMaybeObject(args)
const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null
const rawChoices = row.choices
const choices = normalizeChoices(rawChoices)
const question = stringField(row, 'question')
if (rawChoices != null && choices.length === 0 && question) {
warnDroppedChoices('tool_args', question, rawChoices)
}
return {
question: stringField(row, 'question'),
choices: choices && choices.length > 0 ? choices : null
question,
choices: choices.length > 0 ? choices : null
}
}
@@ -131,16 +138,20 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean
* selects an answer) and the settled skip card (where a click drafts a
* follow-up), so both stay visually identical. */
function ChoiceButton({
active = false,
char,
choice,
disabled,
keyShortcuts,
onClick,
selected = false,
title
}: {
active?: boolean
char: string
choice: string
disabled?: boolean
keyShortcuts?: string
onClick: () => void
selected?: boolean
title?: string
@@ -149,20 +160,28 @@ function ChoiceButton({
// tooltip on a <button> is banned by the no-native-title guard). It renders
// the child untouched when `label` is falsy, so the live card (no tip) is
// unaffected and only the settled skip card gets the hover hint.
//
// `active` is the keyboard cursor on the live card (arrow-key navigation);
// it highlights the row and previews its key badge. The settled skip card
// never passes it, so its rows stay plain.
return (
<Tip label={title}>
<button
aria-current={active || undefined}
aria-keyshortcuts={keyShortcuts}
className={cn(
OPTION_ROW_CLASS,
'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
active && 'bg-(--chrome-action-hover) text-(--ui-text-primary)',
selected && 'text-(--ui-text-primary)'
)}
data-choice
data-highlighted={active || undefined}
disabled={disabled}
onClick={onClick}
type="button"
>
<KeyBadge char={char} selected={selected} />
<KeyBadge char={char} preview={active} selected={selected} />
<span className="flex-1 wrap-anywhere">{choice}</span>
</button>
</Tip>
@@ -291,6 +310,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const [draft, setDraft] = useState('')
const [submitting, setSubmitting] = useState(false)
const [selectedChoice, setSelectedChoice] = useState<string | null>(null)
// The keyboard cursor. Indices 0..choices.length-1 are the options; the
// trailing index (=== choices.length) is the "Other" free-text row.
const [activeIndex, setActiveIndex] = useState(0)
const [otherFocused, setOtherFocused] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
@@ -339,12 +361,31 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
// confirms with Continue (or Enter from the field).
const pendingAnswer = selectedChoice ?? (trimmedDraft || null)
const selectChoice = useCallback((choice: string) => {
const selectChoice = useCallback((choice: string, index: number) => {
// Picking a choice and typing are mutually exclusive answers.
setDraft('')
setSelectedChoice(choice)
setActiveIndex(index)
}, [])
// Keep the cursor in range when the choice set changes (never past "Other").
useEffect(() => {
setActiveIndex(index => Math.min(index, choices.length))
}, [choices.length])
const moveActive = useCallback(
(delta: number) => {
const itemCount = choices.length + 1
// Arrow navigation is a move, not a pick — clear any staged answer so the
// cursor and the selection can't disagree.
setDraft('')
setSelectedChoice(null)
setActiveIndex(index => (index + delta + itemCount) % itemCount)
},
[choices.length]
)
const submitAnswer = useCallback(() => {
if (selectedChoice !== null) {
void respond(selectedChoice)
@@ -357,6 +398,27 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
}
}, [respond, selectedChoice, trimmedDraft])
const activateActive = useCallback(() => {
// A staged answer (picked choice or typed text) wins — confirm it.
if (pendingAnswer) {
submitAnswer()
return
}
// Otherwise act on the highlighted row: a choice responds immediately, and
// the trailing "Other" row focuses the free-text field.
const choice = choices[activeIndex]
if (choice) {
void respond(choice)
return
}
textareaRef.current?.focus()
}, [activeIndex, choices, pendingAnswer, respond, submitAnswer])
const handleTextareaKey = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.nativeEvent.isComposing) {
@@ -379,10 +441,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
[submitAnswer]
)
// Letter shortcuts: A/B/C… pick the matching option, the trailing letter jumps
// into "Other", and Enter confirms the current pick. Stands down whenever a
// field is focused (you're typing, not navigating) so it never eats keystrokes
// meant for the composer or the Other box.
// Arrow keys move a visual cursor, 1-9 and A/B/C… pick directly, and Enter
// confirms the current answer (or acts on the highlighted row). Stands down
// whenever a focusable control (a field, a choice button, the action bar) is
// focused, so it never eats keystrokes meant for the composer, the Other box,
// or a button the user tabbed to.
useEffect(() => {
if (!ready || !hasChoices || submitting) {
return
@@ -395,7 +458,32 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
if (
active &&
(active.isContentEditable || active.matches('a[href], button, input, select, textarea, [role="button"]'))
) {
return
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveActive(event.key === 'ArrowDown' ? 1 : -1)
return
}
if (/^[1-9]$/.test(event.key)) {
const index = Number(event.key) - 1
if (index < choices.length) {
event.preventDefault()
selectChoice(choices[index], index)
} else if (index === choices.length) {
event.preventDefault()
setActiveIndex(index)
textareaRef.current?.focus()
}
return
}
@@ -406,25 +494,26 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
if (index < choices.length) {
event.preventDefault()
selectChoice(choices[index])
selectChoice(choices[index], index)
} else if (index === choices.length) {
event.preventDefault()
setActiveIndex(index)
textareaRef.current?.focus()
}
return
}
if (event.key === 'Enter' && pendingAnswer) {
if (event.key === 'Enter') {
event.preventDefault()
submitAnswer()
activateActive()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [choices, hasChoices, pendingAnswer, ready, selectChoice, submitAnswer, submitting])
}, [activateActive, choices, hasChoices, moveActive, ready, selectChoice, submitting])
if (loading) {
return (
@@ -449,7 +538,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
}
return (
<ClarifyShell className="grid gap-2 px-2.5 py-2">
// `data-clarify-choices` marks the panel as owning printable/Enter keys
// while its A/B/C… shortcuts are live, so the global type-to-focus listener
// (`composerFocusBlockedBySurface`) stands down and the letters reach this
// card instead of being redirected into the composer.
<ClarifyShell className="grid gap-2 px-2.5 py-2" data-clarify-choices={hasChoices ? '' : undefined}>
<div className="flex items-start gap-2">
<span className="flex-1 whitespace-pre-wrap font-medium leading-(--conversation-line-height)">{question}</span>
<MessageQuestion aria-hidden className="mt-px size-4 shrink-0 text-(--ui-text-tertiary)" />
@@ -460,23 +553,39 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
<div className="grid gap-px" role="group">
{choices.map((choice, index) => (
<ChoiceButton
active={activeIndex === index}
char={letterFor(index)}
choice={choice}
disabled={submitting}
key={`${index}-${choice}`}
onClick={() => selectChoice(choice)}
keyShortcuts={`${letterFor(index)} ${index + 1}`}
onClick={() => selectChoice(choice, index)}
selected={selectedChoice === choice}
/>
))}
<label className={cn(OPTION_ROW_CLASS, 'items-center')}>
<KeyBadge char={letterFor(choices.length)} preview={otherFocused} selected={Boolean(trimmedDraft)} />
<label
className={cn(
OPTION_ROW_CLASS,
'items-center',
activeIndex === choices.length && 'bg-(--chrome-action-hover)'
)}
data-highlighted={activeIndex === choices.length || undefined}
>
<KeyBadge
char={letterFor(choices.length)}
preview={otherFocused || activeIndex === choices.length}
selected={Boolean(trimmedDraft)}
/>
<Textarea
aria-current={activeIndex === choices.length || undefined}
aria-keyshortcuts={`${letterFor(choices.length)} ${choices.length + 1}`}
className={CLARIFY_TEXTAREA_CLASS}
disabled={submitting}
onBlur={() => setOtherFocused(false)}
onChange={event => onDraftChange(event.target.value)}
onFocus={() => {
setSelectedChoice(null)
setActiveIndex(choices.length)
setOtherFocused(true)
}}
onKeyDown={handleTextareaKey}
@@ -0,0 +1,52 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { MarkdownTextContent } from './markdown-text'
const REMOTE_IMAGE_PATH = '/home/user/project/images/remote-preview.png'
const REMOTE_IMAGE_DATA_URL = 'data:image/png;base64,cmVtb3RlLWltYWdl'
describe('MarkdownTextContent remote images', () => {
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/read-data-url?')) {
return { dataUrl: REMOTE_IMAGE_DATA_URL }
}
throw new Error(`unexpected path ${path}`)
})
let originalDesktop: typeof window.hermesDesktop
beforeEach(() => {
api.mockClear()
originalDesktop = window.hermesDesktop
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api }
})
$connection.set({ mode: 'remote', profile: 'remote-work' } as never)
})
afterEach(() => {
cleanup()
$connection.set(null)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: originalDesktop
})
})
it('passes the gateway bridge data URL through Streamdown to the zoomable image', async () => {
render(<MarkdownTextContent isRunning={false} text={`![Remote preview](${REMOTE_IMAGE_PATH})`} />)
const image = await screen.findByRole('img', { name: 'Remote preview' })
expect(image.getAttribute('src')).toBe(REMOTE_IMAGE_DATA_URL)
expect(api).toHaveBeenCalledWith({
path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fimages%2Fremote-preview.png',
profile: 'remote-work'
})
})
})
@@ -20,14 +20,14 @@ import { parseMarkdownIntoBlocksCached } from '@/lib/markdown-blocks'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
import {
downloadGatewayMediaFile,
filePathFromMediaPath,
gatewayMediaDataUrl,
isInlineMediaSrc,
isRemoteGateway,
mediaExternalUrl,
mediaKind,
mediaName,
mediaPathFromMarkdownHref,
mediaStreamUrl
mediaStreamUrl,
resolveMediaDisplaySrc
} from '@/lib/media'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
@@ -60,27 +60,13 @@ function preprocessWithTailRepair(text: string): string {
}
async function mediaSrc(path: string): Promise<string> {
if (/^(?:https?|data):/i.test(path)) {
return path
}
// Stream audio/video through the custom protocol: data URLs are capped and
// load the whole file into memory, which broke playback for larger videos.
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
return mediaStreamUrl(path)
}
// Remote gateway: the image lives on the gateway machine, so read it over the
// authenticated API rather than this machine's disk.
if (window.hermesDesktop && isRemoteGateway()) {
return gatewayMediaDataUrl(path)
}
if (!window.hermesDesktop?.readFileDataUrl) {
return mediaExternalUrl(path)
}
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
return resolveMediaDisplaySrc(path)
}
function useOpenMediaFile(path: string) {
@@ -286,6 +272,65 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
}
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
const rawSrc = typeof src === 'string' ? src : ''
const [resolvedSrc, setResolvedSrc] = useState(() => (rawSrc && isInlineMediaSrc(rawSrc) ? rawSrc : ''))
const [failed, setFailed] = useState(false)
const { open, openFailed } = useOpenMediaFile(rawSrc)
const name = mediaName(rawSrc || String(alt || 'image'))
useEffect(() => {
let cancelled = false
setFailed(false)
setResolvedSrc(rawSrc && isInlineMediaSrc(rawSrc) ? rawSrc : '')
if (!rawSrc || isInlineMediaSrc(rawSrc)) {
return () => {
cancelled = true
}
}
void resolveMediaDisplaySrc(rawSrc)
.then(value => {
if (!cancelled) {
setResolvedSrc(value)
}
})
.catch(() => {
if (!cancelled) {
setFailed(true)
}
})
return () => {
cancelled = true
}
}, [rawSrc])
if (!rawSrc) {
return null
}
if (failed) {
return (
<span className="my-2 block text-sm text-muted-foreground">
Couldn&apos;t load {name}.{' '}
<button
className="bg-transparent font-medium text-foreground underline underline-offset-4 decoration-current/20 hover:text-foreground"
onClick={open}
type="button"
>
Open image
</button>
{openFailed && <OpenMediaFailedNote name={name} />}
</span>
)
}
if (!resolvedSrc) {
return <span className="my-2 block text-sm text-muted-foreground">Loading {name}...</span>
}
return (
<ZoomableImage
alt={alt}
@@ -295,7 +340,7 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
)}
containerClassName="my-2 block w-fit max-w-full"
slot="aui_markdown-image"
src={src}
src={resolvedSrc}
{...props}
/>
)
@@ -14,12 +14,21 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { GeneratedImage } from '@/components/chat/generated-image-result'
import { useI18n } from '@/i18n'
import { generatedImageFromResult } from '@/lib/generated-images'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ args, result }) => {
const ImageGenerateTool: FC<ToolCallMessagePartProps> = props => {
const { args, result } = props
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
// The image card owns successful generations. Failed or malformed results
// still need the normal tool row: it extracts the error text and gives the
// user an honest, expandable failure rather than silently dropping the call.
if (result !== undefined && !generatedImageFromResult(result)) {
return <ToolFallback {...props} />
}
return (
<div className="mt-1.5">
<GeneratedImage aspectRatio={aspectRatio} result={result} />
@@ -217,7 +217,10 @@ function assistantTodoMessage(
} as ThreadMessage
}
function assistantImageMessage(running = false): ThreadMessage {
function assistantImageMessage(
running = false,
result: unknown = { image: 'https://cdn.example/cat.png', success: true }
): ThreadMessage {
return {
id: `assistant-image-${running ? 'running' : 'done'}`,
role: 'assistant',
@@ -228,7 +231,7 @@ function assistantImageMessage(running = false): ThreadMessage {
toolName: 'image_generate',
args: { prompt: 'draw a cat' },
argsText: JSON.stringify({ prompt: 'draw a cat' }),
...(running ? {} : { result: { image: 'https://cdn.example/cat.png', success: true } })
...(running ? {} : { result })
}
],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
@@ -243,6 +246,32 @@ function assistantImageMessage(running = false): ThreadMessage {
} as ThreadMessage
}
function assistantTerminalMessage(): ThreadMessage {
return {
id: 'assistant-terminal-1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'terminal-1',
toolName: 'terminal',
args: { command: 'npm run check --workspace=apps/desktop' },
argsText: JSON.stringify({ command: 'npm run check --workspace=apps/desktop' }),
result: { exit_code: 0, stdout: 'all checks passed' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
interface StreamingControls {
emitSecond: () => void
complete: () => void
@@ -634,4 +663,32 @@ describe('assistant-ui streaming renderer', () => {
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy()
expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull()
})
it('uses the normal tool row for failed image generations instead of dropping their error payload', async () => {
const { container } = render(
<MessageHarness
message={assistantImageMessage(false, { error: 'FAL rejected the prompt', image: null, success: false })}
/>
)
fireEvent.click(container.querySelector('[data-tool-row] button')!)
await waitFor(() => {
expect(container.textContent).toContain('FAL rejected the prompt')
})
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeNull()
expect(container.textContent).not.toContain('"success":false')
})
it('shows the command prompt and exit code for terminal calls', async () => {
const { container } = render(<MessageHarness message={assistantTerminalMessage()} />)
fireEvent.click(container.querySelector('[data-tool-row] button')!)
await waitFor(() => {
expect(container.textContent).toContain('$ npm run check --workspace=apps/desktop')
expect(container.textContent).toContain('exit 0')
expect(container.textContent).toContain('all checks passed')
})
})
})
@@ -76,6 +76,38 @@ describe('buildToolView terminal exit-code status', () => {
'error'
)
})
it('keeps the command and exit code for the terminal transcript', () => {
const view = buildToolView(
part({
args: { command: 'npm run check --workspace=apps/desktop' },
result: { exit_code: 0, output: 'done' },
toolName: 'terminal'
}),
''
)
expect(view.terminalCommand).toBe('npm run check --workspace=apps/desktop')
expect(view.terminalExitCode).toBe(0)
})
})
describe('buildToolView web-search query', () => {
it('keeps the query separate from structured search results', () => {
const view = buildToolView(
part({
args: { query: 'Hermes Agent Desktop tool calls' },
result: { web: [{ snippet: 'Desktop docs', title: 'Hermes docs', url: 'https://example.com/docs' }] },
toolName: 'web_search'
}),
''
)
expect(view.searchQuery).toBe('Hermes Agent Desktop tool calls')
expect(view.searchHits).toEqual([
{ snippet: 'Desktop docs', title: 'Hermes docs', url: 'https://example.com/docs' }
])
})
})
describe('buildToolView browser_navigate title', () => {
@@ -128,6 +128,12 @@ function readFileDisplayTarget(args: Record<string, unknown>, result: Record<str
return [fileEditBasename(path), lineLabel].filter(Boolean).join(' ')
}
function shellCommand(args: Record<string, unknown>): string {
return (
firstStringField(args, ['context', 'preview']) || firstStringField(args, ['command', 'code']) || contextValue(args)
)
}
const TOOL_META: Record<ToolTitleKey, ToolMetaSpec> = {
browser_click: {
icon: 'globe',
@@ -1315,10 +1321,7 @@ function dynamicTitle(
}
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
const command =
firstStringField(args, ['context', 'preview']) ||
firstStringField(args, ['command', 'code']) ||
contextValue(args)
const command = shellCommand(args)
if (command) {
const action =
@@ -1385,6 +1388,11 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
const searchHits =
part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined
const searchQuery =
part.toolName === 'web_search'
? firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord)
: ''
const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
// For shell/code tools we surface stdout and stderr as separate labeled
@@ -1398,6 +1406,8 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
// field — otherwise the merged `detail` already covers it and double-
// rendering would duplicate output.
const hasSplitStreams = rendersAnsi && (Boolean(stdout) || Boolean(stderrRaw))
const terminalCommand = part.toolName === 'terminal' ? shellCommand(argsRecord) : undefined
const terminalExitCode = part.toolName === 'terminal' ? numericField(resultRecord, 'exit_code') : undefined
return {
countLabel: resultCount ? formatCountLabel(resultCount) : undefined,
@@ -1409,8 +1419,11 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
inlineDiff,
previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
rendersAnsi: rendersAnsi || undefined,
searchQuery: searchQuery || undefined,
searchHits: searchHits?.length ? searchHits : undefined,
stderr: hasSplitStreams ? stderrRaw || undefined : undefined,
terminalCommand,
terminalExitCode,
stdout: hasSplitStreams ? stdout || undefined : undefined,
status,
subtitle,
@@ -40,11 +40,17 @@ export interface ToolView {
* (terminal/execute_code) so the renderer knows to run them through
* the ANSI parser instead of printing them as literals. */
rendersAnsi?: boolean
/** Original query, shown above structured web-search results. */
searchQuery?: string
searchHits?: SearchResultRow[]
/** When the backend reports stderr as a separate stream (terminal /
* execute_code), the renderer shows it as its own labeled, neutrally
* tinted block under stdout — distinct from an error tone. */
stderr?: string
/** Terminal-only command shown as the prompt in the expanded transcript. */
terminalCommand?: string
/** Terminal-only process exit code, when the backend reported one. */
terminalExitCode?: number
/** When set, the renderer uses stdout+stderr as separate sections and
* ignores the merged `detail`. */
stdout?: string
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { shouldBoundToolGroup, UNBOUNDABLE_TOOLS } from './fallback'
import { shouldBoundToolGroup, technicalTrace, UNBOUNDABLE_TOOLS } from './fallback'
describe('shouldBoundToolGroup', () => {
it('bounds long runs of ordinary tool calls', () => {
@@ -22,3 +22,16 @@ describe('UNBOUNDABLE_TOOLS', () => {
expect(UNBOUNDABLE_TOOLS.has('image_generate')).toBe(true)
})
})
describe('technicalTrace', () => {
it('indents object payloads and persisted JSON strings', () => {
expect(technicalTrace({ offset: 2, path: '/tmp/demo.txt' }, '{"success":true,"lines":["a","b"]}')).toBe(
'Arguments:\n{\n "offset": 2,\n "path": "/tmp/demo.txt"\n}\n\nResult:\n{\n "success": true,\n "lines": [\n "a",\n "b"\n ]\n}'
)
})
it('leaves scalar strings untouched', () => {
expect(technicalTrace(undefined, 'plain text')).toBe('Result:\nplain text')
expect(technicalTrace(undefined, '"already quoted"')).toBe('Result:\n"already quoted"')
})
})
@@ -62,7 +62,6 @@ import {
type ToolStatus,
type ToolTitleAction
} from './fallback-model'
import { prettyJson } from './fallback-model/format'
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
@@ -104,23 +103,39 @@ interface ToolStatusCopy {
statusRunning: string
}
function rawTechnicalTrace(args: unknown, result: unknown): string {
const parts = [args, result]
.filter(value => value !== undefined && value !== null)
.map(value => {
if (typeof value === 'string') {
return value
}
function prettyTechnicalValue(value: unknown): string {
if (typeof value === 'string') {
const trimmed = value.trim()
try {
return JSON.stringify(value)
} catch {
return String(value)
}
})
.filter(Boolean)
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return value
}
return clampForDisplay(parts.join('\n'))
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : value
} catch {
return value
}
}
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
export function technicalTrace(args: unknown, result: unknown): string {
const parts = [
['Arguments', args],
['Result', result]
]
.filter(([, value]) => value !== undefined && value !== null)
.map(([label, value]) => `${label}:\n${prettyTechnicalValue(value)}`)
return clampForDisplay(parts.join('\n\n'))
}
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
@@ -363,7 +378,8 @@ function ToolEntry({ part }: ToolEntryProps) {
const showDetail =
!view.inlineDiff &&
((view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
(Boolean(view.stdout || view.stderr) ||
(view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
(view.status !== 'error' && Boolean(view.detail) && !detailMatchesTitle && !detailMatchesSubtitle))
const renderDetailAsCode =
@@ -373,21 +389,16 @@ function ToolEntry({ part }: ToolEntryProps) {
const hasSearchHits = Boolean(view.searchHits?.length)
const searchResultsLabel = part.toolName === 'web_search' ? 'Search results' : view.detailLabel
// Only web_search renders the raw JSON drilldown, so serialize the result
// lazily here instead of prettyJson-ing every tool's result in buildToolView.
const rawResult = useMemo(
() => (part.toolName === 'web_search' && toolViewMode !== 'technical' ? prettyJson(part.result) : ''),
[part.toolName, part.result, toolViewMode]
)
const showRawSearchDrilldown =
part.toolName === 'web_search' &&
part.result !== undefined &&
toolViewMode !== 'technical' &&
Boolean(rawResult.trim())
const hasExpandableContent = Boolean(
view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
view.imageUrl ||
view.inlineDiff ||
showDetail ||
hasSearchHits ||
view.stdout ||
view.stderr ||
view.terminalCommand ||
view.terminalExitCode !== undefined ||
toolViewMode === 'technical'
)
// copyAction reads the uncapped view.detail; clampForDisplay below only bounds
@@ -511,6 +522,9 @@ function ToolEntry({ part }: ToolEntryProps) {
text={copyAction.text}
/>
)}
{part.toolName === 'terminal' && toolViewMode !== 'technical' && (
<TerminalTranscript command={view.terminalCommand} exitCode={view.terminalExitCode} />
)}
{view.imageUrl && (
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />
@@ -518,6 +532,12 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
{hasSearchHits && view.searchHits && (
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{view.searchQuery && (
<p className="mb-1 flex min-w-0 gap-1.5 wrap-anywhere">
<span className="shrink-0 font-medium text-(--ui-text-tertiary)">Search</span>
<span>{view.searchQuery}</span>
</p>
)}
{searchResultsLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>}
<SearchResultsList hits={view.searchHits} />
</div>
@@ -596,22 +616,16 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
</div>
))}
{showRawSearchDrilldown && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>{copy.rawResponse}</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>{rawResult}</pre>
</details>
)}
{toolViewMode === 'technical' && !(isFileEdit && view.inlineDiff) && (
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{rawTechnicalTrace(part.args, part.result)}
{technicalTrace(part.args, part.result)}
</pre>
)}
{toolViewMode === 'technical' && isFileEdit && view.inlineDiff && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0 cursor-pointer')}>Tool payload</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
{rawTechnicalTrace(part.args, part.result)}
{technicalTrace(part.args, part.result)}
</pre>
</details>
)}
@@ -621,6 +635,40 @@ function ToolEntry({ part }: ToolEntryProps) {
)
}
interface TerminalTranscriptProps {
command?: string
exitCode?: number
}
function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) {
if (!command && exitCode === undefined) {
return null
}
return (
<div className="flex min-w-0 items-center gap-2 rounded-[0.25rem] border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 py-1.5 font-mono text-[0.7rem] leading-relaxed">
{command && (
<code className="min-w-0 flex-1 whitespace-pre-wrap wrap-anywhere text-(--ui-text-secondary)">
<span aria-hidden className="select-none text-(--ui-accent-secondary)">
${' '}
</span>
{command}
</code>
)}
{exitCode !== undefined && (
<span
className={cn(
'shrink-0 rounded bg-(--ui-bg-tertiary) px-1 py-px text-[0.6rem] tabular-nums',
exitCode === 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'
)}
>
exit {exitCode}
</span>
)}
</div>
)
}
// A back-to-back run of this many tool calls collapses into the bounded,
// auto-scrolling window; fewer than this stays a plain inline stack.
const TOOL_GROUP_SCROLL_THRESHOLD = 3
@@ -0,0 +1,72 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ExpandableBlock } from './expandable-block'
// jsdom has no ResizeObserver and reports scrollHeight === 0, so the block
// never flips to `overflowing` on its own. Stub RO to fire immediately and
// force a tall scrollHeight on the observed node so the toggle mounts.
class TestResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
observe(target: Element) {
Object.defineProperty(target, 'scrollHeight', { configurable: true, value: 400 })
this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver)
}
unobserve() {}
disconnect() {}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('ExpandableBlock', () => {
it('lets horizontal scroll through and keeps the last line selectable', () => {
vi.stubGlobal('ResizeObserver', TestResizeObserver)
const { container } = render(
<ExpandableBlock>
<pre data-testid="content">{'const x = 1\n'.repeat(20)}</pre>
</ExpandableBlock>
)
const inner = container.querySelector('[data-testid="content"]')!.parentElement!
const toggle = screen.getByRole('button', { name: /expand|collapse/i })
const fade = toggle.parentElement!
// Inner container allows horizontal scroll so wide code gets a scrollbar.
expect(inner.className).toContain('overflow-x-auto')
// The full-width fade is a pure cue: it spans the bottom edge but must not
// intercept pointer events, so the scrollbar drag and text selection on the
// last line pass through to the content underneath.
expect(fade.className).toContain('pointer-events-none')
expect(fade.className).toContain('inset-x-0')
// Only the compact toggle is clickable, and it is pinned to the right edge
// rather than spanning the full width (the old bug).
expect(toggle.className).toContain('pointer-events-auto')
expect(toggle.className).toContain('w-9')
expect(toggle.className).not.toContain('inset-x-0')
})
it('still toggles expanded state when the compact control is clicked', () => {
vi.stubGlobal('ResizeObserver', TestResizeObserver)
render(
<ExpandableBlock>
<pre data-testid="content">{'line\n'.repeat(20)}</pre>
</ExpandableBlock>
)
const toggle = screen.getByRole('button', { name: 'Expand' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(toggle)
expect(screen.getByRole('button', { name: 'Collapse' }).getAttribute('aria-expanded')).toBe('true')
})
})
@@ -31,19 +31,30 @@ export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
return (
<div className="relative">
<div className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)} ref={innerRef}>
<div
className={cn('overflow-y-auto overflow-x-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
ref={innerRef}
>
{children}
</div>
{overflowing && (
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
// The fade is a pure overflow cue and must not intercept pointer events:
// it spans the full bottom edge (over the horizontal scrollbar of a wide
// code block AND the block's last line), so making it clickable killed
// both sideways scrolling and text selection. Keep the fade
// `pointer-events-none` and pin the only clickable target — a compact
// toggle — to the right edge, clear of the draggable scrollbar track.
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex h-7 justify-end bg-linear-to-t from-(--ui-chat-surface-background) to-transparent">
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="pointer-events-auto flex h-7 w-9 cursor-pointer items-end justify-center pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
</div>
)}
</div>
)
+35 -4
View File
@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
@@ -162,6 +162,30 @@ function BottomRightStack({
)
}
// Emphasize only the leading money figure ("$16.00" — the amount used) with the
// accent color (semibold), leaving the rest of the line in its default muted
// tone. No accent, or no figure in the message → render the text untouched.
function renderMessage(message: string, accent?: string): ReactNode {
const match = accent ? /\$\d+(?:\.\d{2})?/.exec(message) : null
if (!match) {
return message
}
const start = match.index
const end = start + match[0].length
return (
<>
{message.slice(0, start)}
<span className="font-semibold" style={{ color: accent }}>
{match[0]}
</span>
{message.slice(end)}
</>
)
}
function NotificationItem({ notification }: { notification: AppNotification }) {
const styles = tone[notification.kind]
const Icon = styles.icon
@@ -169,6 +193,12 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
const { t } = useI18n()
const copy = t.notifications
// Nudge the icon down to sit on the first text line, in `ch` so it tracks the
// toast's font size instead of a fixed rem. `accentColor` (when set) tints the
// icon + message as a severity ramp, overriding the kind's default color.
const accent = notification.accentColor
const iconStyle: CSSProperties = { marginTop: '0.42ch', ...(accent ? { color: accent } : {}) }
return (
<Alert
aria-live={notification.kind === 'error' ? 'assertive' : 'polite'}
@@ -177,14 +207,15 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
variant={styles.variant}
>
{notification.icon ? (
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" />
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" style={iconStyle} />
) : (
<Icon className={styles.iconClass} />
<Icon className={styles.iconClass} style={iconStyle} />
)}
<div className="col-start-2 min-w-0">
{notification.title && <AlertTitle className="col-start-auto">{notification.title}</AlertTitle>}
<AlertDescription className="col-start-auto">
<p className="m-0">{notification.message}</p>
<p className="m-0">{renderMessage(notification.message, accent)}</p>
{notification.meta && <p className="m-0 text-xs text-muted-foreground tabular-nums">{notification.meta}</p>}
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
{notification.action && (
<Button
+8 -1
View File
@@ -132,6 +132,7 @@ export const en: Translations = {
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.',
elevenLabsRejectedKey: 'ElevenLabs rejected the API key (401).',
gatewayAuthFailed: 'Gateway authentication failed — check your API_SERVER_KEY.',
methodNotAllowed:
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.',
microphonePermission: 'Microphone permission was denied.',
@@ -168,7 +169,8 @@ export const en: Translations = {
turnDoneBody: 'The response is ready.',
turnErrorTitle: 'Turn failed',
backgroundDoneTitle: 'Background task finished',
backgroundFailedTitle: 'Background task failed'
backgroundFailedTitle: 'Background task failed',
creditsTitle: 'Credits'
}
},
@@ -292,6 +294,7 @@ export const en: Translations = {
'composer.send': 'Send message',
'composer.newline': 'Insert newline',
'composer.steer': 'Steer the running turn',
'composer.queue': 'Queue message',
'composer.sendQueued': 'Send next queued turn',
'composer.mention': 'Reference files, folders, URLs',
'composer.slash': 'Slash command palette',
@@ -377,6 +380,10 @@ export const en: Translations = {
backgroundDone: {
label: 'Background task finished',
description: 'A backgrounded terminal command completed.'
},
credits: {
label: 'Credit alerts',
description: 'Credit access is paused or restored.'
}
},
test: 'Send test notification',
+7 -1
View File
@@ -133,6 +133,7 @@ export const ja = defineLocale({
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT には ELEVENLABS_API_KEY が必要です。',
elevenLabsRejectedKey: 'ElevenLabs が API キーを拒否しました (401)。',
gatewayAuthFailed: 'ゲートウェイ認証に失敗しました — API_SERVER_KEY を確認してください。',
methodNotAllowed:
'デスクトップバックエンドがそのリクエストを拒否しました (405 Method Not Allowed)。Hermes Desktop を再起動してください。',
microphonePermission: 'マイクのアクセス許可が拒否されました。',
@@ -169,7 +170,8 @@ export const ja = defineLocale({
turnDoneBody: '応答の準備ができました。',
turnErrorTitle: 'ターンが失敗しました',
backgroundDoneTitle: 'バックグラウンドタスクが完了しました',
backgroundFailedTitle: 'バックグラウンドタスクが失敗しました'
backgroundFailedTitle: 'バックグラウンドタスクが失敗しました',
creditsTitle: 'クレジット'
}
},
@@ -262,6 +264,10 @@ export const ja = defineLocale({
backgroundDone: {
label: 'バックグラウンドタスク完了',
description: 'バックグラウンドのターミナルコマンドが完了しました。'
},
credits: {
label: 'クレジット通知',
description: 'クレジットの利用が停止または復旧しました。'
}
},
test: 'テスト通知を送信',
+3 -1
View File
@@ -173,6 +173,7 @@ export interface Translations {
errors: {
elevenLabsNeedsKey: string
elevenLabsRejectedKey: string
gatewayAuthFailed: string
methodNotAllowed: string
microphonePermission: string
openaiRejectedApiKey: string
@@ -211,6 +212,7 @@ export interface Translations {
turnErrorTitle: string
backgroundDoneTitle: string
backgroundFailedTitle: string
creditsTitle: string
}
}
@@ -314,7 +316,7 @@ export interface Translations {
enableAllDesc: string
focusedHint: string
kinds: Record<
'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError',
'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError',
{ label: string; description: string }
>
test: string
+7 -1
View File
@@ -129,6 +129,7 @@ export const zhHant = defineLocale({
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
elevenLabsRejectedKey: 'ElevenLabs 拒絕了該 API 金鑰 (401)。',
gatewayAuthFailed: '閘道認證失敗 — 請檢查你的 API_SERVER_KEY。',
methodNotAllowed: '桌面後端拒絕了該請求 (405 Method Not Allowed)。請嘗試重新啟動 Hermes Desktop。',
microphonePermission: '麥克風權限已被拒絕。',
openaiRejectedApiKey: 'OpenAI 拒絕了該 API 金鑰。',
@@ -164,7 +165,8 @@ export const zhHant = defineLocale({
turnDoneBody: '回覆已就緒。',
turnErrorTitle: '本輪失敗',
backgroundDoneTitle: '背景工作已完成',
backgroundFailedTitle: '背景工作失敗'
backgroundFailedTitle: '背景工作失敗',
creditsTitle: '額度'
}
},
@@ -256,6 +258,10 @@ export const zhHant = defineLocale({
backgroundDone: {
label: '背景工作完成',
description: '背景終端機指令已完成。'
},
credits: {
label: '額度提醒',
description: '額度存取被暫停或恢復。'
}
},
test: '傳送測試通知',
+8 -1
View File
@@ -129,6 +129,7 @@ export const zh: Translations = {
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
elevenLabsRejectedKey: 'ElevenLabs 拒绝了该 API key (401)。',
gatewayAuthFailed: '网关认证失败 — 请检查你的 API_SERVER_KEY。',
methodNotAllowed: '桌面后端拒绝了该请求 (405 Method Not Allowed)。请尝试重启 Hermes Desktop。',
microphonePermission: '麦克风权限已被拒绝。',
openaiRejectedApiKey: 'OpenAI 拒绝了该 API key。',
@@ -164,7 +165,8 @@ export const zh: Translations = {
turnDoneBody: '回复已就绪。',
turnErrorTitle: '本轮失败',
backgroundDoneTitle: '后台任务已完成',
backgroundFailedTitle: '后台任务失败'
backgroundFailedTitle: '后台任务失败',
creditsTitle: '额度'
}
},
@@ -283,6 +285,7 @@ export const zh: Translations = {
'composer.send': '发送消息',
'composer.newline': '插入换行',
'composer.steer': '引导正在运行的回合',
'composer.queue': '消息排队',
'composer.sendQueued': '发送下一条排队消息',
'composer.mention': '引用文件、文件夹、网址',
'composer.slash': '斜杠命令面板',
@@ -367,6 +370,10 @@ export const zh: Translations = {
backgroundDone: {
label: '后台任务完成',
description: '后台终端命令已完成。'
},
credits: {
label: '额度提醒',
description: '额度访问被暂停或恢复。'
}
},
test: '发送测试通知',
+141
View File
@@ -5,6 +5,7 @@ import {
appendAssistantTextPart,
appendReasoningPart,
chatMessageText,
collectUnspokenTurnSpeech,
mergeFinalAssistantText,
preserveLocalAssistantErrors,
reasoningPart,
@@ -157,6 +158,39 @@ describe('toChatMessages', () => {
expect(chatMessageText(message)).toBe('@file:foo.ts\n\nlook')
})
it('projects durable timeline kinds without inspecting their text', () => {
const messages = toChatMessages([
{ role: 'user', content: 'real user turn', timestamp: 1 },
{ role: 'assistant', content: 'real assistant reply', timestamp: 2 },
{
role: 'user',
content: 'opaque compaction payload',
display_kind: 'hidden',
timestamp: 3
},
{
role: 'user',
content: 'opaque model context payload',
display_kind: 'model_switch',
timestamp: 4
},
{
role: 'user',
content: 'opaque delegation context payload',
display_kind: 'async_delegation_complete',
timestamp: 5
}
])
expect(messages.map(message => message.role)).toEqual(['user', 'assistant', 'system', 'system'])
expect(messages.map(chatMessageText)).toEqual([
'real user turn',
'real assistant reply',
'model changed',
'background agent work finished'
])
})
})
describe('renderMediaTags', () => {
@@ -538,6 +572,37 @@ describe('upsertToolPart', () => {
expect(summaries).toEqual(['Did 5 searches', 'Did 5 searches'])
})
it('pairs a terminal completion with its context-only start when event IDs differ', () => {
const started = upsertToolPart(
[],
{ context: 'echo "Hello from the terminal"', name: 'terminal', tool_id: 'terminal-start' },
'running'
)
const completed = upsertToolPart(
started,
{
args: { command: 'echo "Hello from the terminal"' },
name: 'terminal',
result: { exit_code: 0, stdout: 'Hello from the terminal' },
tool_id: 'terminal-complete'
},
'complete'
)
const terminalParts = completed.filter(
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
part.type === 'tool-call' && part.toolName === 'terminal'
)
expect(terminalParts).toHaveLength(1)
expect(terminalParts[0]?.toolCallId).toBe('terminal-complete')
expect(terminalParts[0] && 'result' in terminalParts[0] ? terminalParts[0].result : undefined).toMatchObject({
exit_code: 0,
stdout: 'Hello from the terminal'
})
})
it('preserves query args when completion payload omits context', () => {
const started = upsertToolPart(
[],
@@ -849,3 +914,79 @@ describe('mergeFinalAssistantText', () => {
expect(result.filter(p => p.type === 'reasoning')).toHaveLength(1)
})
})
describe('collectUnspokenTurnSpeech', () => {
const assistant = (id: string, text: string, extra: Partial<ChatMessage> = {}): ChatMessage => ({
id,
role: 'assistant',
parts: text ? [{ type: 'text', text }] : [],
...extra
})
const user = (id: string, text: string): ChatMessage => ({
id,
role: 'user',
parts: [{ type: 'text', text }]
})
it('includes sealed interim narration AND the final answer of a tool-calling turn', () => {
const messages = [
user('u1', 'what time is it?'),
assistant('a1', 'Let me check the clock.', { interim: true }),
assistant('a2', 'It is 9 PM.')
]
const speech = collectUnspokenTurnSpeech(messages, null)
expect(speech).not.toBeNull()
expect(speech?.id).toBe('a1')
expect(speech?.text).toBe('Let me check the clock.\n\nIt is 9 PM.')
expect(speech?.pending).toBe(false)
})
it('keeps the binding id stable while later bubbles stream in', () => {
const turnStart = [user('u1', 'go'), assistant('a1', 'Let me check.', { interim: true })]
const first = collectUnspokenTurnSpeech(turnStart, null)
const turnLater = [...turnStart, assistant('a2', 'Still work', { pending: true })]
const later = collectUnspokenTurnSpeech(turnLater, null)
expect(first?.id).toBe('a1')
expect(later?.id).toBe('a1')
// The earlier snapshot's text is a prefix of the later one — the live
// session appends by length, so aggregation must be append-only.
expect(later?.text.startsWith(first?.text ?? '')).toBe(true)
expect(later?.pending).toBe(true)
})
it('starts after the last spoken message and skips hidden/empty bubbles', () => {
const messages = [
assistant('a0', 'Spoken last turn.'),
user('u1', 'next'),
assistant('a1', '', { pending: false }),
assistant('a2', 'hidden note', { hidden: true }),
assistant('a3', 'The real reply.')
]
const speech = collectUnspokenTurnSpeech(messages, 'a0')
expect(speech?.id).toBe('a3')
expect(speech?.text).toBe('The real reply.')
})
it('reports pending from the newest assistant bubble even when it has no text yet', () => {
const messages = [assistant('a1', 'Narration done.', { interim: true }), assistant('a2', '', { pending: true })]
const speech = collectUnspokenTurnSpeech(messages, null)
expect(speech?.id).toBe('a1')
expect(speech?.text).toBe('Narration done.')
expect(speech?.pending).toBe(true)
})
it('returns null when everything is spoken or there is no assistant text', () => {
expect(collectUnspokenTurnSpeech([], null)).toBeNull()
expect(collectUnspokenTurnSpeech([assistant('a1', 'Done.')], 'a1')).toBeNull()
expect(collectUnspokenTurnSpeech([user('u1', 'hello'), assistant('a1', '')], null)).toBeNull()
})
})
+96 -6
View File
@@ -150,6 +150,59 @@ export function chatMessageText(message: ChatMessage): string {
.join('')
}
export interface UnspokenTurnSpeech {
/** First unspoken assistant bubble — stable for the turn, the live speech session binds to it. */
id: string
/** Whether the newest assistant bubble is still streaming. */
pending: boolean
/** All unspoken assistant text in message order, bubbles joined on a blank line. */
text: string
}
/**
* Collect every unspoken assistant bubble after `lastSpokenId`, in order.
*
* A turn with tool calls produces several assistant bubbles — narration
* ("Let me check…") sealed as interims, then the final answer as a fresh
* bubble. Voice conversation speaks a turn through ONE live session bound to
* one response id, so it needs all of that text as a single growing string;
* selecting only one bubble silently drops everything after it. The blank-line
* join is a sentence boundary for the server's cutter, so a sealed bubble's
* tail is flushed as soon as the next bubble starts.
*/
export function collectUnspokenTurnSpeech(
messages: ChatMessage[],
lastSpokenId: string | null
): UnspokenTurnSpeech | null {
const spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1
let id: string | null = null
let pending = false
const parts: string[] = []
for (const message of messages.slice(spokenIndex + 1)) {
if (message.role !== 'assistant' || message.hidden) {
continue
}
pending = Boolean(message.pending)
const text = chatMessageText(message).trim()
if (!text) {
continue
}
id ??= message.id
parts.push(text)
}
if (!id) {
return null
}
return { id, pending, text: parts.join('\n\n') }
}
const normalizeWs = (value: string) => value.replace(/\s+/g, ' ').trim()
/**
@@ -250,6 +303,29 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
}
function transcriptContent(displayKind: SessionMessage['display_kind'], content: string): string | null {
return displayKind === 'hidden' ? null : content
}
function timelineDisplayContent(message: SessionMessage, content: string): string {
if (message.display_kind === 'model_switch') {
return 'model changed'
}
if (message.display_kind === 'async_delegation_complete') {
const count =
message.display_metadata && 'task_count' in message.display_metadata
? message.display_metadata.task_count
: undefined
return count === undefined
? 'background agent work finished'
: `${count} background agent${count === 1 ? '' : 's'} finished`
}
return content
}
const STREAM_PART: Record<'reasoning' | 'text', (text: string) => ChatMessagePart> = {
reasoning: reasoningPart,
text: textPart
@@ -356,7 +432,11 @@ function collectToolMatchValues(query: string, context: string, preview: string)
function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): string[] {
const payloadArgs = liveToolArgs(payload)
const query = firstStringField(payloadArgs, ['search_term', 'query'])
// `question` is clarify's identifying arg: a synthetic row hydrated from
// `clarify.request` (a fresh request id) must correlate with the `tool.start`
// row (the model's tool_call_id) so the two ids don't produce a duplicate
// clarify card — same correlation ClarifyToolPending uses for request↔args.
const query = firstStringField(payloadArgs, ['search_term', 'query', 'question', 'command', 'code', 'path'])
const context = typeof payload?.context === 'string' ? payload.context.trim() : ''
const preview = typeof payload?.preview === 'string' ? payload.preview.trim() : ''
@@ -369,7 +449,7 @@ function toolPartMatchValues(part: ChatMessagePart): string[] {
}
const args = part.args as Record<string, unknown>
const query = firstStringField(args, ['search_term', 'query'])
const query = firstStringField(args, ['search_term', 'query', 'question', 'command', 'code', 'path'])
const context = typeof args.context === 'string' ? args.context.trim() : ''
const preview = typeof args.preview === 'string' ? args.preview.trim() : ''
@@ -827,7 +907,17 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
}
const content = message.content || message.text || message.context || message.name
const displayContent = displayContentForMessage(message.role, content)
const displayContent = transcriptContent(
message.display_kind,
timelineDisplayContent(message, displayContentForMessage(message.role, content))
)
const displayRole =
message.display_kind === 'model_switch' || message.display_kind === 'async_delegation_complete'
? 'system'
: message.role
const parts: ChatMessagePart[] = []
const reasoning =
@@ -840,7 +930,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
}
if (displayContent) {
parts.push(message.role === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent))
parts.push(displayRole === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent))
}
if (message.role === 'assistant' && Array.isArray(message.tool_calls)) {
@@ -894,8 +984,8 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
}
result.push({
id: `${message.timestamp || Date.now()}-${index}-${message.role}`,
role: message.role,
id: `${message.timestamp || Date.now()}-${index}-${displayRole}`,
role: displayRole,
parts,
timestamp: message.timestamp
})
+2 -1
View File
@@ -197,7 +197,8 @@ export interface KeybindReadonly {
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
{ id: 'composer.send', category: 'composer', keys: ['enter'] },
{ id: 'composer.newline', category: 'composer', keys: ['shift+enter'] },
{ id: 'composer.steer', category: 'composer', keys: ['mod+enter'] },
{ id: 'composer.steer', category: 'composer', keys: ['enter'] },
{ id: 'composer.queue', category: 'composer', keys: ['mod+enter'] },
{ id: 'composer.sendQueued', category: 'composer', keys: ['mod+shift+k'] },
{ id: 'composer.mention', category: 'composer', keys: ['@'] },
{ id: 'composer.slash', category: 'composer', keys: ['/'] },
@@ -72,6 +72,22 @@ describe('composerFocusBlockedBySurface', () => {
expect(composerFocusBlockedBySurface()).toBe(true)
})
it('blocks while an overlay covers the chat (composer sits behind it)', () => {
const overlay = document.createElement('div')
overlay.setAttribute('data-overlay-surface', '')
document.body.append(overlay)
expect(composerFocusBlockedBySurface()).toBe(true)
})
it('blocks while a live clarify choices card owns its letter keys', () => {
const card = document.createElement('div')
card.setAttribute('data-clarify-choices', '')
document.body.append(card)
expect(composerFocusBlockedBySurface()).toBe(true)
})
it('blocks when focus is inside a terminal', () => {
const term = document.createElement('div')
term.setAttribute('data-terminal', '')
@@ -146,4 +162,16 @@ describe('composerFocusKeysAllowed', () => {
expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: document.body }), 'type')).toBe(false)
})
it('yields letter + Enter keys to a live clarify choices card', () => {
const card = document.createElement('div')
card.setAttribute('data-clarify-choices', '')
document.body.append(card)
// The clarify card's own A/B/C… + Enter shortcuts must win over type-to-focus.
expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: document.body }), 'type')).toBe(false)
expect(composerFocusKeysAllowed(keydown({ key: 'Enter', code: 'Enter', target: document.body }), 'enter')).toBe(
false
)
})
})
@@ -38,7 +38,7 @@ const ENTER_ACTIVATES = [
].join(',')
const BLOCKING_SURFACE =
'[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[data-radix-popper-content-wrapper]'
'[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[data-radix-popper-content-wrapper],[data-overlay-surface],[data-clarify-choices]'
/** True when the focused control would normally handle Enter itself. */
export function isActivateOnEnterTarget(target: EventTarget | null): boolean {
@@ -47,7 +47,13 @@ export function isActivateOnEnterTarget(target: EventTarget | null): boolean {
return Boolean(el && el !== document.body && el !== document.documentElement && el.closest(ENTER_ACTIVATES))
}
/** Dialogs, menus, terminal, full pages, session switcher — they keep their keys. */
/**
* Dialogs, menus, terminal, full pages, session switcher, any open overlay
* (settings / command-center / star map / …), and a live clarify choices card —
* they keep their keys, so type-to-focus / soft `/` / Enter stand down rather
* than stealing keystrokes those surfaces own (or leaking them into the composer
* mounted behind an overlay).
*/
export function composerFocusBlockedBySurface(): boolean {
return (
switcherActive() ||
+65 -1
View File
@@ -8,8 +8,10 @@ import {
downloadGatewayMediaFile,
filePathFromMediaPath,
gatewayMediaDataUrl,
isInlineMediaSrc,
isRemoteGateway,
mediaExternalUrl
mediaExternalUrl,
resolveMediaDisplaySrc
} from './media'
describe('isRemoteGateway', () => {
@@ -75,6 +77,68 @@ describe('mediaExternalUrl', () => {
})
})
describe('resolveMediaDisplaySrc', () => {
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/read-data-url?')) {
return { dataUrl: 'data:image/png;base64,ZHVtbXk=' }
}
throw new Error(`unexpected path ${path}`)
})
beforeEach(() => {
api.mockClear()
})
afterEach(() => {
vi.unstubAllGlobals()
$connection.set(null)
})
it('recognizes inline image URLs', () => {
expect(isInlineMediaSrc('https://example.com/a.png')).toBe(true)
expect(isInlineMediaSrc('data:image/png;base64,ZHVtbXk=')).toBe(true)
expect(isInlineMediaSrc('/Users/me/a.png')).toBe(false)
})
it('leaves web, data, and relative markdown image sources unchanged', async () => {
vi.stubGlobal('window', { hermesDesktop: { api } })
$connection.set({ mode: 'remote', profile: 'remote-work' } as never)
await expect(resolveMediaDisplaySrc('https://example.com/a.png')).resolves.toBe('https://example.com/a.png')
await expect(resolveMediaDisplaySrc('data:image/png;base64,ZHVtbXk=')).resolves.toBe(
'data:image/png;base64,ZHVtbXk='
)
await expect(resolveMediaDisplaySrc('images/a.png')).resolves.toBe('images/a.png')
await expect(resolveMediaDisplaySrc('./images/a.png')).resolves.toBe('./images/a.png')
await expect(resolveMediaDisplaySrc('../images/a.png')).resolves.toBe('../images/a.png')
expect(api).not.toHaveBeenCalled()
})
it('reads remote gateway-local file paths through the desktop fs bridge', async () => {
vi.stubGlobal('window', { hermesDesktop: { api } })
$connection.set({ mode: 'remote', profile: 'remote-work' } as never)
await expect(resolveMediaDisplaySrc('/Users/me/project/a b.png')).resolves.toBe('data:image/png;base64,ZHVtbXk=')
expect(api).toHaveBeenCalledWith({
path: '/api/fs/read-data-url?path=%2FUsers%2Fme%2Fproject%2Fa%20b.png',
profile: 'remote-work'
})
})
it('reads local desktop file paths from the local desktop shell', async () => {
const readFileDataUrl = vi.fn(async () => 'data:image/png;base64,bG9jYWw=')
vi.stubGlobal('window', { hermesDesktop: { readFileDataUrl } })
$connection.set({ mode: 'local' } as never)
await expect(resolveMediaDisplaySrc('file:///Users/me/project/a%20b.png')).resolves.toBe(
'data:image/png;base64,bG9jYWw='
)
expect(readFileDataUrl).toHaveBeenCalledWith('/Users/me/project/a b.png')
})
})
describe('gatewayMediaDataUrl', () => {
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/read-data-url?')) {
+24
View File
@@ -58,6 +58,30 @@ export function mediaMarkdownHref(path: string): string {
return `#media:${encodeURIComponent(path)}`
}
export function isInlineMediaSrc(path: string): boolean {
return /^(?:https?|data):/i.test(path)
}
function isFileMediaPath(path: string): boolean {
return /^(?:file:|\/|~\/|[a-z]:[\\/]|\\\\)/i.test(path)
}
export async function resolveMediaDisplaySrc(path: string): Promise<string> {
if (isInlineMediaSrc(path) || !isFileMediaPath(path)) {
return path
}
if (window.hermesDesktop && isRemoteGateway()) {
return gatewayMediaDataUrl(path)
}
if (!window.hermesDesktop?.readFileDataUrl) {
return mediaExternalUrl(path)
}
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
}
// Resolve a media path to a URL the shell can open. Remote mode rewrites
// gateway-local paths to an authenticated /api/files/download URL (the file
// lives on the gateway, not this disk); local mode keeps the file:// form.
@@ -12,6 +12,24 @@ describe('isProviderSetupErrorMessage', () => {
expect(isProviderSetupErrorMessage('set an API key (OPENROUTER_API_KEY) in ~/.hermes/.env')).toBe(true)
})
it('matches the exact empty-key warning emitted in session.info', () => {
expect(
isProviderSetupErrorMessage("No API key configured for provider 'openrouter'. First message will fail.")
).toBe(true)
})
it('does not match bare env var mentions from auxiliary warnings', () => {
expect(isProviderSetupErrorMessage('OPENROUTER_API_KEY not set')).toBe(false)
expect(isProviderSetupErrorMessage('Run `hermes setup` or set OPENROUTER_API_KEY.')).toBe(false)
expect(
isProviderSetupErrorMessage(
'⚠ No auxiliary LLM provider configured — context compression will drop middle turns without a summary. Run `hermes setup` or set OPENROUTER_API_KEY.'
)
).toBe(false)
expect(isProviderSetupErrorMessage('OPENAI_API_KEY missing')).toBe(false)
expect(isProviderSetupErrorMessage('ANTHROPIC_API_KEY not found')).toBe(false)
})
it('does not match non-provider runtime failures', () => {
expect(
isProviderSetupErrorMessage('Selected runtime is not available. setup.status reports configured credentials.')
@@ -1,5 +1,7 @@
const PROVIDER_SETUP_ERROR_RE =
/No (?:inference|Hermes) provider(?: is)? configured|no_provider_configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i
/No (?:inference|Hermes) provider(?: is)? configured|no_provider_configured|set an API key/i
const SESSION_INFO_CREDENTIAL_WARNING_RE = /^No API key configured for provider '[^']*'\. First message will fail\.$/
export function isProviderSetupErrorMessage(message: null | string | undefined): boolean {
const text = message?.trim()
@@ -8,5 +10,5 @@ export function isProviderSetupErrorMessage(message: null | string | undefined):
return false
}
return PROVIDER_SETUP_ERROR_RE.test(text)
return PROVIDER_SETUP_ERROR_RE.test(text) || SESSION_INFO_CREDENTIAL_WARNING_RE.test(text)
}
@@ -0,0 +1,217 @@
import { beforeEach, expect, test } from 'vitest'
import {
type AgentNoticePayload,
clearAgentNotice,
nativeNoticeInput,
noticeAccent,
noticeToToast,
showAgentNotice,
splitMeta,
stripGlyph,
usageFraction
} from './agent-notices'
import { $notifications, clearNotifications } from './notifications'
function usage(overrides: Partial<AgentNoticePayload> = {}): AgentNoticePayload {
return {
key: 'credits.usage',
kind: 'sticky',
level: 'info',
text: "• You've used $110.00 of your $220.00 cap",
...overrides
}
}
beforeEach(() => {
clearNotifications()
})
// ── noticeToToast: the whole mapping contract ────────────────────────────────
test('drops a notice with no text', () => {
expect(noticeToToast(undefined)).toBeNull()
expect(noticeToToast({ text: '' })).toBeNull()
expect(noticeToToast({ text: ' ' })).toBeNull()
})
test('level maps to toast kind (warn → warning)', () => {
expect(noticeToToast(usage({ level: 'info' }))?.kind).toBe('info')
expect(noticeToToast(usage({ level: 'warn' }))?.kind).toBe('warning')
expect(noticeToToast(usage({ level: 'error' }))?.kind).toBe('error')
expect(noticeToToast(usage({ level: 'success' }))?.kind).toBe('success')
})
test('unknown / missing level falls back to info', () => {
expect(noticeToToast({ text: 'x', level: 'bogus' })?.kind).toBe('info')
expect(noticeToToast({ text: 'x' })?.kind).toBe('info')
})
test('sticky notices never auto-dismiss', () => {
expect(noticeToToast(usage({ kind: 'sticky' }))?.durationMs).toBe(0)
})
test('ttl notice carries its ttl_ms as the duration', () => {
const toast = noticeToToast({
key: 'credits.restored',
kind: 'ttl',
level: 'success',
text: '✓ restored',
ttl_ms: 8000
})
expect(toast?.durationMs).toBe(8000)
})
test('ttl notice without a usable ttl_ms defers to notify()’s default', () => {
expect(noticeToToast({ text: 'x', kind: 'ttl' })?.durationMs).toBeUndefined()
expect(noticeToToast({ text: 'x', kind: 'ttl', ttl_ms: 0 })?.durationMs).toBeUndefined()
})
test('the notice key is the toast id, falling back to id', () => {
expect(noticeToToast(usage({ key: 'credits.usage' }))?.id).toBe('credits.usage')
expect(noticeToToast({ text: 'x', id: 'n1', key: undefined })?.id).toBe('n1')
})
test('the leading severity glyph is stripped from the toast message', () => {
// The toast renders a kind icon, so the message must not double up on a glyph.
expect(noticeToToast(usage())?.message).toBe("You've used $110.00 of your $220.00 cap")
expect(noticeToToast(usage({ level: 'error', text: '✕ Credit access paused' }))?.message).toBe('Credit access paused')
expect(noticeToToast(usage({ level: 'success', text: '✓ Credit access restored' }))?.message).toBe(
'Credit access restored'
)
})
test('the trailing "· detail" is split off as a secondary meta line, not inlined', () => {
// grant_spent still carries a `· detail` tail.
const grant = noticeToToast({ key: 'credits.grant_spent', level: 'info', text: '• Grant spent · $12.00 top-up left' })
expect(grant?.message).toBe('Grant spent')
expect(grant?.meta).toBe('$12.00 top-up left')
// The usage line has no middot → whole line is the message, no meta.
const plain = noticeToToast(usage())
expect(plain?.message).toBe("You've used $110.00 of your $220.00 cap")
expect(plain?.meta).toBeUndefined()
})
test('splitMeta splits on the first space-middot-space only', () => {
expect(splitMeta('Grant spent · $12.00 top-up left')).toEqual(['Grant spent', '$12.00 top-up left'])
expect(splitMeta('Credit access restored')).toEqual(['Credit access restored', undefined])
// Interior middots after the first split stay in the meta.
expect(splitMeta('a · b · c')).toEqual(['a', 'b · c'])
})
test('stripGlyph removes only a single leading severity glyph', () => {
expect(stripGlyph('• Credits 50% used')).toBe('Credits 50% used')
expect(stripGlyph('⚠ warn')).toBe('warn')
expect(stripGlyph('✕ paused')).toBe('paused')
expect(stripGlyph('✓ ok')).toBe('ok')
// No leading glyph → unchanged; interior glyphs are preserved.
expect(stripGlyph('Credits 50% used')).toBe('Credits 50% used')
expect(stripGlyph('spent · $12.00 • top-up left')).toBe('spent · $12.00 • top-up left')
})
// ── noticeAccent: severity color ramp keyed off $used / $cap ─────────────────
test('usageFraction derives $used / $cap from the notice text', () => {
expect(usageFraction("You've used $15.00 of your $20.00 cap")).toBeCloseTo(0.75)
expect(usageFraction("You've used $198.00 of your $220.00 cap")).toBeCloseTo(0.9)
// Fewer than two amounts, or a zero cap → no fraction.
expect(usageFraction('Grant spent')).toBeNull()
expect(usageFraction("You've used $5.00 of your $0.00 cap")).toBeNull()
expect(usageFraction(undefined)).toBeNull()
})
test('usage accent stays muted below 75%, then ramps orange → red', () => {
expect(noticeAccent(usage({ text: "• You've used $10.00 of your $20.00 cap" }))).toBeUndefined() // 50%
expect(noticeAccent(usage({ text: "• You've used $14.80 of your $20.00 cap" }))).toBeUndefined() // 74%
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))).toBe(
'var(--ui-orange)'
) // 75%
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $17.80 of your $20.00 cap" }))).toBe(
'var(--ui-orange)'
) // 89%
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $18.00 of your $20.00 cap" }))).toBe('var(--ui-red)') // 90%
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $20.00 of your $20.00 cap" }))).toBe('var(--ui-red)') // 100%
})
test('terminal credit states carry their own accent; others stay default', () => {
expect(noticeAccent({ key: 'credits.depleted', text: '✕ paused' })).toBe('var(--ui-red)')
expect(noticeAccent({ key: 'credits.restored', text: '✓ restored' })).toBe('var(--ui-green)')
expect(noticeAccent({ key: 'credits.grant_spent', text: '• Grant spent' })).toBeUndefined()
expect(noticeAccent(undefined)).toBeUndefined()
})
test('noticeToToast attaches the band accent to the toast', () => {
expect(noticeToToast(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))?.accentColor).toBe(
'var(--ui-orange)'
)
expect(noticeToToast(usage({ text: "• You've used $10.00 of your $20.00 cap" }))?.accentColor).toBeUndefined()
})
// ── show / clear: rendered through the notifications store ────────────────────
test('showAgentNotice renders a toast; empty text is a no-op', () => {
showAgentNotice(usage())
expect($notifications.get()).toHaveLength(1)
expect($notifications.get()[0]?.id).toBe('credits.usage')
showAgentNotice({ text: '' })
expect($notifications.get()).toHaveLength(1)
})
test('re-emitting the same key replaces the toast instead of stacking (50→75→90)', () => {
showAgentNotice(usage({ level: 'info', text: "• You've used $10.00 of your $20.00 cap" }))
showAgentNotice(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))
showAgentNotice(usage({ level: 'warn', text: "⚠ You've used $18.00 of your $20.00 cap" }))
const toasts = $notifications.get().filter(item => item.id === 'credits.usage')
expect(toasts).toHaveLength(1)
expect(toasts[0]?.message).toBe("You've used $18.00 of your $20.00 cap")
expect(toasts[0]?.kind).toBe('warning')
})
test('clearAgentNotice dismisses only the matching key', () => {
showAgentNotice(usage())
showAgentNotice({ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ paused' })
expect($notifications.get()).toHaveLength(2)
clearAgentNotice('credits.usage')
const ids = $notifications.get().map(item => item.id)
expect(ids).toContain('credits.depleted')
expect(ids).not.toContain('credits.usage')
clearAgentNotice(undefined)
expect($notifications.get()).toHaveLength(1)
})
// ── nativeNoticeInput: only the urgent credit pair breaks through the OS ──────
test('only credits.depleted and credits.restored map to a native notification', () => {
expect(nativeNoticeInput(usage({ key: 'credits.usage' }), 'Credits')).toBeNull()
expect(nativeNoticeInput(usage({ key: 'credits.grant_spent' }), 'Credits')).toBeNull()
expect(nativeNoticeInput({ text: 'x', key: undefined }, 'Credits')).toBeNull()
expect(nativeNoticeInput({ text: '', key: 'credits.depleted' }, 'Credits')).toBeNull()
})
test('the urgent pair maps to a global native input carrying the text as its body', () => {
const depleted = nativeNoticeInput(
{ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
'Credits'
)
expect(depleted).toEqual({
body: '✕ Credit access paused · run /topup to top up',
global: true,
kind: 'credits',
title: 'Credits'
})
const restored = nativeNoticeInput(
{ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 },
'Credits'
)
expect(restored?.kind).toBe('credits')
expect(restored?.body).toBe('✓ Credit access restored')
})
+206
View File
@@ -0,0 +1,206 @@
import type { NativeNotificationInput } from '@/store/native-notifications'
import { dismissNotification, type NotificationInput, type NotificationKind, notify } from '@/store/notifications'
/**
* Wire shape of a `notification.show` payload — the driver-agnostic
* `AgentNotice` spine (`agent/credits_tracker.py`) as forwarded by
* `tui_gateway/server.py`. Snake_case to match the wire.
*
* The `text` carries its own leading severity glyph (• ⚠ ✕ ✓) from the Python
* policy — that's how the CLI/TUI render it (glyph in a status line, no separate
* icon). The desktop toast is different: every toast renders a kind icon, so we
* strip the leading glyph and let that icon carry severity (see `stripGlyph`),
* otherwise the toast shows two markers. The native OS notification keeps the
* glyph (it has no icon of ours).
*
* - `level` is severity: info | warn | error | success.
* - `kind` is lifetime: `sticky` (stays until an explicit clear) or `ttl`
* (self-expires after `ttl_ms`).
*/
export interface AgentNoticePayload {
text?: string
level?: string
kind?: string
ttl_ms?: null | number
key?: string
id?: string
}
const LEVEL_TO_TOAST_KIND: Record<string, NotificationKind> = {
error: 'error',
info: 'info',
success: 'success',
warn: 'warning'
}
// The severity glyphs the Python notice policy prefixes (`•` `⚠` `✕`/`✗` `✓`),
// optionally with a variation selector, plus trailing space. Stripped for the
// desktop toast because the toast already renders a kind icon.
const LEADING_GLYPH = /^[•⚠✕✗✓]\uFE0F?\s*/u
/** Drop a single leading severity glyph so the toast doesn't double up on it. */
export function stripGlyph(text: string): string {
return text.replace(LEADING_GLYPH, '')
}
/** A `$12.34` money token, as the Nous notice policy formats amounts. */
const MONEY = /\$(\d+(?:\.\d{2})?)/g
/**
* Used fraction of the cap derived from a "You've used $X of your $Y cap" notice
* — the first two money tokens are (used, cap). Returns a value in [0, 1], or
* `null` when the line has no usable pair.
*/
export function usageFraction(text: string | undefined): null | number {
const amounts = [...(text ?? '').matchAll(MONEY)].map(m => Number(m[1]))
if (amounts.length < 2 || !(amounts[1] > 0)) {
return null
}
return amounts[0] / amounts[1]
}
/**
* Accent color for a credit notice, as a range/severity ramp. The usage gauge
* keys off how much of the cap is spent ($used / $cap): it stays on the toast's
* default muted color under 75%, then escalates to orange, then red, as the cap
* nears. `credits.depleted` is red (paused) and `credits.restored` green.
*
* Returns a CSS color token or `undefined` (= keep the default muted color).
* These reuse the app's existing usage palette (`--ui-*`; see the
* `--context-usage-*` block in styles.css) — no new colors are introduced.
*/
export function noticeAccent(payload: AgentNoticePayload | undefined): string | undefined {
if (payload?.key === 'credits.depleted') {
return 'var(--ui-red)'
}
if (payload?.key === 'credits.restored') {
return 'var(--ui-green)'
}
const frac = usageFraction(payload?.text)
if (frac === null) {
return undefined
}
if (frac >= 0.9) {
return 'var(--ui-red)'
}
if (frac >= 0.75) {
return 'var(--ui-orange)'
}
return undefined
}
/**
* Map an agent notice to a toast input, or `null` when it carries no text.
*
* Pure and side-effect free so it can be unit-tested directly. The mapping is
* the whole contract:
* - `level` → toast kind (info/warn/error/success, warn→warning).
* - `sticky` → `durationMs: 0` (persists); `ttl` → `durationMs: ttl_ms`.
* - the notice `key` doubles as the toast `id`, so re-emitting the same key
* REPLACES the prior toast — the credits 50→75→90 line escalates in place
* instead of stacking, and a key-matched `notification.clear` can dismiss it.
*/
export function noticeToToast(payload: AgentNoticePayload | undefined): NotificationInput | null {
const text = payload?.text?.trim()
if (!text) {
return null
}
const isTtl = payload?.kind === 'ttl'
const ttl = typeof payload?.ttl_ms === 'number' && payload.ttl_ms > 0 ? payload.ttl_ms : undefined
// The Python notice text packs a trailing detail after a middot
// (`… used · $220.00 cap`, `Grant spent · $12.00 top-up left`). On one CLI/TUI
// status line that reads fine, but the toast follows the title-plus-description
// convention (Sonner/shadcn): the primary status is the message and the detail
// drops to a muted second line, instead of inlining a `·` separator.
const [primary, meta] = splitMeta(stripGlyph(text))
return {
// Icon + text tint by usage band (muted → orange → red); undefined keeps
// the default muted color.
accentColor: noticeAccent(payload),
// sticky → 0 (never auto-dismiss); ttl with a ttl_ms → that value; a ttl
// without a usable ttl_ms falls back to notify()'s per-kind default.
durationMs: isTtl ? ttl : 0,
id: payload?.key || payload?.id,
kind: LEVEL_TO_TOAST_KIND[payload?.level ?? 'info'] ?? 'info',
message: primary,
meta
}
}
/**
* Split a notice line into its primary status and a trailing detail on the first
* ` · ` (space-middot-space). No middot → the whole line is the primary and
* there's no meta.
*/
export function splitMeta(text: string): [primary: string, meta: string | undefined] {
const at = text.indexOf(' · ')
if (at === -1) {
return [text, undefined]
}
return [text.slice(0, at), text.slice(at + 3) || undefined]
}
/** Render a `notification.show` notice as a toast (no-op when it has no text). */
export function showAgentNotice(payload: AgentNoticePayload | undefined): void {
const toast = noticeToToast(payload)
if (toast) {
notify(toast)
}
}
/**
* Dismiss the toast a `notification.clear` targets. The clear only ever names a
* `key`, which we used as the toast id, so this is a key-matched dismissal.
*/
export function clearAgentNotice(key: string | undefined): void {
if (key) {
dismissNotification(key)
}
}
// Only these two credit notices are urgent enough to break through as a native
// OS notification (when Hermes is backgrounded). The escalating usage line
// (`credits.usage`) and the grant-spent notice stay in-app toasts only — they
// aren't worth interrupting the user's OS for.
const NATIVE_NOTICE_KEYS = new Set(['credits.depleted', 'credits.restored'])
/**
* Map a notice to a native OS notification input, or `null` when it isn't one of
* the urgent credit notices. Pure — the caller passes the localized `title` and
* decides whether to dispatch. `global: true` because credit state is
* account-wide, not tied to a chat session, so it should fire whenever the user
* is away regardless of which session (if any) is focused. The notice `text`
* already carries its glyph and is passed through as the raw body.
*/
export function nativeNoticeInput(
payload: AgentNoticePayload | undefined,
title: string
): NativeNotificationInput | null {
const text = payload?.text?.trim()
if (!text || !payload?.key || !NATIVE_NOTICE_KEYS.has(payload.key)) {
return null
}
return {
body: text,
global: true,
kind: 'credits',
title
}
}
+41
View File
@@ -5,6 +5,7 @@ import {
$clarifyRequests,
type ClarifyRequest,
clearClarifyRequest,
normalizeChoices,
setClarifyRequest
} from './clarify'
import { $activeSessionId } from './session'
@@ -79,3 +80,43 @@ describe('clarify store', () => {
expect($clarifyRequests.get()['session-b']?.requestId).toBe('other')
})
})
describe('normalizeChoices', () => {
it('returns empty array for null/undefined', () => {
expect(normalizeChoices(null)).toEqual([])
expect(normalizeChoices(undefined)).toEqual([])
})
it('returns empty array for non-array input', () => {
expect(normalizeChoices('hello')).toEqual([])
expect(normalizeChoices(42)).toEqual([])
expect(normalizeChoices({})).toEqual([])
})
it('filters out non-string items', () => {
expect(normalizeChoices(['a', 42, 'b', null, 'c'])).toEqual(['a', 'b', 'c'])
})
it('drops blank and whitespace-only strings', () => {
expect(normalizeChoices(['a', '', 'b', ' ', 'c'])).toEqual(['a', 'b', 'c'])
})
it('drops strings with newlines', () => {
expect(normalizeChoices(['a', 'b\nc', 'd'])).toEqual(['a', 'd'])
})
it('drops strings over 200 chars', () => {
const long = 'x'.repeat(201)
const ok = 'y'.repeat(200)
expect(normalizeChoices(['a', long, ok])).toEqual(['a', ok])
})
it('drops empty items and keeps valid ones', () => {
expect(normalizeChoices(['valid', ' ', '', 'also valid'])).toEqual(['valid', 'also valid'])
})
it('returns empty array when nothing survives', () => {
expect(normalizeChoices(['', ' ', null, undefined])).toEqual([])
expect(normalizeChoices([])).toEqual([])
})
})
+30
View File
@@ -9,6 +9,36 @@ export interface ClarifyRequest {
sessionId: string | null
}
/**
* Validate and normalize a choices array.
*
* Keeps non-blank, newline-free strings of length ≤ 200; drops everything else
* and returns an empty array when nothing usable survives — the caller then
* falls back to a free-text answer instead of dead buttons.
*/
export function normalizeChoices(choices: unknown): string[] {
if (!Array.isArray(choices)) {
return []
}
return choices.filter(
(c): c is string => typeof c === 'string' && c.trim().length > 0 && c.length <= 200 && !c.includes('\n')
)
}
/**
* Structured warning for a clarify payload that arrived with choices but had
* them all normalized away — keeps the remaining #69122 "no selectable choices"
* triggers diagnosable in the field without dead constant fields.
*/
export function warnDroppedChoices(source: 'gateway' | 'tool_args', question: string, rawChoices: unknown): void {
console.warn('[clarify] choices dropped after normalization', {
choices_count: Array.isArray(rawChoices) ? rawChoices.length : 0,
question_length: question.length,
source
})
}
// Pending clarify requests keyed by the runtime session id that raised them.
// Storing per-session (instead of one shared slot) lets a *background* session
// park its clarify request while the user is looking at a different chat, then
+122 -56
View File
@@ -20,30 +20,10 @@ const normKey = (profile: string | null | undefined): string => (profile ?? '').
// narrow the getter to a constant across guards (it genuinely changes).
const isOpen = (gateway: HermesGateway | null): boolean => gateway?.connectionState === 'open'
// The active gateway instance, exposed for inline message-stream components
// (e.g. inline ClarifyTool, model overlays) that call gateway methods without
// the instance threaded down through props.
export const $gateway = atom<HermesGateway | null>(null)
interface RegistryConfig {
onEvent: (event: GatewayEvent) => void
}
let config: RegistryConfig | null = null
export function configureGatewayRegistry(cfg: RegistryConfig): void {
config = cfg
}
// ── Primary (window) backend ───────────────────────────────────────────────
let primaryGateway: HermesGateway | null = null
let primaryProfile = 'default'
export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void {
primaryGateway = gateway
primaryProfile = normKey(profile)
}
// ── Secondary (pool) backends ──────────────────────────────────────────────
interface Secondary {
profile: string
@@ -58,20 +38,97 @@ interface Secondary {
wantOpen: boolean
}
const secondaries = new Map<string, Secondary>()
// ── HMR-stable module state ─────────────────────────────────────────────────
// All mutable singletons (live sockets, active-profile routing, the event
// registry) live in ONE container parked on globalThis, NOT in module-level
// `let`/`const` bindings. Reason: this module is imported widely without an HMR
// boundary that accepts it, so editing it (or anything that fans out to it)
// makes Vite issue a FULL PAGE RELOAD — which would kill every live socket and
// drop the agent session on an unrelated edit. Persisting the state on
// globalThis + self-accepting HMR (bottom of file) turns that full reload into
// an in-place hot update that preserves the sockets. Production strips
// import.meta.hot, and a fresh page realm starts with an empty container, so the
// runtime behavior is identical to plain module state.
interface GatewayRegistryState {
config: RegistryConfig | null
primaryGateway: HermesGateway | null
primaryProfile: string
activeKey: string
secondaries: Map<string, Secondary>
$gateway: ReturnType<typeof atom<HermesGateway | null>>
}
let activeKey = 'default'
const STATE_KEY = Symbol.for('hermes.desktop.gatewayRegistryState')
function createRegistryState(): GatewayRegistryState {
return {
config: null,
primaryGateway: null,
primaryProfile: 'default',
activeKey: 'default',
secondaries: new Map<string, Secondary>(),
// The active gateway instance, exposed for inline message-stream
// components (inline ClarifyTool, model overlays) that call gateway
// methods without the instance threaded down through props.
$gateway: atom<HermesGateway | null>(null)
}
}
// Dev only: park the singletons on globalThis so an HMR re-eval of this module
// (self-accepted at the bottom) hands back the SAME live sockets/atoms instead
// of resetting them — that's what keeps the agent session alive across UI edits.
// `import.meta.hot` is undefined in production, so Vite dead-code-eliminates the
// entire globalThis branch and prod uses a plain module-local singleton — no
// globalThis, no Symbol.for. Both realms load the module once, so the container's
// shape and lifetime are identical either way.
function gatewayState(): GatewayRegistryState {
if (import.meta.hot) {
const store = globalThis as unknown as { [STATE_KEY]?: GatewayRegistryState }
store[STATE_KEY] ??= createRegistryState()
return store[STATE_KEY]
}
return createRegistryState()
}
const g = gatewayState()
// Re-exported as a stable binding: the atom instance lives in `g`, so every hot
// reload of this module hands back the SAME atom subscribers are already wired
// to. (A fresh `atom()` per reload would orphan existing subscriptions.)
export const $gateway = g.$gateway
export function configureGatewayRegistry(cfg: RegistryConfig): void {
g.config = cfg
}
/**
* Feed a synthetic event through the exact same fan-out a real socket frame
* takes (`config.onEvent` → the desktop's `handleGatewayEvent`). Used by
* dev-only tooling to exercise the real event branches (e.g. the credit-notice
* demo) without a backend that can produce the event on demand. No-op until a
* registry is configured.
*/
export function emitLocalGatewayEvent(event: GatewayEvent): void {
g.config?.onEvent(event)
}
export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void {
g.primaryGateway = gateway
g.primaryProfile = normKey(profile)
}
export function isActivePrimary(): boolean {
return activeKey === primaryProfile
return g.activeKey === g.primaryProfile
}
export function activeGateway(): HermesGateway | null {
if (activeKey === primaryProfile) {
return primaryGateway
if (g.activeKey === g.primaryProfile) {
return g.primaryGateway
}
return secondaries.get(activeKey)?.gateway ?? primaryGateway
return g.secondaries.get(g.activeKey)?.gateway ?? g.primaryGateway
}
// Mirror a backend's connection state into the global composer state, but only
@@ -79,19 +136,19 @@ export function activeGateway(): HermesGateway | null {
// composer reflect the active profile's socket without a background reconnect
// flipping the foreground enabled/disabled state.
function reportGatewayState(profile: string, state: ConnectionState): void {
if (normKey(profile) === activeKey) {
if (normKey(profile) === g.activeKey) {
setGatewayState(state)
}
}
export function reportPrimaryGatewayState(state: ConnectionState): void {
reportGatewayState(primaryProfile, state)
reportGatewayState(g.primaryProfile, state)
}
function setActive(profile: string): void {
activeKey = normKey(profile)
g.activeKey = normKey(profile)
const gateway = activeGateway()
$gateway.set(gateway)
g.$gateway.set(gateway)
setGatewayState(gateway?.connectionState ?? 'closed')
}
@@ -164,7 +221,7 @@ function createSecondary(profile: string): Secondary {
wantOpen: true
}
entry.offEvent = gateway.onEvent(event => config?.onEvent({ ...event, profile }))
entry.offEvent = gateway.onEvent(event => g.config?.onEvent({ ...event, profile }))
entry.offState = gateway.onState(state => {
reportGatewayState(profile, state)
@@ -176,7 +233,7 @@ function createSecondary(profile: string): Secondary {
}
})
secondaries.set(profile, entry)
g.secondaries.set(profile, entry)
return entry
}
@@ -190,11 +247,11 @@ function createSecondary(profile: string): Secondary {
export async function openGatewayForProfile(profile: string): Promise<void> {
const key = normKey(profile)
if (key === primaryProfile) {
if (key === g.primaryProfile) {
return
}
const entry = secondaries.get(key) ?? createSecondary(key)
const entry = g.secondaries.get(key) ?? createSecondary(key)
entry.wantOpen = true
if (!isOpen(entry.gateway)) {
@@ -207,13 +264,13 @@ export async function openGatewayForProfile(profile: string): Promise<void> {
export async function ensureGatewayForProfile(profile: string): Promise<void> {
const key = normKey(profile)
if (key === primaryProfile) {
if (key === g.primaryProfile) {
setActive(key)
return
}
let entry = secondaries.get(key)
let entry = g.secondaries.get(key)
if (!entry) {
entry = createSecondary(key)
@@ -238,11 +295,11 @@ export async function ensureGatewayForProfile(profile: string): Promise<void> {
// Reconnect the active gateway after a transient request failure. Primary
// reconnects are owned by use-gateway-boot, so we only drive secondaries here.
export async function ensureActiveGatewayOpen(): Promise<HermesGateway | null> {
if (activeKey === primaryProfile) {
return primaryGateway
if (g.activeKey === g.primaryProfile) {
return g.primaryGateway
}
const entry = secondaries.get(activeKey)
const entry = g.secondaries.get(g.activeKey)
if (!entry) {
return null
@@ -257,7 +314,7 @@ export async function ensureActiveGatewayOpen(): Promise<HermesGateway | null> {
// Wake signal (sleep/network/visibility): nudge every live secondary back open.
export function reconnectSecondaryGateways(): void {
for (const entry of secondaries.values()) {
for (const entry of g.secondaries.values()) {
if (!entry.wantOpen || isOpen(entry.gateway)) {
continue
}
@@ -273,38 +330,47 @@ export function reconnectSecondaryGateways(): void {
export function touchSecondaryGateways(): void {
const desktop = window.hermesDesktop
for (const entry of secondaries.values()) {
for (const entry of g.secondaries.values()) {
if (entry.wantOpen) {
void desktop?.touchBackend?.(entry.profile).catch(() => undefined)
}
}
}
// Tear a secondary down: stop its reconnect loop, detach listeners, close the
// socket. Caller handles removal from the map.
function disposeSecondary(entry: Secondary): void {
entry.wantOpen = false
clearTimer(entry)
entry.offEvent()
entry.offState()
entry.gateway.close()
}
// Close + evict secondaries whose profile is neither active nor in `keep`
// (profiles with a running / needs-input session). Bounds cost to live work.
export function pruneSecondaryGateways(keep: Set<string>): void {
for (const [key, entry] of [...secondaries]) {
if (key === activeKey || keep.has(key)) {
for (const [key, entry] of [...g.secondaries]) {
if (key === g.activeKey || keep.has(key)) {
continue
}
entry.wantOpen = false
clearTimer(entry)
entry.offEvent()
entry.offState()
entry.gateway.close()
secondaries.delete(key)
disposeSecondary(entry)
g.secondaries.delete(key)
}
}
export function closeSecondaryGateways(): void {
for (const entry of secondaries.values()) {
entry.wantOpen = false
clearTimer(entry)
entry.offEvent()
entry.offState()
entry.gateway.close()
for (const entry of g.secondaries.values()) {
disposeSecondary(entry)
}
secondaries.clear()
g.secondaries.clear()
}
// Self-accept so editing this module (or a fan-out that lands here) is an
// in-place hot update instead of a full page reload — the live sockets in `g`
// survive the swap. Dev-only: production strips import.meta.hot.
if (import.meta.hot) {
import.meta.hot.accept()
}
+1
View File
@@ -152,6 +152,7 @@ function migrateWorkspaceCollapsedIds(): Record<string, boolean> {
return {}
}
}
// Auto-derived (git-repo) projects the user has dismissed ("deleted") from the
// overview. Keyed by repo-root path; persisted so they stay hidden. Explicit
// projects are deleted for real instead — this only declutters the auto tier.
@@ -8,14 +8,15 @@ import { $activeSessionId } from './session'
// Native OS notifications (Electron `Notification`), separate from the in-app
// toast feed in `notifications.ts`. Each kind toggles independently.
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError'
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError'
export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
'approval',
'input',
'turnDone',
'turnError',
'backgroundDone'
'backgroundDone',
'credits'
]
// Blocking prompts — surface even while focused if they're for another session.
@@ -30,7 +31,7 @@ const STORAGE_KEY = 'hermes:native-notifications'
const DEFAULT_PREFS: NativeNotificationPrefs = {
enabled: true,
kinds: { approval: true, backgroundDone: true, input: true, turnDone: true, turnError: true }
kinds: { approval: true, backgroundDone: true, credits: true, input: true, turnDone: true, turnError: true }
}
function readPrefs(): NativeNotificationPrefs {
@@ -0,0 +1,34 @@
import { beforeEach, expect, test } from 'vitest'
import { $notifications, clearNotifications, notifyError } from './notifications'
beforeEach(() => {
clearNotifications()
})
function lastMessage(): string {
return $notifications.get()[0]?.message ?? ''
}
// Regression for #39365: a gateway auth 401 (bad API_SERVER_KEY) must not be
// summarized as a provider (OpenAI/OpenRouter) API key problem.
test('gateway_auth_failed error is summarized as gateway auth, not provider key', () => {
notifyError(
new Error(
'401 {"error": {"message": "Invalid gateway API key (API_SERVER_KEY)", "type": "gateway_auth_error", "code": "gateway_auth_failed"}}'
),
'Request failed'
)
expect(lastMessage()).toContain('API_SERVER_KEY')
expect(lastMessage()).not.toMatch(/OpenAI/i)
})
test('provider invalid_api_key error still maps to the OpenAI summary', () => {
notifyError(
new Error('401 {"error": {"message": "Incorrect API key provided", "code": "invalid_api_key"}}'),
'Request failed'
)
expect(lastMessage()).toMatch(/OpenAI rejected the API key/i)
})

Some files were not shown because too many files have changed in this diff Show More