Merge upstream main into feat/hermes-relay-model-metrics
Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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])
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
Vendored
+2
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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' })]
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 "
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user