Merge upstream main into feat/hermes-relay-model-metrics

Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
Alex Fournier
2026-07-31 07:04:37 -07:00
102 changed files with 8700 additions and 664 deletions
+2 -2
View File
@@ -114,6 +114,7 @@ def _named_custom_provider_catalogs() -> list[tuple[str, str, list[tuple[str, st
load_config,
)
from hermes_cli.models import fetch_api_models
from hermes_cli.providers import custom_provider_slug
except ImportError:
return []
@@ -145,8 +146,7 @@ def _named_custom_provider_catalogs() -> list[tuple[str, str, list[tuple[str, st
base_url = str(entry.get("base_url", "") or "").strip()
if not name or not base_url:
continue
slug_source = provider_key or name
slug = "custom:" + slug_source.strip().lower().replace(" ", "-")
slug = custom_provider_slug(name, provider_key)
api_key = str(entry.get("api_key", "") or "").strip()
if not api_key:
+35
View File
@@ -1963,6 +1963,31 @@ def init_agent(
compression_in_place = is_truthy_value(
_compression_cfg.get("in_place"), default=True
)
# Opt-in (default False): a micro-compaction pass rewrites already-sent
# history every turn, which breaks the provider prompt-cache prefix on a
# per-turn cadence rather than at an episodic boundary. That is the cost
# `proactive_prune_min_reclaim_tokens` exists to amortize, so the feature
# stays off until an operator opts in and accepts the tradeoff.
compression_micro_compact = is_truthy_value(
_compression_cfg.get("micro_compact"), default=False
)
# How often a pass runs, in completed turns. Each pass rewrites
# already-sent history and costs one prompt-cache break, so this is the
# dial for how often that cost is paid: 1 = every turn (most aggressive
# reclaim), 5 = one break per five turns. Clamped to >= 1.
compression_micro_compact_every_n_turns = max(
1,
_parse_prune_int(_compression_cfg.get("micro_compact_every_n_turns", 1), 1),
)
# Rolling-summary defrag threshold, in tokens. Lived on the compressor as
# a hardcoded attribute with no path from config until now.
compression_micro_compact_defrag_tokens = max(
1,
_parse_prune_int(
_compression_cfg.get("micro_compact_defrag_threshold_tokens", 2000),
2000,
),
)
codex_app_server_auto_compaction = str(
_compression_cfg.get("codex_app_server_auto", "native") or "native"
).lower()
@@ -2418,6 +2443,16 @@ def init_agent(
pass
agent.compression_enabled = compression_enabled
agent.compression_in_place = compression_in_place
# Apply micro-compaction settings to the compressor (feature is opt-in)
_cc = getattr(agent, "context_compressor", None)
if _cc is not None and hasattr(_cc, "_micro_compact_enabled"):
_cc._micro_compact_enabled = compression_micro_compact
if _cc is not None and hasattr(_cc, "_micro_compact_every_n_turns"):
_cc._micro_compact_every_n_turns = compression_micro_compact_every_n_turns
if _cc is not None and hasattr(_cc, "_micro_compact_defrag_threshold_tokens"):
_cc._micro_compact_defrag_threshold_tokens = (
compression_micro_compact_defrag_tokens
)
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
agent.max_compression_attempts = compression_max_attempts
agent.compression_idle_compact_after_seconds = (
+796
View File
@@ -137,6 +137,12 @@ LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# "is_compressed_summary" would reach the wire and trip exactly that.
COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary"
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY = "_compressed_summary_has_user_turn"
# Distinguishes rolling micro-compaction markers from batch-compaction
# markers (both carry COMPRESSED_SUMMARY_METADATA_KEY so resume/handoff
# treat them alike). Supersede/defrag/rehydration must only ever touch
# micro markers: a batch marker's content is NOT contained in the micro
# rolling summary, so dropping or rewriting one destroys history.
MICRO_COMPACT_MARKER_KEY = "_micro_compact_marker"
_DB_PERSISTED_MARKER = "_db_persisted"
_NO_USER_TASK_SENTINEL = "None. This session contains no user-authored turns."
@@ -364,6 +370,11 @@ _SUMMARY_RATIO = 0.20
# itself a context-pressure source and slows every compaction.
_SUMMARY_TOKENS_CEILING = 10_000
# Micro-compaction failure guard: after this many consecutive failures on the
# same cursor position, skip the stuck exchange and advance the cursor so the
# system doesn't busy-loop on an unsummarizable exchange every turn.
_MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES = 3
# Aggregate cap on the serialized turn block fed to the summarizer prompt
# (chars). Per-message truncation (_CONTENT_MAX / _TOOL_ARGS_MAX) alone is
# not enough: a compression window with hundreds of already-truncated turns
@@ -1290,6 +1301,15 @@ class ContextCompressor(ContextEngine):
self._active_compression_telemetry = None
self._compression_telemetry_seed = None
# Micro-compaction state reset
self._micro_compact_cursor = 0
self._micro_compact_rolling_summary = ""
self._micro_compact_consecutive_failures = 0
self._micro_compact_last_failure_cursor = -1
self._micro_compact_passes = 0
self._micro_compact_tokens_saved_total = 0
self._micro_compact_turns_since_pass = 0
def _begin_compression_telemetry(
self,
*,
@@ -2136,6 +2156,25 @@ class ContextCompressor(ContextEngine):
# deterministic "summary unavailable" handoff and drop the middle window.
self.abort_on_summary_failure = abort_on_summary_failure
# ── Micro-compaction (per-turn rolling compaction) ─────────
# Default: OFF. Each pass rewrites already-sent history, so it breaks
# the prompt-cache prefix every turn instead of at an episodic
# boundary. Operators opt in via `compression.micro_compact: true`.
self._micro_compact_enabled: bool = False
self._micro_compact_cursor: int = 0
self._micro_compact_rolling_summary: str = ""
self._micro_compact_consecutive_failures: int = 0
self._micro_compact_last_failure_cursor: int = -1
self._micro_compact_defrag_threshold_tokens: int = 2000
self._micro_compact_passes: int = 0
self._micro_compact_tokens_saved_total: int = 0
# Cadence: run a pass every Nth completed turn. Each pass rewrites
# already-sent history and so breaks the prompt-cache prefix, which
# makes this the dial that sets how often that break is paid. 1 =
# every turn (most aggressive reclaim, one break per turn).
self._micro_compact_every_n_turns: int = 1
self._micro_compact_turns_since_pass: int = 0
# Defer context-length resolution to first access (#32221):
# get_model_context_length() can issue a synchronous /models HTTP
# probe, which must not block AIAgent construction. The small-context
@@ -4987,6 +5026,750 @@ This compaction should PRIORITISE preserving all information related to the focu
# Main compression entry point
# ------------------------------------------------------------------
def _resolve_compact_cursor(
self,
messages: List[Dict[str, Any]],
head_end: int,
tail_start: int,
) -> int:
"""Derive the micro-compaction cursor from in-memory state or transcript scan.
Returns the index of the first message that has NOT yet been absorbed
into the rolling summary. If the in-memory cursor ``_micro_compact_cursor``
is valid (non-zero and within the compressible window), use it directly.
Otherwise scan from *head_end* through *tail_start* for the last context
summary marker and set the cursor past it.
"""
if self._micro_compact_cursor > head_end and self._micro_compact_cursor < tail_start:
return self._micro_compact_cursor
# Scan transcript for the last summary marker
last_summary_idx = -1
for idx in range(head_end, tail_start):
if self._is_context_summary_message(messages[idx]):
last_summary_idx = idx
if last_summary_idx >= head_end:
cursor = last_summary_idx + 1
# Resumed session: in-memory state is gone but the marker survives.
# Carry its text forward so the next pass merges into the existing
# history instead of replacing it with a single-exchange summary.
if not self._micro_compact_rolling_summary.strip():
recovered = self._rolling_summary_from_marker(
messages[last_summary_idx].get("content")
)
if recovered:
self._micro_compact_rolling_summary = recovered
# Rehydration is containment proof: this marker's text now
# lives inside the rolling summary, so it becomes
# supersede/defrag-eligible. This also covers a BATCH
# marker adopted as the rolling base after a batch
# compaction reset — safe precisely because we just
# absorbed its content. Markers whose content we did NOT
# absorb never get the key and are never dropped.
messages[last_summary_idx][MICRO_COMPACT_MARKER_KEY] = True
logger.info(
"Micro-compaction: recovered rolling summary from "
"transcript (%d chars)", len(recovered),
)
else:
cursor = head_end
self._micro_compact_cursor = cursor
return cursor
def _find_one_exchange(
self,
messages: List[Dict[str, Any]],
start: int,
tail_start: int,
) -> Optional[tuple[int, int]]:
"""Find the next complete exchange starting at *start*.
An exchange is one full agent turn: the first assistant message after
*start* plus everything through the end of that turn — tool results
and any follow-up assistant iterations — up to (exclusive) the next
``user`` message. Returns ``(exchange_start, exchange_end)`` indices
into *messages*, or ``None`` if no complete, safely-spliceable turn is
available before *tail_start*.
The full-turn shape is an alternation-safety requirement, not a
convenience: the splice replaces the span with a single
``assistant``-role summary marker, so the span must be bounded by
user messages on the right (``messages[exchange_end]`` is ``user``).
Absorbing only the first assistant+tools group of a multi-iteration
turn would leave the marker adjacent to the turn's next assistant
message — two consecutive assistant turns, which strict providers
reject and ``repair_message_sequence`` would then mangle.
User messages are deliberately NOT part of an exchange. The walk skips
past them to reach the assistant message, and ``exchange_start`` is that
assistant index, so user turns are never absorbed into the rolling
summary and their text stays verbatim for the life of the session.
This is the intended behaviour, not an oversight: what the assistant
emits is largely an account of what it did, which survives summarising,
while the user's own words are the instructions everything else is
derived from and are the one thing that cannot be reconstructed from
context. They are also cheap — a prompt is normally a tiny fraction
of the tokens a single tool result costs.
"""
idx = start
n = len(messages)
if idx >= n or idx >= tail_start:
return None
# Walk past user messages and existing summary markers until we hit a
# real assistant message with actual output (content or tool_calls).
# Summary markers are assistant-role themselves, so they must be
# skipped explicitly or a rehydrated cursor could try to absorb the
# marker that carries the compacted history.
while idx < tail_start and idx < n:
msg = messages[idx]
if msg.get("role") == "assistant" and not self._is_context_summary_message(msg):
break
idx += 1
if idx >= tail_start or idx >= n:
return None
exchange_start = idx
# Consume the full turn: assistant / tool messages until the next
# user message (or an existing summary marker) ends the turn.
idx += 1
while idx < tail_start and idx < n:
msg = messages[idx]
role = msg.get("role")
if role not in ("assistant", "tool"):
break
if self._is_context_summary_message(msg):
break
idx += 1
if idx <= exchange_start:
return None
# Splice-boundary guard: the message right after the exchange must
# close the turn. If the walk stopped because it ran into
# *tail_start* mid-turn (boundary is assistant or tool — including
# an assistant-role summary marker), splicing here would leave the
# assistant-role marker adjacent to the turn's remaining
# assistant/tool messages — invalid alternation. Skip this pass; the
# tail recedes as the conversation grows and the turn becomes
# absorbable later. Any other boundary role (user, or a stray
# system/injected message) is a safe splice point — the marker is
# assistant-role, so no same-role adjacency is possible — and
# accepting them keeps one odd message from wedging the cursor
# forever.
if idx >= n:
return None
boundary = messages[idx]
if not isinstance(boundary, dict) or boundary.get("role") in ("assistant", "tool"):
return None
return (exchange_start, idx)
def _serialize_one_exchange(
self,
messages: List[Dict[str, Any]],
start: int,
end: int,
) -> str:
"""Serialize a single exchange for the micro-summarizer.
Delegates to the batch path's ``_serialize_for_summary`` (same
truncation, redaction, think-block stripping, and media labeling),
scoped to one exchange — one serializer, one place to fix.
"""
return self._serialize_for_summary(messages[start:end])
def _build_micro_summary_prompt(
self,
existing_summary: str,
exchange_text: str,
) -> List[Dict[str, str]]:
"""Build the prompt messages for a single-exchange micro-summary."""
if existing_summary.strip():
summary_block = existing_summary
else:
summary_block = "(No previous summary yet.)"
user_prompt = (
"You are a summarization agent creating a compact record of an "
"ongoing conversation. You are given a running summary and the "
"next exchange from the conversation. Merge the exchange's key "
"decisions, requirements, file paths, and open questions into the "
"summary. Preserve the summary's structure. Drop resolved details "
"that are no longer relevant. Add new decisions, file paths, and "
"open questions.\n\n"
"NEVER include API keys, tokens, passwords, secrets, credentials, "
"or connection strings in the summary \u2014 replace any that appear "
f"with [REDACTED].\n\n"
f"## Current Running Summary\n{summary_block}\n\n"
f"## Next Exchange to Merge\n{exchange_text}\n\n"
"Return ONLY the updated summary text, no preamble or explanation. "
"Do not include this instruction block in your output."
)
return [
{"role": "system", "content": "You are a conversation summarization assistant."},
{"role": "user", "content": user_prompt},
]
def _micro_summarize_one(
self,
exchange_text: str,
) -> Optional[str]:
"""Micro-summarize one exchange into the rolling summary via aux LLM.
Calls the same auxiliary compression model as the batch path, with
a focused prompt that merges one exchange into the running summary.
Returns the updated summary text, or ``None`` on failure.
"""
from agent.auxiliary_client import call_llm, aux_interrupt_protection
messages = self._build_micro_summary_prompt(
self._micro_compact_rolling_summary,
exchange_text,
)
call_kwargs = {
"task": "compression",
"messages": messages,
"max_tokens": min(1500, self.max_summary_tokens or 1500),
"temperature": 0.1,
}
if self.summary_model:
call_kwargs["model"] = self.summary_model
if self.model:
call_kwargs.setdefault("main_runtime", {
"model": self.model,
"provider": self.provider or "",
"base_url": self.base_url or "",
"api_key": self.api_key or "",
"api_mode": getattr(self, "api_mode", "") or "",
})
try:
with aux_interrupt_protection():
response = call_llm(**call_kwargs)
except Exception as exc:
logger.info("micro-summarization call failed: %s", exc)
return None
message = response.choices[0].message
if isinstance(message, dict):
content = message.get("content")
else:
content = getattr(message, "content", message)
if not isinstance(content, str):
content = str(content) if content else ""
content = content.strip()
if not content:
logger.info("micro-summarization returned empty content")
return None
from agent.agent_runtime_helpers import strip_think_blocks
stripped = strip_think_blocks(None, content).strip()
return stripped if stripped else None
def _needs_defrag(self) -> bool:
"""Return True when the rolling summary is large enough to defrag."""
content_tokens = estimate_tokens_rough(self._micro_compact_rolling_summary)
return content_tokens >= self._micro_compact_defrag_threshold_tokens
def _defrag_rolling_summary(
self,
messages: List[Dict[str, Any]],
) -> bool:
"""Re-summarize the rolling summary TEXT and rewrite the marker in place.
Merging exchange after exchange makes the rolling summary baggy —
repetitive, and larger than the material justifies. Defrag compacts
the summary *itself*: one aux call over the accumulated summary text,
then the existing marker's content is rewritten in place.
Deliberately transcript-shape-neutral: no messages are spliced, no
user turns are touched, and the cursor does not move. The original
implementation serialized the whole remaining middle (user turns
included) and spliced it into the marker, which silently absorbed
user messages — violating the feature's core "your messages are never
compacted" invariant. Un-absorbed exchanges stay where they are and
get absorbed by later per-exchange passes.
Returns True when a pass actually rewrote the summary.
"""
old_summary = self._micro_compact_rolling_summary
if not old_summary.strip():
return False
# Feed the old summary through the merge prompt with an empty base:
# "merge these decisions into (no previous summary)" is exactly a
# rewrite-compactly instruction for the accumulated text.
self._micro_compact_rolling_summary = ""
fresh_summary = self._micro_summarize_one(old_summary)
if not fresh_summary:
self._micro_compact_rolling_summary = old_summary
return False
self._micro_compact_rolling_summary = fresh_summary
# Rewrite the newest MICRO marker's content in place so the transcript
# and the in-memory summary stay in step (resume rehydrates from it).
# Scoped to micro-tagged markers: rewriting a batch-compaction marker
# would overwrite history the rolling summary does not contain.
for idx in range(len(messages) - 1, -1, -1):
entry = messages[idx]
if (
isinstance(entry, dict)
and entry.get(COMPRESSED_SUMMARY_METADATA_KEY)
and entry.get(MICRO_COMPACT_MARKER_KEY)
):
entry["content"] = self._render_micro_marker_content(fresh_summary)
# Content changed after a possible flush — clear the persisted
# stamp so the DB sync/flush rewrites the row.
entry.pop(_DB_PERSISTED_MARKER, None)
break
logger.info(
"Micro-compaction defrag: rolling summary re-summarized "
"(%d -> %d chars)", len(old_summary), len(fresh_summary),
)
return True
def _micro_compact(
self,
messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Run one round of micro-compaction on the conversation.
Absorbs the oldest uncompacted exchange into the rolling summary,
advancing the in-memory cursor. Runs in post-turn idle time.
This is the public entry point called from ``finalize_turn()``.
Returns the (possibly modified) message list.
NOTE: the in-memory splice alone is not persisted — the subsequent
``_persist_session`` flush is append-only, so old DB rows stay
``active=1`` and a session resume double-loads both the summary and
the original exchanges. This method therefore also calls
``archive_and_compact`` on the session DB to soft-archive old rows
and insert the compacted set atomically.
"""
if not self._micro_compact_enabled:
return messages
# Cadence gate. A pass rewrites already-sent history, so it costs one
# prompt-cache break; `every_n_turns` is how an operator trades reclaim
# frequency against that cost. Counted per invocation rather than per
# committed pass so a turn that finds nothing to absorb still advances
# the cadence and cannot wedge it.
every_n = max(1, int(self._micro_compact_every_n_turns or 1))
if every_n > 1:
self._micro_compact_turns_since_pass += 1
if self._micro_compact_turns_since_pass < every_n:
return messages
self._micro_compact_turns_since_pass = 0
n_messages = len(messages)
if n_messages < 4:
return messages
head_size = self._protect_head_size(messages)
compress_start = self._align_boundary_forward(messages, head_size)
compress_end = self._find_tail_cut_by_tokens(messages, compress_start)
if compress_start >= compress_end:
return messages
cursor = self._resolve_compact_cursor(messages, compress_start, compress_end)
if cursor >= compress_end:
return messages
# Find the next exchange
exchange = self._find_one_exchange(messages, cursor, compress_end)
if exchange is None:
return messages
exchange_start, exchange_end = exchange
# Baseline for telemetry. Taken only once an exchange is in hand, so
# turns that no-op early don't pay for the scan.
_started_at = time.monotonic()
_tokens_before = estimate_messages_tokens_rough(messages)
_messages_before = n_messages
def _elapsed_ms() -> int:
return int((time.monotonic() - _started_at) * 1000)
# Check for defrag trigger: the rolling summary itself has grown
# baggy. Defrag rewrites the summary text and the existing marker in
# place — no splice, no cursor movement, no user turns touched — so
# the transcript shape is unchanged and this pass does not also
# absorb an exchange (one aux call per turn either way).
if self._needs_defrag():
defragged = self._defrag_rolling_summary(messages)
if defragged:
self._sync_micro_compact_to_db(messages)
self._micro_compact_consecutive_failures = 0
self._micro_compact_last_failure_cursor = -1
self._emit_micro_compaction_telemetry(
outcome="defrag" if defragged else "defrag_failed",
messages_before=_messages_before,
messages_after=len(messages),
tokens_before=_tokens_before,
tokens_after=estimate_messages_tokens_rough(messages),
duration_ms=_elapsed_ms(),
)
return messages
# Whether this pass's summary will be cumulative — i.e. whether it
# subsumes any earlier marker. Captured before summarizing.
_cumulative = bool(self._micro_compact_rolling_summary.strip())
# Micro-summarize one exchange
exchange_text = self._serialize_one_exchange(messages, exchange_start, exchange_end)
_exchange_tokens = estimate_tokens_rough(exchange_text)
updated_summary = self._micro_summarize_one(exchange_text)
if updated_summary is None:
# Track consecutive failures on the same cursor position so we
# don't busy-loop on an unsummarizable exchange every turn.
if exchange_start == self._micro_compact_last_failure_cursor:
self._micro_compact_consecutive_failures += 1
else:
self._micro_compact_consecutive_failures = 1
self._micro_compact_last_failure_cursor = exchange_start
if self._micro_compact_consecutive_failures >= _MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES:
logger.info(
"Micro-compaction: skipping exchange at cursor %d "
"after %d consecutive failures",
exchange_start, self._micro_compact_consecutive_failures,
)
# Advance the cursor past the stuck exchange so we don't
# retry it every turn. The skipped messages remain in the
# transcript and will be absorbed by the next batch
# compression or defrag.
self._micro_compact_cursor = exchange_end
self._micro_compact_consecutive_failures = 0
self._micro_compact_last_failure_cursor = -1
_outcome = "exchange_skipped"
else:
_outcome = "summarize_failed"
self._emit_micro_compaction_telemetry(
outcome=_outcome,
messages_before=_messages_before,
messages_after=len(messages),
tokens_before=_tokens_before,
tokens_after=_tokens_before,
exchange_tokens=_exchange_tokens,
duration_ms=_elapsed_ms(),
)
return messages
self._micro_compact_rolling_summary = updated_summary
self._micro_compact_cursor = exchange_end
self._micro_compact_consecutive_failures = 0
self._micro_compact_last_failure_cursor = -1
result = self._splice_micro_compact_result(
messages, exchange_start, exchange_end, supersede=_cumulative,
)
self._micro_compact_cursor = self._cursor_after_splice(result, exchange_start + 1)
self._sync_micro_compact_to_db(result)
self._emit_micro_compaction_telemetry(
outcome="absorbed",
messages_before=_messages_before,
messages_after=len(result),
tokens_before=_tokens_before,
tokens_after=estimate_messages_tokens_rough(result),
exchange_tokens=_exchange_tokens,
duration_ms=_elapsed_ms(),
)
return result
@staticmethod
def _rolling_summary_from_marker(content: Any) -> str:
"""Recover the rolling-summary text from a summary marker's content.
The rolling summary lives in memory, but a resumed session starts with
an empty one while the marker holding every previous exchange is still
in the transcript. Without rehydrating from it, the first post-resume
pass would build a marker from nothing and supersede the one carrying
the whole history.
"""
if not isinstance(content, str) or not content.strip():
return ""
body = content
# rfind, not find: SUMMARY_PREFIX itself references the heading text,
# so the first occurrence is inside the preamble, not the real heading.
idx = body.rfind(HISTORICAL_TASK_HEADING)
if idx != -1:
body = body[idx + len(HISTORICAL_TASK_HEADING):]
end = body.find(_SUMMARY_END_MARKER)
if end != -1:
body = body[:end]
return body.strip()
def _cursor_after_splice(
self,
result: List[Dict[str, Any]],
fallback: int,
) -> int:
"""Cursor position just past the summary marker in *result*.
The cursor must be derived from the spliced list, never carried over
from pre-splice indices. A splice collapses the absorbed span (an
assistant plus its tool results -- often several messages) into a
single marker, and may also drop a superseded marker further back, so
every index after it shifts. Reusing the old ``exchange_end`` left the
cursor pointing into the middle of a *later* exchange's tool group;
the next pass then walked forward to the following assistant and
skipped that exchange entirely, so roughly half the work silently
never happened on tool-bearing conversations.
"""
for idx in range(len(result) - 1, -1, -1):
entry = result[idx]
if isinstance(entry, dict) and entry.get(COMPRESSED_SUMMARY_METADATA_KEY):
return idx + 1
return fallback
def _emit_micro_compaction_telemetry(
self,
*,
outcome: str,
messages_before: int,
messages_after: int,
tokens_before: int | None,
tokens_after: int | None,
exchange_tokens: int | None = None,
duration_ms: int | None = None,
) -> None:
"""Emit one content-free JSON log line describing a micro-compaction pass.
Mirrors ``_emit_compression_attempt_telemetry`` for the batch path.
Message counts move by one or two even when the saving is large, so the
token fields are the ones that actually answer "is this helping?".
``tokens_delta`` is negative when the pass shrank the transcript, and
the ``*_total`` fields accumulate across the session so a whole run can
be summarised from the last line alone.
"""
try:
delta = None
if tokens_before is not None and tokens_after is not None:
delta = tokens_after - tokens_before
self._micro_compact_tokens_saved_total -= delta
self._micro_compact_passes += 1
# Cached reads only. The ``threshold_tokens`` / ``context_length``
# properties resolve lazily and can fire a synchronous /models
# probe on first access (#32221) — telemetry must never be the
# thing that blocks a turn. Unresolved simply reports null.
threshold = self._threshold_tokens
context_limit = self._resolved_context_length
occupancy = None
if threshold and tokens_after is not None and threshold > 0:
occupancy = round(tokens_after / threshold * 100, 1)
payload = {
"event": "micro_compaction",
"session_id": getattr(self, "_session_id", "") or "",
"outcome": outcome,
"messages_before": messages_before,
"messages_after": messages_after,
"tokens_before": _safe_int(tokens_before),
"tokens_after": _safe_int(tokens_after),
"tokens_delta": _safe_int(delta),
"exchange_tokens": _safe_int(exchange_tokens),
"rolling_summary_tokens": estimate_tokens_rough(
self._micro_compact_rolling_summary
),
"cursor": _safe_int(self._micro_compact_cursor),
"passes_total": self._micro_compact_passes,
"tokens_saved_total": self._micro_compact_tokens_saved_total,
"duration_ms": _safe_int(duration_ms),
# Headroom, not efficiency: how full the window is being kept.
# This is the number that says whether the session can keep
# going without a hard batch compaction.
"threshold_tokens": _safe_int(threshold),
"context_limit": _safe_int(context_limit),
"occupancy_pct": occupancy,
"main_model": self.model or "",
"aux_model": self.summary_model or "",
}
logger.info(
"micro compaction telemetry: %s",
json.dumps(payload, sort_keys=True, separators=(",", ":")),
)
except Exception as exc:
logger.debug("failed to emit micro-compaction telemetry: %s", exc)
def _sync_micro_compact_to_db(
self,
compacted_messages: List[Dict[str, Any]],
) -> None:
"""Persist the micro-compacted message set to the session DB.
Soft-archives every currently-active message row (``active = 0``)
and inserts *compacted_messages* as fresh active rows — atomically,
via ``archive_and_compact``. Then stamps ``_DB_PERSISTED_MARKER`` on
every dict so the upcoming append-only flush (``_persist_session`` →
``_flush_messages_to_session_db_unlocked``) skips them: they are
already correctly stored.
Without this, the in-memory-only splice leaves old exchange rows at
``active=1``, and a session resume double-loads both the summary and
the original messages — blowing past the model's context limit.
"""
session_db = getattr(self, "_session_db", None)
session_id = getattr(self, "_session_id", "")
if not session_db or not session_id:
return
try:
session_db.archive_and_compact(session_id, compacted_messages)
for msg in compacted_messages:
if isinstance(msg, dict):
msg[_DB_PERSISTED_MARKER] = True
except Exception:
logger.info(
"Micro-compaction DB sync failed — resume will double-load "
"compacted messages until the next batch compression"
)
def _splice_micro_compact_result(
self,
messages: List[Dict[str, Any]],
splice_start: int,
splice_end: int,
supersede: bool = True,
) -> List[Dict[str, Any]]:
"""Replace *messages[splice_start:splice_end]* with a summary marker.
The summary marker carries the rolling summary text and the
``_compressed_summary`` metadata flag so downstream consumers
(resume, handoff, /compress) handle it identically to batch
compaction summaries.
Alternation safety: the marker is ``assistant``-role. An exchange is
a full agent turn bounded by user messages on both sides (see
``_find_one_exchange``), so the spliced result is
``user → marker(assistant) → user`` — valid alternation that the
pre-request ``repair_message_sequence`` pass leaves untouched. A
``user``-role marker in that position produced ``user → user → user``,
and repair then merged the marker into the neighbouring real user
message: metadata gone, cursor unrecoverable, and the summary text
duplicated into the transcript on every subsequent pass.
Superseding an earlier marker removes the assistant turn that stood
between two real user messages, leaving them adjacent. Those two are
merged (plain-text only, ``\\n\\n``-joined — the same repair pass 2
would apply) so the transcript is alternation-valid as returned
rather than relying on downstream repair to fix it up.
"""
summary_text = self._micro_compact_rolling_summary
if not summary_text.strip():
return messages
summary_msg = {
"role": "assistant",
"content": self._render_micro_marker_content(summary_text),
COMPRESSED_SUMMARY_METADATA_KEY: True,
# Micro-created marker: eligible for supersede/defrag rewrites.
# Batch markers never carry this key and are never touched —
# their content is not contained in the rolling summary.
MICRO_COMPACT_MARKER_KEY: True,
# Honest provenance (#64650): this marker absorbs only
# assistant/tool content — user turns are never micro-compacted,
# so they remain in the transcript and _transcript_has_real_user_turn
# keeps reporting them directly.
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: False,
}
result = messages[:splice_start] + [summary_msg] + messages[splice_end:]
# The rolling summary is cumulative: this marker already contains
# everything every earlier micro-compaction marker held. Leaving those
# in place stacks near-duplicate copies of the same text — each with
# its own prefix/heading/end-marker scaffolding — so the transcript
# grows with every turn instead of shrinking, which defeats the point.
# Keep only the newest marker.
# Two containment gates before dropping an earlier marker:
# 1. supersede (the rolling summary was non-empty going into this
# pass) — a pass that started from nothing (a resume that could
# not rehydrate) covers one exchange, and dropping the previous
# marker would throw away the entire compacted history.
# 2. MICRO_COMPACT_MARKER_KEY on the candidate — only markers whose
# text is provably inside the rolling summary (created by our own
# splice, or rehydrated into the summary by
# _resolve_compact_cursor) carry it. A batch-compaction marker
# that landed after our last pass holds MORE history than the
# stale rolling summary; dropping it would destroy that history.
if supersede:
marker_idxs = [
i for i, m in enumerate(result)
if isinstance(m, dict)
and m.get(COMPRESSED_SUMMARY_METADATA_KEY)
and m.get(MICRO_COMPACT_MARKER_KEY)
]
if len(marker_idxs) > 1:
superseded = set(marker_idxs[:-1])
result = [m for i, m in enumerate(result) if i not in superseded]
result = self._merge_adjacent_user_turns(result)
# NOTE: deliberately NO _strip_persistence_markers here. The batch
# path strips because compress() copies head/tail into a rotated
# child session (#57491); micro-compaction archives in place under
# the SAME session id, and the surviving dicts' _db_persisted stamps
# are accurate. Stripping them meant an archive_and_compact failure
# left every previously-persisted message unstamped, and the next
# append-only flush re-inserted them as duplicate active rows on top
# of the still-active originals. _sync_micro_compact_to_db re-stamps
# everything after a SUCCESSFUL archive; on failure the old stamps
# keep the flush idempotent (only the new marker row is appended).
return result
@staticmethod
def _render_micro_marker_content(summary_text: str) -> str:
"""Assemble the marker content wrapper around *summary_text*."""
return (
f"{SUMMARY_PREFIX}\n\n"
f"{HISTORICAL_TASK_HEADING}\n"
f"{summary_text.strip()}"
f"\n\n{_SUMMARY_END_MARKER}"
)
@staticmethod
def _merge_adjacent_user_turns(
result: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Merge consecutive plain-text real user turns left by a supersede.
Dropping a superseded marker removes the assistant turn that separated
two real user messages. Merging them here (``\\n\\n``-joined, exactly
what ``repair_message_sequence`` pass 2 does) keeps every byte the
user typed while restoring alternation deliberately, so the marker
and cursor state are never collateral damage of the downstream repair.
Multimodal (list) content is left alone, mirroring the repair pass.
"""
from agent.turn_context import drop_stale_api_content
merged: List[Dict[str, Any]] = []
for msg in result:
prev = merged[-1] if merged else None
if (
isinstance(msg, dict)
and isinstance(prev, dict)
and msg.get("role") == "user"
and prev.get("role") == "user"
and not msg.get(COMPRESSED_SUMMARY_METADATA_KEY)
and not prev.get(COMPRESSED_SUMMARY_METADATA_KEY)
and isinstance(prev.get("content"), str)
and isinstance(msg.get("content"), str)
):
prev_content = prev["content"]
new_content = msg["content"]
prev["content"] = (
(prev_content + "\n\n" + new_content)
if prev_content and new_content
else (prev_content or new_content)
)
# Merged content invalidates the api_content sidecar (exact
# bytes previously sent for the pre-merge message).
drop_stale_api_content(prev)
continue
merged.append(msg)
return merged
def compress(
self,
messages: List[Dict[str, Any]],
@@ -5670,6 +6453,19 @@ This compaction should PRIORITISE preserving all information related to the focu
_strip_persistence_markers(compressed)
self._last_compression_made_progress = True
# Batch compaction invalidates micro-compaction state: the batch
# marker now holds MORE history than the in-memory rolling summary
# (it summarized everything in the window, including exchanges micro
# never absorbed). Keeping the stale summary would let the next micro
# pass supersede-drop or defrag-rewrite content it does not contain.
# Reset instead; the next micro pass rehydrates from the batch marker
# via _resolve_compact_cursor, which re-tags it as micro-eligible
# only after absorbing its content into the rolling summary.
self._micro_compact_rolling_summary = ""
self._micro_compact_cursor = 0
self._micro_compact_consecutive_failures = 0
self._micro_compact_last_failure_cursor = -1
return compressed
+40
View File
@@ -349,6 +349,46 @@ def finalize_turn(
_apply_override = getattr(agent, "_apply_persist_user_message_override", None)
if callable(_apply_override):
_apply_override(messages)
# ── Post-turn micro-compaction ────────────────────────────
# After the assistant response is finalized but before the session is
# persisted, run micro-compaction to absorb the oldest uncompacted
# exchange into the rolling summary. This amortizes compression
# across turns rather than batching it into one big pause.
if not interrupted and not failed:
try:
_compressor = getattr(agent, "context_compressor", None)
# Strict `is True` + isinstance gates: plugin context engines
# (and MagicMock compressors in tests) satisfy getattr/duck
# checks with truthy auto-attributes — a bare truthiness check
# here called _micro_compact on a mock and spliced its (empty-
# iterating) return value over the transcript, wiping it.
if (
_compressor
and getattr(_compressor, '_micro_compact_enabled', False) is True
and callable(getattr(_compressor, '_micro_compact', None))
and final_response
# Persistence-isolated agents (background review fork)
# must not micro-compact: the pass burns a real aux-LLM
# call on a throwaway replay transcript, and if the
# compressor ever holds a session_db binding it would
# archive_and_compact the CANONICAL session rows — the
# exact write class _persist_disabled exists to stop.
and not getattr(agent, "_persist_disabled", False)
):
_before = len(messages)
_compacted = _compressor._micro_compact(messages)
if isinstance(_compacted, list) and _compacted:
messages[:] = _compacted
_after = len(messages)
if _before != _after:
logger.info(
"Micro-compaction: %d -> %d messages",
_before, _after,
)
except Exception as _mc_err:
logger.info("Micro-compaction failed: %s", _mc_err)
agent._persist_session(messages, conversation_history)
except Exception as _persist_err:
_cleanup_errors.append(f"persist_session: {_persist_err}")
+12 -52
View File
@@ -72,64 +72,24 @@ def _filter_verifiable_paths(paths: Iterable[str]) -> list[str]:
return [p for p in paths if p and not _is_non_code_path(p)]
# Session identities (platform or source) that are NOT human conversational
# messaging surfaces: interactive coding surfaces (CLI, TUI, desktop, codex,
# local, gateway) and programmatic callers (API server, webhooks, tools).
# Verify-on-stop stays ON by default for these. Any other resolved gateway
# platform is a conversational messaging surface (Telegram, Discord, WhatsApp,
# Signal, Slack, etc.) where the verification narrative would reach a human as
# chat noise, so it defaults OFF. Mirrors LOCAL_SESSION_SOURCE_IDS in
# apps/desktop/src/lib/session-source.ts; keep roughly in sync when adding a
# local or programmatic surface. Default-deny by design: an unrecognized
# identity is treated as messaging (OFF) so a new chat platform never leaks the
# verification receipt before this set is updated.
_NON_MESSAGING_SESSION_SURFACES = frozenset(
{
"",
"cli",
"codex",
"desktop",
"gateway",
"local",
"tui",
"tool",
"api_server",
"webhook",
"msgraph_webhook",
}
)
def _session_is_messaging_surface() -> bool:
"""Return whether this turn is delivered over a human messaging channel.
"""Whether this turn is delivered over a human messaging channel.
The gateway binds the platform value (e.g. ``telegram``) to
``HERMES_SESSION_PLATFORM``; the CLI and TUI set ``HERMES_SESSION_SOURCE``
(e.g. ``cli``, ``tui``) instead. Both are consulted via the session-context
helper (with an ``os.environ`` fallback), alongside the ``HERMES_PLATFORM``
override, matching the sibling platform resolution in
``agent/skill_commands.py`` and ``agent/prompt_builder.py``. A turn is a
messaging surface when a resolved identity is present and is not a known
non-messaging surface.
Verify-on-stop defaults ON for the interactive coding surfaces and
programmatic callers, and OFF on a conversational platform (Telegram,
Discord, Slack, ...) where the verification narrative reaches a human as
chat noise. The surface classification itself is shared with the other
consumers of this distinction — see
``gateway.session_context.session_is_messaging_surface``.
"""
try:
from gateway.session_context import get_session_env
from gateway.session_context import session_is_messaging_surface
platform = (
os.getenv("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM", "")
)
source = get_session_env("HERMES_SESSION_SOURCE", "")
return session_is_messaging_surface()
except Exception:
platform = os.getenv("HERMES_PLATFORM", "") or os.environ.get(
"HERMES_SESSION_PLATFORM", ""
)
source = os.environ.get("HERMES_SESSION_SOURCE", "")
for identity in (platform, source):
identity = str(identity or "").strip().lower()
if identity and identity not in _NON_MESSAGING_SESSION_SURFACES:
return True
return False
# The gateway package is unreachable, so there is no messaging channel
# to be on. Reporting a local surface keeps verify-on-stop enabled.
return False
def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
+7 -1
View File
@@ -9,7 +9,13 @@ export function linkTitleWindowOptions(partitionSession) {
width: 1280,
height: 800,
webPreferences: {
backgroundThrottling: false,
// Deliberately throttled: this hidden window loads arbitrary user-linked
// pages, and an unthrottled heavy page burns full CPU for the window's
// whole lifetime. Title resolution rides load events
// (page-title-updated / did-finish-load) plus main-process timers, none
// of which the renderer clamp touches — hidden-page throttling only
// slows the page's own timer-driven JS, and the grace window already
// absorbs that.
contextIsolation: true,
javascript: true,
nodeIntegration: false,
+69 -17
View File
@@ -183,6 +183,7 @@ import {
redactSecrets,
SshConnection
} from './ssh-connection'
import { createStreamThrottle } from './stream-throttle'
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
import { resolveBehindCount, shouldCountCommits } from './update-count'
import { waitForUpdateClearance } from './update-gate'
@@ -421,18 +422,24 @@ if (IS_WINDOWS) {
ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON)
// Keep the renderer running at full speed while the window is in the background
// or occluded. The chat transcript streams to screen through a bounded timer
// flush; Chromium clamps timers for backgrounded/occluded renderers, so without
// these the live answer stalls
// whenever the window loses focus (switching to your editor mid-turn, detached
// devtools, another window covering it) and only paints on refocus or refresh.
// `backgroundThrottling: false` on the BrowserWindow covers the blurred case;
// these process-level switches additionally stop Chromium from backgrounding or
// occlusion-throttling the renderer. Must run before app `ready`.
// Keep the renderer's PROCESS priority normal while its windows are hidden —
// a deprioritized renderer streams a live answer visibly slower once the
// window is minimized. This switch only affects scheduling priority; it does
// not exempt timers from throttling and costs nothing at idle.
//
// The timer/rAF throttling story is deliberately NOT handled here anymore.
// The old process-wide `disable-background-timer-throttling` /
// `disable-backgrounding-occluded-windows` switches (plus a static
// `backgroundThrottling: false` on every chat window) pinned every renderer's
// `document.visibilityState` to 'visible' forever — which silently turned all
// the renderer's visibility-gated backstop polls and clock ticks into
// always-on timers. A completely idle, minimized Hermes burned ~20% CPU
// around the clock. Throttling is now a runtime dial scoped to streaming:
// see createStreamThrottle() — chat windows are unthrottled while any turn is
// in flight (so a live answer keeps painting while blurred, occluded, or
// minimized, exactly as before) and return to Chromium's default throttling
// once the work settles.
app.commandLine.appendSwitch('disable-renderer-backgrounding')
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows')
app.commandLine.appendSwitch('disable-background-timer-throttling')
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
@@ -5188,6 +5195,31 @@ function sendPowerResume() {
let powerResumeRegistered = false
// Mirror of powerMonitor's AC/battery state, broadcast to every window so
// renderer backstop polls can slow down on battery (see store/power.ts).
// `null` until the first powerMonitor read after app ready.
let onBatteryPower: boolean | null = null
// Renderer-side battery gating seeds from this and stays current via the
// 'hermes:power-battery' push below.
ipcMain.handle('hermes:power-battery:get', () => onBatteryPower === true)
function broadcastBatteryState(next: boolean) {
if (onBatteryPower === next) {
return
}
onBatteryPower = next
for (const win of BrowserWindow.getAllWindows()) {
const { webContents } = win
if (webContents && !webContents.isDestroyed()) {
webContents.send('hermes:power-battery', next)
}
}
}
function registerPowerResumeListeners() {
if (powerResumeRegistered) {
return
@@ -5200,6 +5232,9 @@ function registerPowerResumeListeners() {
// full suspend. Either can drop an idle socket.
powerMonitor.on('resume', sendPowerResume)
powerMonitor.on('unlock-screen', sendPowerResume)
powerMonitor.on('on-battery', () => broadcastBatteryState(true))
powerMonitor.on('on-ac', () => broadcastBatteryState(false))
onBatteryPower = powerMonitor.isOnBatteryPower()
} catch {
// powerMonitor is unavailable before app 'ready' on some platforms; the
// caller registers after 'ready', so this should not normally throw.
@@ -8697,6 +8732,7 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
streamThrottle.register(win)
wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat'))
loadWindowUrl(
@@ -8739,7 +8775,7 @@ function nextInstanceBounds() {
}
// Open a new full-chrome instance window. Mirrors createWindow()'s window
// options (shared chatWindowWebPreferences keeps backgroundThrottling:false so a
// options (shared chatWindowWebPreferences + streamThrottle registration so a
// streamed answer never stalls in the background) but is a peer, not the
// primary: it never overwrites the mainWindow global, doesn't start the backend
// (the renderer's getConnection() joins the already-running one), and loads the
@@ -8780,6 +8816,7 @@ function createInstanceWindow() {
win.on('enter-full-screen', () => sendWindowStateChanged(true, win))
win.on('leave-full-screen', () => sendWindowStateChanged(false, win))
streamThrottle.register(win)
wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat'))
win.on('closed', () => {
@@ -9162,10 +9199,11 @@ function createWindow() {
// material before the renderer paints the app theme. See createSessionWindow.
show: false,
backgroundColor: getWindowBackgroundColor(),
// Shared with the secondary session windows (chatWindowWebPreferences) so
// both keep `backgroundThrottling: false` — the chat transcript uses a
// bounded timer flush that Chromium clamps for blurred windows, stalling
// the live answer until refocus. See session-windows.ts.
// Shared with the secondary session windows (chatWindowWebPreferences);
// stream-aware throttling is applied per-window via streamThrottle so a
// live answer keeps painting while the window is blurred or minimized,
// without pinning visibilityState to 'visible' at idle. See
// session-windows.ts and stream-throttle.ts.
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
})
@@ -9258,6 +9296,7 @@ function createWindow() {
}
})
streamThrottle.register(mainWindow)
wireCommonWindowHandlers(mainWindow, zoomWiringForWindowKind('chat'))
mainWindow.webContents.on('render-process-gone', (_event, details) => {
@@ -10427,14 +10466,27 @@ ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWat
// merged picture. Keyed by webContents id so a closed window stops counting.
const activeWorkByWebContents = new Map<number, ActiveWork>()
// The same merged picture drives background throttling: chat windows run
// unthrottled while any turn is in flight (streaming must paint while hidden)
// and fall back to Chromium's default throttling at idle. See stream-throttle.ts.
const streamThrottle = createStreamThrottle()
function updateStreamThrottleFromActiveWork() {
streamThrottle.update(mergeActiveWork(activeWorkByWebContents.values()).count > 0)
}
ipcMain.on('hermes:active-work', (event, payload) => {
const id = event.sender.id
if (!activeWorkByWebContents.has(id)) {
event.sender.once('destroyed', () => activeWorkByWebContents.delete(id))
event.sender.once('destroyed', () => {
activeWorkByWebContents.delete(id)
updateStreamThrottleFromActiveWork()
})
}
activeWorkByWebContents.set(id, normalizeActiveWork(payload))
updateStreamThrottleFromActiveWork()
})
ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
+8
View File
@@ -275,6 +275,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:power-resume', listener)
},
// AC ↔ battery transitions; renderers slow their backstop polls on battery.
getOnBattery: () => ipcRenderer.invoke('hermes:power-battery:get'),
onBatteryChanged: callback => {
const listener = (_event, onBattery) => callback(Boolean(onBattery))
ipcRenderer.on('hermes:power-battery', listener)
return () => ipcRenderer.removeListener('hermes:power-battery', listener)
},
onBootProgress: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:boot-progress', listener)
@@ -191,13 +191,16 @@ test('registry trims the session id before keying', () => {
assert.equal(registry.has('s1'), true)
})
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
// Regression: secondary session windows used to omit this flag, so a streamed
// answer stalled until the window regained focus (Chromium clamps the
// transcript flush timer for backgrounded windows).
test('chatWindowWebPreferences leaves background throttling to the runtime stream dial', () => {
// Regression (both directions): a static `backgroundThrottling: false` here
// pinned document.visibilityState to 'visible' forever, turning every
// visibility-gated poll into an always-on timer (~20% CPU at idle,
// minimized). Streaming's "paint while blurred" need is served by
// stream-throttle.ts flipping setBackgroundThrottling at turn boundaries —
// so the static flag must stay absent.
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
assert.equal(prefs.backgroundThrottling, false)
assert.equal('backgroundThrottling' in prefs, false)
})
test('chatWindowWebPreferences passes the preload path through and keeps the hardened defaults', () => {
+13 -8
View File
@@ -13,14 +13,20 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// Shared webPreferences for every window that renders the chat transcript — the
// primary window AND the secondary session windows. Keeping it in one place is
// the whole point: the two BrowserWindow definitions in main.ts used to be
// hand-copied, and the secondary windows silently lost `backgroundThrottling:
// false`, so a streamed answer stalled until the window regained focus.
// hand-copied, and the secondary windows silently drifted apart (a streamed
// answer stalled until the window regained focus because one of them lost the
// throttling opt-out).
//
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
// screen through a bounded timer flush, which Chromium clamps for blurred/
// occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
// Background throttling is deliberately NOT set here. It is managed at runtime
// by main.ts (`setBackgroundThrottling` driven by the merged `hermes:active-work`
// reports): while any turn is in flight every chat window is unthrottled so the
// transcript's bounded timer flush keeps painting while blurred, occluded, or
// minimized — and once all turns finish, Chromium's default throttling returns
// so an idle hidden window costs ~nothing. A static `backgroundThrottling:
// false` here would pin `document.visibilityState` to 'visible' forever,
// turning every visibility-gated poll in the renderer into an always-on timer
// (the "Hermes idles at 20% CPU while minimized" bug). The preload path is
// injected because it depends on the Electron entry's __dirname.
//
// `autoplayPolicy: 'no-user-gesture-required'` is load-bearing for voice:
// Chromium's default autoplay policy suspends audio (HTMLAudioElement.play()
@@ -39,7 +45,6 @@ function chatWindowWebPreferences(preloadPath: string) {
sandbox: true,
nodeIntegration: false,
devTools: true,
backgroundThrottling: false,
autoplayPolicy: 'no-user-gesture-required' as const
}
}
@@ -0,0 +1,152 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { createStreamThrottle, type ThrottleWindowLike } from './stream-throttle'
function makeTimers() {
const pending = new Map<number, () => void>()
let nextId = 1
return {
clearTimeout: (handle: unknown) => {
pending.delete(handle as number)
},
fire() {
const jobs = [...pending.values()]
pending.clear()
for (const job of jobs) {
job()
}
},
get pendingCount() {
return pending.size
},
setTimeout: (fn: () => void, _ms: number) => {
const id = nextId++
pending.set(id, fn)
return id
}
}
}
function makeWindow() {
const calls: boolean[] = []
const listeners = new Map<string, () => void>()
let destroyed = false
const win = {
calls,
close() {
destroyed = true
listeners.get('closed')?.()
},
isDestroyed: () => destroyed,
on(event: string, fn: () => void) {
listeners.set(event, fn)
},
webContents: {
isDestroyed: () => destroyed,
setBackgroundThrottling(allowed: boolean) {
calls.push(allowed)
}
}
}
return win
}
test('registering a window applies the current throttle state immediately', () => {
const timers = makeTimers()
const throttle = createStreamThrottle(timers)
const idle = makeWindow()
throttle.register(idle)
// Idle default: throttling allowed.
assert.deepEqual(idle.calls, [true])
throttle.update(true)
const late = makeWindow()
throttle.register(late)
// A window created mid-stream starts unthrottled.
assert.deepEqual(late.calls, [false])
})
test('a turn in flight unthrottles every chat window; settling re-throttles after the trailing delay', () => {
const timers = makeTimers()
const throttle = createStreamThrottle(timers)
const win = makeWindow()
throttle.register(win)
throttle.update(true)
assert.deepEqual(win.calls, [true, false])
assert.equal(throttle.isUnthrottled(), true)
// Turn ends: not re-throttled synchronously — the tail flush needs full
// cadence — only after the trailing timer fires.
throttle.update(false)
assert.deepEqual(win.calls, [true, false])
assert.equal(throttle.isUnthrottled(), true)
timers.fire()
assert.deepEqual(win.calls, [true, false, true])
assert.equal(throttle.isUnthrottled(), false)
})
test('a new turn during the trailing window cancels the pending re-throttle', () => {
const timers = makeTimers()
const throttle = createStreamThrottle(timers)
const win = makeWindow()
throttle.register(win)
throttle.update(true)
throttle.update(false)
assert.equal(timers.pendingCount, 1)
// Busy again before the delay elapses: stay unthrottled, timer cancelled.
throttle.update(true)
assert.equal(timers.pendingCount, 0)
assert.equal(throttle.isUnthrottled(), true)
// The cancelled timer firing late must be a no-op.
timers.fire()
assert.equal(throttle.isUnthrottled(), true)
})
test('repeated busy reports do not re-apply or stack timers', () => {
const timers = makeTimers()
const throttle = createStreamThrottle(timers)
const win = makeWindow()
throttle.register(win)
throttle.update(true)
throttle.update(true)
throttle.update(true)
assert.deepEqual(win.calls, [true, false])
throttle.update(false)
throttle.update(false)
assert.equal(timers.pendingCount, 1)
})
test('closed and destroyed windows drop out without throwing', () => {
const timers = makeTimers()
const throttle = createStreamThrottle(timers)
const closedWin = makeWindow()
throttle.register(closedWin)
closedWin.close()
const gone: ThrottleWindowLike & { on?: never } = {
isDestroyed: () => true,
webContents: null
}
throttle.register(gone)
throttle.update(true)
// Only the registration-time call landed; nothing after close.
assert.deepEqual(closedWin.calls, [true])
})
+119
View File
@@ -0,0 +1,119 @@
// Stream-aware background throttling for chat windows.
//
// Chat windows must paint the live transcript while blurred, occluded, or
// minimized — but a static `backgroundThrottling: false` in webPreferences
// costs far more than that feature needs: it pins the renderer's
// `document.visibilityState` to 'visible' for the life of the window, which
// turns every visibility-gated poll and clock tick in the renderer into an
// always-on timer. An idle, hidden Hermes burned ~20% CPU forever.
//
// So throttling is a runtime dial instead: the renderers already report
// "which chats are mid-turn" for the quit guard (`hermes:active-work`), and
// this controller rides the merged edge of those reports. Any turn in flight →
// every registered chat window gets `setBackgroundThrottling(false)`, exactly
// the streaming behavior the static flag used to provide. All turns done →
// after a short trailing delay (so tail flushes land at full cadence) Chromium's
// default throttling returns and hidden windows go quiet.
//
// Pure and Electron-free (timers + the WebContents surface are injected) so it
// can be unit-tested, mirroring session-windows.ts.
/** How long after the last turn ends before throttling is restored. Covers the
* stream queue's final coalesced flush and the settle writes that trail a
* turn's completion, so re-throttling never strands a visible delta. */
const RETHROTTLE_DELAY_MS = 5_000
export interface ThrottleWindowLike {
isDestroyed(): boolean
webContents?: {
isDestroyed(): boolean
setBackgroundThrottling(allowed: boolean): void
} | null
}
interface TimersLike {
clearTimeout(handle: unknown): void
setTimeout(fn: () => void, ms: number): unknown
}
export interface StreamThrottle {
/** True while windows are currently unthrottled (streaming or trailing). */
isUnthrottled(): boolean
/** Track a chat window; applies the current state immediately and stops
* tracking on close. */
register(win: ThrottleWindowLike & { on?: (event: string, fn: () => void) => void }): void
/** Report whether any turn is in flight across all renderers. */
update(busy: boolean): void
}
export function createStreamThrottle(
timers: TimersLike = { clearTimeout: handle => clearTimeout(handle as never), setTimeout },
delayMs: number = RETHROTTLE_DELAY_MS
): StreamThrottle {
const windows = new Set<ThrottleWindowLike>()
let unthrottled = false
let trailing: unknown = null
function apply(win: ThrottleWindowLike) {
if (win.isDestroyed()) {
windows.delete(win)
return
}
const contents = win.webContents
if (!contents || contents.isDestroyed()) {
return
}
try {
contents.setBackgroundThrottling(!unthrottled)
} catch {
// A window mid-teardown can throw; it's about to leave the set anyway.
}
}
function applyAll() {
for (const win of windows) {
apply(win)
}
}
return {
isUnthrottled: () => unthrottled,
register(win) {
windows.add(win)
win.on?.('closed', () => windows.delete(win))
apply(win)
},
update(busy) {
if (busy) {
if (trailing !== null) {
timers.clearTimeout(trailing)
trailing = null
}
if (!unthrottled) {
unthrottled = true
applyAll()
}
return
}
if (!unthrottled || trailing !== null) {
return
}
// Trailing edge: keep full cadence briefly so the final flush paints.
trailing = timers.setTimeout(() => {
trailing = null
unthrottled = false
applyAll()
}, delayMs)
}
}
}
+99 -13
View File
@@ -1,9 +1,30 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const closeFocusedSessionTab = vi.fn(() => false)
const nextSessionTileForWorkspace = vi.fn<() => null | string>(() => null)
const closeSessionTile = vi.fn()
const requestFreshSession = vi.fn()
vi.mock('@/components/pane-shell/tree/store', () => ({
closeFocusedSessionTab: () => closeFocusedSessionTab()
}))
vi.mock('@/store/session-states', () => ({
closeSessionTile: (...args: unknown[]) => closeSessionTile(...args),
nextSessionTileForWorkspace: () => nextSessionTileForWorkspace()
}))
vi.mock('@/store/profile', () => ({
requestFreshSession: () => requestFreshSession()
}))
import { $rightRailActiveTabId } from '@/store/layout'
import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { closeActiveTab } from './close-tab'
import { $workspaceIsPage } from '../routes'
import { closeActiveTab, closeWorkspaceTab } from './close-tab'
function fileTarget(path: string): PreviewTarget {
return {
@@ -16,19 +37,31 @@ function fileTarget(path: string): PreviewTarget {
}
}
/** Main is holding a loaded chat and nothing else is stacked with it. */
function loadedMainOnly() {
$selectedStoredSessionId.set('stored-a')
$activeSessionId.set('runtime-a')
}
beforeEach(() => {
vi.stubGlobal('document', { activeElement: null })
closeRightRail()
window.localStorage.clear()
$selectedStoredSessionId.set(null)
$activeSessionId.set(null)
$workspaceIsPage.set(false)
closeFocusedSessionTab.mockReturnValue(false)
nextSessionTileForWorkspace.mockReturnValue(null)
vi.clearAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
closeRightRail()
window.localStorage.clear()
})
describe('closeActiveTab', () => {
beforeEach(() => {
vi.stubGlobal('document', { activeElement: null })
closeRightRail()
window.localStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
closeRightRail()
window.localStorage.clear()
})
it('closes the active file preview tab (⌘W happy path)', () => {
openPreview(fileTarget('/work/notes.md'), 'manual')
@@ -50,3 +83,56 @@ describe('closeActiveTab', () => {
expect($previewTabs.get()).toHaveLength(0)
})
})
/**
* The main tab's own close. The workspace pane can never leave the tree, so
* every answer here is about what FILLS it — a stacked session, or an empty
* draft. The gesture used to dead-end whenever main was the only tab.
*/
describe('closeWorkspaceTab', () => {
it('shifts the next stacked session into main', () => {
loadedMainOnly()
nextSessionTileForWorkspace.mockReturnValue('stored-b')
const load = vi.fn()
expect(closeWorkspaceTab(load)).toBe(true)
expect(closeSessionTile).toHaveBeenCalledWith('stored-b')
expect(load).toHaveBeenCalledWith('stored-b')
// Promotion refills main — it must not ALSO blank it.
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('drops a lone loaded main to a fresh draft', () => {
loadedMainOnly()
expect(closeWorkspaceTab(vi.fn())).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
it('empties main even with no session loader wired', () => {
loadedMainOnly()
expect(closeWorkspaceTab()).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
it('is a no-op on a blank draft — that IS the post-close state', () => {
expect(closeWorkspaceTab(vi.fn())).toBe(false)
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('is a no-op over a full-page view, which owns no chat tab', () => {
loadedMainOnly()
$workspaceIsPage.set(true)
expect(closeWorkspaceTab(vi.fn())).toBe(false)
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('⌘W reaches it once the terminal, rail and zone tabs pass', () => {
loadedMainOnly()
expect(closeActiveTab(vi.fn())).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
})
+51 -26
View File
@@ -1,27 +1,68 @@
import { mainChatOccupied } from '@/app/open-session'
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { $workspaceIsPage } from '@/app/routes'
import { closeFocusedSessionTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $previewTabs, closeActiveRightRailTab } from '@/store/preview'
import { requestFreshSession } from '@/store/profile'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
/**
* Close the MAIN tab. The workspace pane itself can't leave the tree, so
* "closing" it means emptying it, and what fills the hole depends on what's
* stacked beside it:
*
* - session tabs stacked with it → the next one shifts INTO main (drop its
* tile, load it as the primary — the session stays alive, no busy prompt),
* - nothing stacked → main drops to a fresh "New session" draft.
*
* The second half is what makes the gesture honest when main is the ONLY tab:
* ⌘W / ⌘-click / middle-click used to be a dead key there, since the only
* available answer was "remove the pane", which this app never does.
*
* Returns false when there is nothing to close — a blank draft (already the
* post-close state) or a full-page view (skills / artifacts, which isn't a
* chat and owns no tab). ⌘W then stays a no-op; it never closes the window.
*
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
* into main"; omitting it disables the promotion half.
*/
export function closeWorkspaceTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
// Order matters — close the tile FIRST so the selection homes to the
// workspace instead of re-fronting the tile.
if (loadSessionIntoWorkspace) {
const next = nextSessionTileForWorkspace()
if (next) {
closeSessionTile(next)
loadSessionIntoWorkspace(next)
return true
}
}
if ($workspaceIsPage.get() || !mainChatOccupied($activeSessionId.get(), $selectedStoredSessionId.get())) {
return false
}
requestFreshSession()
return true
}
/**
* ⌘W — close the tab of the context you're in, by precedence:
* 1. a focused terminal → its active terminal tab,
* 2. right-rail tabs (live preview and/or file peeks),
* 3. the FOCUSED chat zone → its active tab (a session tile stacked into it).
* 4. the workspace tab itself, when session tabs are stacked with it:
* the workspace can't close, so ⌘W shifts the NEXT session tab into main
* (loads it as the primary + drops its now-redundant tile).
* 4. the workspace tab itself — see `closeWorkspaceTab`.
* Returns false when nothing closes, so ⌘W is a no-op — it never closes the
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
* and the macOS menu-accelerator IPC.
* window. Shared by the keyboard path (Win/Linux) and the macOS
* menu-accelerator IPC.
*
* Steps 3-4 follow the same focused zone ⌘1…⌘9 indexes, so a second chat zone
* with its own tab strip closes ITS tab instead of main's.
*
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
* into main" (the two call sites have router access); omitting it disables the
* step-4 promotion (⌘W stays the pre-existing no-op on the main tab).
*/
export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
if (isFocusWithin('[data-terminal]')) {
@@ -43,21 +84,5 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri
return true
}
// The main (workspace) tab is active and can't be closed — but if session
// tabs are stacked with it, ⌘W shifts the next one into the main tab: drop
// its tile (the session stays alive, no busy-close prompt) and load it into
// main. Order matters — close the tile FIRST so the selection homes to the
// workspace instead of re-fronting the tile.
if (loadSessionIntoWorkspace) {
const next = nextSessionTileForWorkspace()
if (next) {
closeSessionTile(next)
loadSessionIntoWorkspace(next)
return true
}
}
return false
return closeWorkspaceTab(loadSessionIntoWorkspace)
}
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { composerPlainText, normalizeComposerEditorDom, RICH_INPUT_SLOT } from './rich-editor'
import {
composerPlainText,
normalizeComposerEditorDom,
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
function editor(): HTMLDivElement {
const el = document.createElement('div')
@@ -69,3 +74,66 @@ describe('an emptied composer reads as empty', () => {
expect(composerPlainText(el)).toBe('one\n\n')
})
})
/** The rule the stylesheet paints the placeholder with. `:empty` alone goes
* false the instant the scaffolding <br> lands. */
const PLACEHOLDER_SHOWS = ':is(:empty, [data-empty])'
describe('an emptied composer shows its placeholder again', () => {
it('advertises emptiness once the scaffolding break is in place', () => {
expect(emptied().matches(PLACEHOLDER_SHOWS)).toBe(true)
})
it('advertises emptiness for a truly childless editor', () => {
expect(editor().matches(PLACEHOLDER_SHOWS)).toBe(true)
})
it('stops advertising it once something is typed', () => {
const el = emptied()
el.replaceChildren(document.createTextNode('hi'))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
// A text node is invisible to selectors, so `one<br>` and `<br>` are the same
// shape to any pure-CSS rule (`:has(> br:only-child)` matches both and paints
// the placeholder straight over the user's text). The DOM writer has to say.
it('does not advertise emptiness for a trailing break after text', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
it('does not advertise emptiness for a Shift+Enter break between text', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'), document.createTextNode('two'))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
// Repainting from text (restored draft, undo, completion rebuild) is the
// other writer that reshapes the editor root — it must not strand the marker.
it('drops the marker when a draft is painted back in', () => {
const el = emptied()
renderComposerContents(el, 'restored draft')
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
it('re-advertises emptiness when a draft is painted back out', () => {
const el = editor()
renderComposerContents(el, 'temporary')
renderComposerContents(el, '')
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
})
})
@@ -0,0 +1,258 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $voicePlayback } from '@/store/voice-playback'
import { useVoiceConversation } from './use-voice-conversation'
const mocks = vi.hoisted(() => {
let deferStreamStart = false
let onSilence: null | (() => void) = null
let resolveStreamStart: null | (() => void) = null
let resolveSpeech: null | ((outcome: 'done' | 'fallback') => void) = null
let streamAvailable = true
const stopVoicePlayback = vi.fn(() => {
const current = $voicePlayback.get()
$voicePlayback.set({ ...current, sequence: current.sequence + 1, status: 'idle' })
})
const playSpeechText = vi.fn(() => {
stopVoicePlayback()
return Promise.resolve(true)
})
const handle = {
cancel: vi.fn(),
start: vi.fn(async (options: { onSilence: () => void }) => {
onSilence = options.onSilence
}),
stop: vi.fn(async () => ({
audio: new Blob(['voice'], { type: 'audio/webm' }),
heardSpeech: true
}))
}
return {
continueStreamStart() {
resolveStreamStart?.()
resolveStreamStart = null
},
deferStreamStart() {
deferStreamStart = true
},
finishSpeech(outcome: 'done' | 'fallback') {
resolveSpeech?.(outcome)
},
handle,
playSpeechText,
resetSpeechMocks() {
deferStreamStart = false
resolveStreamStart = null
resolveSpeech = null
streamAvailable = true
},
startSpeechStream: vi.fn(async () => {
if (deferStreamStart) {
await new Promise<void>(resolve => {
resolveStreamStart = resolve
})
}
if (!streamAvailable) {
return null
}
const current = $voicePlayback.get()
$voicePlayback.set({ ...current, sequence: current.sequence + 1, status: 'preparing' })
return {
append: vi.fn(),
done: new Promise<'done' | 'fallback'>(resolve => {
resolveSpeech = resolve
}),
finish: vi.fn()
}
}),
stopVoicePlayback,
triggerSilence() {
onSilence?.()
},
useFallbackSpeech() {
streamAvailable = false
}
}
})
vi.mock('./use-mic-recorder', () => ({
useMicRecorder: () => ({ handle: mocks.handle, level: 0 })
}))
vi.mock('@/lib/voice-barge-in', () => ({
monitorSpeechDuringPlayback: () => vi.fn()
}))
vi.mock('@/lib/voice-playback', () => ({
markVoicePlaybackInterrupted: vi.fn(),
playSpeechText: mocks.playSpeechText,
startSpeechStream: mocks.startSpeechStream,
stopVoicePlayback: mocks.stopVoicePlayback
}))
vi.mock('@/lib/thinking-sound', () => ({
startThinkingSound: vi.fn(),
stopThinkingSound: vi.fn()
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
notifications: {
voice: {
configureSpeechToText: '',
couldNotStartSession: '',
microphoneFailed: '',
playbackFailed: '',
transcriptionFailed: '',
unavailable: ''
}
}
}
})
}))
function renderRearmConversation(responseId: string, responseText: string) {
let response: null | { id: string; pending: boolean; text: string } = null
return renderHook(
({ enabled }) =>
useVoiceConversation({
busy: false,
consumePendingResponse: vi.fn(),
enabled,
onSubmit: async () => {
response = { id: responseId, pending: false, text: responseText }
},
onTranscribeAudio: async () => 'Hello',
pendingResponse: () => response
}),
{ initialProps: { enabled: false } }
)
}
async function beginReply(hook: ReturnType<typeof renderRearmConversation>) {
hook.rerender({ enabled: true })
await waitFor(() => expect(mocks.handle.start).toHaveBeenCalledTimes(1))
await act(async () => {
mocks.triggerSilence()
})
}
describe('useVoiceConversation playback rearm', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
mocks.resetSpeechMocks()
$voicePlayback.set({
audioElement: null,
messageId: null,
sequence: 0,
source: null,
status: 'idle'
})
})
it('re-arms the microphone after normal streaming playback completes', async () => {
$voicePlayback.set({
audioElement: null,
messageId: null,
sequence: 7,
source: null,
status: 'idle'
})
const hook = renderRearmConversation('reply-1', 'Hello back')
await beginReply(hook)
await waitFor(() => expect(mocks.startSpeechStream).toHaveBeenCalled())
expect($voicePlayback.get().sequence).toBeGreaterThan(7)
await act(async () => {
mocks.finishSpeech('done')
})
await waitFor(() => expect(mocks.handle.start).toHaveBeenCalledTimes(2))
expect(hook.result.current.status).toBe('listening')
})
it('honors Stop while streaming playback is still preparing', async () => {
mocks.deferStreamStart()
const hook = renderRearmConversation('reply-preparing', 'Do not play this')
await beginReply(hook)
await waitFor(() => expect(mocks.startSpeechStream).toHaveBeenCalled())
mocks.stopVoicePlayback()
await act(async () => {
mocks.continueStreamStart()
})
await waitFor(() => expect(hook.result.current.status).toBe('idle'))
expect(mocks.stopVoicePlayback).toHaveBeenCalledTimes(2)
expect(mocks.handle.start).toHaveBeenCalledTimes(1)
})
it('does not start fallback playback after Stop during stream discovery', async () => {
mocks.deferStreamStart()
mocks.useFallbackSpeech()
const hook = renderRearmConversation('reply-no-stream', 'Do not fall back')
await beginReply(hook)
await waitFor(() => expect(mocks.startSpeechStream).toHaveBeenCalled())
mocks.stopVoicePlayback()
await act(async () => {
mocks.continueStreamStart()
})
await waitFor(() => expect(hook.result.current.status).toBe('idle'))
expect(mocks.playSpeechText).not.toHaveBeenCalled()
expect(mocks.handle.start).toHaveBeenCalledTimes(1)
})
it('does not re-arm after an external Stop during streaming playback', async () => {
const hook = renderRearmConversation('reply-stopped', 'Playing now')
await beginReply(hook)
await waitFor(() => expect(mocks.startSpeechStream).toHaveBeenCalled())
mocks.stopVoicePlayback()
await act(async () => {
mocks.finishSpeech('done')
})
await waitFor(() => expect(hook.result.current.status).toBe('idle'))
expect(mocks.handle.start).toHaveBeenCalledTimes(1)
})
it('re-arms the microphone after normal fallback playback completes', async () => {
mocks.useFallbackSpeech()
const hook = renderRearmConversation('reply-fallback', 'Fallback reply')
await beginReply(hook)
await waitFor(() =>
expect(mocks.playSpeechText).toHaveBeenCalledWith('Fallback reply', {
source: 'voice-conversation'
})
)
await waitFor(() => expect(mocks.handle.start).toHaveBeenCalledTimes(2))
expect(hook.result.current.status).toBe('listening')
})
})
@@ -262,7 +262,7 @@ export function useVoiceConversation({
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
const settleAfterSpeech = useCallback(
(barged: boolean) => {
(barged: boolean, stoppedDuringSetup = false) => {
if (barged || !awaitingSpokenResponseRef.current) {
awaitingSpokenResponseRef.current = false
consumePendingResponse()
@@ -286,7 +286,9 @@ export function useVoiceConversation({
// voice-playback sequence has advanced past what we captured at speech
// start — don't auto-start the next sentence, the user chose to stop.
const stoppedByUser =
speechStartSequenceRef.current > 0 && $voicePlayback.get().sequence > speechStartSequenceRef.current
stoppedDuringSetup ||
(speechStartSequenceRef.current > 0 &&
$voicePlayback.get().sequence > speechStartSequenceRef.current)
speechStartSequenceRef.current = 0
@@ -458,9 +460,13 @@ export function useVoiceConversation({
// this is a safety net for read-aloud-style entries into the loop.
ensureBargeMonitor()
const playback = playSpeechText(response.text, { source: 'voice-conversation' })
// playSpeechText performs its normal cleanup synchronously before
// returning. Capture the sequence after that internal increment so
// only a later, external stop suppresses the next listen cycle.
speechStartSequenceRef.current = $voicePlayback.get().sequence
void playSpeechText(response.text, { source: 'voice-conversation' })
void playback
.catch(error => notifyError(error, voiceCopy.playbackFailed))
.finally(() => {
if (responseIdRef.current === responseId) {
@@ -482,9 +488,10 @@ export function useVoiceConversation({
*/
const openLiveSpeech = useCallback(
(responseId: string) => {
const sequenceBeforeStart = $voicePlayback.get().sequence
responseIdRef.current = responseId
spokenSourceLengthRef.current = 0
speechStartSequenceRef.current = $voicePlayback.get().sequence
setStatus('speaking')
// VAD barge-in: the user talking over the reply cuts playback, drops
@@ -506,6 +513,16 @@ export function useVoiceConversation({
}
if (!session) {
// Stream discovery can also fail after an explicit Stop landed
// during its async URL lookup. In that case, do not turn the stopped
// live attempt into fresh fallback playback.
if ($voicePlayback.get().sequence > sequenceBeforeStart) {
awaitingSpokenResponseRef.current = false
settleAfterSpeech(false, true)
return
}
// No streaming backend/provider: speak the whole reply once it lands.
speechSessionRef.current = null
awaitFallbackSpeech(responseId)
@@ -513,8 +530,24 @@ export function useVoiceConversation({
return
}
// startSpeechStream calls stopVoicePlayback once after its async URL
// lookup. A second sequence bump means the user pressed Stop while
// setup was still pending. Do not absorb that explicit stop into the
// post-start baseline or allow the new session to play.
const sequenceAfterStart = $voicePlayback.get().sequence
const stoppedDuringStart = sequenceAfterStart > sequenceBeforeStart + 1
speechStartSequenceRef.current = sequenceAfterStart
speechSessionRef.current = session
if (stoppedDuringStart) {
stopVoicePlayback()
awaitingSpokenResponseRef.current = false
settleAfterSpeech(false, true)
return
}
// Timer-driven feed: reply text flows into the session at delta rate
// regardless of React render cadence.
const feedTimer = window.setInterval(() => feedSpeechSession(responseId), 150)
+2 -1
View File
@@ -57,6 +57,7 @@ import { ActionBadges } from './micro-actions'
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
import { QueuePanel } from './queue-panel'
import {
COMPOSER_PLACEHOLDER_CLASS,
composerPlainText,
deleteChipBeforeCaret,
deleteSelectionInEditor,
@@ -946,7 +947,7 @@ export function ChatBar({
autoCorrect="off"
className={cn(
'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
COMPOSER_PLACEHOLDER_CLASS,
'**:data-ref-text:cursor-default',
stacked && 'pl-3',
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
@@ -21,6 +21,28 @@ import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
export const RICH_INPUT_SLOT = 'composer-rich-input'
/** Paints `data-placeholder` while the editor is empty.
*
* `:empty` can't be the whole test: a cleared editor keeps a scaffolding <br>
* so the contenteditable doesn't collapse, and that break makes `:empty`
* false. Nor can CSS infer it on its own — a text node is invisible to
* selectors, so `one<br>` and a lone `<br>` are the same shape, and
* `:has(> br:only-child)` would paint the placeholder straight over the
* user's text. The code that empties the editor is what knows, so it marks it.
*
* @see markEditorEmptiness */
export const COMPOSER_PLACEHOLDER_CLASS =
'[&:is(:empty,[data-empty])]:before:content-[attr(data-placeholder)] [&:is(:empty,[data-empty])]:before:text-muted-foreground/60'
/** Keep that marker in step with the editor root's contents. */
export function markEditorEmptiness(editor: HTMLElement) {
if (editor.childNodes.length === 0) {
editor.dataset.empty = ''
} else {
delete editor.dataset.empty
}
}
/** @see referenceRe — the shared pattern every surface recognises a reference
* with. Module-level `/g` regexes carry `lastIndex`, so call sites reset it. */
export const REF_RE = referenceRe()
@@ -165,6 +187,10 @@ export function renderComposerContents(target: HTMLElement, text: string, option
// typed (`/wor`) and must stay editable. Callers repainting inert text (a
// restored draft, a sent message opened for edit) pass `trailingCommitted`.
appendComposerContents(target, text, options)
// The other writer that reshapes the editor root: painting a restored draft
// in clears the marker, clearing back to '' sets it.
markEditorEmptiness(target)
}
/** Caret range when the selection lives inside `editor`; else null. */
@@ -681,6 +707,9 @@ export function normalizeComposerEditorDom(editor: HTMLElement) {
// composer to appear as a tiny dot/pixel. Ensure there's always at least
// one <br> so the element maintains intrinsic height. The CSS min-height
// is a belt; the <br> is suspenders — together they prevent the shrink.
// That break is also why emptiness has to be marked, not inferred.
markEditorEmptiness(editor)
if (editor.childNodes.length === 0) {
editor.appendChild(document.createElement('br'))
}
@@ -15,13 +15,7 @@ import { Codicon } from '@/components/ui/codicon'
import { DiffCount } from '@/components/ui/diff-count'
import type { HermesGitBranch } from '@/global'
import { useI18n } from '@/i18n'
import {
$repoStatus,
$repoWorktrees,
registerRepoStatusCwd,
repoStatusForCwd,
repoWorktreesForCwd
} from '@/store/coding-status'
import { registerRepoStatusCwd, repoStatusForCwd, repoWorktreesForCwd } from '@/store/coding-status'
import { notifyError } from '@/store/notifications'
import { $newWorktreeRequest } from '@/store/projects'
@@ -69,10 +63,13 @@ export const CodingStatusRow = memo(function CodingStatusRow({
const s = t.statusStack.coding
const p = t.sidebar.projects
const resolvedRepoPath = repoPath?.trim() || undefined
// Per-cwd slice when this surface knows its worktree (tiles); otherwise the
// primary main-pane computed — so a blank/missing repoPath still paints.
const status = useStore(resolvedRepoPath ? repoStatusForCwd(resolvedRepoPath) : $repoStatus)
const worktrees = useStore(resolvedRepoPath ? repoWorktreesForCwd(resolvedRepoPath) : $repoWorktrees)
// This surface's OWN worktree, always — never the primary's. The row used to
// fall back to the global `$repoStatus` for a blank repoPath, which painted
// the main pane's branch/± onto a tile whose cwd hadn't resolved yet. That
// fallback bought nothing (the primary's computed is keyed to `$currentCwd`,
// which is blank in exactly the same case) and cost a wrong-tree rail.
const status = useStore(repoStatusForCwd(resolvedRepoPath))
const worktrees = useStore(repoWorktreesForCwd(resolvedRepoPath))
// While mounted, keep this worktree in the coding-status refresh set so the
// turn-settle / tool-complete / focus edges re-probe it too (tiles otherwise
+9 -3
View File
@@ -39,7 +39,8 @@ import {
$sessions,
resolveComposerSessionKey,
sessionMatchesStoredId,
sessionPinId
sessionPinId,
shouldMigrateComposerScope
} from '@/store/session'
import { isSecondaryWindow, isWatchWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
@@ -326,14 +327,19 @@ export function ChatView({
// When the tip row arrives after compression, migrate any tip-keyed stash onto
// the durable lineage key before the composer remounts onto that key.
//
// ONLY same-conversation rekeys (tip → root). The route-driven queueSessionKey
// can flip to Session B a frame before the store selection leaves Session A;
// migrating on bare inequality would re-home A's queued prompts onto B and
// auto-drain them into the wrong chat.
useEffect(() => {
if (!selectedSessionId || !queueSessionKey || selectedSessionId === queueSessionKey) {
if (!shouldMigrateComposerScope(selectedSessionId, queueSessionKey, sessions)) {
return
}
migrateSessionDraft(selectedSessionId, queueSessionKey)
migrateQueuedPrompts(selectedSessionId, queueSessionKey)
}, [queueSessionKey, selectedSessionId])
}, [queueSessionKey, selectedSessionId, sessions])
// Transcript-side stops (the streaming message's hover Stop, the runtime's
// cancel) are explicit halts, same as the composer's Stop button: park any
+11 -5
View File
@@ -27,7 +27,7 @@ import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
import { $layoutTree, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
import { $layoutTree, closeTreePane, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { transcribeAudio } from '@/hermes'
@@ -504,9 +504,10 @@ export function SessionTabMenu({
}
/** The MAIN tab's menu: the same session verbs targeting the primary's loaded
* session, plus the bar's off switch (the bar sticky-shows once a tab is
* ever gained; this is the explicit way back). A fresh draft has no session —
* no menu. */
* session, plus Close (the tab empties to a fresh draft — the workspace pane
* itself never leaves the tree) and the bar's off switch (the bar sticky-shows
* once a tab is ever gained; this is the explicit way back). A fresh draft has
* no session — no menu. */
export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) {
const selected = useStore($selectedStoredSessionId)
@@ -524,7 +525,12 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
}
return (
<SessionTabMenu onHideTabBar={hideTabBar} storedSessionId={selected} tabPaneId="workspace">
<SessionTabMenu
onClose={() => closeTreePane('workspace')}
onHideTabBar={hideTabBar}
storedSessionId={selected}
tabPaneId="workspace"
>
{children}
</SessionTabMenu>
)
@@ -13,6 +13,7 @@ import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { middleClickHandlers } from '@/lib/middle-click'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
@@ -168,16 +169,11 @@ function SidebarSessionRowImpl({
)}
<SidebarRowBody
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
// Middle-click = open in a new tab (browser muscle memory). Swallow
// the mousedown so Chromium doesn't enter autoscroll mode.
onAuxClick={event => {
if (event.button === 1) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
openSession(session.id, () => undefined, 'tab')
}
}}
// Middle-click = open in a new tab (browser muscle memory).
{...middleClickHandlers(() => {
triggerHaptic('selection')
openSession(session.id, () => undefined, 'tab')
})}
onClick={event => {
const mod = event.metaKey || event.ctrlKey
@@ -213,7 +209,6 @@ function SidebarSessionRowImpl({
onResume()
}}
onMouseDown={event => event.button === 1 && event.preventDefault()}
>
{reorderable ? (
<SidebarRowGrab
@@ -3,6 +3,7 @@ import { useEffect } from 'react'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync'
import { $onBattery, batteryPollInterval } from '@/store/power'
import { refreshActiveProfile } from '@/store/profile'
import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session'
import {
@@ -180,7 +181,10 @@ interface BackgroundSyncParams {
}
/** Poll a callback while the tab is visible, on `intervalMs`; re-checks on tab
* re-focus. Returns nothing — meant to live inside an effect. */
* re-focus. On battery the cadence stretches (see store/power) — these are
* safety-net refreshes, not the live path, so they're the right thing to slow
* when the machine is spending its charge. Returns nothing — meant to live
* inside an effect. */
function visiblePoll(intervalMs: number, tick: () => void): () => void {
const run = () => {
if (document.visibilityState === 'visible') {
@@ -188,10 +192,17 @@ function visiblePoll(intervalMs: number, tick: () => void): () => void {
}
}
const intervalId = window.setInterval(run, intervalMs)
let intervalId = window.setInterval(run, batteryPollInterval(intervalMs, $onBattery.get()))
const unsubscribeBattery = $onBattery.listen(onBattery => {
window.clearInterval(intervalId)
intervalId = window.setInterval(run, batteryPollInterval(intervalMs, onBattery))
})
document.addEventListener('visibilitychange', run)
return () => {
unsubscribeBattery()
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', run)
}
+21 -2
View File
@@ -20,7 +20,7 @@ import { FindBar } from '@/components/find-bar'
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 { $newSessionTabAction, registerPaneCloser } 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'
@@ -68,6 +68,7 @@ import { armWakeWord } from '@/store/wake-word'
import { isSecondaryWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
import { closeWorkspaceTab } from '../chat/close-tab'
import { requestComposerInsert } from '../chat/composer/focus'
import { useComposerActions } from '../chat/hooks/use-composer-actions'
import { CommandPalette } from '../command-palette'
@@ -83,7 +84,14 @@ import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree'
import { PersistentTerminal } from '../right-sidebar/terminal/persistent'
import { closeAllTerminals } from '../right-sidebar/terminal/terminals'
import { CRON_ROUTE, navigateToWorkspacePage, routeSessionId, SETTINGS_ROUTE, syncWorkspaceRoute } from '../routes'
import {
CRON_ROUTE,
navigateToWorkspacePage,
routeSessionId,
sessionRoute,
SETTINGS_ROUTE,
syncWorkspaceRoute
} from '../routes'
import { SessionPickerOverlay } from '../session-picker-overlay'
import { SessionSwitcher } from '../session-switcher'
import { useBackgroundQueueDrain } from '../session/hooks/use-background-queue-drain'
@@ -819,6 +827,17 @@ export function ContribWiring({ children }: { children: ReactNode }) {
return () => $newSessionTabAction.set(null)
}, [openNewSessionTab])
// The MAIN tab's Close. The workspace pane can't leave the tree, so its
// closer empties it instead: the next stacked session shifts in, else main
// drops to a fresh draft. Registering it here is also what gives the tab its
// close GESTURE (⌘-click / middle-click) — the strip reads the closer, not
// the `uncloseable` flag, so the pane stays undismissable either way.
useEffect(() => {
registerPaneCloser('workspace', () => void closeWorkspaceTab(id => navigate(sessionRoute(id))))
return () => registerPaneCloser('workspace')
}, [navigate])
// The controller's entire callback surface, gathered into the stable
// `actions` bag. `nextActions` is TS-checked against WiringActions each
// render; its fields are copied into the ref object so `actions` keeps one
@@ -0,0 +1,75 @@
import type { ILink, Terminal as TerminalType } from '@xterm/xterm'
import { Terminal } from '@xterm/xterm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isTerminalLinkActivation, terminalLinkHandler, terminalWebLinksAddon } from './links'
const openExternal = vi.fn()
const click = (init: Partial<MouseEvent> = {}) => ({ ctrlKey: false, metaKey: false, ...init })
beforeEach(() => {
openExternal.mockClear()
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { openExternal } })
// jsdom reports a non-mac platform, so the activation modifier resolves to
// Ctrl unless we say otherwise.
Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel' })
})
// Drive the addon the way xterm does: load it on a real terminal, write a URL
// into the buffer, then activate the link its provider reports.
async function clickLinkIn(text: string, event: MouseEvent) {
const term = new Terminal({ allowProposedApi: true, cols: 80, rows: 10 })
const providers: Array<Parameters<TerminalType['registerLinkProvider']>[0]> = []
const register = term.registerLinkProvider.bind(term)
term.registerLinkProvider = provider => {
providers.push(provider)
return register(provider)
}
term.loadAddon(terminalWebLinksAddon())
await new Promise<void>(resolve => term.write(`${text}\r\n`, resolve))
const links = await new Promise<ILink[]>(resolve => providers[0].provideLinks(1, found => resolve(found ?? [])))
links[0]?.activate(event, links[0].text)
return links[0]?.text
}
describe('terminal links', () => {
it('opens a ⌘-clicked URL through the desktop bridge, not the window.open Electron denies', async () => {
const uri = 'https://example.com/path'
expect(await clickLinkIn(uri, new MouseEvent('click', { metaKey: true }))).toBe(uri)
expect(openExternal).toHaveBeenCalledWith(uri)
})
it('leaves a bare click to the selection, so a misclick never launches a browser', async () => {
await clickLinkIn('https://example.com/path', new MouseEvent('click'))
expect(openExternal).not.toHaveBeenCalled()
})
it('routes OSC 8 hyperlinks the same way, instead of xterm\u2019s confirm() dialog', () => {
terminalLinkHandler.activate(new MouseEvent('click', { metaKey: true }), 'https://example.com/osc8', {
end: { x: 10, y: 1 },
start: { x: 1, y: 1 }
})
expect(openExternal).toHaveBeenCalledWith('https://example.com/osc8')
})
})
describe('isTerminalLinkActivation', () => {
it('takes the platform modifier: ⌘ on macOS, Ctrl elsewhere', () => {
expect(isTerminalLinkActivation(click({ metaKey: true }), true)).toBe(true)
expect(isTerminalLinkActivation(click({ ctrlKey: true }), false)).toBe(true)
})
it('keeps Ctrl+click free on macOS, where the OS reads it as a right-click', () => {
expect(isTerminalLinkActivation(click({ ctrlKey: true }), true)).toBe(false)
})
})
@@ -0,0 +1,35 @@
import { WebLinksAddon } from '@xterm/addon-web-links'
import type { ILinkHandler } from '@xterm/xterm'
import { openExternalLink } from '@/lib/external-link'
import { isMacPlatform } from './selection'
// Both of xterm's link paths — the web-links addon (URLs it finds in the
// buffer) and the core OSC 8 provider (hyperlinks a CLI emits explicitly) —
// activate through `window.open()`, which the window's setWindowOpenHandler
// denies: a click did nothing but log "Opening link blocked as opener could not
// be cleared", and OSC 8 fronted that dead end with a raw confirm() dialog.
// Route both through the desktop bridge, the path every other external link in
// the app takes.
//
// ⌘-click on macOS, Ctrl-click elsewhere — VS Code's integrated terminal,
// Terminal.app, and iTerm2 all agree. A bare click belongs to the selection, so
// a misclick on a URL can't launch a browser mid-sentence. ⌥ stays out of it:
// that's the force-selection drag over mouse-mode TUIs.
export function isTerminalLinkActivation(
event: Pick<MouseEvent, 'ctrlKey' | 'metaKey'>,
isMac = isMacPlatform()
): boolean {
return isMac ? event.metaKey : event.ctrlKey
}
const activate = (event: MouseEvent, uri: string) => {
if (isTerminalLinkActivation(event)) {
openExternalLink(uri)
}
}
export const terminalLinkHandler: ILinkHandler = { activate }
export const terminalWebLinksAddon = () => new WebLinksAddon(activate)
@@ -11,6 +11,7 @@ import {
import { Tip, TipHintLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
import { middleClickHandlers } from '@/lib/middle-click'
import { cn } from '@/lib/utils'
import { $bindings } from '@/store/keybinds'
@@ -130,18 +131,8 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T
? 'bg-(--chrome-action-hover) text-foreground'
: 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onAuxClick={event => {
if (event.button === 1) {
event.preventDefault()
closeTerminal(term.id)
}
}}
{...middleClickHandlers(() => closeTerminal(term.id))}
onClick={() => selectTerminal(term.id)}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
}}
role="tab"
type="button"
>
@@ -1,6 +1,5 @@
import { FitAddon } from '@xterm/addon-fit'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import { useEffect, useRef } from 'react'
@@ -12,6 +11,7 @@ import { useTheme } from '@/themes/context'
import { registerAgentTerminalWriter } from './agent-terminal-stream'
import { makeTerminalReader, registerTerminalReader } from './buffer'
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
import { terminalLinkHandler, terminalWebLinksAddon } from './links'
import { isMacPlatform, resolveSurfaceColor, terminalTheme } from './selection'
// Read-only terminal for an agent background process: a write-only xterm (no PTY,
@@ -51,6 +51,7 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
fontWeightBold: 'bold',
letterSpacing: 0,
lineHeight: 1.12,
linkHandler: terminalLinkHandler,
minimumContrastRatio: 4.5,
scrollback: 1000,
theme: surfaceTheme()
@@ -59,7 +60,7 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
const fit = new FitAddon()
term.loadAddon(fit)
term.loadAddon(new Unicode11Addon())
term.loadAddon(new WebLinksAddon())
term.loadAddon(terminalWebLinksAddon())
term.unicode.activeVersion = '11'
term.open(host)
termRef.current = term
@@ -1,7 +1,6 @@
import { FitAddon } from '@xterm/addon-fit'
import { SerializeAddon } from '@xterm/addon-serialize'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -16,6 +15,7 @@ import { $terminalInjection } from '../store'
import { makeTerminalReader, registerTerminalReader } from './buffer'
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
import { terminalLinkHandler, terminalWebLinksAddon } from './links'
import {
isAddSelectionShortcut,
isMacPlatform,
@@ -499,6 +499,11 @@ export function useTerminalSession({
const term = new Terminal({
allowProposedApi: true,
// ⌥-drag is our force-selection gesture (below), and xterm's default
// alt-click-moves-cursor claims the same click, emitting one cursor
// left/right escape per column of travel — shells that don't consume them
// echo the raw `^[[D` burst into the buffer. One gesture, one meaning.
altClickMovesCursor: false,
// Opaque canvas = WebGL's crisp fast-path. allowTransparency instead bakes
// glyphs as grayscale-alpha for compositing over a see-through canvas, which
// reads soft on every platform; VS Code keeps it off and our surface
@@ -514,6 +519,10 @@ export function useTerminalSession({
fontWeightBold: 'bold',
letterSpacing: 0,
lineHeight: 1.12,
// OSC 8 hyperlinks (gh, cargo, npm, ls --hyperlink) activate through this
// handler; without it xterm shows a raw confirm() and then a window.open
// Electron denies.
linkHandler: terminalLinkHandler,
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
// selection over mouse-mode apps, which ⌘/Ctrl+L then sends to chat.
@@ -536,7 +545,7 @@ export function useTerminalSession({
term.loadAddon(fit)
term.loadAddon(serialize)
term.loadAddon(new Unicode11Addon())
term.loadAddon(new WebLinksAddon())
term.loadAddon(terminalWebLinksAddon())
term.unicode.activeVersion = '11'
// Replay last session's scrollback before the fresh shell boots. The process
@@ -10,11 +10,33 @@ import {
getQueuedPrompts,
parkQueuedPrompts
} from '@/store/composer-queue'
import { $sessions, setSessions } from '@/store/session'
import { clearAllSessionStates, publishSessionState } from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'
import { useBackgroundQueueDrain } from './use-background-queue-drain'
import type { SubmitTextOptions } from './use-prompt-actions/utils'
const lineageSession = (over: Partial<SessionInfo>): SessionInfo =>
({
archived: false,
cwd: null,
ended_at: null,
id: 'live',
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 0,
model: null,
output_tokens: 0,
preview: null,
source: null,
started_at: 0,
title: null,
tool_call_count: 0,
...over
}) as SessionInfo
function Harness({
enabled = true,
runtimeMap,
@@ -48,6 +70,7 @@ describe('useBackgroundQueueDrain', () => {
vi.useRealTimers()
$queuedPromptsBySession.set({})
$parkedQueueSessions.set({})
$sessions.set([])
clearAllSessionStates()
})
@@ -103,6 +126,40 @@ describe('useBackgroundQueueDrain', () => {
expect(getQueuedPrompts('stored-session-a')).toHaveLength(1)
})
it('treats a tip working id as busy for a root queue key via lineage', async () => {
// Queue keys use the lineage root (resolveComposerSessionKey) while
// $workingSessionIds may hold the compression tip — strict equality misses.
const runtimeMap = { current: new Map([['root-a', 'rt-tip-a']]) }
const submitText = vi.fn(async () => true)
setSessions([lineageSession({ id: 'tip-a', _lineage_root_id: 'root-a' })])
enqueueQueuedPrompt('root-a', { text: 'wait for tip turn', attachments: [] })
publishSessionState('rt-tip-a', { ...createClientSessionState('tip-a'), busy: true })
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
await new Promise(resolve => window.setTimeout(resolve, 0))
expect(submitText).not.toHaveBeenCalled()
expect(getQueuedPrompts('root-a')).toHaveLength(1)
})
it('leaves a root queue to ChatBar when the selected id is the compression tip', async () => {
const runtimeMap = { current: new Map([['root-a', 'rt-tip-a']]) }
const submitText = vi.fn(async () => true)
setSessions([lineageSession({ id: 'tip-a', _lineage_root_id: 'root-a' })])
enqueueQueuedPrompt('root-a', { text: 'visible after tip select', attachments: [] })
clearAllSessionStates()
render(<Harness runtimeMap={runtimeMap} selectedStoredSessionId="tip-a" submitText={submitText} />)
await new Promise(resolve => window.setTimeout(resolve, 0))
expect(submitText).not.toHaveBeenCalled()
expect(getQueuedPrompts('root-a')).toHaveLength(1)
})
it('does not drain a parked background session, even when idle', async () => {
// A Stop in a tile parks that session's queue; when the user then focuses
// another chat, THIS drainer takes over the tile's queue — it must honor
@@ -13,6 +13,7 @@ import {
shouldAutoDrain
} from '@/store/composer-queue'
import { notify } from '@/store/notifications'
import { $sessions, idsShareLineage } from '@/store/session'
import { $workingSessionIds } from '@/store/session-states'
import type { SubmitTextOptions } from './use-prompt-actions/utils'
@@ -154,14 +155,23 @@ export function useBackgroundQueueDrain({
return
}
const working = new Set(workingSessionIds)
// Queue keys prefer the lineage root (resolveComposerSessionKey) while
// $workingSessionIds / selection may hold the compression tip. Strict
// equality then mis-classifies a busy or selected chat as idle/offscreen.
const sessions = $sessions.get()
const working = [...workingSessionIds]
for (const [sessionKey, entries] of Object.entries(queuedPromptsBySession)) {
const isSelected =
Boolean(selectedStoredSessionId) && idsShareLineage(sessionKey, selectedStoredSessionId!, sessions)
const isBusy = working.some(workingId => idsShareLineage(sessionKey, workingId, sessions))
if (
sessionKey === selectedStoredSessionId ||
isSelected ||
drainingSessionIdsRef.current.has(sessionKey) ||
!shouldAutoDrain({
isBusy: working.has(sessionKey),
isBusy,
parked: Boolean(parkedQueueSessions[sessionKey]),
queueLength: entries.length
})
@@ -273,18 +273,20 @@ export function useMessageStream({
// Always a timer, never requestAnimationFrame. Chromium pauses rAF for a
// renderer it considers hidden, and "hidden" is not something this code can
// verify: `backgroundThrottling: false` plus the process-level switches in
// electron/main.ts cover the blurred and occluded cases, but they don't
// cover a minimized window, a fully off-screen one, or a renderer the
// compositor has otherwise parked. In those states an rAF-gated flush never
// runs, so a finished answer sits in this queue until some later input or
// focus event happens to wake a frame — the reply looks stalled, then
// arrives all at once on refocus.
// verify: while a turn is in flight the main process unthrottles every chat
// window (stream-throttle.ts), but that doesn't guarantee frames for a
// minimized window, a fully off-screen one, or a renderer the compositor
// has otherwise parked. In those states an rAF-gated flush never runs, so a
// finished answer sits in this queue until some later input or focus event
// happens to wake a frame — the reply looks stalled, then arrives all at
// once on refocus.
//
// A timer keeps the same coalescing cadence (that's what the floor above is
// for) while guaranteeing delivery without user interaction. Timers are
// clamped in background renderers rather than suspended, and
// disable-background-timer-throttling already opts out of that clamp.
// clamped in background renderers rather than suspended, and the
// stream-aware unthrottle lifts even that clamp for the life of the turn;
// in the worst case (a delta arriving before the unthrottle lands) the
// clamp only stretches one flush to ~1s in a window nobody can see.
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, adaptiveFloor - sinceLast))
}, [flushQueuedDeltas])
@@ -504,7 +504,9 @@ export function useSessionActions({
upsertOptimisticSession(created, stored, null, null)
}
const runtimeInfo = applyRuntimeInfo(created.info)
// A tile lives in its OWN worktree — it must not publish its cwd/branch
// into the composer atoms the main pane renders from.
const runtimeInfo = applyRuntimeInfo(created.info, { foreground: false })
updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored)
openSessionTile(stored, dir)
@@ -1182,7 +1184,9 @@ export function useSessionActions({
routedSessionId
)
const runtimeInfo = applyRuntimeInfo(branched.info)
// The branch opens as its own tile in the parent's worktree, not as the
// primary session — keep its runtime out of the main composer atoms.
const runtimeInfo = applyRuntimeInfo(branched.info, { foreground: false })
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
if (runtimeInfo) {
@@ -4,6 +4,7 @@ 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 { $currentBranch, $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import {
@@ -65,6 +66,36 @@ describe('applyRuntimeInfo credential warnings', () => {
})
})
describe('applyRuntimeInfo foreground scoping', () => {
beforeEach(() => {
setCurrentCwd('/main-repo')
setCurrentBranch('main')
})
afterEach(() => {
setCurrentCwd('')
setCurrentBranch('')
})
it('publishes a foreground runtime into the composer atoms', () => {
const patch = applyRuntimeInfo({ branch: 'bb/feature', cwd: '/main-repo/worktree' })
expect($currentCwd.get()).toBe('/main-repo/worktree')
expect($currentBranch.get()).toBe('bb/feature')
expect(patch).toMatchObject({ branch: 'bb/feature', cwd: '/main-repo/worktree' })
})
it('keeps a background runtime out of the composer atoms but still returns its patch', () => {
const patch = applyRuntimeInfo({ branch: 'bb/tile', cwd: '/other-worktree' }, { foreground: false })
// The main pane's rail must stay on its own tree.
expect($currentCwd.get()).toBe('/main-repo')
expect($currentBranch.get()).toBe('main')
// ...while the caller still gets everything it needs for its own session.
expect(patch).toMatchObject({ branch: 'bb/tile', cwd: '/other-worktree' })
})
})
describe('isSessionGoneError', () => {
it('is true for 404 / session-not-found, false otherwise', () => {
expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true)
@@ -706,13 +706,72 @@ type SessionRuntimeStatePatch = Partial<
>
>
export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null {
interface ApplyRuntimeInfoOptions {
/**
* Whether this runtime belongs to the session the MAIN pane is showing.
* Foreground (the default) mirrors into the composer atoms every main-pane
* surface reads.
*
* A tile or a background branch must pass `false`: it owns a different
* worktree, and writing its cwd into `$currentCwd` re-pointed the main
* composer's coding rail (and the persisted workspace cwd) at the tile's
* repo — the main rail painted a branch from a tree its session was never
* in. The returned patch still carries every field, so the caller's own
* per-session state is unaffected.
*/
foreground?: boolean
}
/** Mirror a session's runtime state into the composer atoms the MAIN pane
* renders from. Foreground sessions only — see ApplyRuntimeInfoOptions. */
function publishRuntimeToComposer(state: SessionRuntimeStatePatch): void {
if (state.model !== undefined) {
setCurrentModel(state.model)
}
if (state.provider !== undefined) {
setCurrentProvider(state.provider)
}
if (state.cwd !== undefined) {
setCurrentCwd(state.cwd)
}
if (state.branch !== undefined) {
setCurrentBranch(state.branch)
}
if (state.personality !== undefined) {
setCurrentPersonality(state.personality)
}
if (state.reasoningEffort !== undefined) {
setCurrentReasoningEffort(state.reasoningEffort)
}
if (state.serviceTier !== undefined) {
setCurrentServiceTier(state.serviceTier)
}
if (state.fast !== undefined) {
setCurrentFastMode(state.fast)
}
if (state.yolo !== undefined) {
setYoloActive(state.yolo)
}
}
export function applyRuntimeInfo(
info: SessionRuntimeInfo | undefined,
{ foreground = true }: ApplyRuntimeInfoOptions = {}
): SessionRuntimeStatePatch | null {
if (!info) {
return null
}
const sessionState: SessionRuntimeStatePatch = {}
// App/profile-level reporting is session-independent — a tile's runtime
// reports backend skew and credential warnings just as usefully.
reportBackendContract(info.desktop_contract)
if (info.approval_mode !== undefined) {
@@ -723,54 +782,50 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
reportInstallMethodWarning(info.install_warning)
const sessionState: SessionRuntimeStatePatch = {}
if (typeof info.model === 'string') {
setCurrentModel(info.model)
sessionState.model = info.model
}
if (typeof info.provider === 'string') {
setCurrentProvider(info.provider)
sessionState.provider = info.provider
}
if (info.cwd) {
setCurrentCwd(info.cwd)
sessionState.cwd = info.cwd
}
if (info.branch !== undefined) {
setCurrentBranch(info.branch || '')
sessionState.branch = info.branch || ''
}
if (typeof info.personality === 'string') {
const personality = normalizePersonalityValue(info.personality)
setCurrentPersonality(personality)
sessionState.personality = personality
sessionState.personality = normalizePersonalityValue(info.personality)
}
if (typeof info.reasoning_effort === 'string') {
setCurrentReasoningEffort(info.reasoning_effort)
sessionState.reasoningEffort = info.reasoning_effort
}
if (typeof info.service_tier === 'string') {
setCurrentServiceTier(info.service_tier)
sessionState.serviceTier = info.service_tier
}
if (typeof info.fast === 'boolean') {
setCurrentFastMode(info.fast)
sessionState.fast = info.fast
}
if (typeof info.yolo === 'boolean') {
setYoloActive(info.yolo)
sessionState.yolo = info.yolo
}
if (info.usage) {
setCurrentUsage(current => ({ ...current, ...info.usage }))
if (foreground) {
publishRuntimeToComposer(sessionState)
if (info.usage) {
setCurrentUsage(current => ({ ...current, ...info.usage }))
}
}
return sessionState
@@ -35,6 +35,7 @@ import {
} from '@/app/chat/composer/inline-refs'
import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs'
import {
COMPOSER_PLACEHOLDER_CLASS,
composerPlainText,
insertComposerContentsAtCaret,
placeCaretEnd,
@@ -772,7 +773,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
autoCorrect="off"
className={cn(
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
COMPOSER_PLACEHOLDER_CLASS,
'**:data-ref-text:cursor-default',
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
)}
@@ -31,6 +31,7 @@ import {
$hiddenTreePanes,
$narrowViewport,
$newSessionTabAction,
$panesWithCloser,
$treeDragging,
activateTreePane,
closeAllTreeTabs,
@@ -177,6 +178,7 @@ export function TreeGroup({
const hiddenPanes = useStore($hiddenTreePanes)
const narrow = useStore($narrowViewport)
const newSessionTabAction = useStore($newSessionTabAction)
const panesWithCloser = useStore($panesWithCloser)
const paneFor = (id: string) => panes.find(p => p.id === id)
@@ -290,6 +292,11 @@ export function TreeGroup({
// closes the session, a store-bound pane collapses).
const closeTab = (paneId: string) => (isCollapsePane(paneId) ? dismissTreePane(paneId) : closeTreePane(paneId))
// A pane whose store owns Close keeps the gesture even when the pane itself
// is uncloseable — the workspace tab empties to a fresh draft rather than
// leaving the tree.
const closeableTab = (paneId: string) => !paneChrome(paneFor(paneId)).uncloseable || panesWithCloser.has(paneId)
// Collapse/restore a tool panel (or plain minimize elsewhere) — the header
// chevron + tap gesture, routed so ⌃`/the titlebar toggle stay truthful.
const toggleCollapse = () => (node.minimized ? restoreTreePane(activeId) : collapseTreePane(activeId))
@@ -347,7 +354,7 @@ export function TreeGroup({
role="tablist"
>
{shown.map(paneId => {
const closeable = !paneChrome(paneFor(paneId)).uncloseable
const closeable = closeableTab(paneId)
const title = paneFor(paneId)?.title ?? paneId
return (
@@ -414,7 +421,7 @@ export function TreeGroup({
{shown.map(paneId => {
const isActive = paneId === activeId && !node.minimized
const chrome = paneChrome(paneFor(paneId))
const closeable = !chrome.uncloseable
const closeable = closeableTab(paneId)
const title = paneFor(paneId)?.title ?? paneId
const tab = (
@@ -198,9 +198,24 @@ function setDismissed(paneId: string, dismissed: boolean) {
const paneClosers: Record<string, () => void> = {}
const paneOpeners: Record<string, () => void> = {}
/** Route a pane's Close through the app store that owns its visibility. */
export function registerPaneCloser(paneId: string, close: () => void) {
paneClosers[paneId] = close
/** Pane ids whose Close an app store owns. True for the main workspace, whose
* pane can't leave the tree but whose TAB can still be emptied — the close
* GESTURE (⌘-click / middle-click) keys off this rather than `uncloseable`.
* An atom, not a lookup: a closer registered by a wiring EFFECT lands after
* the strip's first paint, and a plain read would leave that tab gestureless
* until something else happened to re-render it. */
export const $panesWithCloser = atom<ReadonlySet<string>>(new Set())
/** Route a pane's Close through the app store that owns its visibility.
* Passing no closer unregisters (a wiring effect's cleanup). */
export function registerPaneCloser(paneId: string, close?: () => void) {
if (close) {
paneClosers[paneId] = close
} else {
delete paneClosers[paneId]
}
$panesWithCloser.set(new Set(Object.keys(paneClosers)))
}
/**
@@ -6,7 +6,7 @@ import { PaneTab, PaneTabLabel } from './pane-tab'
afterEach(cleanup)
describe('PaneTab close gestures', () => {
it('middle-click (button 1) closes', () => {
it('middle-click closes — pointer events only, no auxclick', () => {
const onClose = vi.fn()
render(
<PaneTab onClose={onClose}>
@@ -14,7 +14,9 @@ describe('PaneTab close gestures', () => {
</PaneTab>
)
fireEvent(screen.getByText('tab'), new MouseEvent('auxclick', { bubbles: true, button: 1 }))
const tab = screen.getByText('tab')
fireEvent.pointerDown(tab, { button: 1 })
fireEvent.pointerUp(tab, { button: 1 })
expect(onClose).toHaveBeenCalledTimes(1)
})
+10 -15
View File
@@ -1,5 +1,6 @@
import * as React from 'react'
import { middleClickHandlers } from '@/lib/middle-click'
import { cn } from '@/lib/utils'
/** Inset stroke for a vertical tab rail — content-facing edge. */
@@ -60,9 +61,9 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
active = false,
dirty = false,
onClose,
onAuxClick,
onMouseDown,
onPointerDown,
onPointerUp,
onClickCapture,
vertical = false,
side = 'left',
@@ -75,6 +76,7 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
// Vertical rails only. Horizontal tabs draw no bottom border — the strip owns
// that rule, and a per-tab border stacked a second translucent line over it.
const edge = vertical ? (side === 'right' ? 'border-l' : 'border-r') : undefined
const middle = middleClickHandlers(onClose)
return (
<div
@@ -89,16 +91,6 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
)}
data-active={active}
data-vertical={vertical || undefined}
onAuxClick={event => {
// Middle-click closes (browser/IDE). Swallow mousedown so Chromium
// doesn't autoscroll.
if (onClose && event.button === 1) {
event.preventDefault()
onClose()
}
onAuxClick?.(event)
}}
onClickCapture={event => {
// Sites whose tab activates on the label's own onClick (the preview
// rail) fire it AFTER our pointerdown close — swallow that stray click
@@ -111,13 +103,12 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
onClickCapture?.(event)
}}
onMouseDown={event => {
if (onClose && event.button === 1) {
event.preventDefault()
}
middle.onMouseDown(event)
onMouseDown?.(event)
}}
onPointerDown={event => {
middle.onPointerDown(event)
// ⌘-click closes. Preempt here — the tab strips activate/drag on
// pointerdown (drag-session onTap), so we must claim the press before
// the shell's own handler starts a drag, and skip it entirely.
@@ -131,6 +122,10 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
onPointerDown?.(event)
}}
onPointerUp={event => {
middle.onPointerUp(event)
onPointerUp?.(event)
}}
ref={ref}
{...props}
>
+2
View File
@@ -256,6 +256,8 @@ declare global {
// reload. Wipe session lists (skeletons) and re-dial.
onConnectionApplied?: (callback: () => void) => () => void
onPowerResume?: (callback: () => void) => () => void
getOnBattery?: () => Promise<boolean>
onBatteryChanged?: (callback: (onBattery: boolean) => void) => () => void
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
getBootstrapState: () => Promise<DesktopBootstrapState>
continueBootstrapLocal: () => Promise<{ ok: boolean }>
@@ -0,0 +1,86 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { middleClickHandlers } from './middle-click'
afterEach(cleanup)
/** A middle click as a real three-button mouse delivers it. Chromium on
* Windows/Linux swallows the trailing `auxclick` when the press starts
* autoscroll, so the gesture may NOT depend on that event. */
function middleClick(element: Element, upOn: Element = element) {
fireEvent.mouseDown(element, { button: 1 })
fireEvent.pointerDown(element, { button: 1 })
fireEvent.pointerUp(upOn, { button: 1 })
}
function Target({ action, id = 'target' }: { action?: () => void; id?: string }) {
return (
<button {...middleClickHandlers(action)} id={id} type="button">
{id}
</button>
)
}
describe('middleClickHandlers', () => {
it('fires without an auxclick — the event Chromium eats when autoscroll starts', () => {
const action = vi.fn()
render(<Target action={action} />)
middleClick(screen.getByText('target'))
expect(action).toHaveBeenCalledTimes(1)
})
it('cancels mousedown so the autoscroll pan widget never appears', () => {
render(<Target action={vi.fn()} />)
const down = fireEvent.mouseDown(screen.getByText('target'), { button: 1 })
expect(down).toBe(false) // preventDefault() called
})
it('cancels the middle mousedown even with no action — the surface owns the button', () => {
render(<Target />)
expect(fireEvent.mouseDown(screen.getByText('target'), { button: 1 })).toBe(false)
})
it('ignores left and right buttons', () => {
const action = vi.fn()
render(<Target action={action} />)
const target = screen.getByText('target')
fireEvent.pointerDown(target, { button: 0 })
fireEvent.pointerUp(target, { button: 0 })
fireEvent.pointerDown(target, { button: 2 })
fireEvent.pointerUp(target, { button: 2 })
expect(action).not.toHaveBeenCalled()
})
it('does nothing when the release lands on a different element', () => {
const pressed = vi.fn()
const released = vi.fn()
render(
<>
<Target action={pressed} id="pressed" />
<Target action={released} id="released" />
</>
)
middleClick(screen.getByText('pressed'), screen.getByText('released'))
expect(pressed).not.toHaveBeenCalled()
expect(released).not.toHaveBeenCalled()
})
it('a press with no action cannot arm the NEXT element it releases over', () => {
const action = vi.fn()
render(
<>
<Target id="inert" />
<Target action={action} id="live" />
</>
)
middleClick(screen.getByText('inert'), screen.getByText('live'))
expect(action).not.toHaveBeenCalled()
})
})
+58
View File
@@ -0,0 +1,58 @@
import type * as React from 'react'
/** `MouseEvent.button` for the middle (wheel) button. */
const MIDDLE_BUTTON = 1
/** Where the current middle press started. One pointer holds one button, so a
* single slot is the whole state, and it's only ever compared by identity in
* the pointerup right after — a value left behind by a press released
* elsewhere is inert, not stale. */
let pressedOn: EventTarget | null = null
/**
* Middle-click as a gesture that survives a real three-button mouse.
*
* `auxclick` is the obvious event and the wrong one to build on. Windows and
* Linux Chromium answer a middle press inside a scroller by starting the
* AUTOSCROLL pan, and the mouseup that ends the pan is spent stopping it
* instead of completing a click — so `auxclick` never arrives. Every surface
* carrying this gesture (tab strips, the session list, the terminal rail) is a
* scroller, which is why it only ever worked on macOS, where autoscroll
* doesn't exist.
*
* Pointer events fire either way, so the gesture arms on pointerdown and is
* spent on the pointerup over the SAME element — press one tab, release on
* another and nothing happens (Chrome / VS Code semantics). mousedown's default
* dies on every middle press, action or not, so the pan widget can't appear on
* a surface that owns the button.
*
* A plain factory, not a hook: tab strips call it inside `map()`.
*/
export function middleClickHandlers(action: (() => void) | undefined) {
return {
onMouseDown: (event: React.MouseEvent) => {
if (event.button === MIDDLE_BUTTON) {
event.preventDefault()
}
},
onPointerDown: (event: React.PointerEvent) => {
if (event.button === MIDDLE_BUTTON) {
pressedOn = action ? event.currentTarget : null
}
},
onPointerUp: (event: React.PointerEvent) => {
if (event.button !== MIDDLE_BUTTON) {
return
}
const armed = pressedOn === event.currentTarget
pressedOn = null
if (armed) {
action?.()
}
}
}
}
+2
View File
@@ -1,6 +1,8 @@
import './styles.css'
// Side-effect: reports in-flight turns to the main process for the quit guard.
import './store/active-work'
// Side-effect: mirrors the machine's AC/battery state for poll demotion.
import './store/power'
// Side-effect: applies the persisted window translucency on load.
import './store/translucency'
// Dev-only render/state churn counters. MUST precede the `react-dom` import
@@ -151,6 +151,19 @@ describe('migrateQueuedPrompts', () => {
expect(migrateQueuedPrompts('rt-old', 'rt-new')).toBe(false)
expect(migrateQueuedPrompts('rt-x', 'rt-x')).toBe(false)
})
it('must not be used across sessions without a same-lineage guard (documents the leak)', () => {
// ChatView used to call migrateQueuedPrompts(selectedA, routeKeyB) during the
// route-ahead/store-lag window of a session switch. That re-homes A's queue
// onto B so the idle ChatBar on B auto-drains it into the wrong conversation.
// The guard lives in shouldMigrateComposerScope — this test locks the
// underlying hazard so a future caller cannot treat migrate as free.
enqueueQueuedPrompt('root-a', { attachments: [], text: 'belongs to A' })
expect(migrateQueuedPrompts('root-a', 'root-b')).toBe(true)
expect(getQueuedPrompts('root-a')).toEqual([])
expect(getQueuedPrompts('root-b').map(e => e.text)).toEqual(['belongs to A'])
})
})
describe('shouldAutoDrain', () => {
+30
View File
@@ -0,0 +1,30 @@
/**
* AC ↔ battery mirror of the main process's powerMonitor.
*
* Backstop polls are already event-demoted and visibility-gated; battery is
* the third gate — on battery their cadence stretches (×4) so a laptop
* running Hermes in the background isn't spending its charge on safety-net
* refreshes. Live streaming and event-driven refreshes are untouched: this
* only slows timers whose job is catching what a degraded socket missed.
*
* Imported for its side effect from `main.tsx` (same pattern as
* store/active-work).
*/
import { atom } from 'nanostores'
export const $onBattery = atom<boolean>(false)
/** Multiply a backstop poll interval by this on battery. */
export const BATTERY_POLL_MULTIPLIER = 4
export function batteryPollInterval(intervalMs: number, onBattery: boolean): number {
return onBattery ? intervalMs * BATTERY_POLL_MULTIPLIER : intervalMs
}
if (typeof window !== 'undefined') {
const desktop = window.hermesDesktop
void desktop?.getOnBattery?.().then(onBattery => $onBattery.set(onBattery))
desktop?.onBatteryChanged?.(onBattery => $onBattery.set(onBattery))
}
+30
View File
@@ -23,6 +23,7 @@ import {
setRememberedSessionId,
setSelectedStoredSessionId,
setSessions,
shouldMigrateComposerScope,
touchSessionActivity,
workspaceCwdForNewSession
} from './session'
@@ -109,6 +110,35 @@ describe('resolveComposerSessionKey', () => {
})
})
describe('shouldMigrateComposerScope', () => {
it('allows tip → lineage-root rekey within the same conversation', () => {
const sessions = [session({ id: 'tip-a', _lineage_root_id: 'root-a' })]
expect(shouldMigrateComposerScope('tip-a', 'root-a', sessions)).toBe(true)
})
it('blocks cross-session migrate when route flipped but store selection lags', () => {
// ChatView mid-switch: selectedStoredSessionId still A, route-driven
// queueSessionKey already B. Migrating would re-home A's queue onto B.
const sessions = [
session({ id: 'tip-a', _lineage_root_id: 'root-a' }),
session({ id: 'tip-b', _lineage_root_id: 'root-b' })
]
expect(shouldMigrateComposerScope('tip-a', 'root-b', sessions)).toBe(false)
expect(shouldMigrateComposerScope('root-a', 'root-b', sessions)).toBe(false)
expect(shouldMigrateComposerScope('tip-a', 'tip-b', sessions)).toBe(false)
})
it('is a no-op for identical or missing keys', () => {
const sessions = [session({ id: 'tip-a', _lineage_root_id: 'root-a' })]
expect(shouldMigrateComposerScope('root-a', 'root-a', sessions)).toBe(false)
expect(shouldMigrateComposerScope(null, 'root-a', sessions)).toBe(false)
expect(shouldMigrateComposerScope('root-a', null, sessions)).toBe(false)
})
})
describe('mergeSessionPage', () => {
it('returns the server page untouched when there is nothing to keep', () => {
const previous = [session({ id: 'a' }), session({ id: 'b' })]
+36
View File
@@ -192,6 +192,42 @@ export const sessionMatchesStoredId = (
storedSessionId: string
): boolean => session.id === storedSessionId || session._lineage_root_id === storedSessionId
/** True when two ids name the same conversation across compression tip rotation. */
export function idsShareLineage(
a: string,
b: string,
sessions: readonly Pick<SessionInfo, '_lineage_root_id' | 'id'>[]
): boolean {
if (a === b) {
return true
}
return sessions.some(session => sessionMatchesStoredId(session, a) && sessionMatchesStoredId(session, b))
}
/**
* Whether a composer draft/queue key should move from `fromKey` onto `toKey`.
*
* Only same-conversation rekeys are allowed (compression tip → lineage root).
* A session-switch window where the route already points at B while the store
* selection still holds A must NOT migrate — that would re-home Session A's
* queued prompts onto B and auto-drain them into the wrong chat.
*/
export function shouldMigrateComposerScope(
fromKey: string | null | undefined,
toKey: string | null | undefined,
sessions: readonly Pick<SessionInfo, '_lineage_root_id' | 'id'>[]
): boolean {
const from = fromKey?.trim()
const to = toKey?.trim()
if (!from || !to || from === to) {
return false
}
return idsShareLineage(from, to, sessions)
}
/**
* Stable composer + `/queue` scope for a selected stored session.
*
+1 -1
View File
@@ -1436,7 +1436,7 @@ text-* variant utilities. */ .btn-arc {
font-size: 0.8125rem;
}
[data-slot='composer-rich-input']:empty::before {
[data-slot='composer-rich-input']:is(:empty, [data-empty])::before {
color: var(--ui-text-tertiary) !important;
}
@@ -0,0 +1,2 @@
lxman
# PR #74522 salvage (per-turn micro-compaction)
+401
View File
@@ -0,0 +1,401 @@
# Micro-compaction
**A way to amortize the cost of compression.**
Long conversations eventually outgrow the model's context window, and something
has to be thrown away or summarized. Hermes has always done this in one batch:
when the transcript crosses a threshold, the session stops, a large chunk of the
middle is summarized in a single call, and the conversation resumes. That works,
but the whole bill comes due at once — one visible pause, one big summarization
request, at whatever moment you happened to cross the line.
Micro-compaction pays the same bill in instalments. After each completed turn,
Hermes folds the single oldest un-absorbed exchange into a running summary. The
work is the same work; it just happens continuously, a piece at a time, instead
of all at once in the middle of your session.
It is not free and it is not a magic bullet, and it is **off by default** —
`compression.micro_compact: true` turns it on. Each pass is a real call to the
compression model, and it runs at the end of a turn — your answer has already
streamed, but the turn does not close until the pass finishes. Each pass also
rewrites already-sent history, which breaks the provider prompt-cache prefix
every turn; read [Prompt caching](#prompt-caching--the-cost-you-are-opting-into)
before enabling it, because for some setups that cost exceeds the benefit.
What the feature gives you is a **tuning option**: you choose how the
compression cost is distributed, and which model pays it. See
[Choosing a compression model](#choosing-a-compression-model), because that
choice matters more than anything else here.
**The tradeoff is that knowledge gets a little earlier than you may be used to.**
Because compaction is always running, older parts of the conversation become
summaries sooner than they would under batch compaction — which leaves
everything verbatim until the window actually fills. Detail from earlier in the
session turns second-hand faster. You trade some of that fidelity for never
eating one long stall, and for a context window that stays consistently smaller
rather than sawtoothing up to the threshold and back.
---
## What it does
After every turn that finishes normally, `finalize_turn` asks the context
compressor to absorb **one** exchange:
1. Find the oldest exchange that hasn't been summarized yet.
2. Send just that exchange, plus the current running summary, to the auxiliary
summarization model.
3. Replace those messages in the transcript with a single summary marker
carrying the updated running summary.
One exchange per turn. The per-turn cost stays bounded no matter how long the
conversation gets.
An **exchange** is one full agent turn: an assistant message together with its
tool results and any follow-up assistant iterations, up to the next user
message. In tool-heavy work that's where the bulk of the tokens live — a
file read or a command's output dwarfs the surrounding prose — which is why
absorbing one exchange at a time is worth doing at all. Taking the whole turn
(rather than a single assistant+tools group) also keeps the transcript's role
alternation strictly valid: the summary marker is an assistant-role message,
and a full turn is always bounded by user messages on both sides.
## Your messages are never compacted
An exchange deliberately starts at the *assistant* message. Micro-compaction
walks straight past user messages to get there, so **what you typed is never
summarized** — your prompts stay verbatim for the entire session, no matter how
long it runs or how many times compaction fires.
This is the most useful property of the whole design, and it's worth being
explicit about why. What the assistant produces is largely an account of what it
did: it read this file, it ran that command, it got this result. That kind of
narration survives summarising with very little loss — "it did it this way" is
about as informative compressed as it was in full. Your instructions are a
different kind of thing. They're the intent everything else is derived from, and
they cannot be reconstructed from the work that followed. Paraphrasing "use the
existing retry helper, don't add a new one" into a summary is exactly how an
agent ends up confidently doing the thing you told it not to, six turns later.
So the asymmetry is on purpose: compact the derived material, keep the source of
truth. The cost is a floor on how small the middle can get, since user turns
accumulate and are never absorbed. In practice that floor is low — a prompt is
normally a tiny fraction of what a single tool result costs — but it is a real
floor. If you routinely paste 10–20K-token prompts, that weight stays in context
by design.
## What it never touches
Two more regions are protected and stay verbatim:
- **The head** — the system prompt and the opening messages, so the session's
founding instructions are never paraphrased.
- **The tail** — a token-budgeted window of the most recent messages, so
everything that's immediately relevant is still there in full.
Micro-compaction only ever works in the middle, between those two.
## How it works
### The cursor
The compressor keeps a cursor: the index of the first message not yet absorbed.
Each successful pass advances it past the exchange it just summarized.
If that in-memory cursor is missing or out of range — a fresh process, a resumed
session — it's recovered by scanning the transcript for the last summary marker
and resuming just after it. The transcript itself is the source of truth, so
resuming a session doesn't re-summarize work already done.
### The rolling summary
Rather than keeping a pile of per-exchange summaries, there is exactly one
running summary that each new exchange is merged into. The summarizer is asked
to fold in the new material's decisions, requirements, file paths and open
questions, drop details that are no longer relevant, and preserve the existing
structure. It's also explicitly instructed to replace any credentials it
encounters with `[REDACTED]`.
Because that summary is cumulative, only the newest marker is kept in the
transcript. Earlier markers are strictly redundant — the current summary already
contains everything they held — so they're dropped as they're superseded. This
matters more than it sounds: leaving them in place stacks near-duplicate copies
of the same text, each with its own heading and end-marker scaffolding, and the
transcript grows on every turn instead of shrinking.
### Defrag
Merge into a summary often enough and it gets baggy — repetitive, and larger
than the material justifies. When the running summary crosses a token threshold
(2000 by default), the next pass **defrags**: one auxiliary call re-summarizes
the running summary *itself* into a fresh compact version, and the summary
marker in the transcript is rewritten in place.
Defrag never touches the transcript's structure — no messages are absorbed or
spliced, the cursor doesn't move, and user turns are untouched. It processes
only the accumulated summary text, never conversation messages, so the
"your messages are never compacted" guarantee holds through it.
### Staying in step with the session database
The in-memory splice alone isn't enough. Hermes's normal session flush is
append-only, so the original rows would stay marked active and a resume would
load *both* the summary and the messages it replaced — putting the session
straight over the context limit.
So each pass also calls `archive_and_compact`, which atomically soft-archives the
active rows and inserts the compacted set. The messages are then stamped as
already-persisted so the append-only flush that follows skips them. If that
database step fails, it's logged and the session continues; the resume would
double-load until the next batch compression cleans up.
### When the summarizer fails
A summarization call can fail — the auxiliary model is unreachable, out of quota,
or the exchange itself is somehow unsummarizable. The transcript is left
untouched and the failure is counted.
If the *same* exchange fails three times in a row, the cursor is advanced past it
anyway. Without that, one bad exchange would be retried on every single turn
forever. Those skipped messages stay in the transcript and get picked up by the
next batch compaction.
## Interaction with batch compaction
Micro-compaction doesn't replace batch compaction — it defers it. Threshold-based
compaction is still there and still fires if the window fills anyway, and its
summary markers are the same format, so the two interoperate. In practice
micro-compaction keeps the transcript far enough below the threshold that the
batch path fires much less often.
## Configuration
Micro-compaction is **off by default**. Turn it on explicitly:
```yaml
compression:
micro_compact: true # default: false
micro_compact_every_n_turns: 1 # cadence — how often a pass runs
micro_compact_defrag_threshold_tokens: 2000
```
With `micro_compact` unset or `false` Hermes behaves exactly as it always has:
batch-only compaction. Everything else about compression is unchanged.
`micro_compact_every_n_turns` is the knob that matters most after the on/off
switch, because it sets how often you pay the cache break described below. At
`1` a pass runs after every completed turn: the most aggressive reclaim, and
one broken prefix per turn. At `5` you get a fifth of the breaks and a fifth of
the reclaim rate, which is the right direction if your sessions are long-lived
and your provider's cache discount is deep. Values below `1` are clamped to `1`
rather than silently disabling the feature. The counter advances per turn, not
per committed pass, so a turn with nothing to absorb still moves the cadence
along and cannot wedge it.
`micro_compact_defrag_threshold_tokens` is when the rolling summary gets
re-summarized instead of growing forever — see [Defrag](#defrag).
It ships opt-in rather than on because of the prompt-cache cost described in
the next section — that cost is real, it is not universally worth paying, and
it should be a decision you make rather than one you inherit.
## Prompt caching — the cost you are opting into
Read this before enabling the feature. It is the strongest argument against it.
A long-lived conversation reuses a cached prompt prefix every turn, and cached
input tokens are billed at a fraction of uncached ones. That discount survives
only as long as the prefix does not change. **A micro-compaction pass rewrites
already-sent history**, which invalidates the prefix from the rewrite point
onward — so with micro-compaction on, you break the cache *every turn* instead
of once per batch compaction.
This is the same cost the proactive prune deliberately avoids. That path gates
itself behind `compression.proactive_prune_min_reclaim_tokens` (4096 by
default) precisely so its rewrites stay, in the words of the config comment,
"one big episodic break instead of a tiny break every tool iteration."
Micro-compaction has no equivalent *reclaim-size* gate — a pass commits
whatever the one absorbed exchange happened to save, large or small. What it
has instead is a *frequency* dial, `micro_compact_every_n_turns`. Raising it
makes the breaks rarer and more episodic, which is the same end the prune's
gate serves by a different route, though it gets there by absorbing less rather
than by waiting for a bigger win. If you want the prune's exact semantics here,
a reclaim threshold on micro-compaction is the obvious follow-up and does not
exist yet.
So the honest framing is a trade of one cost for another, not a saving:
| | Batch-only (default) | Micro-compaction on |
|---|---|---|
| Compression stalls | One long stall at the threshold | Spread across turns |
| Context occupancy | Sawtooths up to the threshold | Stays low and flat |
| Cache prefix | Intact between compactions | Broken every turn |
Which side wins depends on numbers specific to you: how much your provider
discounts cached input, how large your prefix is, how long your sessions run,
and how much a mid-session stall actually costs you. On a provider with a deep
cache discount and a big prefix, the per-turn invalidation can plausibly cost
more than the stall it removes. Measure your own sessions — see
[Measuring it](#measuring-it) — rather than assuming.
## Choosing a compression model
Micro-compaction uses the `auxiliary.compression` model:
```yaml
auxiliary:
compression:
provider: openai-api
model: <your choice>
base_url: <endpoint>
```
This is the single most important knob, and there is no universally right
answer — it depends on your hardware and what you are willing to trade.
Each pass sends the running summary plus one exchange, so the prompt is small
(a few thousand tokens) but the call happens **every turn**, at the end of the
turn. Two properties matter:
- **Latency dominates.** Because a pass runs per turn, its wall-clock cost is
felt repeatedly. A model that takes 30 seconds turns every turn into a turn
plus 30 seconds.
- **Reasoning models are a poor fit.** Merging one exchange into a summary is
mechanical work. A thinking model will spend reasoning tokens on it and be
substantially slower than a plain instruct model of similar size, for no
benefit to the output.
Some measured points, on one particular setup — treat them as illustrations of
the shape, not as recommendations:
| model | observed |
|---|---|
| 7B 4-bit instruct, local (MLX, Apple Silicon) | ~31s per pass; box also serving other work |
| large MoE reasoning model, remote GPU | noticeably slower still — thinking tokens on a summarisation task |
The pattern is that a small, fast, non-reasoning instruct model is usually the
right shape, and that a bigger or "smarter" model is often worse here rather
than better. Where that lands for you depends on what you have to run it on.
If passes feel too slow, your options in rough order of effect are: pick a
faster or smaller compression model; give it a less contended host; or turn
micro-compaction off and go back to batch compaction.
## Measuring it
Micro-compaction is not primarily a token-saving or time-saving optimisation,
and judging it on tokens saved will undersell it. The two things it actually
buys you are:
1. **The long pause is amortized.** The same summarization work happens, but as
small increments after turns instead of one stall in the middle of a session.
2. **Your context lasts longer.** Because the middle is continuously reclaimed,
occupancy stays low instead of sawtoothing up to the threshold. A session
runs much further — often indefinitely — before it needs a hard compaction
at all.
So the number that matters is **occupancy**: how full the window is being kept,
as a percentage of the compaction threshold. A session that holds steady around
40% has headroom to keep going; one climbing through 90% is about to stall. The
second number is **how many batch compactions actually fired** — ideally none.
A session can save nothing on paper and still be a clear win on both counts.
Every pass emits one content-free JSON line, in the same style as the batch
compaction telemetry:
```
micro compaction telemetry: {"event":"micro_compaction","outcome":"absorbed",
"tokens_before":12739,"tokens_after":12060,"tokens_delta":-679,
"occupancy_pct":38.4,"threshold_tokens":34816,"context_limit":40960,
"exchange_tokens":868,"rolling_summary_tokens":31,"passes_total":1,
"tokens_saved_total":679,"duration_ms":14,...}
```
`occupancy_pct` is `tokens_after` as a share of the compaction threshold -- the
headroom figure. It is null when the model's window has not been resolved yet:
the telemetry reads only the cached value, because resolving it can issue a
synchronous `/models` probe and telemetry must never be what blocks a turn.
`tokens_delta` is negative when the pass shrank the transcript.
`tokens_saved_total` and `passes_total` accumulate across the session, so a whole
run can be summarised from its last line. No transcript content appears in the
payload — only counts.
To turn a log into an answer:
```
python scripts/micro_compaction_report.py [--per-session] [LOGFILE ...]
```
Defaults to `$HERMES_HOME/logs/agent.log`. It reports passes, outcome mix, net
tokens saved, mean absorbed-exchange size and pass durations.
### What it looks like when it is working
One real session — a 3.5 hour whole-project code review, ~75K tokens of
transcript, 400K window, compaction threshold at 320K:
| pass | messages | tokens | delta | occupancy | duration |
|---|---|---|---|---|---|
| 1 | 40 -> 39 | 27,479 -> 27,778 | +299 | 8.7% | 2.2s |
| 2 | 61 -> 59 | 48,676 -> 48,128 | -548 | 15.0% | 4.5s |
| 3 | 70 -> 67 | 58,309 -> 55,915 | -2,394 | 17.5% | 9.1s |
| 4 | 84 -> 80 | 75,251 -> 69,818 | -5,433 | 21.8% | 36.2s |
| 5 | 84 -> 80 | 74,659 -> 70,264 | -4,395 | 22.0% | 31.2s |
Three things to read off it.
**Occupancy flattened.** It climbed to about 22% and stopped. The last two
passes are identical (84 -> 80 messages); between them the conversation added
4,841 tokens and micro-compaction reclaimed 4,395. That is equilibrium: the
window holds steady instead of marching toward the threshold.
**No batch compaction fired.** Across the whole session the long pause never
happened.
**Reclamation only ramps after the tail budget.** The first passes recovered
almost nothing, because below the tail budget (here 64,000 tokens, 16% of the
window) nearly the whole transcript is protected tail and there is very little
that may be touched. Early sessions legitimately show no passes at all.
And the cost, stated plainly: passes ran 2 to 37 seconds, median around 31, on
a small local model that was also serving other work. Roughly two minutes of
summarisation spread across three and a half hours. Against one batch
compaction of a 75K-token middle that is still the better trade, but a
37-second increment is not a rounding error. See
[Choosing a compression model](#choosing-a-compression-model).
### Reading the numbers honestly
**The first pass in a session usually costs tokens rather than saving them.**
Inserting the summary marker carries a fixed ~400 tokens of scaffolding — the
compaction preamble, the historical heading, the end marker — and on pass one
that is paid against a single absorbed exchange. A first pass showing
`tokens_delta: +330` is not a malfunction.
From the second pass on, the marker is *replaced* rather than added, so the
scaffolding is already paid for and each absorbed exchange is close to pure
saving. The break-even is normally the second or third pass. This is why the
per-session view matters more than any single line: judge the feature on a
session's trajectory, not on one turn.
The plainer human-readable lines are still there too:
```
Micro-compaction: 37 -> 36 messages
Micro-compaction defrag: rolling summary re-summarized (1843 chars)
Micro-compaction: skipping exchange at cursor 12 after 3 consecutive failures
```
Message counts move by small amounts — that's expected. The token count is where
the effect shows: absorbing one tool-heavy exchange can drop hundreds of tokens
while changing the message count by one or two.
## Failure behaviour
Micro-compaction is best-effort throughout. The call in `finalize_turn` is wrapped
so that any exception is logged and swallowed — a failure returns the conversation
unchanged and the turn completes normally. It can degrade, but it shouldn't be
able to break a session.
+1
View File
@@ -1340,6 +1340,7 @@ _AUTO_APPEND_MEDIA_TOOL_NAMES = {
"text_to_speech",
"text_to_speech_tool",
"image_generate",
"bfl_flux3_get_result",
}
# ---- helpers: detect interrupted tool tails & auto-continue noise ----------
+51
View File
@@ -331,6 +331,57 @@ def get_session_env(name: str, default: str = "") -> str:
return os.getenv(name, default)
# Surfaces that are not a human chat channel. The gateway binds a platform
# value (``telegram``) to HERMES_SESSION_PLATFORM, while the CLI, TUI, and
# desktop bind HERMES_SESSION_SOURCE (``cli``, ``tui``, ``desktop``) and leave
# the platform empty — so both have to be consulted. ``local``, ``api_server``,
# ``webhook``, and ``msgraph_webhook`` are real Platform values that reach
# HERMES_SESSION_PLATFORM but have no attachment channel behind them.
# Default-deny: an unrecognized identity counts as messaging so a newly added
# chat platform is never treated as a private surface before this set is
# updated. Mirrors LOCAL_SESSION_SOURCE_IDS in
# apps/desktop/src/lib/session-source.ts; keep roughly in sync when adding a
# local or programmatic surface.
NON_MESSAGING_SESSION_SURFACES = frozenset(
{
"",
"api_server",
"cli",
"codex",
"desktop",
"gateway",
"local",
"msgraph_webhook",
"tool",
"tui",
"webhook",
}
)
def session_is_messaging_surface() -> bool:
"""Whether this turn is delivered over a human messaging channel.
Callers use this to decide anything that differs between "the user is
reading a chat message" and "the user is at a machine they own": whether
to emit a delivery tag, whether a file has to land somewhere the gateway
is allowed to send from, whether narration would read as chat noise.
Resolves ``HERMES_PLATFORM``, then the session platform, then the session
source, and reports messaging when any of them names a surface outside
:data:`NON_MESSAGING_SESSION_SURFACES`.
"""
import os
platform = os.getenv("HERMES_PLATFORM") or get_session_env("HERMES_SESSION_PLATFORM", "")
source = get_session_env("HERMES_SESSION_SOURCE", "")
for identity in (platform, source):
identity = str(identity or "").strip().lower()
if identity and identity not in NON_MESSAGING_SESSION_SURFACES:
return True
return False
def declare_stateless_channel() -> None:
"""Declare that this session cannot receive an async background completion.
+2 -2
View File
@@ -14,8 +14,8 @@ Provides subcommands for:
import os
import sys
__version__ = "0.19.0"
__release_date__ = "2026.7.20"
__version__ = "0.19.1"
__release_date__ = "2026.7.30"
def _ensure_utf8():
+1
View File
@@ -1853,6 +1853,7 @@ _EXTRA_KNOWN_ROOT_KEYS = {
"smart_model_routing", # written by the setup wizard (hermes_cli/setup.py)
"platform_toolsets", # written by the setup wizard (hermes_cli/setup.py)
"known_plugin_toolsets", # written/read by hermes_cli/tools_config.py toolset-save flow
"known_builtin_toolsets", # ditto — which builtin toolsets a platform's checklist has offered
"session_reset", # top-level form read by gateway/config.py + setup
"group_sessions_per_user", # top-level form bridged by gateway/config.py
"thread_sessions_per_user", # top-level form bridged by gateway/config.py
+23
View File
@@ -578,6 +578,29 @@ DEFAULT_CONFIG = {
# prompt-cache invalidation amortized: one big
# episodic break instead of a tiny break every
# tool iteration. 0 = commit any non-zero prune.
"micro_compact": False, # opt-in: after each completed turn, fold the
# oldest un-absorbed exchange into a rolling
# summary, amortizing compression cost instead
# of paying it in one batch stall. Default False
# because a pass rewrites already-sent history
# and so breaks the provider prompt-cache prefix
# EVERY turn — the per-turn cache break that
# `proactive_prune_min_reclaim_tokens` above
# exists to avoid. Enable only when you have
# measured that the amortized stall is worth
# more to you than the cached-prefix discount.
# See docs/micro-compaction.md.
"micro_compact_every_n_turns": 1, # cadence: run a pass every Nth completed
# turn. Since each pass costs one prompt-cache
# break, this is the dial for how often that
# cost is paid — 1 reclaims most aggressively
# at one break per turn, 5 trades reclaim rate
# for a fifth of the breaks. Clamped to >= 1.
# Ignored unless `micro_compact` is true.
"micro_compact_defrag_threshold_tokens": 2000, # once the rolling summary
# exceeds this many tokens, the next pass
# re-summarizes the summary itself instead of
# letting it grow without bound.
"hygiene_hard_message_limit": 5000, # gateway session-hygiene force-compress threshold by message count
"hygiene_timeout_seconds": 30, # max seconds gateway waits for pre-agent hygiene compression
# WITHOUT forward progress. The summary call streams, so
+7 -2
View File
@@ -980,11 +980,13 @@ def run_doctor(args):
try:
from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers
from hermes_cli.providers import (
custom_provider_aliases as _custom_provider_aliases,
normalize_provider as _normalize_catalog_provider,
resolve_provider_full as _resolve_provider_full,
)
except Exception:
_compatible_custom_providers = None
_custom_provider_aliases = None
_normalize_catalog_provider = None
_resolve_provider_full = None
@@ -1007,8 +1009,11 @@ def run_doctor(args):
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or "").strip()
if name:
known_providers.add("custom:" + name.lower().replace(" ", "-"))
provider_key = str(entry.get("provider_key") or "").strip()
if name and _custom_provider_aliases is not None:
known_providers.update(
_custom_provider_aliases(name, provider_key)
)
valid_provider_ids = set(known_providers)
provider_ids_to_accept = {provider} if provider else set()
+18 -7
View File
@@ -3029,7 +3029,11 @@ def select_provider_and_model(args=None):
load_config,
get_env_value,
)
from hermes_cli.providers import resolve_provider_full
from hermes_cli.providers import (
custom_provider_aliases,
custom_provider_slug,
resolve_provider_full,
)
config = load_config()
current_model = config.get("model")
@@ -3149,13 +3153,8 @@ def select_provider_and_model(args=None):
base_url = (entry.get("base_url") or "").strip()
if not name or not base_url:
continue
key = "custom:" + name.lower().replace(" ", "-")
provider_key = (entry.get("provider_key") or "").strip()
if provider_key:
try:
resolve_provider(provider_key)
except AuthError:
key = provider_key
key = custom_provider_slug(name, provider_key)
custom_provider_map[key] = {
"name": name,
"base_url": base_url,
@@ -3183,6 +3182,16 @@ def select_provider_and_model(args=None):
config
) # key → {name, base_url, api_key}
def _canonical_named_custom_key(provider_id: str) -> str:
requested = str(provider_id or "").strip().lower()
for key, provider_info in _custom_provider_map.items():
if requested in custom_provider_aliases(
provider_info.get("name", ""),
provider_info.get("provider_key", ""),
):
return key
return provider_id
def _active_custom_key_from_base_url() -> str:
if effective_provider != "custom" or not isinstance(model_cfg, dict):
return ""
@@ -3205,6 +3214,8 @@ def select_provider_and_model(args=None):
)
if active_def is not None:
active = active_def.id
if active_def.source == "user-config":
active = _canonical_named_custom_key(active)
else:
warning = (
f"Unknown provider '{effective_provider}'. Check 'hermes model' for "
+2 -1
View File
@@ -26,6 +26,7 @@ import subprocess
import urllib.parse
from hermes_cli.config import clear_model_endpoint_credentials
from hermes_cli.providers import custom_provider_slug
# AWS cross-region inference profile prefixes. Any geo-prefixed profile only
@@ -1636,7 +1637,7 @@ def _model_flow_named_custom(config, provider_info):
model = {"default": model} if model else {}
cfg["model"] = model
if provider_key:
model["provider"] = "custom:" + provider_key.strip().lower().replace(" ", "-")
model["provider"] = custom_provider_slug(name, provider_key)
model.pop("base_url", None)
model.pop("api_key", None)
else:
+35 -12
View File
@@ -27,6 +27,7 @@ from typing import Any, List, NamedTuple, Optional
from hermes_cli.providers import (
ProviderDef,
custom_provider_aliases,
custom_provider_slug,
determine_api_mode,
get_label,
@@ -1145,11 +1146,10 @@ def _resolve_named_custom_model_id(
for entry in custom_providers or []:
if not isinstance(entry, dict):
continue
entry_slugs = {
custom_provider_slug(str(entry.get(key) or "")).lower()
for key in ("name", "provider_key")
if str(entry.get(key) or "").strip()
}
entry_slugs = custom_provider_aliases(
str(entry.get("name") or ""),
str(entry.get("provider_key") or ""),
)
if provider not in entry_slugs or f"custom:{prefix}" not in entry_slugs:
continue
for model_id in _declared_model_ids(entry.get("models")):
@@ -1684,9 +1684,12 @@ def switch_model(
continue
# Match by provider slug (custom:<name>) or by base_url
entry_name = entry.get("name", "")
entry_slug = f"custom:{entry_name}" if entry_name else ""
entry_aliases = custom_provider_aliases(
str(entry_name or ""),
str(entry.get("provider_key") or ""),
)
entry_url = entry.get("base_url", "")
if entry_slug == target_provider or entry_url == base_url:
if target_provider.lower() in entry_aliases or entry_url == base_url:
# Check if the requested model matches the entry's model
entry_model = entry.get("model", "")
entry_models = entry.get("models", {})
@@ -2580,6 +2583,7 @@ def list_authenticated_providers(
"has_explicit_models": False,
"ep_cfg": ep_cfg, # used below for discover_models / api_key
"raw_names": [],
"aliases": set(),
}
# Aggregate models across all members of the group (preserve order).
for _m in entry_models:
@@ -2593,6 +2597,9 @@ def list_authenticated_providers(
if entry_declared_models:
ep_groups[group_key]["has_explicit_models"] = True
ep_groups[group_key]["raw_names"].append(display_name)
ep_groups[group_key]["aliases"].update(
custom_provider_aliases(display_name, str(ep_name))
)
for grp in ep_groups.values():
ep_cfg = grp["ep_cfg"]
@@ -2632,10 +2639,12 @@ def list_authenticated_providers(
has_explicit_models = bool(grp.get("has_explicit_models"))
_ep_url_norm = str(api_url).strip().rstrip("/").lower()
_ep_slug_norm = str(ep_name).strip().lower()
_ep_custom_slug_norm = custom_provider_slug(display_name).lower()
_ep_aliases = {
str(alias).lower() for alias in grp.get("aliases", set())
}
_ep_is_current = (
_ep_slug_norm == _current_provider_norm
or _ep_custom_slug_norm == _current_provider_norm
or _current_provider_norm in _ep_aliases
or (
_current_provider_norm == "custom"
and bool(_current_base_url_norm)
@@ -2669,7 +2678,7 @@ def list_authenticated_providers(
"api_url": api_url,
})
seen_slugs.add(ep_name.lower())
seen_slugs.add(custom_provider_slug(display_name).lower())
seen_slugs.update(_ep_aliases)
# Record (display_name, api_url) for each raw entry that joined
# this group so section-4's _section3_emitted_pairs dedup can
# match per-model custom_providers rows ("Palantir Claude 4.7 Opus")
@@ -2817,7 +2826,8 @@ def list_authenticated_providers(
# Reuse the prefix computed above as the row display name;
# fall back to the raw name if stripping left it empty.
display_name = _display_prefix or raw_name
slug = custom_provider_slug(display_name)
provider_key = str(entry.get("provider_key") or "").strip()
slug = custom_provider_slug(display_name, provider_key)
groups[group_key] = {
"slug": slug,
"name": display_name,
@@ -2827,6 +2837,7 @@ def list_authenticated_providers(
"has_explicit_models": False,
"discover_models": discover,
"extra_headers": entry_extra_headers,
"aliases": set(),
}
else:
if api_key and not groups[group_key].get("api_key"):
@@ -2837,6 +2848,12 @@ def list_authenticated_providers(
# honour that for the whole grouped row.
if not discover:
groups[group_key]["discover_models"] = False
groups[group_key]["aliases"].update(
custom_provider_aliases(
raw_name,
str(entry.get("provider_key") or ""),
)
)
# The singular ``model:`` field only holds the currently
# active model. Hermes's own writer (main.py::_save_custom_provider)
@@ -2928,7 +2945,13 @@ def list_authenticated_providers(
# api_key is present. This supports endpoints that expose a
# full aggregator catalog via /models but only serve a subset
# (parity with section 3's user ``providers:`` behaviour).
_grp_is_current = slug.lower() == _current_provider_norm or (
_grp_is_current = (
slug.lower() == _current_provider_norm
or _current_provider_norm in {
str(alias).lower()
for alias in grp.get("aliases", set())
}
) or (
_current_provider_norm == "custom"
and bool(_current_base_url_norm)
and _grp_url_norm == _current_base_url_norm
+39 -12
View File
@@ -708,14 +708,36 @@ def resolve_user_provider(name: str, user_config: Dict[str, Any]) -> Optional[Pr
)
def custom_provider_slug(display_name: str) -> str:
"""Build a canonical slug for a custom_providers entry.
def custom_provider_slug(display_name: str, provider_key: str = "") -> str:
"""Build the stable ``custom:`` identity for a configured provider.
Matches the convention used by runtime_provider and credential_pool
(``custom:<normalized-name>``). Centralised here so all call-sites
produce identical slugs.
Keyed ``providers:`` entries keep their config key as the durable
identity even when their display name changes. Legacy
``custom_providers:`` entries have no key, so their normalized display
name remains the identity.
"""
return "custom:" + display_name.strip().lower().replace(" ", "-")
identity = str(provider_key or "").strip() or str(display_name or "").strip()
normalized = identity.lower().replace(" ", "-")
return normalized if normalized.startswith("custom:") else f"custom:{normalized}"
def custom_provider_aliases(
display_name: str,
provider_key: str = "",
) -> frozenset[str]:
"""Return every current and legacy identity accepted for one endpoint."""
aliases: set[str] = set()
for value in (display_name, provider_key):
raw = str(value or "").strip().lower()
if not raw:
continue
normalized = raw.replace(" ", "-")
aliases.update({raw, normalized, custom_provider_slug(normalized)})
if normalized.startswith("custom:"):
suffix = normalized.split(":", 1)[1]
if suffix:
aliases.update({suffix, f"custom:{normalized}"})
return frozenset(aliases)
def resolve_custom_provider(
@@ -734,7 +756,7 @@ def resolve_custom_provider(
# from a prior model-switch bug), fall back to the first custom
# provider entry so existing configs self-heal. (GH #17478)
bare_custom_fallback = requested == "custom"
first_valid: Optional[Tuple[str, str, Tuple[str, ...]]] = None
first_valid: Optional[Tuple[str, str, Tuple[str, ...], str]] = None
for entry in custom_providers:
if not isinstance(entry, dict):
@@ -751,16 +773,22 @@ def resolve_custom_provider(
continue
key_env = (entry.get("key_env") or "").strip()
provider_key = (entry.get("provider_key") or "").strip()
env_vars: List[str] = []
if key_env:
env_vars.append(key_env)
# Stash the first valid entry for bare-"custom" fallback
if first_valid is None:
first_valid = (display_name, api_url, tuple(env_vars))
first_valid = (
display_name,
api_url,
tuple(env_vars),
custom_provider_slug(display_name, provider_key),
)
slug = custom_provider_slug(display_name)
if requested not in {display_name.lower(), slug}:
slug = custom_provider_slug(display_name, provider_key)
if requested not in custom_provider_aliases(display_name, provider_key):
continue
return ProviderDef(
@@ -776,8 +804,7 @@ def resolve_custom_provider(
# Self-heal: bare "custom" matched nothing — return first valid entry
if bare_custom_fallback and first_valid:
dname, aurl, denv = first_valid
slug = custom_provider_slug(dname)
dname, aurl, denv, slug = first_valid
return ProviderDef(
id=slug,
name=dname,
+17 -35
View File
@@ -42,6 +42,7 @@ from hermes_cli.config import (
load_config,
normalize_extra_headers,
)
from hermes_cli.providers import custom_provider_aliases, custom_provider_slug
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname, env_int
@@ -686,8 +687,6 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
# they're not configured.
if not is_provider_enabled(entry):
continue
# Match exact name or normalized name
name_norm = _normalize_custom_provider_name(ep_name)
# Resolve the API key from the env var name stored in key_env
key_env = str(entry.get("key_env", "") or "").strip()
resolved_api_key = _getenv(key_env, "").strip() if key_env else ""
@@ -695,7 +694,11 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
if not resolved_api_key:
resolved_api_key = str(entry.get("api_key", "") or "").strip()
if requested_norm in {ep_name, name_norm, f"custom:{name_norm}"}:
display_name = entry.get("name", "")
if requested_norm in custom_provider_aliases(
str(display_name or ep_name),
str(ep_name),
):
# Found match by provider key
base_url = entry.get("api") or entry.get("url") or entry.get("base_url") or ""
if base_url:
@@ -721,29 +724,6 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
result["api_mode"] = api_mode
_lift_max_output_tokens(entry, result)
return result
# Also check the 'name' field if present
display_name = entry.get("name", "")
if display_name:
display_norm = _normalize_custom_provider_name(display_name)
if requested_norm in {display_name, display_norm, f"custom:{display_norm}"}:
# Found match by display name
base_url = entry.get("api") or entry.get("url") or entry.get("base_url") or ""
if base_url:
result = {
"name": display_name,
"base_url": base_url.strip(),
"api_key": resolved_api_key,
"model": entry.get("default_model", ""),
}
extra_body = entry.get("extra_body")
if isinstance(extra_body, dict):
result["extra_body"] = dict(extra_body)
_lift_extra_headers(entry, result)
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
if api_mode:
result["api_mode"] = api_mode
_lift_max_output_tokens(entry, result)
return result
# Fall back to custom_providers: list (legacy format)
custom_providers = config.get("custom_providers")
@@ -766,12 +746,8 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
base_url = entry.get("base_url")
if not isinstance(name, str) or not isinstance(base_url, str):
continue
name_norm = _normalize_custom_provider_name(name)
menu_key = f"custom:{name_norm}"
provider_key = str(entry.get("provider_key", "") or "").strip()
provider_key_norm = _normalize_custom_provider_name(provider_key) if provider_key else ""
provider_menu_key = f"custom:{provider_key_norm}" if provider_key_norm else ""
if requested_norm not in {name_norm, menu_key, provider_key_norm, provider_menu_key}:
if requested_norm not in custom_provider_aliases(name, provider_key):
continue
result = {
"name": name.strip(),
@@ -846,7 +822,7 @@ def find_custom_provider_identity(base_url: str) -> Optional[str]:
entry.get("api") or entry.get("url") or entry.get("base_url") or ""
)
if _normalize_base_url_for_match(entry_url) == target:
return f"custom:{_normalize_custom_provider_name(str(ep_name))}"
return custom_provider_slug(str(ep_name), str(ep_name))
try:
custom_providers = get_compatible_custom_providers(config)
@@ -859,7 +835,10 @@ def find_custom_provider_identity(base_url: str) -> Optional[str]:
if not isinstance(name, str) or not name.strip():
continue
if _normalize_base_url_for_match(entry.get("base_url")) == target:
return f"custom:{_normalize_custom_provider_name(name)}"
return custom_provider_slug(
name,
str(entry.get("provider_key", "") or ""),
)
return None
@@ -913,7 +892,7 @@ def find_custom_provider_identity_by_model(model: str) -> Optional[str]:
if not isinstance(entry, dict):
continue
if _entry_serves_model(entry):
return f"custom:{_normalize_custom_provider_name(str(ep_name))}"
return custom_provider_slug(str(ep_name), str(ep_name))
try:
custom_providers = get_compatible_custom_providers(config)
@@ -926,7 +905,10 @@ def find_custom_provider_identity_by_model(model: str) -> Optional[str]:
if not isinstance(name, str) or not name.strip():
continue
if _entry_serves_model(entry):
return f"custom:{_normalize_custom_provider_name(name)}"
return custom_provider_slug(
name,
str(entry.get("provider_key", "") or ""),
)
return None
+74
View File
@@ -102,6 +102,7 @@ CONFIGURABLE_TOOLSETS = [
("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"),
("image_gen", "🎨 Image Generation", "image_generate"),
("video_gen", "🎬 Video Generation", "video_generate (text/image/reference)"),
("bfl", "🎬 BFL FLUX 3 Video", "bfl_flux3_*"),
("x_search", "🐦 X (Twitter) Search", "x_search (requires xAI OAuth or XAI_API_KEY)"),
("tts", "🔊 Text-to-Speech", "text_to_speech"),
("stt", "🎙️ Speech-to-Text", "voice transcription (gateway voice messages + voice mode)"),
@@ -2131,6 +2132,66 @@ def _exempt_explicit_platform_native(
default_off.discard(ts)
#: Toolsets young enough that absence from a saved ``platform_toolsets`` list
#: means "never offered" rather than "declined".
#:
#: Saving ``hermes tools`` (or one toggle in the desktop Toolsets UI) replaces
#: a platform's composite with a frozen explicit list, and nothing ever adds to
#: that list — so a toolset shipped afterwards stays off forever for anyone who
#: has touched the picker, while everyone still on ``[hermes-cli]`` inherits it
#: on upgrade. Listing it here restores that parity.
#:
#: MUST ship in the same release as the toolset it names, and be emptied in the
#: next one. The inference only holds while no released build has put the
#: toolset on a checklist: once one has, a user who unchecks it writes a config
#: byte-identical to one saved before the toolset existed (the record below is
#: only written from that point on), and this rule turns their opt-out back on.
#: Landing late — or leaving an entry here for a second release — converts a
#: back-fill into a stuck checkbox.
#:
#: Not gated on a Nous subscription here: the six ``bfl_flux3_*`` tools carry
#: ``check_fn=check_bfl_requirements`` (logged in AND paid), so an enabled
#: toolset still ships zero schemas to a user without paid portal access — the
#: same split Home Assistant uses. Probing the portal from this path would put
#: a network call on every CLI start, gateway session and cron tick.
_RECENTLY_SHIPPED_TOOLSETS = frozenset({"bfl"})
def _enable_recently_shipped_toolsets(
enabled_toolsets: Set[str], config: dict, platform: str
) -> None:
"""Turn on toolsets that shipped after this platform's saved list.
Either way of saying no outlives this: unchecking in ``hermes tools``
records the toolset in ``known_builtin_toolsets`` so it reads as declined
from then on, and ``agent.disabled_toolsets`` is subtracted after every
rule in :func:`_get_platform_tools`. Mutates ``enabled_toolsets`` in place.
"""
from toolsets import resolve_toolset
offered = (config.get("known_builtin_toolsets") or {}).get(platform)
declined = {str(ts) for ts in offered} if isinstance(offered, list) else set()
plat_info = PLATFORMS.get(platform)
default_ts = plat_info["default_toolset"] if plat_info else f"hermes-{platform}"
composite_tools = None
for ts_key in sorted(_RECENTLY_SHIPPED_TOOLSETS):
if ts_key in enabled_toolsets or ts_key in declined:
continue
if not _toolset_allowed_for_platform(ts_key, platform):
continue
# Parity is the whole justification, so only enable the toolset where
# staying on the composite would have enabled it anyway. Deliberately
# narrow composites (hermes-acp, hermes-webhook) stay narrow.
ts_tools = set(resolve_toolset(ts_key, include_registry=False))
if composite_tools is None:
composite_tools = set(resolve_toolset(default_ts))
if not ts_tools or not ts_tools.issubset(composite_tools):
continue
enabled_toolsets.add(ts_key)
def _get_platform_tools(
config: dict,
platform: str,
@@ -2216,6 +2277,8 @@ def _get_platform_tools(
expanded -= default_off
enabled_toolsets |= expanded
_enable_recently_shipped_toolsets(enabled_toolsets, config, platform)
else:
# No explicit config — fall back to resolving composite toolset names
# (e.g. "hermes-cli") to individual tool names and reverse-mapping.
@@ -2483,6 +2546,17 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
config["known_plugin_toolsets"] = {}
config["known_plugin_toolsets"][platform] = sorted(plugin_keys)
# Same record for builtin toolsets: which ones this platform's checklist
# has actually put in front of the user. Without it, a toolset the user
# unchecks here is indistinguishable from one that shipped after they
# saved, and _enable_recently_shipped_toolsets would turn it straight back
# on. Recorded from the full catalog, since that is what the picker showed.
if not isinstance(config.get("known_builtin_toolsets"), dict):
config["known_builtin_toolsets"] = {}
config["known_builtin_toolsets"][platform] = sorted(
ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS
)
# Reconcile with agent.disabled_toolsets. _get_platform_tools() applies
# that list as a final override AFTER reading platform_toolsets.<platform>,
# so a toolset listed there stays permanently OFF no matter what this
+7 -4
View File
@@ -3936,10 +3936,13 @@ class SlackAdapter(BasePlatformAdapter):
return ""
return (
f"You are connected to this Slack workspace as the bot "
f'"@{name}". In messages, each line is prefixed with the sender\'s '
f"name, and mentions are shown as @DisplayName. Only treat a "
f'message as directed at you when it mentions "@{name}" '
f"specifically; a mention of any other participant is not a "
f'"@{name}". The adapter already applied mention and channel '
f"routing; treat every delivered turn as intentionally routed to "
f'you. Your routing mention "@{name}" may have been stripped from '
f'the visible text — do not reject or ignore a message solely '
f'because "@{name}" is absent. In messages, each line is prefixed '
f"with the sender's name, and visible mentions are shown as "
f"@DisplayName; a mention of any other participant is not a "
f"mention of you, even if their name is similar."
)
+25
View File
@@ -581,6 +581,13 @@ _POLLING_ERROR_TASK_STUCK_TIMEOUT = 300.0
# A generation is not healthy until the dedicated getUpdates request returns
# successfully. This exceeds a normal long-poll cycle for healthy idle bots.
_POLLING_PROGRESS_TIMEOUT = 60.0
# Telegram transcodes an uploaded video before it answers sendVideo, so the
# wait for the response is unrelated to how fast the bytes went out and can
# outlast the 20s read timeout the rest of the Bot API is tuned for. Only
# media sends take this longer budget; ordinary calls keep the short one so a
# dead request is still noticed quickly. Kept modest deliberately — this is
# also how long a user waits to be told the attachment failed.
_MEDIA_SEND_READ_TIMEOUT = 60.0
_POLLING_GENERATION_CONTEXT: ContextVar[Optional[int]] = ContextVar(
"telegram_polling_generation", default=None
)
@@ -3664,6 +3671,17 @@ class TelegramAdapter(BasePlatformAdapter):
"connect_timeout": _env_float("HERMES_TELEGRAM_HTTP_CONNECT_TIMEOUT", 10.0),
"read_timeout": _env_float("HERMES_TELEGRAM_HTTP_READ_TIMEOUT", 20.0),
"write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0),
# Not a duplicate of write_timeout: PTB routes any request
# carrying files to media_write_timeout instead, so the line
# above never applied to an upload and every upload was pinned
# to PTB's own 20s default. httpx budgets this per socket
# write rather than across the upload, so it is stall
# tolerance, not a size or bandwidth allowance — a slow but
# steady uplink never accumulates against it. 60s rides out
# the buffer stalls a congested link produces; going higher
# only lengthens how long a dead socket takes to report
# itself.
"media_write_timeout": 60.0,
}
# CLOSE_WAIT fd leak (#31599, same class as #18451): PTB's
@@ -6803,6 +6821,7 @@ class TelegramAdapter(BasePlatformAdapter):
"parse_mode": _cap_parse_mode,
"reply_to_message_id": reply_to_id,
"duration": _duration_secs,
"read_timeout": _MEDIA_SEND_READ_TIMEOUT,
**voice_thread_kwargs,
**self._notification_kwargs(metadata),
},
@@ -6852,6 +6871,7 @@ class TelegramAdapter(BasePlatformAdapter):
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
"duration": _duration_secs,
"read_timeout": _MEDIA_SEND_READ_TIMEOUT,
**audio_thread_kwargs,
**self._notification_kwargs(metadata),
},
@@ -6990,6 +7010,7 @@ class TelegramAdapter(BasePlatformAdapter):
"chat_id": normalize_telegram_chat_id(chat_id),
"media": media,
"reply_to_message_id": reply_to_id,
"read_timeout": _MEDIA_SEND_READ_TIMEOUT,
**thread_kwargs,
**self._notification_kwargs(metadata),
},
@@ -7049,6 +7070,7 @@ class TelegramAdapter(BasePlatformAdapter):
"photo": image_file,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
"read_timeout": _MEDIA_SEND_READ_TIMEOUT,
**thread_kwargs,
**self._notification_kwargs(metadata),
},
@@ -7146,6 +7168,7 @@ class TelegramAdapter(BasePlatformAdapter):
"filename": display_name,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
"read_timeout": _MEDIA_SEND_READ_TIMEOUT,
**thread_kwargs,
**self._notification_kwargs(metadata),
},
@@ -7196,6 +7219,7 @@ class TelegramAdapter(BasePlatformAdapter):
"video": f,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
"read_timeout": _MEDIA_SEND_READ_TIMEOUT,
**thread_kwargs,
**self._notification_kwargs(metadata),
},
@@ -7340,6 +7364,7 @@ class TelegramAdapter(BasePlatformAdapter):
"animation": animation_url,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
"read_timeout": _MEDIA_SEND_READ_TIMEOUT,
**animation_thread_kwargs,
**self._notification_kwargs(metadata),
},
+1 -1
View File
@@ -2,7 +2,7 @@
[project]
name = "hermes-agent"
version = "0.19.0"
version = "0.19.1"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
readme = "README.md"
# Upper bound is load-bearing, not cosmetic. uv resolves the project's
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Summarize micro-compaction telemetry from Hermes logs.
Reads the content-free JSON lines emitted by
``ContextCompressor._emit_micro_compaction_telemetry`` and reports what the
feature actually bought you.
Usage:
python scripts/micro_compaction_report.py [LOGFILE ...]
python scripts/micro_compaction_report.py --per-session
With no LOGFILE, reads ``$HERMES_HOME/logs/agent.log`` (default ~/.hermes).
What to look at
---------------
The point of micro-compaction is not saving tokens or time. It is:
(a) amortizing the one long batch-compaction pause across many turns, and
(b) keeping the context window low enough that a session runs much further
before it needs a hard compaction at all.
So the headline numbers here are OCCUPANCY (how full the window is kept, as a
percentage of the compaction threshold) and BATCH COMPACTIONS (how often the
long pause actually fired). Net tokens saved is reported too, but it is the
least interesting figure -- a session can save nothing on paper and still be a
clear win because the stalls disappeared and the window never filled.
Caveat: running the test suite writes telemetry into the same log. Test lines
cluster inside a sub-second window and carry an empty session_id (they group
as "(unknown)"). Use --per-session to spot them.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import defaultdict
from pathlib import Path
MICRO_MARKER = "micro compaction telemetry: "
BATCH_MARKER = "context compression attempt telemetry: "
def default_log() -> Path:
home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")
return Path(home) / "logs" / "agent.log"
def load(paths: list[Path]) -> tuple[list[dict], list[dict]]:
micro: list[dict] = []
batch: list[dict] = []
for path in paths:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
print(f"warning: cannot read {path}: {exc}", file=sys.stderr)
continue
for line in text.splitlines():
for marker, sink in ((MICRO_MARKER, micro), (BATCH_MARKER, batch)):
idx = line.find(marker)
if idx == -1:
continue
try:
sink.append(json.loads(line[idx + len(marker):]))
except ValueError:
pass
break
return micro, batch
def pct(values: list[float]) -> tuple[float, float, float] | None:
if not values:
return None
ordered = sorted(values)
return ordered[0], ordered[len(ordered) // 2], ordered[-1]
def fmt(n) -> str:
return "-" if n is None else f"{n:,}"
def report(micro: list[dict], batch: list[dict], per_session: bool) -> int:
if not micro:
print("No micro-compaction telemetry found.")
print("It may be disabled (compression.micro_compact), or no session")
print("has run long enough to trigger a pass yet.")
return 1
by_session: dict[str, list[dict]] = defaultdict(list)
for e in micro:
by_session[e.get("session_id") or "(unknown)"].append(e)
outcomes: dict[str, int] = defaultdict(int)
for e in micro:
outcomes[e.get("outcome", "?")] += 1
occupancies = [e["occupancy_pct"] for e in micro if e.get("occupancy_pct") is not None]
saved = sum(-(e.get("tokens_delta") or 0) for e in micro)
absorbed = [e for e in micro if e.get("outcome") == "absorbed"]
durations = [e.get("duration_ms") or 0 for e in micro]
if per_session:
print(f"{'session':<26} {'passes':>6} {'occupancy%':>18} {'batch':>6} {'saved':>10}")
print("-" * 72)
batch_by_session: dict[str, int] = defaultdict(int)
for b in batch:
batch_by_session[b.get("session_id") or "(unknown)"] += 1
for sid, evs in sorted(by_session.items(), key=lambda kv: -len(kv[1])):
occ = [e["occupancy_pct"] for e in evs if e.get("occupancy_pct") is not None]
spread = pct(occ)
occ_s = f"{spread[0]:.0f}-{spread[2]:.0f} (med {spread[1]:.0f})" if spread else "-"
s = sum(-(e.get("tokens_delta") or 0) for e in evs)
print(f"{sid[:26]:<26} {len(evs):>6} {occ_s:>18} "
f"{batch_by_session.get(sid, 0):>6} {s:>+10,}")
print()
print("-- headroom ----------------------------------")
spread = pct(occupancies)
if spread:
print(f"context occupancy min {spread[0]:.0f}% median {spread[1]:.0f}% max {spread[2]:.0f}%")
print(" (% of the batch-compaction threshold)")
else:
print("context occupancy unavailable (window not resolved when logged)")
print(f"batch compactions {len(batch):,}")
if batch:
print(f" micro passes each {len(micro) / len(batch):.1f}")
else:
print(" none fired -- the long pause never happened in this log")
print()
print("-- activity ----------------------------------")
print(f"sessions {len(by_session):,}")
print(f"passes {len(micro):,}")
for name, count in sorted(outcomes.items(), key=lambda kv: -kv[1]):
print(f" {name:<20} {count:,}")
if durations:
ordered = sorted(durations)
print(f"pass duration median {ordered[len(ordered) // 2]:,} ms "
f"max {ordered[-1]:,} ms")
print()
print("-- tokens (least interesting) ----------------")
print(f"net tokens saved {saved:+,}")
if absorbed:
sizes = [e.get("exchange_tokens") or 0 for e in absorbed]
print(f"exchanges absorbed {len(absorbed):,} "
f"(mean {sum(sizes) // len(absorbed):,} tokens each)")
print("note: the first pass in a session costs ~400 tokens of marker")
print("scaffolding; it pays back from the second pass on.")
return 0
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("logs", nargs="*", type=Path, help="log files (default: agent.log)")
ap.add_argument("--per-session", action="store_true", help="break down by session")
args = ap.parse_args()
paths = args.logs or [default_log()]
for p in paths:
if not p.exists():
print(f"warning: {p} does not exist", file=sys.stderr)
micro, batch = load([p for p in paths if p.exists()])
return report(micro, batch, args.per_session)
if __name__ == "__main__":
sys.exit(main())
+754
View File
@@ -0,0 +1,754 @@
"""Tests for per-turn micro-compaction in ``ContextCompressor``.
Micro-compaction amortizes the cost of context compression: instead of one
long pause when the window fills, each turn folds the single oldest
un-absorbed exchange into a rolling summary.
The invariants that matter:
* one call absorbs exactly one exchange (assistant + its tool results), so
the per-turn cost stays bounded;
* the absorbed span is replaced by a summary marker carrying the usual
``_compressed_summary`` metadata, so resume/handoff treat it like a batch
summary;
* the cursor advances, so successive calls walk forward rather than
re-summarising the same exchange;
* protected head and tail messages are never touched;
* an exchange the summarizer cannot handle is retried a bounded number of
times and then skipped, so a poison exchange can't stall every turn.
"""
from unittest.mock import patch
import pytest
from agent.context_compressor import (
COMPRESSED_SUMMARY_METADATA_KEY,
ContextCompressor,
_MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES,
)
def _compressor(summary="ROLLING SUMMARY") -> ContextCompressor:
cc = ContextCompressor(
model="test-model",
threshold_percent=0.75,
protect_first_n=1,
protect_last_n=2,
quiet_mode=True,
config_context_length=40960,
provider="test",
)
cc._micro_compact_enabled = True
# Stand in for the auxiliary summarizer LLM.
cc._micro_summarize_one = lambda _text: summary
return cc
def _conversation(exchanges: int = 6) -> list:
msgs = [{"role": "system", "content": "system prompt"}]
for i in range(exchanges):
msgs.append({"role": "user", "content": f"question {i}"})
msgs.append({"role": "assistant", "content": f"answer {i} " + "z" * 400})
return msgs
def _summary_markers(messages: list) -> list:
return [m for m in messages if m.get(COMPRESSED_SUMMARY_METADATA_KEY)]
class TestMicroCompaction:
def test_absorbs_one_exchange_and_leaves_a_summary_marker(self):
cc = _compressor()
messages = _conversation()
result = cc._micro_compact(list(messages))
# The absorbed assistant turn is gone from the transcript.
assert any("answer 0" in str(m.get("content")) for m in messages)
assert not any("answer 0" in str(m.get("content")) for m in result)
markers = _summary_markers(result)
assert len(markers) == 1
assert "ROLLING SUMMARY" in markers[0]["content"]
# The marker is assistant-role: an exchange is a full agent turn
# bounded by user messages, so user → marker(assistant) → user keeps
# strict alternation. A user-role marker produced user → user → user,
# and the pre-request repair_message_sequence pass merged the marker
# into the neighbouring real user turn — metadata gone, cursor lost.
assert markers[0]["role"] == "assistant"
def test_disabled_is_a_no_op(self):
cc = _compressor()
cc._micro_compact_enabled = False
messages = _conversation()
assert cc._micro_compact(list(messages)) == messages
def test_is_off_unless_explicitly_enabled(self):
# A pass rewrites already-sent history, breaking the prompt-cache
# prefix, so nobody inherits it from an update: it stays off until
# `compression.micro_compact` opts in.
cc = ContextCompressor(
model="test-model",
threshold_percent=0.75,
protect_first_n=1,
protect_last_n=2,
quiet_mode=True,
config_context_length=40960,
provider="test",
)
cc._micro_summarize_one = lambda _text: "ROLLING SUMMARY"
messages = _conversation()
assert cc._micro_compact_enabled is False
assert cc._micro_compact(list(messages)) == messages
def test_cadence_of_one_runs_every_turn(self):
cc = _compressor()
cc._micro_compact_every_n_turns = 1
messages = _conversation(exchanges=8)
first = cc._micro_compact(list(messages))
second = cc._micro_compact(list(first))
assert cc._micro_compact_cursor > 0
assert len(_summary_markers(first)) == 1
# Each turn absorbed something, so the transcript kept shrinking.
assert len(second) < len(first)
def test_cadence_skips_turns_until_a_pass_is_due(self):
cc = _compressor()
cc._micro_compact_every_n_turns = 3
messages = _conversation(exchanges=8)
first = cc._micro_compact(list(messages))
second = cc._micro_compact(list(first))
# Cache prefix untouched on the turns in between.
assert _summary_markers(first) == []
assert _summary_markers(second) == []
assert cc._micro_compact_cursor == 0
third = cc._micro_compact(list(second))
assert len(_summary_markers(third)) == 1
assert not any("answer 0" in str(m.get("content")) for m in third)
# Counter rearmed for the next window.
assert cc._micro_compact_turns_since_pass == 0
def test_cadence_is_clamped_to_at_least_one(self):
# A bogus 0 or negative must not disable compaction silently, nor
# divide-by-zero: it degrades to "every turn".
for bogus in (0, -5):
cc = _compressor()
cc._micro_compact_every_n_turns = bogus
messages = _conversation(exchanges=8)
result = cc._micro_compact(list(messages))
assert len(_summary_markers(result)) == 1
def test_cursor_advances_across_successive_turns(self):
cc = _compressor()
messages = _conversation(exchanges=8)
first = cc._micro_compact(list(messages))
cursor_after_first = cc._micro_compact_cursor
second = cc._micro_compact(list(first))
assert cursor_after_first > 0
assert cc._micro_compact_cursor >= cursor_after_first
# Still exactly one marker: the second pass merges into the rolling
# summary rather than stacking a second summary block.
assert len(_summary_markers(second)) == 1
def test_protected_head_and_tail_survive(self):
cc = _compressor()
messages = _conversation()
result = cc._micro_compact(list(messages))
assert result[0] == messages[0], "system prompt must be preserved"
assert result[-1] == messages[-1], "most recent turn must be preserved"
def test_user_messages_are_never_absorbed(self):
"""Every byte the user typed stays in the transcript — by design.
Assistant output is largely an account of what was done and survives
summarising; the user's own words are the intent everything else is
derived from and can't be reconstructed from it. So an exchange starts
at the assistant message and the walk skips past user turns.
The invariant is on user TEXT, not message-list shape: superseding an
old marker leaves two real user turns adjacent, and they are merged
(\\n\\n-joined, same as repair_message_sequence pass 2) to keep strict
alternation. Text is never summarized or dropped.
"""
cc = _compressor()
messages = _conversation(exchanges=10)
originals = [m["content"] for m in messages if m["role"] == "user"]
for _ in range(5):
messages = cc._micro_compact(messages)
surviving_text = "\n\n".join(
m["content"] for m in messages
if m.get("role") == "user" and not m.get(COMPRESSED_SUMMARY_METADATA_KEY)
)
for original in originals:
assert original in surviving_text, (
f"user text {original!r} must survive verbatim"
)
def test_cursor_is_derived_from_the_spliced_list(self):
"""The cursor must never carry over a pre-splice index.
A splice collapses an assistant plus its tool results -- often several
messages -- into one marker, so every later index shifts. Reusing
``exchange_end`` left the cursor pointing inside a *later* exchange's
tool group; the next pass walked forward to the following assistant
and skipped that exchange entirely, so on tool-bearing conversations
roughly half the work silently never happened.
Tool-free fixtures cannot catch this: the span is one message, so
nothing shifts.
"""
cc = _compressor()
msgs = [{"role": "system", "content": "sys"}]
for i in range(8):
msgs.append({"role": "user", "content": f"q{i}"})
msgs.append({
"role": "assistant",
"content": f"a{i}",
"tool_calls": [
{"id": f"c{i}-{j}", "type": "function",
"function": {"name": "f", "arguments": "{}"}}
for j in range(3)
],
})
for j in range(3):
msgs.append({"role": "tool", "tool_call_id": f"c{i}-{j}",
"content": "T" * 500})
for _ in range(4):
msgs = cc._micro_compact(msgs)
marker_idx = next(
i for i, m in enumerate(msgs)
if m.get(COMPRESSED_SUMMARY_METADATA_KEY)
)
assert cc._micro_compact_cursor == marker_idx + 1, (
"cursor must sit just past the marker in the spliced list"
)
def test_resume_does_not_destroy_the_accumulated_summary(self):
"""A resumed session must not throw away compacted history.
The rolling summary lives in memory; a resumed process starts with an
empty one while the marker holding every previous exchange is still in
the transcript. Superseding on that first pass would replace the whole
history with a summary of one exchange.
"""
msgs = _conversation(exchanges=10)
first = _compressor(summary="IMPORTANT HISTORY: decisions and paths")
for _ in range(3):
msgs = first._micro_compact(msgs)
assert any("IMPORTANT HISTORY" in m["content"] for m in _summary_markers(msgs))
# Fresh compressor over the same transcript = resume.
resumed = _compressor(summary="MERGED: history plus newest exchange")
assert resumed._micro_compact_rolling_summary == ""
result = resumed._micro_compact(msgs)
markers = _summary_markers(result)
assert len(markers) == 1
assert "MERGED" in markers[0]["content"]
def test_resume_keeps_the_old_marker_when_rehydration_fails(self):
"""If the prior summary can't be recovered, it must not be dropped."""
msgs = _conversation(exchanges=10)
first = _compressor(summary="IMPORTANT HISTORY: decisions and paths")
for _ in range(3):
msgs = first._micro_compact(msgs)
resumed = _compressor(summary="BRAND NEW SUMMARY")
resumed._rolling_summary_from_marker = staticmethod(lambda _c: "")
result = resumed._micro_compact(msgs)
markers = _summary_markers(result)
assert len(markers) == 2, "must retain the un-carried history"
assert any("IMPORTANT HISTORY" in m["content"] for m in markers)
def test_rolling_summary_round_trips_through_a_marker(self):
cc = _compressor()
cc._micro_compact_rolling_summary = "decisions: use the existing helper"
msgs = _conversation(exchanges=6)
result = cc._micro_compact(msgs)
marker = _summary_markers(result)[0]
assert (cc._rolling_summary_from_marker(marker["content"])
== cc._micro_compact_rolling_summary)
def test_short_conversation_is_untouched(self):
cc = _compressor()
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
assert cc._micro_compact(list(messages)) == messages
def test_summarizer_failure_leaves_conversation_intact(self):
cc = _compressor()
cc._micro_summarize_one = lambda _text: None
messages = _conversation()
result = cc._micro_compact(list(messages))
assert result == messages
assert cc._micro_compact_consecutive_failures == 1
def test_poison_exchange_is_skipped_after_repeated_failures(self):
"""A repeatedly unsummarizable exchange must not stall every turn."""
cc = _compressor()
cc._micro_summarize_one = lambda _text: None
messages = _conversation()
for _ in range(_MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES):
cc._micro_compact(list(messages))
# The cursor has moved past the stuck exchange and the strike count
# is reset, so the next turn attempts new material.
assert cc._micro_compact_cursor > 0
assert cc._micro_compact_consecutive_failures == 0
def test_repeated_compaction_shrinks_context_and_keeps_one_marker(self):
"""The whole point: successive turns must reduce the transcript.
The rolling summary is cumulative, so an earlier marker's text is a
subset of the current one. Keeping the earlier markers stacked
near-duplicate copies (each with its own heading/end-marker
scaffolding) and made the transcript grow every turn — the opposite
of what compaction is for.
"""
from agent.model_metadata import estimate_messages_tokens_rough
cc = _compressor()
# Cumulative summary, like the real summarizer produces.
state = {"n": 0}
def growing(_text):
state["n"] += 1
return "SUMMARY " + " ".join(f"ex{i}" for i in range(state["n"]))
cc._micro_summarize_one = growing
messages = _conversation(exchanges=12)
before = estimate_messages_tokens_rough(messages)
for _ in range(6):
messages = cc._micro_compact(messages)
after = estimate_messages_tokens_rough(messages)
assert len(_summary_markers(messages)) == 1
assert after < before, f"context grew: {before} -> {after}"
def test_emits_content_free_token_telemetry(self, caplog):
"""Each pass logs one JSON line with the token accounting.
Message counts barely move even when the saving is large, so the token
fields are what make the effect measurable in a real session.
"""
import json
import logging
cc = _compressor()
messages = _conversation(exchanges=8)
with caplog.at_level(logging.INFO, logger="agent.context_compressor"):
result = cc._micro_compact(messages)
lines = [
r.getMessage() for r in caplog.records
if "micro compaction telemetry:" in r.getMessage()
]
assert len(lines) == 1
payload = json.loads(lines[0].split("micro compaction telemetry: ", 1)[1])
assert payload["event"] == "micro_compaction"
assert payload["outcome"] == "absorbed"
assert payload["tokens_saved_total"] == -payload["tokens_delta"]
assert payload["passes_total"] == 1
assert payload["messages_after"] == len(result)
assert payload["exchange_tokens"] > 0
# Content-free: no transcript text may ride along in the payload.
blob = json.dumps(payload)
assert "answer 0" not in blob and "question 0" not in blob
def test_telemetry_reports_occupancy_without_forcing_resolution(self, caplog):
"""Occupancy is the headline: how full the window is being kept.
It must be read from the cached threshold only. The public
``threshold_tokens`` property resolves lazily and can fire a
synchronous /models probe (#32221); telemetry must never be what
blocks a turn, so an unresolved window reports null instead.
"""
import json
import logging
cc = _compressor()
cc.threshold_tokens = 10_000 # pin; also populates the cache
messages = _conversation(exchanges=8)
with caplog.at_level(logging.INFO, logger="agent.context_compressor"):
cc._micro_compact(messages)
line = next(r.getMessage() for r in caplog.records
if "micro compaction telemetry:" in r.getMessage())
payload = json.loads(line.split("micro compaction telemetry: ", 1)[1])
assert payload["threshold_tokens"] == 10_000
assert payload["occupancy_pct"] == pytest.approx(
payload["tokens_after"] / 10_000 * 100, abs=0.1
)
def test_emitter_never_forces_window_resolution(self, caplog):
"""The emitter reads the cached threshold, never the property.
In a real pass the threshold is already resolved by the time
telemetry runs (the tail calculation needs it), so occupancy is
normally populated. This pins the safety property directly: with the
cache empty, emitting reports null rather than triggering the lazy
resolution — which can issue a synchronous /models probe (#32221).
"""
import json
import logging
cc = _compressor()
cc._threshold_tokens = None
cc._resolved_context_length = None
def explode(self): # pragma: no cover - must never be called
raise AssertionError("telemetry forced context-length resolution")
with patch.object(type(cc), "threshold_tokens",
property(explode, lambda s, v: None)):
with caplog.at_level(logging.INFO, logger="agent.context_compressor"):
cc._emit_micro_compaction_telemetry(
outcome="absorbed",
messages_before=10,
messages_after=9,
tokens_before=500,
tokens_after=400,
)
line = next(r.getMessage() for r in caplog.records
if "micro compaction telemetry:" in r.getMessage())
payload = json.loads(line.split("micro compaction telemetry: ", 1)[1])
assert payload["occupancy_pct"] is None
assert payload["threshold_tokens"] is None
def test_first_pass_costs_marker_overhead_then_pays_it_back(self):
"""The first pass can grow the transcript; later passes recover it.
Inserting the summary marker costs a fixed ~400 tokens of scaffolding
(the compaction preamble, the historical heading and the end marker).
On pass one that overhead is paid against a single absorbed exchange,
so the net can be positive. From pass two on the marker is replaced
rather than added, so the scaffolding is already paid for and each
absorbed exchange is pure saving. Anyone reading a single turn's
telemetry needs to know this before concluding it made things worse.
"""
from agent.model_metadata import estimate_messages_tokens_rough
cc = _compressor()
messages = _conversation(exchanges=10)
start = estimate_messages_tokens_rough(messages)
messages = cc._micro_compact(messages)
after_first = estimate_messages_tokens_rough(messages)
for _ in range(5):
messages = cc._micro_compact(messages)
after_many = estimate_messages_tokens_rough(messages)
assert after_first > start, "expected one-time marker overhead"
assert after_many < after_first, "later passes must recover it"
def test_cumulative_savings_accumulate_across_passes(self):
cc = _compressor()
messages = _conversation(exchanges=10)
for _ in range(4):
messages = cc._micro_compact(messages)
assert cc._micro_compact_passes == 4
assert cc._micro_compact_tokens_saved_total > 0
def test_defrag_triggers_once_the_rolling_summary_grows(self):
"""Defrag rewrites the summary text and the marker — nothing else.
The original implementation spliced the whole remaining middle (user
turns included) into the marker, silently absorbing user messages.
Defrag is now transcript-shape-neutral: same message list, same
cursor, marker content rewritten in place.
"""
cc = _compressor(summary="FRESH DEFRAGGED SUMMARY")
messages = _conversation(exchanges=8)
# Seed a real marker + oversized rolling summary, as after many passes.
messages = cc._micro_compact(list(messages))
cc._micro_compact_rolling_summary = "x" * 40_000 # far over the threshold
cursor_before = cc._micro_compact_cursor
shape_before = [m.get("role") for m in messages]
assert cc._needs_defrag() is True
result = cc._micro_compact(list(messages))
assert cc._micro_compact_rolling_summary == "FRESH DEFRAGGED SUMMARY"
markers = _summary_markers(result)
assert len(markers) == 1
assert "FRESH DEFRAGGED SUMMARY" in markers[0]["content"]
# Shape-neutral: no messages absorbed or spliced, cursor unmoved.
assert [m.get("role") for m in result] == shape_before
assert cc._micro_compact_cursor == cursor_before
def test_defrag_never_absorbs_user_messages(self):
"""Defrag must not touch user turns — the feature's core invariant.
The original implementation serialized head..tail (user turns
included) and spliced it away: 8 of 10 user prompts were destroyed in
one pass. Defrag now only rewrites the rolling summary text.
"""
cc = _compressor(summary="DEFRAGGED")
messages = [{"role": "system", "content": "sys"}]
for i in range(10):
messages.append({"role": "user", "content": f"UNIQUE-USER-PROMPT-{i}"})
messages.append({"role": "assistant", "content": f"answer {i} " + "z" * 400})
cc._micro_compact_rolling_summary = "x" * 40_000 # force defrag
result = cc._micro_compact(list(messages))
surviving = [
m["content"] for m in result
if m.get("role") == "user" and not m.get(COMPRESSED_SUMMARY_METADATA_KEY)
]
for i in range(10):
assert any(f"UNIQUE-USER-PROMPT-{i}" in s for s in surviving), (
f"user prompt {i} was absorbed by defrag"
)
def test_defrag_summarizes_only_the_summary_text(self):
"""The defrag aux call receives the rolling summary, not the transcript."""
cc = _compressor()
captured = {}
def capture(text):
captured["text"] = text
return "DEFRAGGED"
cc._micro_summarize_one = capture
cc._micro_compact_rolling_summary = "OLD-SUMMARY " + "x" * 40_000
messages = _conversation(exchanges=8)
cc._micro_compact(list(messages))
assert "OLD-SUMMARY" in captured["text"]
assert "[USER]" not in captured["text"], (
"defrag must never serialize transcript user turns"
)
def test_spliced_transcript_survives_repair_message_sequence(self):
"""The compacted transcript must survive the production repair pass.
conversation_loop runs repair_message_sequence before EVERY API call.
With the old user-role marker, splicing next to a real user turn made
user → user → user; repair merged the marker into the real user
message — metadata gone, cursor unrecoverable, summary text duplicated
into the transcript on every later pass. Pin the integration: markers
survive repair untouched, and no summary text leaks into real user
messages.
"""
from agent.agent_runtime_helpers import repair_message_sequence
class _DummyAgent:
session_id = "probe"
_last_flushed_db_idx = 0
cc = _compressor()
messages = _conversation(exchanges=8)
for _ in range(3):
messages = cc._micro_compact(messages)
repairs = repair_message_sequence(_DummyAgent(), messages)
assert repairs == 0, (
"micro-compacted transcript must already be alternation-valid"
)
markers = _summary_markers(messages)
assert len(markers) == 1, "marker destroyed by repair pass"
polluted = [
m for m in messages
if m.get("role") == "user"
and not m.get(COMPRESSED_SUMMARY_METADATA_KEY)
and "ROLLING SUMMARY" in str(m.get("content"))
]
assert not polluted, "summary text leaked into a real user message"
def test_spliced_transcript_has_no_consecutive_same_role_messages(self):
"""Alternation invariant, checked directly on tool-bearing turns."""
cc = _compressor()
msgs = [{"role": "system", "content": "sys"}]
for i in range(8):
msgs.append({"role": "user", "content": f"q{i}"})
msgs.append({
"role": "assistant",
"content": f"a{i}",
"tool_calls": [
{"id": f"c{i}-{j}", "type": "function",
"function": {"name": "f", "arguments": "{}"}}
for j in range(2)
],
})
for j in range(2):
msgs.append({"role": "tool", "tool_call_id": f"c{i}-{j}",
"content": "T" * 400})
# Multi-iteration turn: a second assistant+tools group before the
# next user message — the splice must absorb the WHOLE turn.
msgs.append({"role": "assistant", "content": f"followup {i} " + "y" * 200})
for _ in range(4):
msgs = cc._micro_compact(msgs)
for a, b in zip(msgs, msgs[1:]):
ra, rb = a.get("role"), b.get("role")
assert not (ra == rb and ra in ("user", "assistant")), (
f"consecutive {ra} messages after micro-compaction"
)
def test_marker_reports_no_user_provenance(self):
"""Micro markers absorb only assistant/tool content (#64650)."""
from agent.context_compressor import COMPRESSED_SUMMARY_HAS_USER_TURN_KEY
cc = _compressor()
result = cc._micro_compact(_conversation(exchanges=6))
marker = _summary_markers(result)[0]
assert marker[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False
def test_supersede_never_drops_a_batch_compaction_marker(self):
"""A batch marker holds history the rolling summary does NOT contain.
Sequence: micro absorbs some exchanges (rolling summary = those k
exchanges only), then batch compaction fires and its marker
summarizes MORE (exchanges 1..m). The next micro pass's supersede
must not treat the batch marker as redundant — dropping it destroys
everything batch summarized beyond exchange k. Only micro-tagged
markers (whose text is provably inside the rolling summary) may be
superseded.
"""
cc = _compressor(summary="MICRO SUMMARY (exchanges 1..k only)")
msgs = _conversation(exchanges=8)
msgs = cc._micro_compact(msgs)
assert cc._micro_compact_rolling_summary
# Simulate a batch-compaction marker replacing the middle (batch
# markers carry the shared metadata key but NOT the micro tag).
batch_marker = {
"role": "user",
"content": "[batch summary] CRITICAL HISTORY: exchanges 1..m",
COMPRESSED_SUMMARY_METADATA_KEY: True,
}
micro_idx = next(
i for i, m in enumerate(msgs)
if m.get(COMPRESSED_SUMMARY_METADATA_KEY)
)
msgs = msgs[:micro_idx] + [batch_marker] + msgs[micro_idx + 3:]
out = cc._micro_compact(msgs)
assert any(
"CRITICAL HISTORY" in str(m.get("content")) for m in out
), "batch-compaction summary destroyed by micro supersede"
def test_defrag_never_rewrites_a_batch_compaction_marker(self):
"""Defrag rewrites only micro-tagged markers, never batch markers."""
cc = _compressor(summary="DEFRAGGED")
msgs = [{"role": "system", "content": "sys"}]
msgs.append({
"role": "user",
"content": "[batch summary] CRITICAL HISTORY: exchanges 1..m",
COMPRESSED_SUMMARY_METADATA_KEY: True,
})
for i in range(6):
msgs.append({"role": "user", "content": f"q{i}"})
msgs.append({"role": "assistant", "content": f"a{i} " + "z" * 400})
cc._micro_compact_rolling_summary = "x" * 40_000 # force defrag
result = cc._micro_compact(list(msgs))
batch = [m for m in result if "CRITICAL HISTORY" in str(m.get("content"))]
assert batch, "batch marker content overwritten by defrag"
def test_batch_compress_resets_micro_state(self):
"""compress() success path invalidates the stale rolling summary.
Without the reset, the in-memory micro summary (exchanges 1..k)
outlives a batch compaction whose marker covers 1..m — the next
micro pass would then treat its stale summary as cumulative.
"""
cc = _compressor()
msgs = _conversation(exchanges=8)
msgs = cc._micro_compact(msgs)
assert cc._micro_compact_rolling_summary
assert cc._micro_compact_cursor > 0
cc.compress(msgs, force=True)
assert cc._micro_compact_rolling_summary == ""
assert cc._micro_compact_cursor == 0
def test_persist_disabled_agent_never_micro_compacts(self):
"""finalize_turn must skip micro-compaction on isolated fork agents.
The background-review fork sets _persist_disabled=True; running a
pass there burns an aux-LLM call on a throwaway replay transcript
and, if the compressor ever holds a DB binding, would
archive_and_compact the CANONICAL session rows.
"""
import inspect
from agent import turn_finalizer
src = inspect.getsource(turn_finalizer.finalize_turn)
micro_block = src.split("Post-turn micro-compaction", 1)[1]
# Scope to the micro block only: stop at the persist call that follows.
micro_block = micro_block.split("agent._persist_session", 1)[0]
assert "_persist_disabled" in micro_block, (
"micro-compaction gate must check agent._persist_disabled"
)
def test_splice_preserves_db_persisted_stamps(self):
"""Surviving messages keep their _db_persisted stamps through a splice.
Micro-compaction archives in place under the SAME session id, so the
stamps on untouched messages stay accurate. Stripping them (as the
batch path does for its child-session rotation) meant an
archive_and_compact failure left every previously-persisted message
unstamped and the next append-only flush re-inserted them all as
duplicate active rows.
"""
from agent.context_compressor import _DB_PERSISTED_MARKER
cc = _compressor()
messages = _conversation(exchanges=8)
for m in messages:
m[_DB_PERSISTED_MARKER] = True
# No DB bound -> _sync_micro_compact_to_db no-ops (the failure shape).
result = cc._micro_compact(messages)
unstamped = [
m for m in result
if not m.get(_DB_PERSISTED_MARKER)
and not m.get(COMPRESSED_SUMMARY_METADATA_KEY)
]
assert not unstamped, (
"splice must not strip _db_persisted from surviving messages"
)
+32
View File
@@ -1864,6 +1864,38 @@ class TestMessageRouting:
assert msg_event.text == "what's the weather?"
assert "<@U_BOT>" not in msg_event.text
@pytest.mark.asyncio
async def test_accepted_mention_prompt_trusts_adapter_routing(self, adapter):
"""Cleaned text must not make the model revalidate an accepted mention."""
adapter.config.extra.update({"require_mention": True, "strict_mention": True})
adapter._bot_display_name = "TestBot"
adapter._team_bot_names = {"T123": "WorkspaceBot"}
event = {
"text": "<@U_BOT> Hi",
"user": "U_USER",
"channel": "C123",
"channel_type": "channel",
"team": "T123",
"ts": "1234567890.000001",
}
await adapter._handle_slack_message(event)
adapter.handle_message.assert_awaited_once()
msg_event = adapter.handle_message.await_args.args[0]
prompt = msg_event.channel_prompt
assert msg_event.text == "Hi"
assert "@WorkspaceBot" in prompt
assert "already applied" in prompt
assert "may have been stripped" in prompt
assert "do not reject or ignore" in prompt
assert "intentionally routed" in prompt
assert "not a mention of you" in prompt
assert "Only treat a message as directed" not in prompt
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_allow_bots_mentions_ignores_bot_user_without_current_mention(
@@ -27,6 +27,69 @@ def test_matches_legacy_custom_providers_list(monkeypatch):
)
def test_matches_providers_dict_by_key(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {"providers": {"local": {"api": "http://127.0.0.1:8000/v1"}}},
)
assert (
rp.find_custom_provider_identity("http://127.0.0.1:8000/v1")
== "custom:local"
)
def test_matches_providers_dict_by_stable_key_not_display_name(monkeypatch):
config = {
"providers": {
"local-127.0.0.1:8000": {
"name": "Local Ollama",
"api": "http://127.0.0.1:8000/v1",
}
}
}
monkeypatch.setattr(
rp,
"load_config",
lambda: config,
)
slug = rp.find_custom_provider_identity("http://127.0.0.1:8000/v1")
assert slug == "custom:local-127.0.0.1:8000"
entry = rp._get_named_custom_provider(slug)
assert entry is not None
assert entry["name"] == "Local Ollama"
def test_match_ignores_trailing_slash_and_case(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "local", "base_url": "http://Localhost:8000/v1/"}
]
},
)
assert (
rp.find_custom_provider_identity("http://localhost:8000/v1")
== "custom:local"
)
def test_no_match_returns_none(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "other", "base_url": "https://elsewhere.example/v1"}
]
},
)
assert rp.find_custom_provider_identity("https://api.mimo.example/v1") is None
def test_empty_base_url_returns_none(monkeypatch):
monkeypatch.setattr(
rp, "load_config", lambda: {"custom_providers": [{"name": "x"}]}
@@ -327,6 +327,7 @@ class TestCustomProviderModelSwitch:
saved_text = config_path.read_text()
saved = yaml.safe_load(saved_text) or {}
entry = saved["providers"]["crs-henkee"]
assert saved["model"]["provider"] == "custom:crs-henkee"
assert "api_key" not in entry, (
f"providers.crs-henkee gained an api_key field: {entry.get('api_key')!r}"
)
@@ -338,6 +339,53 @@ class TestCustomProviderModelSwitch:
# The synthesized template is also redundant here — key_env owns it.
assert "${HERMES_CRS_HENKEE_KEY}" not in saved_text
@pytest.mark.parametrize(
"stored_provider",
[
"local-127.0.0.1:11434",
"custom:local-ollama",
"custom:local-127.0.0.1:11434",
],
)
def test_picker_recognizes_current_provider_alias_when_name_differs(
self, config_home, monkeypatch, stored_provider
):
"""The classic picker maps legacy and stable IDs to the keyed row."""
from hermes_cli.main import select_provider_and_model
config_path = config_home / "config.yaml"
config_path.write_text(
"model:\n"
f" provider: {stored_provider}\n"
" default: qwen3.5:9b\n"
"providers:\n"
" local-127.0.0.1:11434:\n"
" name: Local Ollama\n"
" base_url: http://127.0.0.1:11434/v1\n"
" default_model: qwen3.5:9b\n"
" models:\n"
" qwen3.5:9b: {}\n"
"custom_providers: []\n",
encoding="utf-8",
)
captured = {}
def _capture_and_cancel(labels, default=0):
captured["labels"] = labels
captured["default"] = default
return len(labels) - 1
with patch(
"hermes_cli.main._prompt_provider_choice",
side_effect=_capture_and_cancel,
), patch("builtins.print"):
select_provider_and_model()
active_label = captured["labels"][captured["default"]]
assert "Local Ollama" in active_label
assert "currently active" in active_label
def test_key_env_providers_dict_preserves_existing_api_key(
self, config_home, monkeypatch
):
+404
View File
@@ -272,6 +272,410 @@ class TestDoctorMemoryProviderSection:
assert "Mem0" not in out
def test_mem0_provider_not_installed_shows_fail(self, monkeypatch, tmp_path):
# Make mem0 import fail
monkeypatch.setitem(sys.modules, "plugins.memory.mem0", None)
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="mem0")
assert "Memory Provider" in out
assert "Built-in memory active" not in out
def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkeypatch, tmp_path):
helper = TestDoctorMemoryProviderSection()
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
real_which = doctor_mod.shutil.which
def fake_which(cmd):
if cmd in {"docker", "node", "npm"}:
return None
return real_which(cmd)
monkeypatch.setattr(doctor_mod.shutil, "which", fake_which)
out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
assert "Docker backend is not available inside Termux" in out
assert "Node.js not found (browser tools are optional in the tested Termux path)" in out
assert "Install Node.js on Termux with: pkg install nodejs" in out
assert "Termux browser setup:" in out
assert "1) pkg install nodejs" in out
assert "2) npm install -g agent-browser" in out
assert "3) agent-browser install" in out
assert "Termux compatibility fallbacks:" in out
assert "use .[termux-all] for broad compatibility" in out
assert "Matrix E2EE extra is excluded on Termux" in out
assert "Local faster-whisper extra is excluded on Termux" in out
assert "STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY)." in out
assert "docker not found (optional)" not in out
def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
import yaml
(home / "config.yaml").write_text(
yaml.dump(
{
"model": {
"provider": "volcengine-plan",
"default": "doubao-seed-2.0-code",
},
"providers": {
"volcengine-plan": {
"name": "volcengine-plan",
"base_url": "https://ark.cn-beijing.volces.com/api/coding/v3",
"default_model": "doubao-seed-2.0-code",
"models": {"doubao-seed-2.0-code": {}},
}
},
}
)
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'volcengine-plan' is not a recognised provider" not in out
def test_run_doctor_accepts_stable_key_when_provider_name_differs(
monkeypatch, tmp_path
):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: custom:local-127.0.0.1:11434\n"
" default: qwen3.5:9b\n"
"providers:\n"
" local-127.0.0.1:11434:\n"
" name: Local Ollama\n"
" base_url: http://127.0.0.1:11434/v1\n"
" default_model: qwen3.5:9b\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert (
"model.provider 'custom:local-127.0.0.1:11434' is not a recognised provider"
not in out
)
assert "model.provider 'custom:local-127.0.0.1:11434' is unknown" not in out
def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: custom\n"
" default: local-model\n"
" base_url: http://localhost:8000/v1\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'custom' is not a recognised provider" not in out
def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: openrouter\n"
" default: openai/gpt-4.1-mini\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'openrouter' is set but no API key is configured" in out
assert "No credentials found for provider 'openrouter'." in out
@pytest.mark.parametrize(
("provider", "default_model"),
[
("opencode-zen", "anthropic/claude-sonnet-4.6"),
("kilocode", "anthropic/claude-sonnet-4.6"),
("kimi-coding", "kimi-k2"),
("nvidia", "qwen/qwen3.5-122b-a10b"),
("moa", "anthropic/claude-sonnet-4.6"),
],
)
def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
monkeypatch, tmp_path, provider, default_model
):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
f" provider: {provider}\n"
f" default: {default_model}\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert f"model.provider '{provider}' is not a recognised provider" not in out
assert f"model.provider '{provider}' is unknown" not in out
if provider in {"opencode-zen", "kilocode", "nvidia"}:
assert (
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
not in out
)
def test_run_doctor_accepts_vendor_slugs_for_named_custom_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: custom:hpc-ai\n"
" default: deepseek/deepseek-v4-flash\n"
"custom_providers:\n"
" - name: hpc-ai\n"
" base_url: https://hpc-ai.example/v1\n"
" api_key: test-key\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'custom:hpc-ai' is not a recognised provider" not in out
assert "model.provider 'custom:hpc-ai' is unknown" not in out
assert (
"model.default 'deepseek/deepseek-v4-flash' uses a vendor/model slug but provider is "
"'custom:hpc-ai'"
not in out
)
assert "Either set model.provider to 'openrouter', or drop the vendor prefix." not in out
def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / ".env").write_text("KIMI_CN_API_KEY=***\n", encoding="utf-8")
(home / "config.yaml").write_text(
"model:\n"
" provider: kimi-coding-cn\n"
" default: kimi-k2.6\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out
def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]),
TOOLSET_REQUIREMENTS={
"terminal": {"name": "terminal"},
"browser": {"name": "browser"},
},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "✓ browser" not in out
assert "browser" in out
assert "system dependency not met" in out
assert "agent-browser is not installed (expected in the tested Termux path)" in out
assert "npm install -g agent-browser && agent-browser install" in out
def _run_doctor_with_managed_agent_browser(monkeypatch, tmp_path, runnable):
@@ -63,6 +63,179 @@ def test_list_authenticated_providers_includes_custom_providers(monkeypatch):
def test_resolve_provider_full_finds_named_custom_provider():
"""Explicit /model --provider should resolve saved custom_providers entries."""
resolved = resolve_provider_full(
"custom:local-(127.0.0.1:4141)",
user_providers={},
custom_providers=[
{
"name": "Local (127.0.0.1:4141)",
"base_url": "http://127.0.0.1:4141/v1",
}
],
)
assert resolved is not None
assert resolved.id == "custom:local-(127.0.0.1:4141)"
assert resolved.name == "Local (127.0.0.1:4141)"
assert resolved.base_url == "http://127.0.0.1:4141/v1"
assert resolved.source == "user-config"
@pytest.mark.parametrize(
"requested",
[
"Local Ollama",
"local-ollama",
"local-127.0.0.1:11434",
"custom:local-ollama",
"custom:local-127.0.0.1:11434",
],
)
def test_keyed_custom_provider_legacy_aliases_resolve_to_stable_key(requested):
"""Every historical identity resolves, but keyed providers return one ID."""
resolved = resolve_provider_full(
requested,
user_providers={},
custom_providers=[
{
"name": "Local Ollama",
"provider_key": "local-127.0.0.1:11434",
"base_url": "http://127.0.0.1:11434/v1",
}
],
)
assert resolved is not None
assert resolved.id == "custom:local-127.0.0.1:11434"
assert resolved.name == "Local Ollama"
def test_keyed_custom_provider_bare_custom_fallback_uses_stable_key():
resolved = resolve_provider_full(
"custom",
user_providers={},
custom_providers=[
{
"name": "Local Ollama",
"provider_key": "local-127.0.0.1:11434",
"base_url": "http://127.0.0.1:11434/v1",
}
],
)
assert resolved is not None
assert resolved.id == "custom:local-127.0.0.1:11434"
@pytest.mark.parametrize(
"requested",
["foo", "custom:foo", "custom:custom:foo"],
)
def test_prefixed_provider_key_does_not_accumulate_custom_prefixes(requested):
"""Accept the historical doubled form without writing a third identity."""
resolved = resolve_provider_full(
requested,
user_providers={},
custom_providers=[
{
"name": "Foo Relay",
"provider_key": "custom:foo",
"base_url": "https://foo.example/v1",
}
],
)
assert resolved is not None
assert resolved.id == "custom:foo"
def test_list_authenticated_providers_includes_active_bare_custom_endpoint(monkeypatch):
"""Bare model.provider=custom + model.base_url should still populate /model.
Users can configure a one-off OpenAI-compatible endpoint directly under
``model:`` without a named ``providers:`` or ``custom_providers:`` row.
The gateway picker receives only the current model/base_url slice, so it
must surface that active endpoint rather than looking like config was
ignored.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom",
current_base_url="https://www.ccsub.net/v1",
current_model="gpt-4o",
user_providers={},
custom_providers=[],
max_models=50,
)
bare_custom = next((p for p in providers if p["slug"] == "custom"), None)
assert bare_custom is not None
assert bare_custom["name"] == "Custom endpoint"
assert bare_custom["is_current"] is True
assert bare_custom["is_user_defined"] is True
assert bare_custom["models"] == ["gpt-4o"]
assert bare_custom["api_url"] == "https://www.ccsub.net/v1"
def test_list_authenticated_providers_can_probe_active_bare_custom_endpoint(monkeypatch):
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
monkeypatch.setattr(
"hermes_cli.models.fetch_api_models",
lambda api_key, api_url, **kwargs: ["gpt-4o", "gpt-4o-mini"],
)
providers = list_authenticated_providers(
current_provider="custom",
current_base_url="https://www.ccsub.net/v1",
current_model="gpt-4o",
user_providers={},
custom_providers=[],
probe_custom_providers=False,
probe_current_custom_provider=True,
)
bare_custom = next(p for p in providers if p["slug"] == "custom")
assert bare_custom["is_current"] is True
assert bare_custom["models"] == ["gpt-4o", "gpt-4o-mini"]
def test_switch_model_accepts_explicit_bare_custom_current_endpoint(monkeypatch):
"""Picker selections for bare custom endpoints should route to current base_url."""
monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)
result = switch_model(
raw_input="gpt-4o-mini",
current_provider="custom",
current_model="gpt-4o",
current_base_url="https://www.ccsub.net/v1",
current_api_key="sk-test",
explicit_provider="custom",
user_providers={},
custom_providers=[],
)
assert result.success is True
assert result.target_provider == "custom"
assert result.provider_label == "Custom endpoint"
assert result.new_model == "gpt-4o-mini"
assert result.base_url == "https://www.ccsub.net/v1"
assert result.api_key == "sk-test"
def test_is_aggregator_recognizes_named_custom_provider():
assert providers_mod.is_aggregator("custom:hpc-ai") is True
assert providers_mod.is_aggregator("custom:litellm") is True
def test_is_aggregator_leaves_unknown_provider_non_aggregator():
assert providers_mod.is_aggregator("not-a-provider") is False
def test_is_routing_aggregator_excludes_flat_namespace_resellers():
@@ -162,6 +335,258 @@ def test_list_authenticated_providers_bare_custom_slug_recovers(monkeypatch):
assert group["is_current"] is True
def test_compatible_keyed_provider_uses_stable_key_and_accepts_legacy_current_name(
monkeypatch,
):
"""The merged providers view keeps the config key while old IDs stay current."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom:local-ollama",
user_providers={},
custom_providers=[
{
"name": "Local Ollama",
"provider_key": "local-127.0.0.1:11434",
"base_url": "http://127.0.0.1:11434/v1",
"model": "qwen3.5:9b",
}
],
max_models=50,
probe_custom_providers=False,
)
row = next(p for p in providers if p.get("is_user_defined"))
assert row["slug"] == "custom:local-127.0.0.1:11434"
assert row["is_current"] is True
def test_user_provider_row_recognizes_stable_custom_key_as_current(monkeypatch):
"""Section 3 keeps its legacy row slug but recognizes the stable ID."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom:local-127.0.0.1:11434",
user_providers={
"local-127.0.0.1:11434": {
"name": "Local Ollama",
"base_url": "http://127.0.0.1:11434/v1",
"default_model": "qwen3.5:9b",
}
},
custom_providers=[],
max_models=50,
probe_custom_providers=False,
)
row = next(p for p in providers if p.get("is_user_defined"))
assert row["slug"] == "local-127.0.0.1:11434"
assert row["is_current"] is True
def test_list_authenticated_providers_distinct_endpoints_stay_separate(monkeypatch):
"""Entries with different base_urls must produce separate picker rows
even if some display names happen to be similar."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
user_providers={},
custom_providers=[
{"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1",
"api_key": "ollama", "model": "glm-5.1"},
{"name": "Moonshot", "base_url": "https://api.moonshot.cn/v1",
"api_key": "sk-m", "model": "moonshot-v1"},
{"name": "Ollama — Qwen3-coder", "base_url": "http://localhost:11434/v1",
"api_key": "ollama", "model": "qwen3-coder"},
],
max_models=50,
probe_custom_providers=False,
)
custom_groups = [p for p in providers if p.get("is_user_defined")]
assert len(custom_groups) == 2
# Ollama endpoint collapses to one row with both models
ollama = next(p for p in custom_groups if p["name"] == "Ollama")
assert set(ollama["models"]) == {"glm-5.1", "qwen3-coder"}
moonshot = next(p for p in custom_groups if p["name"] == "Moonshot")
assert moonshot["models"] == ["moonshot-v1"]
def test_list_authenticated_providers_same_url_different_keys_disambiguated(monkeypatch):
"""Two custom_providers entries with the same base_url but different
api_keys (and identical cleaned names) must both stay visible in the
picker — slug is suffixed to disambiguate."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
user_providers={},
custom_providers=[
{"name": "OpenAI — key A", "base_url": "https://api.openai.com/v1",
"api_key": "sk-AAA", "model": "gpt-5.4"},
{"name": "OpenAI — key B", "base_url": "https://api.openai.com/v1",
"api_key": "sk-BBB", "model": "gpt-4.6"},
],
max_models=50,
)
custom_groups = [p for p in providers if p.get("is_user_defined")]
assert len(custom_groups) == 2
slugs = sorted(p["slug"] for p in custom_groups)
# First group keeps the base slug, second gets a numeric suffix
assert slugs == ["custom:openai", "custom:openai-2"]
# Each row has a distinct model
models = {p["slug"]: p["models"] for p in custom_groups}
assert models["custom:openai"] == ["gpt-5.4"]
assert models["custom:openai-2"] == ["gpt-4.6"]
def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch):
"""Same gateway host but different key_env/api_mode entries are distinct providers."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom:gpt",
current_base_url="https://gateway.example.com",
user_providers={},
custom_providers=[
{
"name": "gpt",
"base_url": "https://gateway.example.com",
"key_env": "GPT_KEY",
"api_mode": "codex_responses",
"model": "gpt-5.5",
},
{
"name": "claude",
"base_url": "https://gateway.example.com",
"key_env": "CLAUDE_KEY",
"api_mode": "anthropic_messages",
"model": "claude-opus-4-8",
},
],
max_models=50,
)
custom = [p for p in providers if p.get("is_user_defined")]
by_slug = {p["slug"]: p for p in custom}
assert set(by_slug) == {"custom:gpt", "custom:claude"}
assert by_slug["custom:gpt"]["models"] == ["gpt-5.5"]
assert by_slug["custom:claude"]["models"] == ["claude-opus-4-8"]
assert by_slug["custom:gpt"]["is_current"] is True
assert by_slug["custom:claude"]["is_current"] is False
def test_list_authenticated_providers_total_models_reflects_grouped_count(monkeypatch):
"""After grouping six entries into one row, total_models must reflect
the full count, and every grouped model appears in the list."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
entries = [
{"name": f"Ollama \u2014 Model {i}", "base_url": "http://localhost:11434/v1",
"api_key": "ollama", "model": f"model-{i}"}
for i in range(6)
]
providers = list_authenticated_providers(
user_providers={},
custom_providers=entries,
max_models=4,
probe_custom_providers=False,
)
groups = [p for p in providers if p.get("is_user_defined")]
assert len(groups) == 1
group = groups[0]
assert group["total_models"] == 6
# All six models are preserved in the grouped row.
assert sorted(group["models"]) == sorted(f"model-{i}" for i in range(6))
def test_lmstudio_picker_probes_active_config_base_url(monkeypatch):
"""When `provider: lmstudio` is saved with a remote base_url and no
LM_BASE_URL env var, the picker must probe the saved base_url — not
127.0.0.1. Regression: prior behavior always probed localhost, so users
with LM Studio on a lab box saw the wrong (or empty) model list.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
monkeypatch.delenv("LM_BASE_URL", raising=False)
monkeypatch.delenv("LM_API_KEY", raising=False)
captured: dict = {}
def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
captured["base_url"] = base_url
captured["api_key"] = api_key
return ["qwen/qwen3-coder-30b"]
monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch)
list_authenticated_providers(
current_provider="lmstudio",
current_base_url="http://192.168.1.10:1234/v1",
current_model="qwen/qwen3-coder-30b",
)
assert captured["base_url"] == "http://192.168.1.10:1234/v1"
def test_lmstudio_picker_lm_base_url_env_wins_over_active_config(monkeypatch):
"""LM_BASE_URL env var must still take precedence over the saved
base_url so users can temporarily redirect the picker without editing
config.yaml.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
monkeypatch.setenv("LM_BASE_URL", "http://override.local:9999/v1")
monkeypatch.delenv("LM_API_KEY", raising=False)
captured: dict = {}
def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
captured["base_url"] = base_url
return []
monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch)
list_authenticated_providers(
current_provider="lmstudio",
current_base_url="http://192.168.1.10:1234/v1",
)
assert captured["base_url"] == "http://override.local:9999/v1"
def test_lmstudio_picker_skips_probe_when_not_configured(monkeypatch):
"""If the user has never configured LM Studio (no LM_API_KEY / LM_BASE_URL
and not on lmstudio), the picker must not pay the localhost probe cost
just to discover LM Studio is unavailable.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
monkeypatch.delenv("LM_BASE_URL", raising=False)
monkeypatch.delenv("LM_API_KEY", raising=False)
captured: dict = {}
def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
captured["base_url"] = base_url
return []
monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch)
list_authenticated_providers(
current_provider="openrouter",
current_base_url="https://openrouter.ai/api/v1",
)
assert "base_url" not in captured
def test_custom_providers_uses_live_models_for_multi_model_endpoint(monkeypatch):
+106
View File
@@ -9,6 +9,7 @@ import pytest
from hermes_cli.nous_account import NousPortalAccountInfo
from hermes_cli.tools_config import (
_DEFAULT_OFF_TOOLSETS,
_RECENTLY_SHIPPED_TOOLSETS,
_apply_toolset_change,
_checklist_toolset_keys,
_configure_provider,
@@ -553,3 +554,108 @@ def _fake_features(*, logged_in: bool, paid: bool = True):
# ("browserbase") only the CLI, and camofox its npm package.
# ── Toolsets that shipped after a platform's last `hermes tools` save ────────
#
# Saving the picker (or one toggle in the desktop Toolsets UI) replaces a
# platform's composite (``[hermes-cli]``) with a frozen explicit list, and
# nothing ever adds to that list — so a toolset shipped later stays off
# forever, while everyone still on the composite inherits it on upgrade.
# ``_RECENTLY_SHIPPED_TOOLSETS`` closes that gap for toolsets new enough that
# absence from a saved list cannot mean the user declined them.
#
# Every assertion here is a subset test against that set, which passes
# vacuously once it empties out — and empty is the steady state between
# releases. Skip loudly rather than going quietly green.
_requires_recently_shipped = pytest.mark.skipif(
not _RECENTLY_SHIPPED_TOOLSETS,
reason="no toolset is currently inside its first release",
)
def _saved_list_from_before(platform="cli"):
"""A saved explicit list as it looked before the new toolsets existed."""
from hermes_cli.tools_config import (
_CONFIG_ONLY_TOOLSETS,
_toolset_allowed_for_platform,
)
return {
"platform_toolsets": {
platform: sorted(
ts_key
for ts_key, _, _ in CONFIGURABLE_TOOLSETS
if ts_key not in _RECENTLY_SHIPPED_TOOLSETS
and ts_key not in _DEFAULT_OFF_TOOLSETS
and ts_key not in _CONFIG_ONLY_TOOLSETS
and _toolset_allowed_for_platform(ts_key, platform)
)
}
}
@_requires_recently_shipped
def test_saved_list_gains_toolsets_that_shipped_after_it_was_written():
"""The bug: a frozen list never gained bfl, so composite users got Nous
Portal video generation on upgrade and picker users silently did not."""
on_composite = _get_platform_tools(
{"platform_toolsets": {"cli": ["hermes-cli"]}},
"cli",
include_default_mcp_servers=False,
)
on_saved_list = _get_platform_tools(
_saved_list_from_before(), "cli", include_default_mcp_servers=False
)
assert _RECENTLY_SHIPPED_TOOLSETS <= (on_composite & on_saved_list)
@_requires_recently_shipped
def test_unchecking_the_new_toolset_sticks():
"""Saving records it as offered, so the next read reads absence as a
decline instead of turning it back on."""
config = {"platform_toolsets": {"cli": ["hermes-cli"]}}
enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
with patch("hermes_cli.tools_config.save_config"):
_save_platform_tools(config, "cli", enabled - _RECENTLY_SHIPPED_TOOLSETS)
reread = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
assert not (_RECENTLY_SHIPPED_TOOLSETS & reread)
@_requires_recently_shipped
def test_agent_disabled_toolsets_still_wins():
"""The other way to say no — a global suppression list applied last."""
config = _saved_list_from_before()
config["agent"] = {"disabled_toolsets": sorted(_RECENTLY_SHIPPED_TOOLSETS)}
enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
assert not (_RECENTLY_SHIPPED_TOOLSETS & enabled)
@_requires_recently_shipped
def test_platforms_whose_composite_excludes_it_are_left_narrow():
"""Parity is the justification, so don't widen a deliberately small
composite (hermes-acp, hermes-webhook) that never carried the toolset."""
from toolsets import TOOLSETS, resolve_toolset
narrow = [
platform
for platform in ("acp", "webhook")
if f"hermes-{platform}" in TOOLSETS
and not any(
set(resolve_toolset(ts, include_registry=False))
<= set(resolve_toolset(f"hermes-{platform}"))
for ts in _RECENTLY_SHIPPED_TOOLSETS
)
]
assert narrow, "expected a composite that excludes the new toolset"
for platform in narrow:
enabled = _get_platform_tools(
_saved_list_from_before(platform),
platform,
include_default_mcp_servers=False,
)
assert not (_RECENTLY_SHIPPED_TOOLSETS & enabled), platform
+14 -2
View File
@@ -1998,7 +1998,14 @@ def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys):
# Sorted: ["kanban", "memory", "project"]. `kanban` is auto-recovered by
# _get_platform_tools (a non-configurable platform toolset in hermes-cli's
# universe); `project` is GUI-only, folded in by _load_enabled_toolsets.
assert server._load_enabled_toolsets() == ["kanban", "memory", "project"]
# Toolsets inside their first release (_RECENTLY_SHIPPED_TOOLSETS) are
# back-filled onto saved lists that never offered them — allow those too.
from hermes_cli.tools_config import _RECENTLY_SHIPPED_TOOLSETS
result = server._load_enabled_toolsets()
assert result is not None
assert {"kanban", "memory", "project"} <= set(result)
assert set(result) - {"kanban", "memory", "project"} <= _RECENTLY_SHIPPED_TOOLSETS
err = capsys.readouterr().err
assert "ignoring disabled MCP servers" in err
assert "mcp-off" in err
@@ -2019,7 +2026,12 @@ def test_load_enabled_toolsets_falls_back_when_tui_env_invalid(monkeypatch, caps
config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}
)
assert server._load_enabled_toolsets() == ["kanban", "memory", "project"]
from hermes_cli.tools_config import _RECENTLY_SHIPPED_TOOLSETS
result = server._load_enabled_toolsets()
assert result is not None
assert {"kanban", "memory", "project"} <= set(result)
assert set(result) - {"kanban", "memory", "project"} <= _RECENTLY_SHIPPED_TOOLSETS
assert "using configured CLI toolsets" in capsys.readouterr().err
+59 -44
View File
@@ -242,6 +242,38 @@ def file_ops(mock_env):
return ShellFileOperations(mock_env)
def make_real_subprocess_env(cwd: str, include_stderr: bool = False) -> MagicMock:
"""Mock env whose execute() runs the command in a real subprocess.
For tests that need the generated shell scripts to actually run
(search fallback, atomic-write permissions) instead of being
intercepted by a bare MagicMock. ``include_stderr`` folds stderr
into ``output`` for tests that surface shell error text; leave it
off for tests that parse structured stdout (e.g. find results).
"""
env = MagicMock()
env.cwd = cwd
def execute(command, **kwargs):
completed = subprocess.run(
command,
shell=True,
text=True,
capture_output=True,
input=kwargs.get("stdin_data"),
)
output = completed.stdout
if include_stderr:
output += completed.stderr
return {
"output": output,
"returncode": completed.returncode,
}
env.execute = execute
return env
class TestShellFileOpsHelpers:
def test_normalize_read_pagination_clamps_invalid_values(self):
assert normalize_read_pagination(offset=0, limit=0) == (1, 1)
@@ -393,23 +425,7 @@ class TestSearchPathValidation:
class TestSearchFilesFallbackHiddenPaths:
def _make_env(self):
env = MagicMock()
env.cwd = "/"
def execute(command, **kwargs):
completed = subprocess.run(
command,
shell=True,
text=True,
capture_output=True,
)
return {
"output": completed.stdout,
"returncode": completed.returncode,
}
env.execute = execute
return env
return make_real_subprocess_env("/")
def test_hidden_root_with_hidden_ancestor_includes_files(self, tmp_path, monkeypatch):
"""Fallback find should include visible files when path is inside hidden root."""
@@ -561,42 +577,41 @@ class _DeletedTestGitBaselineCheck:
class TestAtomicWriteNewFilePermissions:
"""_atomic_write should apply umask-default perms to new files (not 0600)."""
def test_new_file_gets_umask_default_permissions(self, tmp_path):
@pytest.mark.parametrize("test_umask", [0o022, 0o002, 0o077])
def test_new_file_gets_umask_default_permissions(self, tmp_path, test_umask):
"""Newly created file should get umask-computed perms, not mktemp's 0600.
Uses a real subprocess so the shell script actually runs.
"""
env = MagicMock()
env.cwd = str(tmp_path)
def execute(command, **kwargs):
completed = subprocess.run(
command,
shell=True,
text=True,
capture_output=True,
input=kwargs.get("stdin_data"),
)
return {
"output": completed.stdout + completed.stderr,
"returncode": completed.returncode,
}
env.execute = execute
ops = ShellFileOperations(env)
ops = ShellFileOperations(make_real_subprocess_env(str(tmp_path)))
dest = tmp_path / "new_file.txt"
assert not dest.exists()
result = ops.write_file(str(dest), "test content\n")
assert result.error is None, f"write failed: {result.error}"
assert dest.exists()
old_umask = os.umask(test_umask)
try:
result = ops.write_file(str(dest), "test content\n")
finally:
os.umask(old_umask)
# Compute expected mode: 0666 & ~umask
current_umask = os.umask(0)
os.umask(current_umask) # restore
expected_mode = 0o666 & ~current_umask
assert result.error is None, f"write failed: {result.error}"
assert dest.read_text() == "test content\n"
expected_mode = 0o666 & ~test_umask
actual_mode = dest.stat().st_mode & 0o777
assert actual_mode == expected_mode, (
f"Expected mode {expected_mode:04o} (umask {current_umask:04o}), "
f"Expected mode {expected_mode:04o} (umask {test_umask:04o}), "
f"got {actual_mode:04o}"
)
def test_overwrite_still_preserves_existing_mode(self, tmp_path):
"""The new-file branch must not disturb the overwrite path's
mode preservation (e.g. an executable script stays 0755)."""
ops = ShellFileOperations(make_real_subprocess_env(str(tmp_path)))
dest = tmp_path / "existing.sh"
dest.write_text("#!/bin/sh\n")
dest.chmod(0o755)
result = ops.write_file(str(dest), "#!/bin/sh\necho updated\n")
assert result.error is None, f"write failed: {result.error}"
assert dest.read_text() == "#!/bin/sh\necho updated\n"
assert dest.stat().st_mode & 0o777 == 0o755
+761
View File
@@ -0,0 +1,761 @@
"""Native BFL FLUX 3 tools: gating, transport, media delivery, redaction."""
import asyncio
import base64
import json
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from tools import flux3_video_tool as flux3
GATEWAY = "https://tool-gateway.example.com"
BASE_URL = f"{GATEWAY}/api/bfl"
UPLOAD_PATH = "/api/uploads/bfl"
_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
@pytest.fixture(autouse=True)
def _endpoints():
"""Every test runs as if the mount is reachable unless it says otherwise."""
with patch.object(
flux3,
"managed_vendor_endpoints",
return_value={"origin": GATEWAY, "base_url": BASE_URL, "upload_path": UPLOAD_PATH},
):
yield
class _FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text or (json.dumps(payload) if payload is not None else "")
def json(self):
if self._payload is None:
raise ValueError("no json")
return self._payload
class _FakeClient:
"""Captures each request a handler makes.
A list of responses is served in order, with the last one repeating, so a
poll that looks twice can be given a job that finishes between looks.
"""
def __init__(self, response, sink):
self._responses = list(response) if isinstance(response, list) else [response]
self._sink = sink
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return False
async def request(self, method, url, headers=None, json=None):
self._sink.append({"method": method, "url": url, "headers": headers or {}, "json": json})
response = self._responses[min(len(self._sink) - 1, len(self._responses) - 1)]
if isinstance(response, Exception):
raise response
return response
class _FakeStream:
"""A streaming GET that yields `body` in one chunk."""
def __init__(self, body, status_code=200):
self._body = body
self.status_code = status_code
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return False
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
async def aiter_bytes(self):
yield self._body
@contextmanager
def _fake_download(body, status_code=200):
"""Stub the clip download; yields the list of URLs that were fetched.
Patched at `create_ssrf_safe_async_client` rather than at httpx, which both
stubs the transport and asserts the download goes through the SSRF-guarded
client — the URL is vendor-supplied and fetched from the user's machine.
"""
from tools import url_safety
fetched = []
class _Client:
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return False
def stream(self, _method, url):
fetched.append(url)
return _FakeStream(body, status_code)
with patch.object(url_safety, "create_ssrf_safe_async_client", lambda **_kw: _Client()):
yield fetched
def _run(coro):
return asyncio.run(coro)
def _record_sleep(sink):
async def _sleep(seconds):
sink.append(seconds)
return _sleep
def _call(handler, args, response, headers=None):
"""Invoke a handler with the transport stubbed; returns (parsed, requests)."""
sink = []
import httpx
with patch.object(
flux3,
"managed_gateway_auth_headers",
return_value=headers if headers is not None else {"Authorization": "Bearer nous-token"},
), patch.object(httpx, "AsyncClient", lambda **_kw: _FakeClient(response, sink)):
raw = _run(handler(args))
return json.loads(raw), sink
class TestGating:
def test_hidden_without_a_reachable_mount(self):
with patch.object(flux3, "managed_vendor_endpoints", return_value=None):
assert flux3.check_bfl_requirements() is False
def test_hidden_without_paid_service_access(self):
# The free tool pool does not fund BFL, so a pool-only user must never
# see the tools rather than see them and be refused.
account = SimpleNamespace(logged_in=True, paid_service_access=False, tool_gateway_entitled=True)
with patch("hermes_cli.nous_account.get_nous_portal_account_info", return_value=account):
assert flux3.check_bfl_requirements() is False
def test_hidden_when_logged_out(self):
account = SimpleNamespace(logged_in=False, paid_service_access=False)
with patch("hermes_cli.nous_account.get_nous_portal_account_info", return_value=account):
assert flux3.check_bfl_requirements() is False
def test_visible_for_a_paid_portal_account(self):
account = SimpleNamespace(logged_in=True, paid_service_access=True)
with patch("hermes_cli.nous_account.get_nous_portal_account_info", return_value=account):
assert flux3.check_bfl_requirements() is True
def test_fails_closed_when_the_account_probe_raises(self):
with patch("hermes_cli.nous_account.get_nous_portal_account_info", side_effect=RuntimeError("portal down")):
assert flux3.check_bfl_requirements() is False
class TestSubmitTransport:
def test_text_to_video_posts_the_mode_and_arguments(self):
response = _FakeResponse(200, {"id": "bfl_job_1", "status": "submitted", "guidance": "Poll bfl_flux3_get_result with id=bfl_job_1"})
parsed, requests = _call(
flux3._handle_text_to_video,
{"prompt": "a lake", "aspect_ratio": "16:9", "duration": 5},
response,
)
assert requests[0]["method"] == "POST"
assert requests[0]["url"] == f"{BASE_URL}/generations"
assert requests[0]["json"] == {
"prompt": "a lake",
"aspect_ratio": "16:9",
"duration": 5,
"mode": "text_to_video",
}
assert requests[0]["headers"]["Authorization"] == "Bearer nous-token"
# The gateway's guidance is the model-facing text, verbatim.
assert parsed["result"] == "Poll bfl_flux3_get_result with id=bfl_job_1"
assert parsed["details"]["id"] == "bfl_job_1"
def test_each_generate_tool_sends_its_own_mode(self):
for handler, args, mode in [
(flux3._handle_text_to_video, {"prompt": "a"}, "text_to_video"),
(flux3._handle_image_to_video, {"prompt": "a", "input_image": "https://x/a.png"}, "image_to_video"),
(
flux3._handle_keyframes_to_video,
{"prompt": "a", "input_images": ["https://x/a.png"], "keyframe_indices": [0]},
"keyframes_to_video",
),
(flux3._handle_video_continuation, {"prompt": "a", "input_video": "https://x/c.mp4"}, "video_continuation"),
]:
_parsed, requests = _call(handler, args, _FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}))
assert requests[0]["json"]["mode"] == mode
def test_urls_pass_through_without_an_upload(self):
# Forwarding a URL is cheaper than downloading and re-uploading it.
_parsed, requests = _call(
flux3._handle_image_to_video,
{"prompt": "a", "input_image": "https://example.com/a.png"},
_FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}),
)
assert requests[0]["json"]["input_image"] == "https://example.com/a.png"
def test_a_refusal_is_surfaced_as_the_tools_result_text(self):
# Throttles are designed to be hit: the message is written for the
# model and must reach it intact, with the machine detail alongside.
response = _FakeResponse(
429,
{
"error": {
"code": "BFL_GENERATION_COOLDOWN",
"message": "A new BFL video generation may be started once every 5 minutes. Wait 210 seconds.",
"details": {"retryAfterSeconds": 210},
}
},
)
parsed, _requests = _call(flux3._handle_text_to_video, {"prompt": "a"}, response)
assert parsed["error"] == "A new BFL video generation may be started once every 5 minutes. Wait 210 seconds."
assert parsed["details"] == {"retryAfterSeconds": 210}
def test_a_401_asks_for_a_nous_sign_in(self):
parsed, _requests = _call(flux3._handle_text_to_video, {"prompt": "a"}, _FakeResponse(401, {"error": {"code": "AUTH_ERROR"}}))
assert parsed["needs_reauth"] is True
assert "sign in" in parsed["error"].lower()
def test_missing_credentials_ask_for_a_sign_in_without_calling_out(self):
parsed, requests = _call(flux3._handle_text_to_video, {"prompt": "a"}, _FakeResponse(200, {}), headers={})
assert requests == []
assert "sign in" in parsed["error"].lower()
def test_a_transport_failure_reports_the_cause(self):
parsed, _requests = _call(
flux3._handle_text_to_video,
{"prompt": "a"},
RuntimeError("connect failed"),
)
assert "Could not reach the video-generation gateway" in parsed["error"]
assert "connect failed" in parsed["error"]
def test_an_unreadable_body_does_not_masquerade_as_success(self):
parsed, _requests = _call(flux3._handle_text_to_video, {"prompt": "a"}, _FakeResponse(502, None, text="upstream exploded"))
assert "error" in parsed
@pytest.fixture(autouse=True)
def _no_real_poll_wait(monkeypatch):
"""Keep the in-call wait out of the test clock."""
monkeypatch.setattr(flux3, "_POLL_FOLLOW_UP_WAIT_SECONDS", 0)
class TestPollTransport:
def test_a_terminal_status_returns_at_once_without_waiting(self):
response = _FakeResponse(200, {"id": "bfl_job_1", "status": "Error", "guidance": "The job is over."})
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response)
assert requests[0]["method"] == "GET"
assert requests[0]["url"] == f"{BASE_URL}/generations/bfl_job_1"
assert requests[0]["json"] is None
assert len(requests) == 1
assert parsed["result"] == "The job is over."
def test_a_running_job_is_waited_out_inside_the_call(self, monkeypatch):
# A model has no clock, so telling it to pause produced a burst of polls
# instead of a paced one. The wait lives here where it cannot be skipped.
monkeypatch.setattr(flux3, "_POLL_FOLLOW_UP_WAIT_SECONDS", 45.0)
running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."})
slept = []
with patch.object(flux3.asyncio, "sleep", new=_record_sleep(slept)):
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, running)
assert len(requests) == 2, "should look again after waiting"
assert sum(slept) == 45.0
assert parsed["details"]["status"] == "Generating"
def test_the_wait_is_answerable_to_a_stop(self, monkeypatch):
# Nothing outside the tool can end a call that has already started —
# the executor only checks for an interrupt between tools — so /stop
# has to land inside the wait rather than at the end of it.
monkeypatch.setattr(flux3, "_POLL_FOLLOW_UP_WAIT_SECONDS", 45.0)
from tools import interrupt as interrupt_module
running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."})
looks = []
def _stop_after_one_slice():
looks.append(True)
return len(looks) > 1
slept = []
with patch.object(interrupt_module, "is_interrupted", _stop_after_one_slice), \
patch.object(flux3.asyncio, "sleep", new=_record_sleep(slept)):
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, running)
assert slept == [flux3._POLL_WAIT_SLICE_SECONDS], "the rest of the wait is abandoned"
assert len(requests) == 1, "and so is the second look"
assert parsed["details"]["status"] == "Generating"
def test_the_call_returns_as_soon_as_the_job_finishes(self):
# The point of waiting in here is that the caller gets the result on the
# wait it was already taking, not one round trip later.
running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."})
done = _FakeResponse(200, {"id": "bfl_job_1", "status": "Error", "guidance": "That job failed."})
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, [running, done])
assert len(requests) == 2
assert parsed["result"] == "That job failed."
def test_a_refusal_is_returned_immediately_rather_than_waited_on(self):
# A 429 carries its own retry guidance; sleeping on it would only delay
# showing the model what to do, and spend the poll budget twice.
response = _FakeResponse(429, {"error": {"message": "Too many polls. Wait 30 seconds."}})
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response)
assert len(requests) == 1
assert "Too many polls" in parsed["error"]
def test_ready_saves_the_clip_and_never_returns_the_signed_url(self, tmp_path):
# The signed URL is a bearer credential for the clip and it used to be
# re-keyed into a shell command by hand, dropping characters. Neither
# can happen if the model never sees it.
signed = "https://cdn.example/container/flux3-clip.mp4?sig=abc%2Bdef%3D&se=2026"
response = _FakeResponse(200, {"id": "bfl_job_1", "status": "Ready", "result": {"sample": signed}, "guidance": "Deliver the saved file."})
with _fake_download(b"x" * (128 * 1024)) as fetched:
parsed, _requests = _call(
flux3._handle_get_result,
{"id": "bfl_job_1", "save_to": str(tmp_path)},
response,
)
saved = tmp_path / "flux3-clip.mp4"
assert saved.read_bytes() == b"x" * (128 * 1024)
assert parsed["details"]["saved_path"] == str(saved)
assert parsed["details"]["result"].get("sample") is None
assert signed not in json.dumps(parsed)
# The gateway still owns the delivery wording; the client only supplies
# the path it cannot know.
assert parsed["result"].startswith(f"Saved to {saved}.")
assert "Deliver the saved file." in parsed["result"]
assert fetched == [signed]
def test_ready_never_overwrites_an_existing_file(self, tmp_path):
(tmp_path / "flux3-clip.mp4").write_bytes(b"an earlier clip")
response = _FakeResponse(200, {"id": "bfl_job_1", "status": "Ready", "result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=a"}, "guidance": "g"})
with _fake_download(b"y" * (128 * 1024)):
parsed, _requests = _call(flux3._handle_get_result, {"id": "bfl_job_1", "save_to": str(tmp_path)}, response)
assert parsed["details"]["saved_path"] == str(tmp_path / "flux3-clip-2.mp4")
assert (tmp_path / "flux3-clip.mp4").read_bytes() == b"an earlier clip"
def test_on_messaging_the_clip_lands_where_the_gateway_may_send_it(self, monkeypatch):
# A chat user has no filesystem: the attachment is the only way they
# ever see the clip. Downloads is not a delivery root on a strict
# gateway, so a clip saved there is dropped on the way out and the
# reply arrives with nothing attached.
monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram")
monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", "1")
# Strict mode also trusts anything written in the last 10 minutes, and
# a clip we just downloaded is always inside that window. Left on, the
# assertion below passes from any directory on earth and stops being a
# statement about where the clip was saved.
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0")
response = _FakeResponse(200, {
"id": "bfl_job_1",
"status": "Ready",
"result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=a"},
"guidance": "Deliver the saved file.",
})
with _fake_download(b"x" * (128 * 1024)):
parsed, _requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response)
from gateway.platforms.base import validate_media_delivery_path
saved = parsed["details"]["saved_path"]
assert validate_media_delivery_path(saved), "the gateway must be allowed to send it"
# The exact line to copy, so the path is never retyped from memory.
assert f"\nMEDIA:{saved}\n" in parsed["result"]
def test_the_offered_tag_is_one_the_gateway_actually_delivers(self, monkeypatch):
# The whole point of spelling the line out is that the model pastes it
# verbatim, so the line has to survive the real extractor. A tag that
# parses but fails validation is the worst outcome: it is stripped from
# the reply either way, so the user is shown a message that looks like
# it simply forgot the attachment.
monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram")
response = _FakeResponse(200, {
"id": "bfl_job_1",
"status": "Ready",
"result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=a"},
"guidance": "Deliver the saved file.",
})
with _fake_download(b"x" * (128 * 1024)):
parsed, _requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response)
from gateway.platforms.base import BasePlatformAdapter
offered = [ln for ln in parsed["result"].splitlines() if ln.startswith("MEDIA:")]
assert len(offered) == 1, "exactly one line to copy"
reply = f"Here's the clip.\n\n{offered[0]}\n"
media, cleaned = BasePlatformAdapter.extract_media(reply)
assert BasePlatformAdapter.filter_media_delivery_paths(media), "must survive validation"
assert "MEDIA:" not in cleaned, "the tag is consumed, not shown to the user"
@pytest.mark.parametrize("platform", ["", "cli", "tui", "desktop"])
def test_off_messaging_the_clip_stays_a_file_and_no_tag_is_offered(self, tmp_path, monkeypatch, platform):
# The CLI has no attachment channel and its prompt forbids the tag —
# emitting one there just prints literal text at the user.
monkeypatch.setenv("HERMES_SESSION_PLATFORM", platform)
response = _FakeResponse(200, {
"id": "bfl_job_1",
"status": "Ready",
"result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=a"},
"guidance": "Deliver the saved file.",
})
with _fake_download(b"x" * (128 * 1024)):
parsed, _requests = _call(
flux3._handle_get_result, {"id": "bfl_job_1", "save_to": str(tmp_path)}, response,
)
assert parsed["result"].startswith(f"Saved to {tmp_path / 'flux3-clip.mp4'}.")
assert "MEDIA:" not in parsed["result"]
@pytest.mark.parametrize("platform", ["api_server", "webhook", "msgraph_webhook", "local"])
def test_platforms_without_an_attachment_channel_are_offered_no_tag(self, tmp_path, monkeypatch, platform):
# These carry a real platform value but no way to attach a file. The
# API server in particular only inlines *images* as data URLs and
# leaves every other MEDIA: tag untouched, so offering one here puts
# the literal text in front of an OpenAI-compatible caller.
monkeypatch.setenv("HERMES_SESSION_PLATFORM", platform)
response = _FakeResponse(200, {
"id": "bfl_job_1",
"status": "Ready",
"result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=a"},
"guidance": "Deliver the saved file.",
})
with _fake_download(b"x" * (128 * 1024)):
parsed, _requests = _call(
flux3._handle_get_result, {"id": "bfl_job_1", "save_to": str(tmp_path)}, response,
)
assert "MEDIA:" not in parsed["result"]
def test_a_cli_session_is_recognised_by_its_source(self, tmp_path, monkeypatch):
# The CLI, TUI, and desktop leave HERMES_SESSION_PLATFORM empty and
# identify themselves on HERMES_SESSION_SOURCE instead, so keying only
# on the platform would miss them.
monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
monkeypatch.setenv("HERMES_SESSION_SOURCE", "tui")
response = _FakeResponse(200, {
"id": "bfl_job_1",
"status": "Ready",
"result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=a"},
"guidance": "Deliver the saved file.",
})
with _fake_download(b"x" * (128 * 1024)):
parsed, _requests = _call(
flux3._handle_get_result, {"id": "bfl_job_1", "save_to": str(tmp_path)}, response,
)
assert "MEDIA:" not in parsed["result"]
def test_a_rejected_download_fails_loudly_and_leaves_no_file(self, tmp_path):
# The original bug: a bad signature returns an XML error body, curl
# writes it to the .mp4 and exits 0, and it reads as success. A short
# body is not a video whatever the status code said.
response = _FakeResponse(200, {"id": "bfl_job_1", "status": "Ready", "result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=bad"}, "guidance": "g"})
with _fake_download(b"<?xml version='1.0'?><Error>AuthenticationFailed</Error>"):
parsed, _requests = _call(flux3._handle_get_result, {"id": "bfl_job_1", "save_to": str(tmp_path)}, response)
assert "saving it failed" in parsed["result"]
assert "Poll this job again" in parsed["result"]
# Neither a half-written .part nor a plausible-looking .mp4 survives.
assert [p.name for p in tmp_path.glob("*.mp4*")] == []
assert "saved_path" not in parsed["details"]
def test_poll_requires_an_id(self):
parsed, requests = _call(flux3._handle_get_result, {}, _FakeResponse(200, {}))
assert "id is required" in parsed["error"]
assert requests == []
def test_poll_url_encodes_the_job_id(self):
_parsed, requests = _call(
flux3._handle_get_result,
{"id": "weird/../id"},
_FakeResponse(200, {"id": "x", "guidance": "ok"}),
)
assert requests[0]["url"] == f"{BASE_URL}/generations/weird%2F..%2Fid"
class TestMediaDelivery:
def _resolved(self, mime="image/png", data=_PNG):
return SimpleNamespace(data=data, mime=mime)
def test_a_local_path_is_uploaded_and_replaced_with_a_reference(self):
async def fake_uploader(data, mime):
assert data == _PNG
assert mime == "image/png"
return "nous-upload:token-1"
with patch.object(flux3, "build_managed_media_uploader", return_value=fake_uploader), patch(
"tools.image_source.resolve_image_source", return_value=self._resolved()
) as resolve:
_parsed, requests = _call(
flux3._handle_image_to_video,
{"prompt": "a", "input_image": "/tmp/frame.png"},
_FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}),
)
assert requests[0]["json"]["input_image"] == "nous-upload:token-1"
# Images and video ride the same safety pipeline; only the permitted
# type differs, and an image field must not accept a video.
assert resolve.call_args.kwargs["permitted"] == ("image",)
def test_video_fields_permit_video_only(self):
async def fake_uploader(data, mime):
return "nous-upload:token-v"
with patch.object(flux3, "build_managed_media_uploader", return_value=fake_uploader), patch(
"tools.image_source.resolve_image_source", return_value=self._resolved("video/mp4", b"\x00\x00\x00\x18ftypmp42")
) as resolve:
_parsed, requests = _call(
flux3._handle_video_continuation,
{"prompt": "a", "input_video": "/tmp/clip.mp4"},
_FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}),
)
assert requests[0]["json"]["input_video"] == "nous-upload:token-v"
assert resolve.call_args.kwargs["permitted"] == ("video",)
def test_every_keyframe_path_is_uploaded(self):
uploads = []
async def fake_uploader(data, mime):
uploads.append(mime)
return f"nous-upload:token-{len(uploads)}"
with patch.object(flux3, "build_managed_media_uploader", return_value=fake_uploader), patch(
"tools.image_source.resolve_image_source", return_value=self._resolved()
):
_parsed, requests = _call(
flux3._handle_keyframes_to_video,
{"prompt": "a", "input_images": ["/tmp/a.png", "https://x/b.png", "/tmp/c.png"], "keyframe_indices": [0, 24, 48]},
_FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}),
)
# The URL in the middle is forwarded untouched.
assert requests[0]["json"]["input_images"] == [
"nous-upload:token-1",
"https://x/b.png",
"nous-upload:token-2",
]
def test_a_list_valued_input_image_is_still_uploaded(self):
# The gateway accepts input_image as a string OR a list, so a list of
# local paths must not slip past unsanitized — that would send raw
# filesystem paths to the vendor and disclose the user's directories.
async def fake_uploader(data, mime):
return "nous-upload:token-1"
with patch.object(flux3, "build_managed_media_uploader", return_value=fake_uploader), patch(
"tools.image_source.resolve_image_source", return_value=self._resolved()
):
_parsed, requests = _call(
flux3._handle_image_to_video,
{"prompt": "a", "input_image": ["/tmp/frame.png"]},
_FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}),
)
assert requests[0]["json"]["input_image"] == ["nous-upload:token-1"]
assert "/tmp/frame.png" not in json.dumps(requests[0]["json"])
def test_media_fields_are_sanitized_whatever_the_mode_expects(self):
# The gateway prefers input_image over input_images, so sanitizing only
# the field this mode documents would let the other one through.
uploads = []
async def fake_uploader(data, mime):
uploads.append(mime)
return f"nous-upload:token-{len(uploads)}"
with patch.object(flux3, "build_managed_media_uploader", return_value=fake_uploader), patch(
"tools.image_source.resolve_image_source", return_value=self._resolved()
):
_parsed, requests = _call(
flux3._handle_keyframes_to_video,
{
"prompt": "a",
"input_images": ["https://x/b.png"],
"input_image": "/tmp/sneaky.png",
"keyframe_indices": [0],
},
_FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}),
)
body = json.dumps(requests[0]["json"])
assert "/tmp/sneaky.png" not in body
assert requests[0]["json"]["input_image"] == "nous-upload:token-1"
def test_text_to_video_strips_media_fields_instead_of_uploading_them(self):
# The mode takes no media, so an upload would spend the caller's quota
# on a value the gateway ignores.
def _must_not_upload(*_args, **_kwargs):
raise AssertionError("text-to-video must not upload anything")
with patch.object(flux3, "build_managed_media_uploader", _must_not_upload):
_parsed, requests = _call(
flux3._handle_text_to_video,
{"prompt": "a", "input_image": "/tmp/frame.png"},
_FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}),
)
assert "input_image" not in requests[0]["json"]
assert requests[0]["json"]["mode"] == "text_to_video"
def test_an_over_long_image_list_is_refused_before_any_upload(self):
def _must_not_upload(*_args, **_kwargs):
raise AssertionError("an over-long list must be refused before uploading")
with patch.object(flux3, "build_managed_media_uploader", _must_not_upload):
parsed, requests = _call(
flux3._handle_keyframes_to_video,
{"prompt": "a", "input_images": [f"/tmp/{i}.png" for i in range(11)], "keyframe_indices": [0]},
_FakeResponse(200, {}),
)
assert "at most 10" in parsed["error"]
assert requests == []
def test_an_upload_refusal_becomes_the_tools_error(self):
async def failing_uploader(data, mime):
raise RuntimeError("the daily upload budget for this account is exhausted")
with patch.object(flux3, "build_managed_media_uploader", return_value=failing_uploader), patch(
"tools.image_source.resolve_image_source", return_value=self._resolved()
):
parsed, requests = _call(
flux3._handle_image_to_video,
{"prompt": "a", "input_image": "/tmp/frame.png"},
_FakeResponse(200, {}),
)
assert "daily upload budget" in parsed["error"]
# A failed upload must not reach the gateway as a bare local path.
assert requests == []
def test_an_unreadable_file_is_reported_without_dumping_the_value(self):
from tools.image_source import SourceNotFound
with patch.object(flux3, "build_managed_media_uploader", return_value=lambda *a: None), patch(
"tools.image_source.resolve_image_source", side_effect=SourceNotFound("media file not found", src="/tmp/x.png")
):
parsed, requests = _call(
flux3._handle_image_to_video,
# Long, but unmistakably a path (dots and dashes are outside
# the base64 alphabet, so the payload guard leaves it alone).
{"prompt": "a", "input_image": "/tmp/" + "a-b." * 2000 + "frame.png"},
_FakeResponse(200, {}),
)
assert "error" in parsed
# The offending value is truncated: echoing it whole would blow up the
# model's context on the way to reporting a bad path.
assert len(parsed["error"]) < 500
assert requests == []
class TestLocalPathDetection:
@pytest.mark.parametrize(
"value",
["/tmp/frame.png", "~/Pictures/f.png", "./f.png", "../f.png", "file:///tmp/f.png", r"C:\Users\me\f.png", r"\\nas\share\f.png"],
)
def test_rooted_paths_are_read_off_disk(self, value):
assert flux3._looks_like_local_path(value) is True
@pytest.mark.parametrize(
"value",
[
"frame.png",
"https://example.com/f.png",
"nous-upload:eyJhbGciOiJIUzI1NiJ9.e30.sig",
"C:frame.png",
# Inline base64 of a JPEG always starts "/9j/" (first byte 0xFF),
# which must not read as an absolute POSIX path.
"/9j/4AAQSkZJRgABAQAAAQ" + "A" * 300 + "==",
],
)
def test_ambiguous_and_remote_values_are_forwarded(self, value):
assert flux3._looks_like_local_path(value) is False
def test_a_short_base64_lookalike_path_is_still_a_path(self):
assert flux3._looks_like_local_path("/tmp/frames/a1") is True
class TestSchemas:
def test_every_tool_is_registered_under_the_bfl_toolset(self):
from tools.registry import registry
for name in [
"bfl_flux3_text_to_video",
"bfl_flux3_image_to_video",
"bfl_flux3_keyframes_to_video",
"bfl_flux3_video_continuation",
"bfl_flux3_get_result",
"bfl_flux3_prompting_guide",
]:
entry = registry.get_entry(name)
assert entry is not None, f"{name} is not registered"
assert entry.toolset == "bfl"
assert entry.check_fn is flux3.check_bfl_requirements
def test_generate_tools_point_at_the_guide_and_the_poll_tool(self):
# Descriptions are the only text guaranteed to be in context when a
# model picks a tool, so the pointers live there.
for schema in [flux3.TEXT_TO_VIDEO_SCHEMA, flux3.IMAGE_TO_VIDEO_SCHEMA, flux3.KEYFRAMES_TO_VIDEO_SCHEMA, flux3.VIDEO_CONTINUATION_SCHEMA]:
assert "bfl_flux3_prompting_guide" in schema["description"]
assert "bfl_flux3_get_result" in schema["description"]
def test_the_guide_covers_the_methodology_without_pinning_server_policy(self):
guide = flux3.FLUX3_PROMPTING_GUIDE
assert "grounding" in guide.lower()
assert "bfl_flux3_get_result" in guide
# Waits and limits ship live in the gateway's responses; pinning them
# here would let the client lie about what the server enforces.
assert "5 minutes" not in guide
assert "per minute" not in guide
def test_the_guide_tool_takes_no_arguments_and_calls_nothing(self):
assert flux3.PROMPTING_GUIDE_SCHEMA["parameters"]["properties"] == {}
assert _run(flux3._handle_prompting_guide({})) == flux3.FLUX3_PROMPTING_GUIDE
+321
View File
@@ -1,3 +1,4 @@
import asyncio
import os
import json
from datetime import datetime, timedelta, timezone
@@ -6,6 +7,8 @@ from pathlib import Path
import sys
from unittest.mock import patch
import pytest
MODULE_PATH = Path(__file__).resolve().parents[2] / "tools" / "managed_tool_gateway.py"
MODULE_SPEC = spec_from_file_location("managed_tool_gateway_test_module", MODULE_PATH)
assert MODULE_SPEC and MODULE_SPEC.loader
@@ -35,6 +38,324 @@ def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain()
assert result.managed_mode is True
def test_resolve_managed_tool_gateway_uses_vendor_specific_override():
with patch.dict(
os.environ,
{
"BROWSER_USE_GATEWAY_URL": "http://browser-use-gateway.localhost:3009/",
},
clear=False,
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
result = resolve_managed_tool_gateway(
"browser-use",
token_reader=lambda: "nous-token",
)
assert result is not None
assert result.gateway_origin == "http://browser-use-gateway.localhost:3009"
def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
with patch.dict(
os.environ,
{
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
},
clear=False,
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
result = resolve_managed_tool_gateway(
"firecrawl",
token_reader=lambda: None,
)
assert result is None
def test_resolve_managed_tool_gateway_is_disabled_without_subscription():
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False), \
patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=False):
result = resolve_managed_tool_gateway(
"firecrawl",
token_reader=lambda: "nous-token",
)
assert result is None
def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch):
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat()
(tmp_path / "auth.json").write_text(json.dumps({
"providers": {
"nous": {
"access_token": "stale-token",
"refresh_token": "refresh-token",
"expires_at": expires_at,
}
}
}))
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_access_token",
lambda refresh_skew_seconds=120: "fresh-token",
)
assert managed_tool_gateway.read_nous_access_token() == "fresh-token"
def test_managed_vendor_endpoints_pin_the_deployed_gateway_url():
"""The exact URL an agent may connect to is a code fact, not a lookup.
Exercises the real ``build_vendor_gateway_url`` (which once resolved a
typo'd pseudo-vendor to a non-existent host while every other test stubbed
it): default builder, real deployed host, pinned vendor path.
"""
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True), \
patch.dict(
os.environ,
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com", "TOOL_GATEWAY_SCHEME": "https"},
clear=False,
):
os.environ.pop("TOOL_GATEWAY_URL", None)
endpoints = managed_tool_gateway.managed_vendor_endpoints("bfl")
assert endpoints == {
"origin": "https://tool-gateway.nousresearch.com",
"base_url": "https://tool-gateway.nousresearch.com/api/bfl",
"upload_path": "/api/uploads/bfl",
}
def test_managed_vendor_endpoints_unreachable_when_managed_tools_disabled():
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=False):
assert managed_tool_gateway.managed_vendor_endpoints("bfl") is None
def test_managed_gateway_auth_headers_carry_the_bearer():
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
headers = managed_tool_gateway.managed_gateway_auth_headers(
"https://tool-gateway.example.com/api/bfl/generations",
gateway_builder=lambda vendor: f"https://{vendor}-gateway.example.com",
token_reader=lambda: "nous-token",
)
assert headers == {"Authorization": "Bearer nous-token"}
def test_managed_gateway_auth_headers_reflect_a_rotated_token():
# Read fresh on every call: a Nous access token expires within the hour,
# and a long session must not keep presenting a dead bearer.
tokens = iter(["first-token", "second-token"])
builder = lambda vendor: f"https://{vendor}-gateway.example.com"
url = "https://tool-gateway.example.com/api/bfl/generations"
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
first = managed_tool_gateway.managed_gateway_auth_headers(url, builder, lambda: next(tokens))
second = managed_tool_gateway.managed_gateway_auth_headers(url, builder, lambda: next(tokens))
assert first["Authorization"] == "Bearer first-token"
assert second["Authorization"] == "Bearer second-token"
def test_managed_gateway_auth_headers_refuse_a_url_off_the_gateway_origin():
# Gated on the URL, never a name: our bearer must never be handed to a
# host that merely looks managed.
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
assert managed_tool_gateway.managed_gateway_auth_headers(
"https://attacker.example/api/bfl/generations",
gateway_builder=lambda vendor: f"https://{vendor}-gateway.example.com",
token_reader=lambda: "nous-token",
) == {}
def test_managed_gateway_auth_headers_empty_without_a_token():
# Empty rather than raising, so a caller can say "sign in" instead of
# sending an unauthenticated request.
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
assert managed_tool_gateway.managed_gateway_auth_headers(
"https://tool-gateway.example.com/api/bfl/generations",
gateway_builder=lambda vendor: f"https://{vendor}-gateway.example.com",
token_reader=lambda: None,
) == {}
class TestManagedMediaUploader:
"""The presign -> PUT -> ``nous-upload:<token>`` protocol.
This is the only way a local image or video reaches a managed vendor, and
the pieces it gets right are not incidental: the presigned URL signs the
content type and byte length, so a PUT that disagrees with the presign is
rejected by storage rather than by us.
"""
GATEWAY = "https://tool-gateway.example.com"
BASE_URL = f"{GATEWAY}/api/bfl"
UPLOAD_PATH = "/api/uploads/bfl"
def _uploader(self, **kwargs):
return managed_tool_gateway.build_managed_media_uploader(
kwargs.pop("server_url", self.BASE_URL),
kwargs.pop("upload_path", self.UPLOAD_PATH),
gateway_builder=lambda vendor: self.GATEWAY,
token_reader=kwargs.pop("token_reader", lambda: "nous-token"),
)
@staticmethod
def _response(status_code=200, payload=None):
class _R:
def __init__(self):
self.status_code = status_code
def json(self):
if payload is None:
raise ValueError("no json")
return payload
return _R()
def _run(self, uploader, data=b"bytes", mime="image/png", presign=None, put=None):
"""Drive one upload with both HTTP legs stubbed; returns the calls made."""
import httpx
from tools import url_safety
calls = {"presign": [], "put": []}
presign = presign if presign is not None else self._response(
200, {"uploadUrl": "https://storage.example/put?sig=abc", "token": "tok-1"}
)
put = put if put is not None else self._response(200)
class _PresignClient:
def __init__(self, **_kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return False
async def post(self, url, headers=None, json=None):
calls["presign"].append({"url": url, "headers": headers, "json": json})
return presign
class _PutClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return False
async def put(self, url, content=None, headers=None):
calls["put"].append({"url": url, "content": content, "headers": headers})
return put
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True), \
patch.object(httpx, "AsyncClient", _PresignClient), \
patch.object(url_safety, "create_ssrf_safe_async_client", lambda **_kw: _PutClient()):
calls["result"] = asyncio.run(uploader(data, mime))
return calls
def test_presign_declares_the_exact_type_and_length_the_put_then_sends(self):
# Storage validates the PUT against what was signed, so a mismatch
# between these two is a rejection with no useful error.
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
uploader = self._uploader()
data = b"\x89PNG\r\n\x1a\n" + b"payload" * 100
calls = self._run(uploader, data=data, mime="image/png")
assert calls["presign"][0]["url"] == f"{self.GATEWAY}{self.UPLOAD_PATH}"
assert calls["presign"][0]["json"] == {
"contentType": "image/png",
"contentLength": len(data),
}
assert calls["presign"][0]["headers"]["Authorization"] == "Bearer nous-token"
assert calls["put"][0]["url"] == "https://storage.example/put?sig=abc"
assert calls["put"][0]["content"] == data
assert calls["put"][0]["headers"] == {"Content-Type": "image/png"}
assert calls["result"] == "nous-upload:tok-1"
def test_the_bytes_go_to_storage_and_never_through_the_gateway(self):
# The whole point of presigning is that the gateway's request-size
# ceiling does not apply to a 50MB clip.
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
uploader = self._uploader()
calls = self._run(uploader, data=b"v" * 4096, mime="video/mp4")
assert len(calls["presign"]) == 1 and len(calls["put"]) == 1
assert self.GATEWAY not in calls["put"][0]["url"]
assert calls["presign"][0]["json"]["contentType"] == "video/mp4"
def test_no_uploader_when_the_url_is_not_a_managed_gateway(self):
# Refusing to build is what makes the caller say "pass a URL instead"
# rather than forwarding a raw local path to a third party.
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
assert self._uploader(server_url="https://attacker.example/api/bfl") is None
@pytest.mark.parametrize("upload_path", [None, "", "api/uploads/bfl", 42])
def test_no_uploader_without_a_rooted_upload_path(self, upload_path):
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
assert self._uploader(upload_path=upload_path) is None
def test_a_missing_credential_fails_before_any_request(self):
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
uploader = self._uploader()
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True), \
patch.object(managed_tool_gateway, "managed_gateway_auth_headers", return_value={}):
with pytest.raises(RuntimeError, match="no Nous credential"):
asyncio.run(uploader(b"x", "image/png"))
def test_a_gateway_refusal_surfaces_its_own_message(self):
# Quota and size refusals carry guidance written for the model; a bare
# status code would throw that away.
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
uploader = self._uploader()
refusal = self._response(
413, {"error": {"message": "That file is 82MB; the limit for video is 50MB."}}
)
with pytest.raises(RuntimeError, match="the limit for video is 50MB"):
self._run(uploader, presign=refusal)
def test_an_unreadable_refusal_still_reports_the_status(self):
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
uploader = self._uploader()
with pytest.raises(RuntimeError, match="HTTP 502"):
self._run(uploader, presign=self._response(502, None))
@pytest.mark.parametrize(
"payload",
[
{},
{"uploadUrl": "https://storage.example/put"},
{"token": "tok-1"},
{"uploadUrl": "", "token": "tok-1"},
{"uploadUrl": "https://storage.example/put", "token": ""},
],
)
def test_a_malformed_presign_response_is_refused_rather_than_guessed(self, payload):
# Half a presign must not become a PUT to nowhere or an empty token
# that later reads as a valid reference.
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
uploader = self._uploader()
with pytest.raises(RuntimeError, match="malformed"):
self._run(uploader, presign=self._response(200, payload))
def test_a_storage_rejection_is_not_reported_as_a_successful_upload(self):
# A signature mismatch answers non-200 with an XML body; returning a
# token here would hand the vendor a reference to nothing.
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
uploader = self._uploader()
with pytest.raises(RuntimeError, match="storage refused the upload"):
self._run(uploader, put=self._response(403))
def test_is_managed_tool_gateway_ready_skips_refresh_for_expired_cached_token(tmp_path, monkeypatch):
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+161 -24
View File
@@ -39,6 +39,12 @@ class _MockState:
# when org_role_admin is False, convert org-HEAD CAS to 202 proposals.
self.org_feature = True
self.org_role_admin = True
self.org_role_present = True
# Org objects live in a SEPARATE scope from personal ones, mirroring
# production's `org:<org_id>` scope key. Keeping them in a distinct
# dict is what makes a personal-route read of org content 404 in tests
# exactly as it does against the real plane.
self.org_objects = {}
self.proposals = [] # [{n, to, base}]
@@ -79,50 +85,74 @@ def _make_handler(state: _MockState):
if part.startswith("prefix="):
from urllib.parse import unquote
prefix = unquote(part[len("prefix="):])
# FAITHFUL TO PRODUCTION: the personal refs route is scoped to
# the caller's own owner and does NOT serve org refs. Asking it
# for an `refs/org/...` prefix yields the caller's personal
# refs, not an error — the exact trap that let a broken client
# look healthy against an over-permissive mock.
refs = [
{"name": n, "hash": h}
for n, h in state.refs.items()
if n.startswith(prefix)
if n.startswith(prefix) and not n.startswith("refs/org/")
]
return self._json(200, {"refs": refs})
if path == "/v1/sync/org/refs":
if not state.org_feature:
return self._json(404, {"error": "unknown"})
if not state.org_role_present:
return self._json(403, {"error": "org_workflow_unavailable"})
refs = [
{"name": n, "hash": h}
for n, h in state.refs.items()
if n.startswith("refs/org/")
]
return self._json(200, {"refs": refs})
if path.startswith("/v1/sync/org/objects/"):
if not state.org_role_present:
return self._json(403, {"error": "org_workflow_unavailable"})
obj_hash = path[len("/v1/sync/org/objects/"):]
if obj_hash not in state.org_objects:
return self._json(404, {"error": "not_found"})
return self._send_object(*state.org_objects[obj_hash])
if path.startswith("/v1/sync/objects/"):
obj_hash = path[len("/v1/sync/objects/"):]
# Org-scoped objects are NOT readable through the personal
# route (production scopes it to the token owner).
if obj_hash not in state.objects:
return self._json(404, {"error": "not_found"})
kind, data = state.objects[obj_hash]
if kind == ssc.KIND_BLOB:
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("X-HSP-Object-Type", "blob")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("X-HSP-Object-Type", kind)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
return self._send_object(*state.objects[obj_hash])
self._json(404, {"error": "unknown"})
def _send_object(self, kind, data):
self.send_response(200)
self.send_header(
"Content-Type",
"application/octet-stream" if kind == ssc.KIND_BLOB else "application/json",
)
self.send_header("X-HSP-Object-Type", kind)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b""
path = self.path.split("?", 1)[0] # e.g. /v1/sync/objects?scope=org
if path == "/v1/sync/objects":
return self._handle_put_objects(raw)
return self._handle_put_objects(raw, org="scope=org" in self.path)
if path.startswith("/v1/sync/refs/"):
return self._handle_cas(raw)
self._json(404, {"error": "unknown"})
def _handle_put_objects(self, raw):
def _handle_put_objects(self, raw, org=False):
# multipart/form-data: parse parts (field=hash, filename=type,
# body=raw bytes). The server recomputes each hash and 422s on
# mismatch (contract §4.2).
@@ -163,10 +193,11 @@ def _make_handler(state: _MockState):
return self._json(422, {
"error": "hash_mismatch", "claimed": claimed_hash,
})
if claimed_hash in state.objects:
store = state.org_objects if org else state.objects
if claimed_hash in store:
already.append(claimed_hash)
else:
state.objects[claimed_hash] = (kind, body)
store[claimed_hash] = (kind, body)
accepted.append(claimed_hash)
return self._json(200, {"accepted": accepted, "already_present": already})
@@ -890,7 +921,9 @@ class TestOrgEndToEnd:
assert result.get("merged") is True
head = state.refs["refs/org/org-1/HEAD"]
assert head == result["head"]
commit = json.loads(state.objects[head][1])
# Org content lands in the ORG object scope, not the personal one.
assert head not in state.objects, "org commit must not be personal-scoped"
commit = json.loads(state.org_objects[head][1])
assert commit["parents"] == [] # first org commit
def test_member_propose_becomes_202_proposal(self, mock_server, synced_env):
@@ -931,8 +964,8 @@ class TestOrgEndToEnd:
member_ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"}
result = ssc.propose_skill("alpha", client, identity=member_ident)
# Walk the proposed commit's root: both skills present.
commit = json.loads(state.objects[result["commit"]][1])
root = json.loads(state.objects[commit["tree"]][1])
commit = json.loads(state.org_objects[result["commit"]][1])
root = json.loads(state.org_objects[commit["tree"]][1])
names = {e["name"] for e in root["entries"]}
assert "alpha" in names and "devops" in names
@@ -976,3 +1009,107 @@ class TestOrgEndToEnd:
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials",
lambda **kw: {"api_key": token})
assert ssc.maybe_pull_org_skills() is None
class TestOrgEndpointScoping:
"""Org reads must use the ORG endpoints, not the personal ones.
The personal refs route is scoped to the caller's own owner: asked for an
``refs/org/...`` prefix it returns the caller's PERSONAL refs rather than
erroring. A client reading org state through it therefore concludes "this
org has no content" and every subsequent CAS races a head it never saw —
which is exactly how a second propose used to die on a raw SyncConflict.
"""
def _admin(self, identity):
return {**identity, "org_id": "org-1", "org_role": "ADMIN"}
def test_org_head_is_not_visible_on_the_personal_route(
self, mock_server, synced_env
):
base, state = mock_server
home, skills, identity = synced_env
state.refs["refs/org/org-1/HEAD"] = "sha256:" + "a" * 64
client = ssc.SyncClient(base, identity["api_key"])
personal = client.get_refs("refs/org/org-1/")
assert personal == [], (
"the personal refs route must not serve org refs — if it does, "
"the mock is more permissive than production and will hide bugs"
)
org = client.get_refs("refs/org/org-1/", org_scope=True)
assert [r["name"] for r in org] == ["refs/org/org-1/HEAD"]
def test_second_propose_splices_onto_the_existing_org_head(
self, mock_server, synced_env
):
"""The regression: propose #1 works, propose #2 used to raise."""
base, state = mock_server
home, skills, identity = synced_env
admin = self._admin(identity)
client = ssc.SyncClient(base, identity["api_key"])
# `synced_env` already seeds alpha and beta (beta under devops/).
first = ssc.propose_skill("alpha", client, identity=admin)
assert first["ok"] is True
# Previously: base_head read as None -> CAS from None -> 409 ->
# SyncConflict escaped to the caller.
second = ssc.propose_skill("beta", client, identity=admin)
assert second["ok"] is True
# And the splice preserved the first skill rather than replacing it.
head = state.refs["refs/org/org-1/HEAD"]
commit = json.loads(state.org_objects[head][1])
root = json.loads(state.org_objects[commit["tree"]][1])
names = {e["name"] for e in root["entries"]}
# beta is seeded under the devops/ category, so it appears as that
# category tree at the root.
assert "alpha" in names and "devops" in names
assert commit["parents"], "second commit must descend from the first"
def test_pull_org_skills_sees_an_existing_org_head(
self, mock_server, synced_env
):
"""pull_org_skills used to report head=None for a populated org."""
base, state = mock_server
home, skills, identity = synced_env
admin = self._admin(identity)
client = ssc.SyncClient(base, identity["api_key"])
ssc.propose_skill("alpha", client, identity=admin)
result = ssc.pull_org_skills(client=client, identity=admin)
assert result["ok"] is True
assert result["head"] == state.refs["refs/org/org-1/HEAD"], (
"pull must resolve the real org HEAD, not None"
)
assert result["updated"], "the org's skill must materialize"
class TestEmptyActualConflict:
"""A 409 with an empty ``actual`` means the ref does not exist."""
def test_conflict_actual_empty_becomes_none(self):
c = ssc.SyncConflict("")
assert c.actual is None
assert "does not exist" in str(c)
def test_push_recovers_from_a_stale_local_head(self, mock_server, synced_env):
"""Switching sync planes leaves a foreign head in local state.
The CAS then fails against a ref that does not exist, and the server
answers 409 with an empty ``actual``. The client must redo the CAS as
a create rather than trying to fetch "" as a commit (which surfaced as
the bizarre `object not found`, with a doubled space).
"""
base, state = mock_server
home, skills, identity = synced_env
st = ssc.read_sync_state()
st["head"] = "sha256:" + "f" * 64 # head from another plane
ssc.write_sync_state(st)
client = ssc.SyncClient(base, identity["api_key"])
result = ssc.push_skills(client=client, identity=identity)
assert result["ok"] is True
assert result.get("recovered_stale_head") is True
assert state.refs[ssc.user_head_ref(identity["owner"])] == result["head"]
+14 -4
View File
@@ -1005,7 +1005,16 @@ class ShellFileOperations(FileOperations):
# - `chmod --reference` is GNU-only, so we read the octal mode with
# `stat` (GNU `-c%a` or BSD `-f%Lp`) and `chmod` it explicitly;
# silent best-effort — a perms-copy failure must not abort the
# write, the file still lands with default umask perms.
# write (the file then lands at mktemp's 0600, same as pre-fix).
# - brand-new targets get `chmod "=rw"` — the POSIX who-less
# symbolic form, which sets rw minus the process umask (e.g.
# 0644 under umask 022) instead of mktemp's hardcoded 0600
# (#70856). Deliberately NOT shell arithmetic on `$(umask)`:
# zsh (reachable via _find_bash's $SHELL fallback) parses
# leading-zero constants as decimal and silently computes a
# garbage mode, while `chmod "=rw"` is spec-identical in
# bash/dash/ash/zsh and degrades to 0600 (pre-fix behavior)
# if an exotic chmod rejects it.
# - `trap ... EXIT` guarantees the temp is removed on every error
# path (cat failure, mv failure, signal) but NOT after a
# successful mv (the temp no longer exists by then).
@@ -1022,11 +1031,12 @@ class ShellFileOperations(FileOperations):
'if [ -e "$t" ]; then '
'm="$(stat -c%a "$t" 2>/dev/null || stat -f%Lp "$t" 2>/dev/null || true)"; '
'[ -n "$m" ] && chmod "$m" "$tmp" 2>/dev/null || true; '
# new file: apply umask-computed default instead of mktemp's 0600
'else '
'u="$(umask)"; chmod $(printf '"'"'%04o'"'"' $((0666 & ~0$u))) "$tmp" 2>/dev/null || true; '
"fi; "
'cat > "$tmp"; '
# new file: umask-default perms instead of mktemp's 0600 (#70856).
# Runs AFTER cat so a write-masking umask can't EACCES the stream;
# quoted "=rw" so zsh doesn't =word-expand it.
'if [ ! -e "$t" ]; then chmod "=rw" "$tmp" 2>/dev/null || true; fi; '
'mv -f "$tmp" "$t"; '
"trap - EXIT"
)
File diff suppressed because it is too large Load Diff
+73 -21
View File
@@ -1,10 +1,16 @@
"""Single resolver for every vision_analyze image source -> bytes + mime.
"""Single resolver for every media source -> bytes + mime.
All source handling (data:/http(s)/file/local/container) funnels through
:func:`resolve_image_source` so size and magic-byte checks are enforced exactly
once. Returns raw bytes (not a path): the downstream step is base64 -> data URL
(RFC 2397) and provider base64 content blocks.
Images are the default and the historical purpose. Callers whose argument
takes video opt in via ``permitted=("video",)`` — the same confinement and
credential-guard pipeline applies, and only the type check at the end differs
(extension-table typing plus an mp4 magic sniff, rather than image magic
bytes). Every existing call site keeps the image-only default unchanged.
Security (terminal-backend confinement, GHSA-gpxw-6wxv-w3qq): under a non-local
terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2),
but vision read images host-side. This resolver enforces the same boundary:
@@ -86,18 +92,23 @@ class ResolvedImage:
_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*://")
async def resolve_image_source(src: str, ctx: ResolveContext) -> ResolvedImage:
async def resolve_image_source(
src: str,
ctx: ResolveContext,
*,
permitted: tuple = ("image",),
) -> ResolvedImage:
if not isinstance(src, str) or not src.strip():
raise SourceNotFound("image_url is required", src=str(src))
s = src.strip()
if s.startswith("data:"):
data, mime = _resolve_data_url(s)
return _finalize(data, mime, "data", s)
return _finalize(data, mime, "data", s, permitted)
if s.startswith(("http://", "https://")):
reason = _http_block_reason(s)
if reason:
raise SourceUnsafe(reason, src=s)
return _finalize(await _download_to_bytes(s), "", "http", s)
return _finalize(await _download_to_bytes(s), "", "http", s, permitted)
if _SCHEME_RE.match(s) and not s.lower().startswith("file://"):
raise UnsupportedScheme(
@@ -134,15 +145,15 @@ async def resolve_image_source(src: str, ctx: ResolveContext) -> ResolvedImage:
except ValueError as exc:
raise SourceUnsafe(str(exc), src=s, origin="file")
data = await asyncio.to_thread(host_target.read_bytes)
return _finalize(data, "", "file", s)
return _finalize(data, "", "file", s, permitted)
if _is_local_terminal_backend():
# Local backend: any path was host-readable, so a miss simply means
# the file doesn't exist — no sandbox to fall back to.
raise SourceNotFound(f"image file not found: '{p}'", src=s, origin="file")
raise SourceNotFound(f"media file not found: '{p}'", src=s, origin="file")
# Not a permitted host read (or the host file is absent) -> read the
# bytes inside the sandbox. Under a sandbox this reads the container's
# filesystem, never the host's.
return await _resolve_container_fallback(p, ctx, s)
return await _resolve_container_fallback(p, ctx, s, permitted)
def _resolve_data_url(s: str) -> tuple[bytes, str]:
@@ -270,7 +281,9 @@ def _get_active_env(task_id: Optional[str]):
return None
async def _resolve_container_fallback(p: Path, ctx: ResolveContext, src: str) -> ResolvedImage:
async def _resolve_container_fallback(
p: Path, ctx: ResolveContext, src: str, permitted: tuple = ("image",)
) -> ResolvedImage:
"""Read the image bytes inside the sandbox (fail-closed when none exists).
Reached when a host read is not permitted or the host file is absent. The
@@ -312,27 +325,66 @@ async def _resolve_container_fallback(p: Path, ctx: ResolveContext, src: str) ->
except Exception as exc:
raise NotAnImage(f"sandbox returned non-image data for '{p}': {exc}", src=src)
if len(data) > _MAX_INGEST_BYTES:
raise SourceTooLarge("image exceeds size limit", src=src, origin="container")
return _finalize(data, "", "container", src)
raise SourceTooLarge("media exceeds size limit", src=src, origin="container")
return _finalize(data, "", "container", src, permitted)
def _finalize(data: bytes, declared_mime: str, origin: str, src: str) -> ResolvedImage:
"""Intrinsic-correctness chokepoint: ingest byte cap + magic-byte sniff.
def _finalize(
data: bytes, declared_mime: str, origin: str, src: str, permitted: tuple = ("image",)
) -> ResolvedImage:
"""Intrinsic-correctness chokepoint: ingest byte cap + type check.
The cap here is the generous 50MB *ingest* budget, not the 20MB provider
payload cap — a 20-50MB image must survive this step so the call site can
resize it under the payload cap. See ``_MAX_INGEST_BYTES``.
Images are typed by magic bytes. Video (opt-in via ``permitted``) is typed
by the extension table plus an mp4 container sniff: extension typing is
sufficient because every downstream consumer re-validates — the upload
gateway signs the content type into its presigned URL and the vendor
rejects undecodable input — so a wrong guess is a clean rejection there
rather than a hole here.
"""
from tools.vision_tools import _detect_image_mime_type_from_bytes
if len(data) > _MAX_INGEST_BYTES:
raise SourceTooLarge("image exceeds size limit", src=src, origin=origin)
raise SourceTooLarge("media exceeds size limit", src=src, origin=origin)
sniffed = _detect_image_mime_type_from_bytes(data)
if sniffed is None:
if b"<svg" in data[:4096].lower():
# Pass SVG through — the vision call sites rasterize it to PNG
# via _normalize_to_supported_image before embedding (providers
# only ingest raster images).
return ResolvedImage(data=data, mime="image/svg+xml", origin=origin)
raise NotAnImage("source is not a recognized image", src=src, origin=origin)
return ResolvedImage(data=data, mime=sniffed, origin=origin)
if sniffed is not None:
if "image" not in permitted:
raise NotAnImage("source is an image, but this argument takes a video", src=src, origin=origin)
return ResolvedImage(data=data, mime=sniffed, origin=origin)
if "image" in permitted and b"<svg" in data[:4096].lower():
# Pass SVG through — the vision call sites rasterize it to PNG
# via _normalize_to_supported_image before embedding (providers
# only ingest raster images).
return ResolvedImage(data=data, mime="image/svg+xml", origin=origin)
if "video" in permitted:
video_mime = _detect_video_mime(data, src)
if video_mime is not None:
return ResolvedImage(data=data, mime=video_mime, origin=origin)
raise NotAnImage("source is not a recognized video (mp4 expected)", src=src, origin=origin)
raise NotAnImage("source is not a recognized image", src=src, origin=origin)
def _detect_video_mime(data: bytes, src: str) -> Optional[str]:
"""Video MIME from the extension table, else the mp4/mov container magic.
The magic fallback covers extensionless sources (data: URLs, URLs with
query strings): ISO base-media files carry ``ftyp`` at offset 4.
"""
from urllib.parse import urlsplit
from tools.vision_tools import _detect_video_mime_type
path_part = urlsplit(src).path if _SCHEME_RE.match(src) else src
by_extension = _detect_video_mime_type(Path(path_part))
if by_extension is not None:
return by_extension
if len(data) > 12 and data[4:8] == b"ftyp":
return "video/mp4"
return None
+230
View File
@@ -8,6 +8,7 @@ import os
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Callable, Optional
from urllib.parse import urlsplit
logger = logging.getLogger(__name__)
@@ -190,3 +191,232 @@ def is_managed_tool_gateway_ready(
gateway_builder=gateway_builder,
token_reader=token_reader or peek_nous_access_token,
) is not None
# ---------------------------------------------------------------------------
# Managed vendor endpoints
# ---------------------------------------------------------------------------
#
# Vendors the gateway serves on its own origin (rather than on a
# `{vendor}-gateway` host) are pinned HERE, in code, the same way every other
# managed vendor's gateway URL is pinned: adding one is a Hermes release, and
# the exact URL a user's agent may connect to is reviewable in this file. A
# runtime discovery catalog was tried and deliberately removed — a remote
# endpoint that can add tools to every entitled install is a bigger trust
# surface than a code diff.
#
# The gateway exposes a Nous-owned REST contract per vendor; it names the
# vendor but not the vendor's own API, so nothing here needs to know the
# upstream's endpoint or field names.
# Pseudo-vendor used only to resolve the shared tool-gateway origin via
# build_vendor_gateway_url (honors TOOL_GATEWAY_URL / TOOL_GATEWAY_DOMAIN).
_MANAGED_GATEWAY_VENDOR = "tool"
def managed_vendor_base_path(vendor: str) -> str:
"""Base path for a managed vendor's REST routes on the gateway host."""
return f"/api/{vendor}"
def managed_vendor_upload_path(vendor: str) -> str:
"""Media upload endpoint for a managed vendor, on the same host."""
return f"/api/uploads/{vendor}"
def managed_vendor_endpoints(
vendor: str,
gateway_builder: Optional[Callable[[str], str]] = None,
) -> Optional[dict]:
"""Absolute URLs for a managed vendor, or ``None`` when unreachable.
``None`` means managed Nous tools are disabled for this build, which is
what keeps a user who could never use the vendor from being shown its
tools.
"""
if not managed_nous_tools_enabled():
return None
builder = gateway_builder or build_vendor_gateway_url
origin = builder(_MANAGED_GATEWAY_VENDOR).rstrip("/")
return {
"origin": origin,
"base_url": f"{origin}{managed_vendor_base_path(vendor)}",
"upload_path": managed_vendor_upload_path(vendor),
}
def is_managed_nous_gateway_url(
url: object,
gateway_builder: Optional[Callable[[str], str]] = None,
) -> bool:
"""True when ``url`` is on the Nous tool-gateway origin this client builds.
Anything granting a URL extra trust — our bearer, reading files off disk to
upload — must gate on this rather than on a name, so an arbitrary URL can
never inherit that trust.
"""
if not isinstance(url, str) or not url.strip():
return False
builder = gateway_builder or build_vendor_gateway_url
try:
expected = urlsplit(builder(_MANAGED_GATEWAY_VENDOR))
actual = urlsplit(url.strip())
except ValueError:
return False
return bool(actual.scheme) and (actual.scheme, actual.netloc) == (expected.scheme, expected.netloc)
def managed_gateway_auth_headers(
url: object,
gateway_builder: Optional[Callable[[str], str]] = None,
token_reader: Optional[Callable[[], Optional[str]]] = None,
) -> dict:
"""Live auth headers for a managed gateway URL, or ``{}`` when not managed.
Read fresh on every call rather than cached: a Nous access token expires
within the hour, and a long session would otherwise keep presenting a dead
bearer. Returns ``{}`` rather than raising when no token is available, so a
caller can report "sign in" instead of sending an unauthenticated request.
"""
if not is_managed_nous_gateway_url(url, gateway_builder):
return {}
resolved_token_reader = token_reader or read_nous_access_token
try:
token = resolved_token_reader()
except Exception as exc: # pragma: no cover — defensive
logger.debug("Managed gateway token read failed for %s: %s", url, exc)
return {}
if not isinstance(token, str) or not token.strip():
return {}
return {"Authorization": f"Bearer {token.strip()}"}
# ---------------------------------------------------------------------------
# Managed media uploads
# ---------------------------------------------------------------------------
#
# Media arguments used to be inlined as base64, which capped a whole tool call
# at ~2MB of real bytes under the gateway's request ceiling and ruled out video
# entirely. Each pinned managed server carries an upload endpoint
# (`upload_path`); the bytes go straight to storage via a presigned URL, and
# the tool argument carries an opaque `nous-upload:<token>` reference instead.
#
# The protocol lives HERE rather than in a vendor tool module: the presign
# request shape, the response contract, and the `nous-upload:` scheme are Nous
# gateway specifics shared by every managed vendor that takes media.
_MEDIA_UPLOAD_PRESIGN_TIMEOUT_SECONDS = 15.0
# The PUT carries up to 50MB of video; a flat 60s would fail a legitimate
# clip on an ordinary residential uplink, so only the write phase is long.
_MEDIA_UPLOAD_PUT_READ_TIMEOUT_SECONDS = 60.0
_MEDIA_UPLOAD_PUT_WRITE_TIMEOUT_SECONDS = 300.0
def _describe_media_upload_refusal(response) -> str:
"""A model-actionable reason from a gateway refusal, or a generic one.
The gateway's 4xx bodies carry deliberate guidance (rate-limit waits, size
caps, "you could not submit anyway"), so surface `error.message` verbatim
rather than a bare status code.
"""
try:
payload = response.json()
message = payload.get("error", {}).get("message")
if isinstance(message, str) and message.strip():
return message.strip()
except Exception:
pass
return f"the gateway refused the upload (HTTP {response.status_code})"
def build_managed_media_uploader(
server_url: object,
upload_path: object,
gateway_builder: Optional[Callable[[str], str]] = None,
token_reader: Optional[Callable[[], Optional[str]]] = None,
) -> Optional[Callable]:
"""Async ``(data, mime) -> argument value`` uploader for one managed vendor.
Returns ``None`` when there is no usable upload endpoint (not a managed
Nous URL, or no ``upload_path``); callers then refuse local paths with a
clear message instead of silently forwarding them.
The three steps of the protocol:
1. POST ``origin + upload_path`` with the declared content type and exact
byte length, using the same live auth headers as the vendor calls.
The gateway answers with a presigned single-object PUT URL (short
expiry; type and length are signed into it) and an upload token.
2. PUT the bytes to that URL. This goes directly to storage — never
through the gateway — which is what removes the request-size ceiling.
3. Return ``nous-upload:<token>`` for the tool argument. The token is
bound to this Nous principal and is redeemable only through the
gateway, so it is inert anywhere else it might end up.
"""
if not is_managed_nous_gateway_url(server_url, gateway_builder):
return None
if not isinstance(upload_path, str) or not upload_path.startswith("/"):
return None
parts = urlsplit(str(server_url).strip())
origin = f"{parts.scheme}://{parts.netloc}"
presign_url = f"{origin}{upload_path}"
async def upload(data: bytes, mime: str) -> str:
import httpx
from tools.url_safety import create_ssrf_safe_async_client
headers = managed_gateway_auth_headers(server_url, gateway_builder, token_reader)
if not headers:
raise RuntimeError("no Nous credential is available for the upload")
# Two clients on purpose, split by whose address we are trusting.
#
# The presign POST goes to `presign_url`, which is entirely determined
# by the managed gateway origin (already validated by
# is_managed_nous_gateway_url) plus the pinned upload_path — the same
# first-party host the vendor calls go to freely. SSRF-guarding it
# protects against nothing and would reject a local gateway on
# 127.0.0.1, so it uses a plain client. The PUT target, by contrast, is
# a URL the gateway *returned*, so it keeps the SSRF-safe client as
# defense in depth (real presigned URLs are public R2, which it allows).
presign_timeout = httpx.Timeout(_MEDIA_UPLOAD_PRESIGN_TIMEOUT_SECONDS)
async with httpx.AsyncClient(timeout=presign_timeout) as client:
presign = await client.post(
presign_url,
headers=headers,
json={"contentType": mime, "contentLength": len(data)},
)
if presign.status_code != 200:
raise RuntimeError(_describe_media_upload_refusal(presign))
try:
payload = presign.json()
except Exception:
payload = None
upload_url = payload.get("uploadUrl") if isinstance(payload, dict) else None
token = payload.get("token") if isinstance(payload, dict) else None
if not (isinstance(upload_url, str) and upload_url and isinstance(token, str) and token):
raise RuntimeError("the gateway's upload response was malformed")
put_timeout = httpx.Timeout(
_MEDIA_UPLOAD_PRESIGN_TIMEOUT_SECONDS,
read=_MEDIA_UPLOAD_PUT_READ_TIMEOUT_SECONDS,
write=_MEDIA_UPLOAD_PUT_WRITE_TIMEOUT_SECONDS,
)
async with create_ssrf_safe_async_client(timeout=put_timeout) as client:
# The presigned URL signs the exact Content-Type and Content-Length,
# so this PUT must send precisely what was declared above.
put = await client.put(upload_url, content=data, headers={"Content-Type": mime})
if put.status_code != 200:
raise RuntimeError(f"storage refused the upload (HTTP {put.status_code})")
return f"nous-upload:{token}"
return upload
+150 -56
View File
@@ -744,12 +744,24 @@ class SyncError(RuntimeError):
class SyncConflict(RuntimeError):
"""CAS lost (409). ``actual`` is the current head to merge against
(contract §4.4). NOT a rejection -- pushed objects are already durable."""
"""CAS lost (409). NOT a rejection -- pushed objects are already durable.
def __init__(self, actual: str):
super().__init__(f"CAS conflict; actual head {actual}")
self.actual = actual
``actual`` is the current head to merge against, or **None** when the ref
does not exist server-side (the server reports that as an empty string).
None means "there is nothing to merge against, retry as a create" -- it
must never be fetched as an object.
"""
def __init__(self, actual: Optional[str]):
# Normalize here, not at the call site: the server sends "" for a
# non-existent ref, and every consumer must see that as None rather
# than an empty hash it might try to fetch.
self.actual: Optional[str] = actual or None
super().__init__(
f"CAS conflict; actual head {self.actual}"
if self.actual
else "CAS conflict; the ref does not exist yet"
)
class SyncClient:
@@ -777,22 +789,39 @@ class SyncClient:
raise SyncError(f"capabilities failed: {r.status_code}", status=r.status_code)
return r.json()
def get_refs(self, prefix: str) -> List[Dict[str, str]]:
"""GET /v1/sync/refs?prefix=... (sync contract)."""
r = self._session.get(
self._url("refs"), params={"prefix": prefix}, timeout=self.timeout
)
def get_refs(self, prefix: str, *, org_scope: bool = False) -> List[Dict[str, str]]:
"""GET /v1/sync/refs?prefix=... (or the org route when ``org_scope``).
Org refs live behind a SEPARATE endpoint, not behind a prefix filter on
the personal one: the personal route is hard-scoped to the token's own
owner, so asking it for ``refs/org/<id>/`` silently returns the
caller's personal refs instead of an error. Callers reading an org ref
MUST pass ``org_scope=True``.
"""
path = "org/refs" if org_scope else "refs"
params = None if org_scope else {"prefix": prefix}
r = self._session.get(self._url(path), params=params, timeout=self.timeout)
if r.status_code != 200:
raise SyncError(f"get_refs failed: {r.status_code}", status=r.status_code)
return (r.json() or {}).get("refs", [])
refs = (r.json() or {}).get("refs", [])
if org_scope:
# The org route returns the org's refs unfiltered; apply the
# prefix client-side so both modes have the same contract.
refs = [r_ for r_ in refs if str(r_.get("name", "")).startswith(prefix)]
return refs
def get_object(self, obj_hash: str) -> Tuple[str, bytes]:
"""GET /v1/sync/objects/:hash (sync contract). Returns (kind, bytes).
def get_object(self, obj_hash: str, *, org_scope: bool = False) -> Tuple[str, bytes]:
"""GET /v1/sync/objects/:hash (or the org route when ``org_scope``).
Kind comes from the object-type response header for tree/commit; a blob
(application/octet-stream) is returned as ``blob``.
Org objects are stored under the ``org:<org_id>`` scope key and are NOT
readable through the personal route (it scopes to the token's owner),
so walking an org commit requires ``org_scope=True`` on every hop.
"""
r = self._session.get(self._url(f"objects/{obj_hash}"), timeout=self.timeout)
path = f"org/objects/{obj_hash}" if org_scope else f"objects/{obj_hash}"
r = self._session.get(self._url(path), timeout=self.timeout)
if r.status_code == 404:
raise SyncError(f"object {obj_hash} not found", status=404)
if r.status_code == 403:
@@ -802,16 +831,20 @@ class SyncClient:
kind = r.headers.get("X-HSP-Object-Type") or KIND_BLOB
return kind, r.content
def get_commit_json(self, commit_hash: str) -> Dict[str, Any]:
def get_commit_json(
self, commit_hash: str, *, org_scope: bool = False
) -> Dict[str, Any]:
"""Fetch a commit object and parse its canonical JSON."""
kind, data = self.get_object(commit_hash)
kind, data = self.get_object(commit_hash, org_scope=org_scope)
if kind != KIND_COMMIT:
raise SyncError(f"{commit_hash} is {kind}, expected commit")
return json.loads(data.decode("utf-8"))
def get_tree_json(self, tree_hash: str) -> Dict[str, Any]:
def get_tree_json(
self, tree_hash: str, *, org_scope: bool = False
) -> Dict[str, Any]:
"""Fetch a tree object and parse its canonical JSON."""
kind, data = self.get_object(tree_hash)
kind, data = self.get_object(tree_hash, org_scope=org_scope)
if kind != KIND_TREE:
raise SyncError(f"{tree_hash} is {kind}, expected tree")
return json.loads(data.decode("utf-8"))
@@ -884,8 +917,10 @@ class SyncClient:
body = r.json() if r.content else {}
return {"proposal_pending": True, **body}
if r.status_code == 409:
actual = (r.json() or {}).get("actual", "")
raise SyncConflict(actual)
# An EMPTY `actual` means the ref does not exist server-side (the
# CAS lost against "no head"), NOT that there is a commit to merge
# against. Callers must not try to fetch it as an object.
raise SyncConflict((r.json() or {}).get("actual", ""))
if r.status_code == 403:
raise SyncError("forbidden (403) -- owner/permission", status=403)
if r.status_code != 200:
@@ -983,7 +1018,9 @@ def write_sync_state(data: Dict[str, Any]) -> None:
# Tree materialization (pull) -- write a tree back to a skill directory
# ---------------------------------------------------------------------------
def materialize_tree(client: SyncClient, tree_hash: str, dest: Path) -> None:
def materialize_tree(
client: SyncClient, tree_hash: str, dest: Path, *, org_scope: bool = False
) -> None:
"""Write the tree at *tree_hash* into *dest* (created if needed).
Blobs become files (with +x restored for ``exec`` mode), nested trees
@@ -991,7 +1028,7 @@ def materialize_tree(client: SyncClient, tree_hash: str, dest: Path) -> None:
caller decides removal semantics. Refuses path traversal via entry names.
"""
dest.mkdir(parents=True, exist_ok=True)
tree = client.get_tree_json(tree_hash)
tree = client.get_tree_json(tree_hash, org_scope=org_scope)
for entry in tree.get("entries", []):
name = entry.get("name", "")
if not name or "/" in name or name in (".", ".."):
@@ -1000,9 +1037,9 @@ def materialize_tree(client: SyncClient, tree_hash: str, dest: Path) -> None:
target = dest / name
kind = entry.get("kind")
if kind == KIND_TREE:
materialize_tree(client, entry["hash"], target)
materialize_tree(client, entry["hash"], target, org_scope=org_scope)
elif kind == KIND_BLOB:
_, data = client.get_object(entry["hash"])
_, data = client.get_object(entry["hash"], org_scope=org_scope)
target.write_bytes(data)
if entry.get("mode") == MODE_EXEC:
try:
@@ -1140,12 +1177,16 @@ def user_conflict_ref(owner: str, n: int) -> str:
return f"refs/user/{owner}/conflict/{n}"
def _root_tree_of_commit(client: "SyncClient", commit_hash: str) -> str:
def _root_tree_of_commit(
client: "SyncClient", commit_hash: str, *, org_scope: bool = False
) -> str:
"""Return the tree hash referenced by a commit."""
return client.get_commit_json(commit_hash)["tree"]
return client.get_commit_json(commit_hash, org_scope=org_scope)["tree"]
def _skill_trees_of_root(client: "SyncClient", root_tree_hash: str) -> Dict[str, str]:
def _skill_trees_of_root(
client: "SyncClient", root_tree_hash: str, *, org_scope: bool = False
) -> Dict[str, str]:
"""Flatten a profile-root tree into ``{posix_rel_path: skill_tree_hash}``.
A skill tree is any tree containing a ``SKILL.md`` blob entry. We walk the
@@ -1155,7 +1196,7 @@ def _skill_trees_of_root(client: "SyncClient", root_tree_hash: str) -> Dict[str,
result: Dict[str, str] = {}
def _walk(tree_hash: str, prefix: str) -> None:
tree = client.get_tree_json(tree_hash)
tree = client.get_tree_json(tree_hash, org_scope=org_scope)
entries = tree.get("entries", [])
has_skill_md = any(
e.get("name") == "SKILL.md" and e.get("kind") == KIND_BLOB for e in entries
@@ -1272,6 +1313,20 @@ def push_skills(
write_sync_state(manifest)
return {"ok": True, "head": commit_hash, "pushed_objects": len(objects)}
except SyncConflict as conflict:
if not conflict.actual:
# The ref does not exist server-side: our `from` was a stale head
# (commonly a local state file carried over from another sync
# plane). There is nothing to merge — redo the CAS as a create.
client.cas_ref(ref, None, commit_hash)
manifest["head"] = commit_hash
manifest["root"] = root_hash
write_sync_state(manifest)
return {
"ok": True,
"head": commit_hash,
"pushed_objects": len(objects),
"recovered_stale_head": True,
}
return _resolve_push_conflict(
client, identity, conflict.actual, root_hash, commit_hash,
objects, skill_names, message, base_head,
@@ -1718,6 +1773,26 @@ def org_sync_available() -> bool:
return False
# How many times a propose will re-splice onto a moved org HEAD before giving
# up. Small: contention means other members are actively proposing, and an
# unbounded loop would spin.
_ORG_CAS_MAX_ATTEMPTS = 5
def _read_org_head(client: "SyncClient", org_id: str) -> Optional[str]:
"""Current ``refs/org/<org_id>/HEAD``, or None if the org has no content.
Reads through the ORG endpoint. The personal refs route is scoped to the
caller's own owner and answers an ``refs/org/...`` prefix with the caller's
PERSONAL refs, so a personal-route read here silently reports "no org head"
and every subsequent CAS races against a head it never saw.
"""
refs = client.get_refs(f"refs/org/{org_id}/", org_scope=True)
return next(
(r["hash"] for r in refs if r.get("name") == org_head_ref(org_id)), None
)
def org_head_ref(org_id: str) -> str:
return f"refs/org/{org_id}/HEAD"
@@ -1755,10 +1830,7 @@ def pull_org_skills(
if "org" not in (caps.get("features") or []):
raise SyncInertError("this server does not support org-shared skills")
refs = client.get_refs(f"refs/org/{org_id}/")
head = next(
(r["hash"] for r in refs if r.get("name") == org_head_ref(org_id)), None
)
head = _read_org_head(client, org_id)
# TOKEN-GATED resolution marker (agent/skill_utils.read_active_org_id):
# written HERE because this function only runs after resolve_org_identity
# verified the token's org_id + org_role. Discovery scans only the marked
@@ -1768,9 +1840,9 @@ def pull_org_skills(
if not head:
return {"ok": True, "org_id": org_id, "head": None, "updated": []}
head_commit = client.get_commit_json(head)
head_commit = client.get_commit_json(head, org_scope=True)
root_tree = head_commit["tree"]
skill_trees = _skill_trees_of_root(client, root_tree)
skill_trees = _skill_trees_of_root(client, root_tree, org_scope=True)
dest_root = _org_dir() / org_id
updated: List[str] = []
@@ -1797,7 +1869,7 @@ def pull_org_skills(
shutil.rmtree(dest)
dest.mkdir(parents=True, exist_ok=True)
materialize_tree(client, tree_hash, dest)
materialize_tree(client, tree_hash, dest, org_scope=True)
baseline[rel_path] = {
"fingerprint": _skill_dir_fingerprint(dest),
"tree": tree_hash,
@@ -1991,29 +2063,51 @@ def propose_skill(
# Base = current org HEAD (None for the org's first content). The proposed
# root is HEAD's skill-tree map with this one skill spliced in — proposals
# are per-skill deltas, never a wholesale replace of the org set.
refs = client.get_refs(f"refs/org/{org_id}/")
base_head = next(
(r["hash"] for r in refs if r.get("name") == org_head_ref(org_id)), None
)
if base_head:
base_root = _root_tree_of_commit(client, base_head)
skill_map = _skill_trees_of_root(client, base_root)
else:
skill_map = {}
skill_map[str(rel)] = skill_tree
#
# Wrapped in a bounded retry: between reading HEAD and the CAS, another
# member's propose (or an admin merge) can advance it. The server answers
# 409 with the new head; we re-splice this one skill onto THAT head and try
# again rather than surfacing a raw conflict. Re-splicing (not replaying
# the old root) is what keeps the other member's skill from being dropped.
attempts = 0
while True:
attempts += 1
base_head = _read_org_head(client, org_id)
if base_head:
base_root = _root_tree_of_commit(client, base_head, org_scope=True)
skill_map = _skill_trees_of_root(client, base_root, org_scope=True)
else:
skill_map = {}
skill_map[str(rel)] = skill_tree
root_hash = _assemble_root_from_skill_trees(client, skill_map, objects)
commit_hash = build_commit(
root_hash,
[base_head] if base_head else [],
owner=identity["owner"],
device=stable_device_id(),
message=message or f"propose {skill_name}",
objects=objects,
)
root_hash = _assemble_root_from_skill_trees(client, skill_map, objects)
commit_hash = build_commit(
root_hash,
[base_head] if base_head else [],
owner=identity["owner"],
device=stable_device_id(),
message=message or f"propose {skill_name}",
objects=objects,
)
client.put_objects(objects.objects, org_scope=True)
result = client.cas_ref(org_head_ref(org_id), base_head, commit_hash)
client.put_objects(objects.objects, org_scope=True)
try:
result = client.cas_ref(org_head_ref(org_id), base_head, commit_hash)
break
except SyncConflict as conflict:
if attempts >= _ORG_CAS_MAX_ATTEMPTS:
raise SyncError(
"the organisation's skills changed while this was being "
f"proposed, and {attempts} attempts to catch up all lost "
"the race — run the command again",
status=409,
) from conflict
logger.debug(
"propose_skill: org HEAD moved (actual=%r), re-splicing (attempt %d)",
conflict.actual,
attempts,
)
continue
if result.get("proposal_pending"):
return {
+27
View File
@@ -42,6 +42,10 @@ _HERMES_CORE_TOOLS = [
"read_file", "write_file", "patch", "search_files",
# Vision + image generation
"vision_analyze", "image_generate",
# BFL FLUX 3 video generation
"bfl_flux3_text_to_video", "bfl_flux3_image_to_video",
"bfl_flux3_keyframes_to_video", "bfl_flux3_video_continuation",
"bfl_flux3_get_result", "bfl_flux3_prompting_guide",
# Skills
"skills_list", "skill_view", "skill_manage",
# Browser automation
@@ -151,6 +155,25 @@ TOOLSETS = {
"includes": []
},
"bfl": {
"description": (
"Black Forest Labs FLUX 3 video generation through the Nous tool "
"gateway: per-mode submit tools (text, image, keyframes, "
"continuation), a poll tool, and a prompting guide. Generations "
"take minutes, so submit returns a job id and the model polls for "
"the result."
),
"tools": [
"bfl_flux3_text_to_video",
"bfl_flux3_image_to_video",
"bfl_flux3_keyframes_to_video",
"bfl_flux3_video_continuation",
"bfl_flux3_get_result",
"bfl_flux3_prompting_guide",
],
"includes": []
},
"computer_use": {
"description": (
"Background desktop control via cua-driver (macOS/Windows/Linux) — "
@@ -410,6 +433,10 @@ TOOLSETS = {
"read_file", "write_file", "patch", "search_files",
# Vision + image generation
"vision_analyze", "image_generate",
# BFL FLUX 3 video generation
"bfl_flux3_text_to_video", "bfl_flux3_image_to_video",
"bfl_flux3_keyframes_to_video", "bfl_flux3_video_continuation",
"bfl_flux3_get_result", "bfl_flux3_prompting_guide",
# Skills
"skills_list", "skill_view", "skill_manage",
# Browser automation
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import type { ComposerToken } from '../app/interfaces.js'
import { droppedTokens, expandTokens, imageToken, nextImageIndex } from '../domain/attachments.js'
const paste = (label: string, text: string): ComposerToken => ({ kind: 'paste', label, text })
const image = (index: number, path = `/tmp/img${index}.png`): ComposerToken => ({
index,
kind: 'image',
label: imageToken(index),
path
})
describe('expandTokens (what the agent actually receives)', () => {
it('replaces a collapsed paste label with its full content', () => {
const label = '[[ hello.. [3 lines] .. world ]]'
const expand = expandTokens([paste(label, 'hello\nfoo\nworld')])
expect(expand(`here: ${label} done`)).toBe('here: hello\nfoo\nworld done')
})
it('is a no-op for already-expanded / token-free text (recall round-trip)', () => {
const expanded = 'hello\nfoo\nworld'
expect(expandTokens([])(expanded)).toBe(expanded)
})
it('expands repeated identical labels in submission order', () => {
const label = '[[ x [1 lines] ]]'
const expand = expandTokens([paste(label, 'first'), paste(label, 'second')])
expect(expand(`${label} then ${label}`)).toBe('first then second')
})
it('leaves an unmatched label intact', () => {
const label = '[[ orphan [2 lines] ]]'
expect(expandTokens([])(label)).toBe(label)
})
it('drops an image token from the text — the gateway already holds the file', () => {
const expand = expandTokens([image(1)])
expect(expand(`what is in ${imageToken(1)}`)).toBe('what is in')
})
it('leaves no double space where an image token sat mid-sentence', () => {
const expand = expandTokens([image(1)])
expect(expand(`before ${imageToken(1)} after`)).toBe('before after')
})
it('resolves an image-only message to empty text', () => {
expect(expandTokens([image(1)])(imageToken(1))).toBe('')
})
it('resolves pastes and images in one pass', () => {
const label = '[[ log.. [9 lines] ]]'
const expand = expandTokens([paste(label, 'stack\ntrace'), image(2)])
expect(expand(`${label} and ${imageToken(2)}`)).toBe('stack\ntrace and')
})
})
describe('nextImageIndex (user-facing numbering)', () => {
it('starts at 1', () => {
expect(nextImageIndex([])).toBe(1)
})
it('counts past existing images', () => {
expect(nextImageIndex([image(1), image(2)])).toBe(3)
})
it('ignores paste tokens', () => {
expect(nextImageIndex([paste('[[ x ]]', 'y')])).toBe(1)
})
it('does not reuse an index after an earlier image is deleted', () => {
// [[ Image 1 ]] was erased; the next attach must not become Image 1 again
// or expandTokens would resolve two different files to one label.
expect(nextImageIndex([image(2)])).toBe(3)
})
})
describe('droppedTokens (deleting the token unattaches the thing)', () => {
it('reports an image whose token was erased from the text', () => {
expect(droppedTokens([image(1)], 'just text now')).toEqual([image(1)])
})
it('reports nothing while the token is still present', () => {
expect(droppedTokens([image(1)], `look at ${imageToken(1)}`)).toEqual([])
})
it('keeps a surviving token when a sibling is erased', () => {
expect(droppedTokens([image(1), image(2)], imageToken(2))).toEqual([image(1)])
})
})
+23 -11
View File
@@ -7,7 +7,6 @@ import type {
BillingCardInfo,
BillingMutationResponse,
BillingStateResponse,
ImageAttachResponse,
SessionCloseResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
@@ -364,6 +363,10 @@ export interface ComposerPasteResult {
export type MaybePromise<T> = Promise<T> | T
export interface ComposerActions {
/** Pull an image off the system clipboard in as a token. */
attachClipboardImage: () => void
/** Attach an image by path in as a token. */
attachImagePath: (path: string) => void
clearIn: () => void
dequeue: () => string | undefined
enqueue: (text: string) => void
@@ -373,12 +376,14 @@ export interface ComposerActions {
removeQueue: (index: number) => void
replaceQueue: (index: number, text: string) => void
setCompIdx: StateSetter<number>
setComposerTokens: StateSetter<ComposerToken[]>
setHistoryIdx: StateSetter<null | number>
setInput: StateSetter<string>
setInputBuf: StateSetter<string[]>
setPasteSnips: StateSetter<PasteSnippet[]>
setQueueEdit: (index: null | number) => void
syncQueue: () => void
/** Reconcile attached payloads against tokens still present in the text. */
syncTokens: (value: string) => void
}
export interface ComposerRefs {
@@ -387,6 +392,7 @@ export interface ComposerRefs {
queueEditRef: MutableRefObject<null | number>
queueRef: MutableRefObject<string[]>
submitRef: MutableRefObject<(value: string) => void>
tokensRef: MutableRefObject<ComposerToken[]>
}
export interface ComposerState {
@@ -396,16 +402,15 @@ export interface ComposerState {
historyIdx: null | number
input: string
inputBuf: string[]
pasteSnips: PasteSnippet[]
queueEditIdx: null | number
queuedDisplay: string[]
tokens: ComposerToken[]
}
export interface UseComposerStateOptions {
gw: GatewayClient
onClipboardPaste: (quiet?: boolean) => Promise<void> | void
onImageAttached?: (info: ImageAttachResponse) => void
submitRef: MutableRefObject<(value: string) => void>
sys: (text: string) => void
}
export interface UseComposerStateResult {
@@ -495,10 +500,11 @@ export interface GatewayEventHandlerContext {
export interface SlashHandlerContext {
composer: {
attachClipboardImage: () => void
attachImagePath: (path: string) => void
enqueue: (text: string) => void
hasSelection: boolean
openEditor: () => Promise<void>
paste: (quiet?: boolean) => void
queueRef: MutableRefObject<string[]>
selection: SelectionApi
setInput: StateSetter<string>
@@ -618,8 +624,14 @@ export interface AppOverlaysProps {
pagerPageSize: number
}
export interface PasteSnippet {
label: string
path?: string
text: string
}
/**
* A `[[ … ]]` token sitting in the composer text, plus the payload it stands
* for. `paste` tokens expand back into their text at submit; `image` tokens
* are a receipt for a file the gateway already holds, and expand to nothing.
*
* `index` is the user-facing number in `[[ Image 2 ]]`; `path` is the gateway
* path, used to detach the image when its token is deleted.
*/
export type ComposerToken =
| { index: number; kind: 'image'; label: string; path: string; text?: undefined }
| { index?: undefined; kind: 'paste'; label: string; path?: string; text: string }
+1 -1
View File
@@ -439,7 +439,7 @@ export const coreCommands: SlashCommand[] = [
{
help: 'attach clipboard image',
name: 'paste',
run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.paste())
run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.attachClipboardImage())
},
{
+2 -13
View File
@@ -1,11 +1,10 @@
import { usageBarsText } from '../../../components/overlayPrimitives.js'
import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
import { introMsg, toTranscriptMessages } from '../../../domain/messages.js'
import { sessionScopedModelArg, TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js'
import type {
BackgroundStartResponse,
ConfigGetValueResponse,
ConfigSetResponse,
ImageAttachResponse,
SessionBranchResponse,
SessionCompressResponse,
SessionUsageResponse,
@@ -192,17 +191,7 @@ export const sessionCommands: SlashCommand[] = [
{
help: 'attach an image',
name: 'image',
run: (arg, ctx) => {
ctx.gateway.rpc<ImageAttachResponse>('image.attach', { path: arg, session_id: ctx.sid }).then(
ctx.guarded<ImageAttachResponse>(r => {
ctx.transcript.sys(attachedImageNotice(r))
if (r.remainder) {
ctx.composer.setInput(r.remainder)
}
})
)
}
run: (arg, ctx) => ctx.composer.attachImagePath(arg)
},
{
+5 -7
View File
@@ -1,4 +1,3 @@
import { attachedImageNotice } from '../domain/messages.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { InputDetectDropResponse, PromptSubmitResponse } from '../gatewayTypes.js'
import type { Msg } from '../types.js'
@@ -109,6 +108,11 @@ export function submitPrompt(
// Always ask the backend whether this looks like a file drop. The backend's
// _detect_file_drop handles paths with spaces, quotes, Windows drive letters,
// and escaped characters correctly.
//
// No notice is emitted for a match: an image dropped into the composer already
// shows as an `[[ Image N ]]` token, and a matched non-image path is rewritten
// in place. Announcing it a second time above the status bar was the old
// out-of-band attachment UI.
deps.gw
.request<InputDetectDropResponse>('input.detect_drop', { session_id: sid, text })
.then(r => {
@@ -116,12 +120,6 @@ export function submitPrompt(
return startSubmit(text, deps.expand(text), showUserMessage)
}
if (r.is_image) {
turnController.pushActivity(attachedImageNotice(r))
} else {
turnController.pushActivity(`detected file: ${r.name}`)
}
startSubmit(r.text || text, deps.expand(r.text || text), showUserMessage)
})
.catch(() => startSubmit(text, deps.expand(text), showUserMessage))
+194 -58
View File
@@ -5,10 +5,11 @@ import { join } from 'node:path'
import { useStdin, withInkSuspended } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import type { PasteEvent } from '../components/textInput.js'
import type { ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js'
import { droppedTokens, imageToken, nextImageIndex } from '../domain/attachments.js'
import type { ClipboardPasteResponse, ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js'
import { useCompletion } from '../hooks/useCompletion.js'
import { useInputHistory } from '../hooks/useInputHistory.js'
import { useQueue } from '../hooks/useQueue.js'
@@ -18,30 +19,37 @@ import { readOsc52Clipboard } from '../lib/osc52.js'
import { isRemoteShellSession } from '../lib/terminalSetup.js'
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
import type { MaybePromise, PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js'
import type {
ComposerPasteResult,
ComposerToken,
MaybePromise,
StateSetter,
UseComposerStateOptions,
UseComposerStateResult
} from './interfaces.js'
import { $isBlocked } from './overlayStore.js'
import { getUiState } from './uiStore.js'
const PASTE_SNIP_MAX_COUNT = 32
const PASTE_SNIP_MAX_TOTAL_BYTES = 4 * 1024 * 1024
const TOKEN_MAX_COUNT = 32
const TOKEN_MAX_TOTAL_BYTES = 4 * 1024 * 1024
const trimSnips = (snips: PasteSnippet[]): PasteSnippet[] => {
const trimTokens = (tokens: ComposerToken[]): ComposerToken[] => {
let total = 0
const out: PasteSnippet[] = []
const out: ComposerToken[] = []
for (let i = snips.length - 1; i >= 0; i--) {
const snip = snips[i]!
const size = snip.text.length
for (let i = tokens.length - 1; i >= 0; i--) {
const token = tokens[i]!
const size = token.text?.length ?? 0
if (out.length >= PASTE_SNIP_MAX_COUNT || total + size > PASTE_SNIP_MAX_TOTAL_BYTES) {
if (out.length >= TOKEN_MAX_COUNT || total + size > TOKEN_MAX_TOTAL_BYTES) {
break
}
total += size
out.unshift(snip)
out.unshift(token)
}
return out.length === snips.length ? snips : out
return out.length === tokens.length ? tokens : out
}
/** Insert text at the cursor position, adding spacing to separate from adjacent non-whitespace. */
@@ -97,15 +105,26 @@ export function looksLikeDroppedPath(text: string): boolean {
return false
}
export function useComposerState({
gw,
onClipboardPaste,
onImageAttached,
submitRef
}: UseComposerStateOptions): UseComposerStateResult {
const [input, setInput] = useState('')
export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions): UseComposerStateResult {
const [input, setInputState] = useState('')
const [inputBuf, setInputBuf] = useState<string[]>([])
const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([])
const [tokens, setTokens] = useState<ComposerToken[]>([])
// Tokens and the input line are read from keystroke handlers that run several
// times before React re-renders, so the refs — not the state — are the source
// of truth for "what is in the composer right now".
const inputRef = useRef('')
const tokensRef = useRef<ComposerToken[]>([])
const setInput = useCallback<StateSetter<string>>(next => {
inputRef.current = typeof next === 'function' ? next(inputRef.current) : next
setInputState(inputRef.current)
}, [])
const setComposerTokens = useCallback<StateSetter<ComposerToken[]>>(next => {
tokensRef.current = typeof next === 'function' ? next(tokensRef.current) : next
setTokens(tokensRef.current)
}, [])
const isBlocked = useStore($isBlocked)
const { querier } = useStdin() as { querier: Parameters<typeof readOsc52Clipboard>[0] }
@@ -128,27 +147,95 @@ export function useComposerState({
const clearIn = useCallback(() => {
setInput('')
setInputBuf([])
setPasteSnips([])
setComposerTokens([])
setQueueEdit(null)
setHistoryIdx(null)
historyDraftRef.current = ''
}, [historyDraftRef, setQueueEdit, setHistoryIdx])
}, [historyDraftRef, setComposerTokens, setHistoryIdx, setInput, setQueueEdit])
/**
* Deleting an `[[ Image N ]]` token IS how you unattach the image — there is
* no separate control. Reconcile on every edit so the gateway's
* `attached_images` never outlives the token the user just erased, which is
* what used to make a stale image ride along on the next unrelated turn.
*/
const syncTokens = useCallback(
(value: string) => {
const gone = droppedTokens(tokensRef.current, value)
if (!gone.length) {
return
}
for (const token of gone) {
if (token.kind === 'image') {
void gw.request('image.detach', { path: token.path, session_id: getUiState().sid }).catch(() => {})
}
}
setComposerTokens(prev => prev.filter(token => !gone.includes(token)))
},
[gw, setComposerTokens]
)
/**
* Attach an image the gateway already resolved: a token at the cursor,
* followed by whatever non-path text came along with it (a drag-drop paste
* of `~/shot.png look at this` keeps the caption).
*/
const attachImageToken = useCallback(
(attached: ImageAttachResponse & { path?: string }, value: string, cursor: number): ComposerPasteResult => {
const index = nextImageIndex(tokensRef.current)
const label = imageToken(index)
setComposerTokens(prev => trimTokens([...prev, { index, kind: 'image', label, path: attached.path ?? '' }]))
const withToken = insertAtCursor(value, cursor, label)
const remainder = attached.remainder?.trim() ?? ''
return remainder ? insertAtCursor(withToken.value, withToken.cursor, remainder) : withToken
},
[setComposerTokens]
)
/**
* Pull an image off the system clipboard into the composer as a token.
*
* `quiet` is the empty-bracketed-paste probe: the terminal delivers an image
* paste as zero text, so we speculatively ask the gateway and stay silent if
* there was nothing there. An explicit `/paste` reports the miss.
*/
const pasteClipboardImage = useCallback(
async (value: string, cursor: number, quiet: boolean): Promise<ComposerPasteResult | null> => {
const sid = getUiState().sid
if (!sid) {
return null
}
const r = await gw
.request<ClipboardPasteResponse & { path?: string }>('clipboard.paste', { session_id: sid })
.catch(() => null)
if (r?.attached) {
return attachImageToken(r, value, cursor)
}
if (!quiet) {
sys(r?.message || 'No image found in clipboard')
}
return null
},
[attachImageToken, gw, sys]
)
const handleResolvedPaste = useCallback(
async ({
bracketed,
cursor,
text,
value
}: Omit<PasteEvent, 'hotkey'>): Promise<null | { cursor: number; value: string }> => {
async ({ bracketed, cursor, text, value }: Omit<PasteEvent, 'hotkey'>): Promise<ComposerPasteResult | null> => {
const cleanedText = stripTrailingPasteNewlines(text)
if (!cleanedText || !/[^\n]/.test(cleanedText)) {
if (bracketed) {
void onClipboardPaste(true)
}
return null
return bracketed ? pasteClipboardImage(value, cursor, true) : null
}
const sid = getUiState().sid
@@ -161,14 +248,11 @@ export function useComposerState({
})
if (attached?.name) {
onImageAttached?.(attached)
const remainder = attached.remainder?.trim() ?? ''
if (!remainder) {
return { cursor, value }
}
return insertAtCursor(value, cursor, remainder)
// Drop an `[[ Image N ]]` token where the path was typed. The old
// path printed a notice above the status bar and left the composer
// untouched, so the only trace of the attachment lived outside the
// input the user was editing.
return attachImageToken(attached, value, cursor)
}
} catch {
// Fall back to generic file-drop detection below.
@@ -204,7 +288,7 @@ export function useComposerState({
const label = pasteTokenLabel(cleanedText, lineCount)
const inserted = insertAtCursor(value, cursor, label)
setPasteSnips(prev => trimSnips([...prev, { label, text: cleanedText }]))
setComposerTokens(prev => trimTokens([...prev, { kind: 'paste', label, text: cleanedText }]))
void gw
.request<{ path?: string }>('paste.collapse', { text: cleanedText })
@@ -215,13 +299,13 @@ export function useComposerState({
return
}
setPasteSnips(prev => prev.map(s => (s.label === label ? { ...s, path } : s)))
setComposerTokens(prev => prev.map(t => (t.label === label ? { ...t, path } : t)))
})
.catch(() => {})
return inserted
},
[gw, onClipboardPaste, onImageAttached]
[attachImageToken, gw, pasteClipboardImage, setComposerTokens]
)
const handleTextPaste = useCallback(
@@ -231,7 +315,7 @@ export function useComposerState({
hotkey,
text,
value
}: PasteEvent): MaybePromise<null | { cursor: number; value: string }> => {
}: PasteEvent): MaybePromise<ComposerPasteResult | null> => {
if (hotkey) {
const preferOsc52 = isRemoteShellSession(process.env)
@@ -256,15 +340,58 @@ export function useComposerState({
return handleResolvedPaste({ bracketed: false, cursor, text: preferredText, value })
}
void onClipboardPaste(false)
return null
// No text on the clipboard — an image paste looks exactly like this.
return pasteClipboardImage(value, cursor, false)
})
}
return handleResolvedPaste({ bracketed: !!bracketed, cursor, text, value })
},
[handleResolvedPaste, onClipboardPaste, querier]
[handleResolvedPaste, pasteClipboardImage, querier]
)
/**
* `/paste` and `/image` attach without a cursor of their own — the token
* lands at the end of whatever is currently typed.
*/
const appendAttachment = useCallback(
(attach: (value: string, cursor: number) => Promise<ComposerPasteResult | null>) => {
const current = inputRef.current
void attach(current, current.length).then(next => {
if (next) {
setInput(next.value)
}
})
},
[setInput]
)
const attachClipboardImage = useCallback(
() => appendAttachment((value, cursor) => pasteClipboardImage(value, cursor, false)),
[appendAttachment, pasteClipboardImage]
)
const attachImagePath = useCallback(
(path: string) =>
appendAttachment(async (value, cursor) => {
const sid = getUiState().sid
if (!sid || !path.trim()) {
return null
}
const attached = await gw
.request<ImageAttachResponse & { path?: string }>('image.attach', { path, session_id: sid })
.catch((e: Error) => {
sys(`error: ${e.message}`)
return null
})
return attached?.name ? attachImageToken(attached, value, cursor) : null
}),
[appendAttachment, attachImageToken, gw, sys]
)
const openEditor = useCallback(async () => {
@@ -297,10 +424,12 @@ export function useComposerState({
} finally {
rmSync(dir, { force: true, recursive: true })
}
}, [input, inputBuf, submitRef])
}, [input, inputBuf, setInput, submitRef])
const actions = useMemo(
() => ({
attachClipboardImage,
attachImagePath,
clearIn,
dequeue,
enqueue,
@@ -310,14 +439,17 @@ export function useComposerState({
removeQueue: removeQ,
replaceQueue: replaceQ,
setCompIdx,
setComposerTokens,
setHistoryIdx,
setInput,
setInputBuf,
setPasteSnips,
setQueueEdit,
syncQueue
syncQueue,
syncTokens
}),
[
attachClipboardImage,
attachImagePath,
clearIn,
dequeue,
enqueue,
@@ -327,9 +459,12 @@ export function useComposerState({
removeQ,
replaceQ,
setCompIdx,
setComposerTokens,
setHistoryIdx,
setInput,
setQueueEdit,
syncQueue
syncQueue,
syncTokens
]
)
@@ -339,7 +474,8 @@ export function useComposerState({
historyRef,
queueEditRef,
queueRef,
submitRef
submitRef,
tokensRef
}),
[historyDraftRef, historyRef, queueEditRef, queueRef, submitRef]
)
@@ -352,11 +488,11 @@ export function useComposerState({
historyIdx,
input,
inputBuf,
pasteSnips,
queueEditIdx,
queuedDisplay
queuedDisplay,
tokens
}),
[compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay]
[compIdx, compReplace, completions, historyIdx, input, inputBuf, queueEditIdx, queuedDisplay, tokens]
)
return {
+27 -34
View File
@@ -16,13 +16,11 @@ import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
import { RESIZE_COALESCE_MS } from '../config/timing.js'
import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js'
import { SECTION_NAMES, sectionMode } from '../domain/details.js'
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
import { composeTabTitle, fmtProjectCwdBranch, shortCwd } from '../domain/paths.js'
import { sessionScopedModelArg } from '../domain/slash.js'
import { type GatewayClient } from '../gatewayClient.js'
import type {
ClarifyRespondResponse,
ClipboardPasteResponse,
ConfigSetResponse,
GatewayEvent,
SessionActiveListResponse,
@@ -46,7 +44,7 @@ import { createGatewayEventHandler } from './createGatewayEventHandler.js'
import { createSlashHandler } from './createSlashHandler.js'
import { planGatewayRecovery } from './gatewayRecovery.js'
import { getInputSelection } from './inputSelectionStore.js'
import { type GatewayRpc, type TranscriptRow } from './interfaces.js'
import { type GatewayRpc, type StateSetter, type TranscriptRow } from './interfaces.js'
import { $overlayState, patchOverlayState } from './overlayStore.js'
import { $goodVibesTick } from './petFlashStore.js'
import { scrollWithSelectionBy } from './scroll.js'
@@ -223,7 +221,7 @@ export function useMainApp(gw: GatewayClient) {
const colsRef = useRef(cols)
const scrollRef = useRef<null | ScrollBoxHandle>(null)
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
const clipboardPasteRef = useRef<(quiet?: boolean) => Promise<void> | void>(() => {})
const sysRef = useRef<(text: string) => void>(() => {})
const submitRef = useRef<(value: string) => void>(() => {})
const terminalHintsShownRef = useRef(new Set<string>())
const historyItemsRef = useRef(historyItems)
@@ -296,11 +294,8 @@ export function useMainApp(gw: GatewayClient) {
const composer = useComposerState({
gw,
onClipboardPaste: quiet => clipboardPasteRef.current(quiet),
onImageAttached: info => {
sys(attachedImageNotice(info))
},
submitRef
submitRef,
sys: text => sysRef.current(text)
})
const { actions: composerActions, refs: composerRefs, state: composerState } = composer
@@ -711,27 +706,7 @@ export function useMainApp(gw: GatewayClient) {
[appendMessage, overlay.clarify, rpc]
)
const paste = useCallback(
(quiet = false) =>
rpc<ClipboardPasteResponse>('clipboard.paste', { session_id: getUiState().sid }).then(r => {
if (!r) {
return
}
if (r.attached) {
const meta = imageTokenMeta(r)
return sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`)
}
if (!quiet) {
sys(r.message || 'No image found in clipboard')
}
}),
[rpc, sys]
)
clipboardPasteRef.current = paste
sysRef.current = sys
const { dispatchSubmission, send, sendQueued, submit } = useSubmission({
appendMessage,
@@ -891,10 +866,11 @@ export function useMainApp(gw: GatewayClient) {
() =>
createSlashHandler({
composer: {
attachClipboardImage: composerActions.attachClipboardImage,
attachImagePath: composerActions.attachImagePath,
enqueue: composerActions.enqueue,
hasSelection,
openEditor: composerActions.openEditor,
paste,
queueRef: composerRefs.queueRef,
selection,
setInput: composerActions.setInput
@@ -933,7 +909,6 @@ export function useMainApp(gw: GatewayClient) {
maybeWarn,
page,
panel,
paste,
selection,
send,
session,
@@ -1137,6 +1112,24 @@ export function useMainApp(gw: GatewayClient) {
]
)
/**
* Every keystroke lands here, so this is where attached payloads are
* reconciled against the tokens still in the text — deleting an
* `[[ Image N ]]` is how the user unattaches it.
*/
const updateInput = useCallback<StateSetter<string>>(
next => {
composerActions.setInput(prev => {
const value = typeof next === 'function' ? next(prev) : next
composerActions.syncTokens(value)
return value
})
},
[composerActions]
)
const appComposer = useMemo(
() => ({
cols,
@@ -1150,10 +1143,10 @@ export function useMainApp(gw: GatewayClient) {
queueEditIdx: composerState.queueEditIdx,
queuedDisplay: composerState.queuedDisplay,
submit,
updateInput: composerActions.setInput,
updateInput,
voiceRecordKey
}),
[cols, composerActions, composerState, empty, pagerPageSize, submit, voiceRecordKey]
[cols, composerActions, composerState, empty, pagerPageSize, submit, updateInput, voiceRecordKey]
)
// Pass current progress through unfrozen — streaming update throttling
+2 -2
View File
@@ -178,7 +178,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
setHistoryItems([])
setLastUserMsg('')
setStickyPrompt('')
composerActions.setPasteSnips([])
composerActions.setComposerTokens([])
// Half-prune: new session has new keys, but keep a warm pool in case
// the user resumes back to the prior session.
evictInkCaches('half')
@@ -202,7 +202,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
setHistoryItems(info ? [introMsg(info)] : [])
setStickyPrompt('')
setLastUserMsg('')
composerActions.setPasteSnips([])
composerActions.setComposerTokens([])
patchTurnState({ activity: [] })
patchUiState({ info, usage: usageFrom(info) })
},
-34
View File
@@ -1,34 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { PasteSnippet } from './interfaces.js'
import { expandSnips } from './useSubmission.js'
const snip = (label: string, text: string): PasteSnippet => ({ label, text })
describe('expandSnips (paste history recall)', () => {
it('replaces a collapsed paste label with its full content', () => {
const label = '[[ hello.. [3 lines] .. world ]]'
const full = `here: ${label} done`
const expand = expandSnips([snip(label, 'hello\nfoo\nworld')])
expect(expand(full)).toBe('here: hello\nfoo\nworld done')
})
it('is a no-op for already-expanded / label-free text (recall round-trip)', () => {
const expanded = 'hello\nfoo\nworld'
// Re-submitting a recalled history entry has no snips and no labels.
expect(expandSnips([])(expanded)).toBe(expanded)
})
it('expands repeated identical labels in submission order', () => {
const label = '[[ x [1 lines] ]]'
const expand = expandSnips([snip(label, 'first'), snip(label, 'second')])
expect(expand(`${label} then ${label}`)).toBe('first then second')
})
it('leaves an unmatched label intact', () => {
const label = '[[ orphan [2 lines] ]]'
expect(expandSnips([])(label)).toBe(label)
})
})
+12 -20
View File
@@ -1,32 +1,21 @@
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { TYPING_IDLE_MS } from '../config/timing.js'
import { expandTokens } from '../domain/attachments.js'
import { completionToApplyOnSubmit, looksLikeSlashCommand } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { SessionSteerResponse, ShellExecResponse } from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js'
import { PASTE_SNIPPET_RE } from '../protocol/paste.js'
import type { Msg } from '../types.js'
import type { ComposerActions, ComposerRefs, ComposerState, PasteSnippet } from './interfaces.js'
import type { ComposerActions, ComposerRefs, ComposerState } from './interfaces.js'
import { submitPrompt } from './submissionCore.js'
import { turnController } from './turnController.js'
import { getUiState, patchUiState } from './uiStore.js'
const DOUBLE_ENTER_MS = 450
export const expandSnips = (snips: PasteSnippet[]) => {
const byLabel = new Map<string, string[]>()
for (const { label, text } of snips) {
const hit = byLabel.get(label)
hit ? hit.push(text) : byLabel.set(label, [text])
}
return (value: string) => value.replace(PASTE_SNIPPET_RE, tok => byLabel.get(tok)?.shift() ?? tok)
}
const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) =>
matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text)
@@ -68,7 +57,9 @@ export function useSubmission(opts: UseSubmissionOptions) {
const send = useCallback(
(text: string, showUserMessage = true, displayText?: string) => {
const expand = expandSnips(composerState.pasteSnips)
// Read tokens off the ref, not render state: a paste immediately followed
// by Enter submits before React has re-rendered with the new token.
const expand = expandTokens(composerRefs.tokensRef.current)
submitPrompt(
text,
@@ -84,7 +75,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
displayText
)
},
[appendMessage, composerActions, composerState.pasteSnips, gw, setLastUserMsg, sys]
[appendMessage, composerActions, composerRefs, gw, setLastUserMsg, sys]
)
const shellExec = useCallback(
@@ -217,10 +208,12 @@ export function useSubmission(opts: UseSubmissionOptions) {
return
}
// History stores expanded paste content, not the `[[…]]` label: snips
// are cleared on submit, so recall must be self-contained. Idempotent on
// label-free text, so re-submitting a recalled entry stays stable.
const toHistory = expandSnips(composerState.pasteSnips)(full)
// History stores resolved content, not `[[…]]` labels: tokens are cleared
// on submit, so recall must be self-contained. Image tokens resolve to
// nothing — a detached image can't be re-attached by recalling the text.
// Idempotent on token-free text, so re-submitting a recalled entry is
// stable.
const toHistory = expandTokens(composerRefs.tokensRef.current)(full)
if (looksLikeSlashCommand(full)) {
appendMessage({ kind: 'slash', role: 'system', text: full })
@@ -294,7 +287,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
appendMessage,
composerActions,
composerRefs,
composerState.pasteSnips,
handleBusyInput,
interpolate,
send,
+60
View File
@@ -0,0 +1,60 @@
import type { ComposerToken } from '../app/interfaces.js'
import { PASTE_SNIPPET_RE } from '../protocol/paste.js'
/**
* Composer tokens are the ONE way deferred content shows up in the input line:
* a collapsed paste and an attached image both render as `[[ … ]]` sitting in
* the text the user is editing. They are ordinary characters — arrow keys,
* backspace, and selection work on them for free — and they carry their real
* payload out-of-band until submit.
*
* Two consequences the rest of the composer relies on:
* - Deleting the token is how you drop the thing. Nothing else to click.
* - Position in the text is meaningful: the model sees the payload where the
* token sat, not stapled to the front of the turn.
*/
export const imageToken = (index: number) => `[[ Image ${index} ]]`
/** Highest image token index handed out so far, so a new one never collides. */
export const nextImageIndex = (tokens: ComposerToken[]) =>
tokens.reduce((max, t) => (t.kind === 'image' ? Math.max(max, t.index) : max), 0) + 1
/** Tokens whose label is no longer anywhere in the composer text. */
export const droppedTokens = (tokens: ComposerToken[], value: string) => {
const live = new Set(value.match(PASTE_SNIPPET_RE) ?? [])
return tokens.filter(t => !live.has(t.label))
}
/**
* Resolve every token in `value` to what the agent should actually receive.
*
* Repeated identical labels expand in submission order (left to right), which
* is why this walks matches instead of doing a global replace per token.
*
* An image token expands to nothing: the gateway already holds the file in
* `session.attached_images` and splices the real vision content in at submit.
* The token's job was to show the user where it landed, so it also eats one
* adjacent space to avoid leaving a gap in the middle of a sentence.
*/
export const expandTokens = (tokens: ComposerToken[]) => {
const byLabel = new Map<string, ComposerToken[]>()
for (const token of tokens) {
const hit = byLabel.get(token.label)
hit ? hit.push(token) : byLabel.set(token.label, [token])
}
return (value: string) =>
value
.replace(new RegExp(`[ \\t]?(?:${PASTE_SNIPPET_RE.source})`, 'g'), match => {
const token = byLabel.get(match.trimStart())?.shift()
if (!token) {
return match
}
return token.kind === 'paste' ? match.slice(0, match.length - token.label.length) + token.text : ''
})
.trim()
}
+1 -22
View File
@@ -1,24 +1,9 @@
import { LONG_MSG } from '../config/limits.js'
import { buildToolTrailLine, fmtK } from '../lib/text.js'
import { buildToolTrailLine } from '../lib/text.js'
import type { Msg, SessionInfo } from '../types.js'
export const introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' })
export const imageTokenMeta = (info?: ImageMeta | null) => {
const { width, height, token_estimate: t } = info ?? {}
return [width && height ? `${width}x${height}` : '', (t ?? 0) > 0 ? `~${fmtK(t!)} tok` : '']
.filter(Boolean)
.join(' · ')
}
export const attachedImageNotice = (info?: ({ name?: string } & ImageMeta) | null) => {
const meta = imageTokenMeta(info)
const label = info?.name ? `📎 Attached image: ${info.name}` : '📎 Attached image'
return `${label}${meta ? ` · ${meta}` : ''}`
}
export const userDisplay = (text: string) => {
if (text.length <= LONG_MSG) {
return text
@@ -112,12 +97,6 @@ export const fmtDuration = (ms: number) => {
return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`
}
interface ImageMeta {
height?: number
token_estimate?: number
width?: number
}
interface TranscriptRow {
context?: string
display_kind?: string
Generated
+3 -1
View File
@@ -1547,7 +1547,7 @@ wheels = [
[[package]]
name = "hermes-agent"
version = "0.19.0"
version = "0.19.1"
source = { editable = "." }
dependencies = [
{ name = "certifi" },
@@ -2673,6 +2673,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/25/65/d320016505457cc30971f575e8dadffb923b7cfc780ab8bb25a4ce9d305c/nemo_relay-0.6.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:ad5dae6febf6532d7b113abc2a404679c8feffc499df3034b93d9a078185d2bb", size = 9917779, upload-time = "2026-07-22T20:07:48.961Z" },
{ url = "https://files.pythonhosted.org/packages/ae/c0/f33250e71c4206da1b339072893f9a1e39295fe1aceb9a2fef4b8620a0f2/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0cd9570f64c6956fe3bfb82af1cdb3ee70cb50b51098cdb0de831c3f9b4e904", size = 8888375, upload-time = "2026-07-22T20:07:51.049Z" },
{ url = "https://files.pythonhosted.org/packages/a3/f4/d1dfaed022da0f6f14765a122867f976a69cc520fe1faaf99757f5719d1f/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:849daa9e45158ac581e54506e0fcc7a24f557d1ed06dbdc074f5de7a00393cbc", size = 9336372, upload-time = "2026-07-22T20:07:53.224Z" },
{ url = "https://files.pythonhosted.org/packages/60/9e/f8b80509eef5e05702b940a3d1e2f60c962548d87dec2d712fd3804e6cd4/nemo_relay-0.6.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8c80e534b76bb0455cfc222aaf5c10fa064c088b3c0d5e137eb3c97db46dbc47", size = 10578834, upload-time = "2026-07-30T15:44:44.726Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a0/84ee49d45a1a874457f2f9260d30fd573af30c9be360d9d97b8bb7835ad9/nemo_relay-0.6.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c8bc4a792a2f8c35ddef1b900cc43be2b2bbbfcd5e7cf65aa0829c04d25eb77f", size = 10849651, upload-time = "2026-07-30T15:44:40.48Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b0/908d77f75b9054e1e403da78f7d9430249a45939070d4298abbb41a04b5b/nemo_relay-0.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:bfbbedfd130fa95c9b8c04643c30910df850e0ed3500beeab82b00ca2d94e7ea", size = 9613425, upload-time = "2026-07-22T20:07:55.175Z" },
{ url = "https://files.pythonhosted.org/packages/cd/71/c438b9d746303ff7f270d99f13b250bf947cdac3e52de2a83fd132cbca0b/nemo_relay-0.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:82fe132943399d89e6ec34dc28df0be7bbe41b84f6698c545928b8816b6010f6", size = 9034810, upload-time = "2026-07-22T20:07:57.467Z" },
]
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { PtyResumeSanitizer } from "./pty-resume-sanitizer";
import {
PTY_RESUME_LOADING_MAX_MS,
shouldFinishResumeHydrationOnChunk,
shouldShowResumeLoadingOverlay,
} from "./pty-resume-loading";
describe("shouldFinishResumeHydrationOnChunk", () => {
it("finishes on the first non-empty chunk", () => {
expect(shouldFinishResumeHydrationOnChunk("")).toBe(false);
expect(shouldFinishResumeHydrationOnChunk("hello")).toBe(true);
});
it("keeps a positive hard-cap timeout for wedged resumes", () => {
expect(PTY_RESUME_LOADING_MAX_MS).toBeGreaterThan(0);
});
});
describe("resume hydration gate over the real sanitizer", () => {
// Regression: the gate must key off the payload actually written to xterm,
// not the raw frame. The sanitizer collapses an erase-only / all-newline /
// partial-CSI resume frame to "", so a nonempty raw first frame would
// otherwise clear the wait notice while the terminal is still blank.
const ESC = String.fromCharCode(27);
const CRLF = String.fromCharCode(13, 10);
const VISIBLE = `Hello world${CRLF}`;
const controlOnlyFirstFrames: Array<[string, string]> = [
["erase-only", `${ESC}[2K`],
["all-newline", `${CRLF}${CRLF}`],
["partial-CSI", `${ESC}[`],
];
it.each(controlOnlyFirstFrames)(
"keeps hydrating on a %s first frame, then finishes on visible replay",
(_label, firstFrame) => {
const sanitizer = new PtyResumeSanitizer();
// Raw first frame is nonempty, but nothing is written to xterm...
expect(firstFrame.length).toBeGreaterThan(0);
const firstRendered = sanitizer.next(firstFrame);
expect(firstRendered).toBe("");
expect(shouldFinishResumeHydrationOnChunk(firstRendered)).toBe(false);
// ...so the notice only clears once real replay output arrives.
const secondRendered = sanitizer.next(VISIBLE);
expect(secondRendered.length).toBeGreaterThan(0);
expect(shouldFinishResumeHydrationOnChunk(secondRendered)).toBe(true);
},
);
});
describe("shouldShowResumeLoadingOverlay", () => {
it("shows while a resume target is connecting or open and still hydrating", () => {
expect(
shouldShowResumeLoadingOverlay({
hasResumeTarget: true,
ptyState: "connecting",
hydrating: true,
}),
).toBe(true);
expect(
shouldShowResumeLoadingOverlay({
hasResumeTarget: true,
ptyState: "open",
hydrating: true,
}),
).toBe(true);
});
it("hides when there is no resume target", () => {
expect(
shouldShowResumeLoadingOverlay({
hasResumeTarget: false,
ptyState: "connecting",
hydrating: true,
}),
).toBe(false);
});
it("hides once hydration finishes", () => {
expect(
shouldShowResumeLoadingOverlay({
hasResumeTarget: true,
ptyState: "open",
hydrating: false,
}),
).toBe(false);
});
it("defers to reconnect / closed / ended overlays", () => {
for (const ptyState of ["reconnecting", "closed", "ended"] as const) {
expect(
shouldShowResumeLoadingOverlay({
hasResumeTarget: true,
ptyState,
hydrating: true,
}),
).toBe(false);
}
});
});

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