diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 7fee2d932f..1b0046fe32 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -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: diff --git a/agent/agent_init.py b/agent/agent_init.py index 407f9a6c7b..c24c5c65ff 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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 = ( diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 209253335b..c4a58c10ac 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -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 diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 0f636c1dd2..86f3f50992 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -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}") diff --git a/agent/verification_stop.py b/agent/verification_stop.py index ac5bc852d0..dc66944883 100644 --- a/agent/verification_stop.py +++ b/agent/verification_stop.py @@ -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: diff --git a/apps/desktop/electron/link-title-window.ts b/apps/desktop/electron/link-title-window.ts index 0920976a52..226ac3ce60 100644 --- a/apps/desktop/electron/link-title-window.ts +++ b/apps/desktop/electron/link-title-window.ts @@ -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, diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index f485e5a542..41686883f7 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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() +// 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) => { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 8822efd2a9..8fb6d97c3f 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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) diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index a60ebb3762..a3dbfc04e4 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -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', () => { diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index 312deb3d1e..81736908fc 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -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 } } diff --git a/apps/desktop/electron/stream-throttle.test.ts b/apps/desktop/electron/stream-throttle.test.ts new file mode 100644 index 0000000000..fc41bcf168 --- /dev/null +++ b/apps/desktop/electron/stream-throttle.test.ts @@ -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 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 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]) +}) diff --git a/apps/desktop/electron/stream-throttle.ts b/apps/desktop/electron/stream-throttle.ts new file mode 100644 index 0000000000..912b40b916 --- /dev/null +++ b/apps/desktop/electron/stream-throttle.ts @@ -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() + 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) + } + } +} diff --git a/apps/desktop/src/app/chat/close-tab.test.ts b/apps/desktop/src/app/chat/close-tab.test.ts index 44368c8040..0326efebea 100644 --- a/apps/desktop/src/app/chat/close-tab.test.ts +++ b/apps/desktop/src/app/chat/close-tab.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index 89c8525931..0d75455bd8 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -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) } diff --git a/apps/desktop/src/app/chat/composer/empty-composer.test.ts b/apps/desktop/src/app/chat/composer/empty-composer.test.ts index 4e5146c174..fe8305d2c4 100644 --- a/apps/desktop/src/app/chat/composer/empty-composer.test.ts +++ b/apps/desktop/src/app/chat/composer/empty-composer.test.ts @@ -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
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
` and `
` 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) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation-rearm.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation-rearm.test.tsx new file mode 100644 index 0000000000..e993abc573 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation-rearm.test.tsx @@ -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(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) { + 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') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 94fb912ff1..5f4be4b637 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -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) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index b05b563c52..7471775922 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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' diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index d7ef6c3082..0c9c709887 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -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
+ * 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
` and a lone `
` 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
so the element maintains intrinsic height. The CSS min-height // is a belt; the
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')) } diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx index 6ad820765e..c4b1a56956 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx @@ -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 diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 5c0bc52148..0cfe8e0205 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -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 diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 086d69fdf5..27954abc2a 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -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 ( - + closeTreePane('workspace')} + onHideTabBar={hideTabBar} + storedSessionId={selected} + tabPaneId="workspace" + > {children} ) diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index fcaa8b19ed..cfbeb9f2af 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -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({ )} { - 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 ? ( 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) } diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 44d35607e1..1306ffb641 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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 diff --git a/apps/desktop/src/app/right-sidebar/terminal/links.test.ts b/apps/desktop/src/app/right-sidebar/terminal/links.test.ts new file mode 100644 index 0000000000..fcbc45853f --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/links.test.ts @@ -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 = {}) => ({ 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[0]> = [] + const register = term.registerLinkProvider.bind(term) + + term.registerLinkProvider = provider => { + providers.push(provider) + + return register(provider) + } + + term.loadAddon(terminalWebLinksAddon()) + + await new Promise(resolve => term.write(`${text}\r\n`, resolve)) + + const links = await new Promise(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) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/links.ts b/apps/desktop/src/app/right-sidebar/terminal/links.ts new file mode 100644 index 0000000000..4102440713 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/links.ts @@ -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, + 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) diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx index 8a81a16925..ced1675032 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx @@ -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" > diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts index bee037094f..a8d92d9e36 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts @@ -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 diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 09cd815413..888d197a55 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -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 diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx b/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx index dcc16593c1..f906098229 100644 --- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx @@ -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 => + ({ + 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() + + 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() + + 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 diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts index 5adf910bdb..8573ab9c8a 100644 --- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts +++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts @@ -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 }) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index d5ec67d384..7aa89a8cf1 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -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]) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 266a33b65f..9bcabd6d3b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -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) { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index a83f8a3840..ea40e7705a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -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) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 2c0913fb6d..1f86fd7c40 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -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 diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 16ec52cf0d..1e6ec792d0 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -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 = ({ 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]' )} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 9df6e3d44d..e81bc1afd2 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -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 = ( diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 49ca0587c7..4bb24460dd 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -198,9 +198,24 @@ function setDismissed(paneId: string, dismissed: boolean) { const paneClosers: Record void> = {} const paneOpeners: Record 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>(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))) } /** diff --git a/apps/desktop/src/components/ui/pane-tab.test.tsx b/apps/desktop/src/components/ui/pane-tab.test.tsx index c36f03fb8a..5b340a9fb1 100644 --- a/apps/desktop/src/components/ui/pane-tab.test.tsx +++ b/apps/desktop/src/components/ui/pane-tab.test.tsx @@ -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( @@ -14,7 +14,9 @@ describe('PaneTab close gestures', () => { ) - 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) }) diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index eb553a770e..87545f018c 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -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(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(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 (
(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(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(function P onPointerDown?.(event) }} + onPointerUp={event => { + middle.onPointerUp(event) + onPointerUp?.(event) + }} ref={ref} {...props} > diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index d2804298fa..f266f13aff 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -256,6 +256,8 @@ declare global { // reload. Wipe session lists (skeletons) and re-dial. onConnectionApplied?: (callback: () => void) => () => void onPowerResume?: (callback: () => void) => () => void + getOnBattery?: () => Promise + onBatteryChanged?: (callback: (onBattery: boolean) => void) => () => void onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void getBootstrapState: () => Promise continueBootstrapLocal: () => Promise<{ ok: boolean }> diff --git a/apps/desktop/src/lib/middle-click.test.tsx b/apps/desktop/src/lib/middle-click.test.tsx new file mode 100644 index 0000000000..c701825932 --- /dev/null +++ b/apps/desktop/src/lib/middle-click.test.tsx @@ -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 ( + + ) +} + +describe('middleClickHandlers', () => { + it('fires without an auxclick — the event Chromium eats when autoscroll starts', () => { + const action = vi.fn() + render() + + middleClick(screen.getByText('target')) + expect(action).toHaveBeenCalledTimes(1) + }) + + it('cancels mousedown so the autoscroll pan widget never appears', () => { + render() + + 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() + + expect(fireEvent.mouseDown(screen.getByText('target'), { button: 1 })).toBe(false) + }) + + it('ignores left and right buttons', () => { + const action = vi.fn() + render() + + 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( + <> + + + + ) + + 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( + <> + + + + ) + + middleClick(screen.getByText('inert'), screen.getByText('live')) + expect(action).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/lib/middle-click.ts b/apps/desktop/src/lib/middle-click.ts new file mode 100644 index 0000000000..bba5e58743 --- /dev/null +++ b/apps/desktop/src/lib/middle-click.ts @@ -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?.() + } + } + } +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 9a14e26e45..f2546d0c01 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -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 diff --git a/apps/desktop/src/store/composer-queue.test.ts b/apps/desktop/src/store/composer-queue.test.ts index 49e9fb3cf9..7e7d0940ee 100644 --- a/apps/desktop/src/store/composer-queue.test.ts +++ b/apps/desktop/src/store/composer-queue.test.ts @@ -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', () => { diff --git a/apps/desktop/src/store/power.ts b/apps/desktop/src/store/power.ts new file mode 100644 index 0000000000..4d6f5e2bb8 --- /dev/null +++ b/apps/desktop/src/store/power.ts @@ -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(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)) +} diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index b44c17b2bd..415c167a19 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -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' })] diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index c9d7c1904d..19e56b2c1b 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -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[] +): 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[] +): 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. * diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 95eeebe29a..4017644307 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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; } diff --git a/contributors/emails/jordan.mymail@gmail.com b/contributors/emails/jordan.mymail@gmail.com new file mode 100644 index 0000000000..c8e734af3d --- /dev/null +++ b/contributors/emails/jordan.mymail@gmail.com @@ -0,0 +1,2 @@ +lxman +# PR #74522 salvage (per-turn micro-compaction) diff --git a/docs/micro-compaction.md b/docs/micro-compaction.md new file mode 100644 index 0000000000..7c4a7176ba --- /dev/null +++ b/docs/micro-compaction.md @@ -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: + base_url: +``` + +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. diff --git a/gateway/run.py b/gateway/run.py index 06a26d73ae..911021a36e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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 ---------- diff --git a/gateway/session_context.py b/gateway/session_context.py index cf227fac12..24d556d2cb 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -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. diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index a10935a68f..c87e06cb63 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -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(): diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4970c4c7f8..4523f92fd7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index dd1cfe88c0..3fa9350af8 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.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 diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index ca8b8897ae..18ea422f3e 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 814fa7e8f3..209ef0d083 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 " diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index cea14a263f..fd5a0265df 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -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: diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 75a1e9d914..8da7910337 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -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:) 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 diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 17cae5d584..8fc2ac3b41 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -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:``). 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, diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c140ee02b4..c96f333d63 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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 diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 85a62aeea7..162a642764 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -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., # so a toolset listed there stays permanently OFF no matter what this diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index a34c995cc8..736598dba8 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -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." ) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 71c244976a..d3f48a9048 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -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), }, diff --git a/pyproject.toml b/pyproject.toml index df69becb53..2f6a0bef16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/scripts/micro_compaction_report.py b/scripts/micro_compaction_report.py new file mode 100644 index 0000000000..30811a6717 --- /dev/null +++ b/scripts/micro_compaction_report.py @@ -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()) diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py new file mode 100644 index 0000000000..c3528d10f9 --- /dev/null +++ b/tests/agent/test_micro_compaction.py @@ -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" + ) diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index fca22ee1c5..d9acaefaf6 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -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( diff --git a/tests/hermes_cli/test_custom_provider_identity.py b/tests/hermes_cli/test_custom_provider_identity.py index f9a6304d37..c4f36ea37f 100644 --- a/tests/hermes_cli/test_custom_provider_identity.py +++ b/tests/hermes_cli/test_custom_provider_identity.py @@ -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"}]} diff --git a/tests/hermes_cli/test_custom_provider_model_switch.py b/tests/hermes_cli/test_custom_provider_model_switch.py index e24f6e8c67..387332eb4f 100644 --- a/tests/hermes_cli/test_custom_provider_model_switch.py +++ b/tests/hermes_cli/test_custom_provider_model_switch.py @@ -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 ): diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index b03aa0757b..aae133a0eb 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -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): diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 221f279228..e1eef0bc5a 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -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): diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index ad05e6b82b..18902ebd58 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -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 diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ada479fafb..24f1b72de9 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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 diff --git a/tests/tools/test_file_operations.py b/tests/tools/test_file_operations.py index bc3b6c4542..e80938d3ef 100644 --- a/tests/tools/test_file_operations.py +++ b/tests/tools/test_file_operations.py @@ -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 diff --git a/tests/tools/test_flux3_video_tool.py b/tests/tools/test_flux3_video_tool.py new file mode 100644 index 0000000000..8092c8976f --- /dev/null +++ b/tests/tools/test_flux3_video_tool.py @@ -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"AuthenticationFailed"): + 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 diff --git a/tests/tools/test_managed_tool_gateway.py b/tests/tools/test_managed_tool_gateway.py index a1aac581ae..1c90bcb90f 100644 --- a/tests/tools/test_managed_tool_gateway.py +++ b/tests/tools/test_managed_tool_gateway.py @@ -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:`` 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)) diff --git a/tests/tools/test_skills_sync_client.py b/tests/tools/test_skills_sync_client.py index ec7e68d933..c967eef187 100644 --- a/tests/tools/test_skills_sync_client.py +++ b/tests/tools/test_skills_sync_client.py @@ -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:` 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"] diff --git a/tools/file_operations.py b/tools/file_operations.py index aff82fa0af..d0d1f53a83 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -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" ) diff --git a/tools/flux3_video_tool.py b/tools/flux3_video_tool.py new file mode 100644 index 0000000000..e80f766746 --- /dev/null +++ b/tools/flux3_video_tool.py @@ -0,0 +1,1047 @@ +"""Native BFL FLUX 3 video generation tools, backed by the Nous tool gateway. + +These are service-gated native tools in the ``image_generate`` mold: schemas +and descriptions are pinned here as build-time facts, the handlers speak the +gateway's own REST contract, and ``check_fn`` hides the whole toolset unless +the user is signed in to Nous Portal with paid service access. No runtime +discovery, and no server-supplied schema is ever consulted — that is the point +of the design. + +The wire is two calls against the gateway's managed mount, and it names the +vendor but not the vendor's API: + +- ``POST {base}/generations`` with ``{mode, prompt, ...}`` -> ``{id, status, + guidance}`` +- ``GET {base}/generations/`` -> the job state plus ``guidance`` + +``guidance`` is the gateway's live policy channel: exact waits, what to do +next, and how to deliver a finished clip ship from the server so they cannot +drift from what it actually enforces. Handlers surface it verbatim as the +tool's result text, and surface ``error.message`` the same way on a refusal. + +Media inputs: handlers know their own media fields explicitly. A local file +path is resolved through :func:`tools.image_source.resolve_image_source` +(sandbox confinement, credential guard) and delivered via the Nous upload +protocol (presign, direct PUT to storage, ``nous-upload:`` reference). +URLs pass through untouched. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from typing import Optional + +from tools.registry import registry +from tools.managed_tool_gateway import ( + build_managed_media_uploader, + managed_gateway_auth_headers, + managed_vendor_endpoints, + read_nous_access_token, +) + +logger = logging.getLogger(__name__) + +_TOOLSET = "bfl" +_VENDOR = "bfl" + +# Submit sits behind the gateway's upstream call plus upload-reference +# resolution, and the gateway bounds a poll server-side. One generous read +# timeout covers both without ever approaching the agent's per-tool budget. +_TRANSPORT_READ_TIMEOUT_SECONDS = 180.0 +_TRANSPORT_CONNECT_TIMEOUT_SECONDS = 10.0 + +_SIGN_IN_MESSAGE = ( + "BFL video generation needs a Nous Portal sign-in with an active paid plan. " + "Ask the user to run `hermes model` and sign in to Nous, then retry." +) + +# --------------------------------------------------------------------------- +# Local-path detection (moved from the retired MCP media-argument walker) +# --------------------------------------------------------------------------- + +# Drive-absolute Windows paths: ``C:\Users\...`` / ``C:/Users/...``. +_WINDOWS_DRIVE_PATH = re.compile(r"^[A-Za-z]:[\\/]") + +# A whole string of base64 alphabet (optionally line-wrapped, optionally +# padded). Base64's alphabet includes "/", so an inline JPEG payload always +# starts with "/9j/" — which reads as an absolute path unless caught first. +_BASE64_PAYLOAD = re.compile(r"^[A-Za-z0-9+/\r\n]+={0,2}[\r\n]*$") +# Real filesystem paths are short; base64 of even a thumbnail runs to +# thousands of characters. Anything this long and alphabet-pure is a payload. +_MIN_BASE64_PAYLOAD_LENGTH = 256 + + +def _looks_like_local_path(value: str) -> bool: + """True for things we should read off disk rather than forward as-is. + + Deliberately narrow: only explicitly rooted paths and ``file://`` qualify. + Bare names are ambiguous with opaque ids, URLs pass through, and base64 + payloads (checked first — a JPEG's base64 starts ``/9j/``) fall through + untouched. + """ + if len(value) >= _MIN_BASE64_PAYLOAD_LENGTH and _BASE64_PAYLOAD.match(value): + return False + if value.startswith("file://"): + return True + if value == "~" or value.startswith(("~/", "~\\")): + return True + if value.startswith(("/", "./", "../", ".\\", "..\\")): + return True + if _WINDOWS_DRIVE_PATH.match(value) or value.startswith("\\\\"): + return True + return False + + +def _display_path(path: str) -> str: + """A value safe to embed in an error message shown to the model.""" + return path if len(path) <= 200 else f"{path[:200]}… ({len(path)} characters)" + + +# --------------------------------------------------------------------------- +# Gateway transport +# --------------------------------------------------------------------------- + +def _endpoints() -> Optional[dict]: + """Absolute URLs for the managed BFL routes, or None when unreachable.""" + return managed_vendor_endpoints(_VENDOR) + + +async def _call_gateway(method: str, url: str, json_body: Optional[dict] = None) -> str: + """One REST round trip, rendered as this tool's result. + + The gateway's ``guidance`` (on success) and ``error.message`` (on a + refusal) are both written for a model to act on, so they are surfaced + verbatim. A refusal is a normal outcome the model can respond to — being + throttled is not a broken tool — so only genuinely unreadable responses + become ``error``. + """ + import httpx + + headers = managed_gateway_auth_headers(url) + if not headers: + return json.dumps({"error": _SIGN_IN_MESSAGE}) + headers["Content-Type"] = "application/json" + + timeout = httpx.Timeout(_TRANSPORT_CONNECT_TIMEOUT_SECONDS, read=_TRANSPORT_READ_TIMEOUT_SECONDS) + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.request(method, url, headers=headers, json=json_body) + except Exception as exc: + return json.dumps({ + "error": f"Could not reach the video-generation gateway: {type(exc).__name__}: {exc}", + }) + + if response.status_code == 401: + return json.dumps({"error": _SIGN_IN_MESSAGE, "needs_reauth": True}) + + try: + payload = response.json() + except Exception: + payload = None + + if not isinstance(payload, dict): + return json.dumps({ + "error": f"The video-generation gateway answered HTTP {response.status_code} with an unreadable body.", + }) + + if response.status_code >= 400: + error = payload.get("error") if isinstance(payload.get("error"), dict) else {} + message = error.get("message") or f"the gateway refused the request (HTTP {response.status_code})" + out: dict = {"error": str(message)} + if isinstance(error.get("details"), dict): + out["details"] = error["details"] + return json.dumps(out, ensure_ascii=False) + + guidance = payload.pop("guidance", None) + return json.dumps( + {"result": guidance or "Request accepted.", "details": payload}, + ensure_ascii=False, + ) + + +# --------------------------------------------------------------------------- +# Media delivery +# --------------------------------------------------------------------------- + +# Every media field the gateway understands, and the media kind each accepts. +# `input_image` and `input_images` are interchangeable server-side, so both +# must be sanitized whatever the mode. +_MEDIA_FIELDS = { + "input_image": ("image",), + "input_images": ("image",), + "input_video": ("video",), +} +# The vendor takes at most ten keyframes. Checked here as well as server-side +# so an over-long list is refused before it spends the caller's upload quota. +_MAX_IMAGES = 10 + + +# How long one get_result call waits before its second look. The bound that +# matters is the whole call rather than this number: model_tools' async bridge +# abandons a tool at 300s, and one call spends two polls (each bounded +# server-side at 45s), this wait, and on Ready the download of the clip. There +# is room to roughly double this; the reason not to is that a finished job is +# only noticed at the next look, so the wait is also the notice delay. +_POLL_FOLLOW_UP_WAIT_SECONDS = 45.0 +# Taken in slices so the wait is answerable. Nothing outside a tool can end a +# call that has already started — the executor only checks for an interrupt +# between tools — so a tool that blocks this long has to watch the flag itself. +_POLL_WAIT_SLICE_SECONDS = 1.0 + +# Mirrors the gateway's BFL statuses +_TERMINAL_POLL_STATUSES = frozenset( + {"Ready", "Error", "Request Moderated", "Content Moderated", "Task not found"} +) + + +def _poll_is_finished(raw: str) -> bool: + """True when there is nothing to wait for: done, refused, or unreadable.""" + try: + payload = json.loads(raw) + except Exception: + return True + if not isinstance(payload, dict) or "error" in payload: + # A refusal carries its own guidance (a wait, a limit, a dead job). + # Sleeping on it would only delay showing the model what to do. + return True + details = payload.get("details") + status = details.get("status") if isinstance(details, dict) else None + return not isinstance(status, str) or status in _TERMINAL_POLL_STATUSES + + +async def _wait_before_second_look() -> bool: + """Hold the call open between looks; False if the user interrupted. + + Counted down rather than clock-driven: this paces polling, so a slice + that runs long changes nothing, and the loop stays testable without a + fake clock. + """ + from tools.interrupt import is_interrupted + + remaining = _POLL_FOLLOW_UP_WAIT_SECONDS + while remaining > 0: + if is_interrupted(): + return False + this_slice = min(_POLL_WAIT_SLICE_SECONDS, remaining) + await asyncio.sleep(this_slice) + remaining -= this_slice + return True + + +def _warm_nous_token() -> None: + """Refresh the Nous token once, before any parallel upload needs it. + + ``read_nous_access_token`` takes no lock and, when a refresh fails, falls + back to returning the stale cached token. Uploading in parallel therefore + had every request discover the token was expiring at the same instant and + fire its own refresh; the rotating refresh token means the first wins and + the rest quietly send the stale bearer, which the gateway answers with 401. + One warm-up call first puts a fresh token in the cache, so the fan-out all + reads it instead of racing for it. + """ + try: + read_nous_access_token() + except Exception as exc: # pragma: no cover — the real read retries below + logger.debug("Nous token warm-up failed before parallel uploads: %s", exc) + + +async def _prepare_media(args: dict, task_id: Optional[str]) -> dict: + """Replace local paths with upload references in every media field. + + Deliberately covers all media fields rather than the one this mode + expects. The gateway accepts `input_image` and `input_images` + interchangeably, so a value left unsanitized in the "wrong" field still + reaches the vendor — as a raw local path, which both fails the generation + and discloses the user's directory layout to a third party. + """ + prepared = dict(args or {}) + _warm_nous_token() + for field, permitted in _MEDIA_FIELDS.items(): + value = prepared.get(field) + if value is None: + continue + if isinstance(value, list): + if len(value) > _MAX_IMAGES: + raise ValueError(f"{field} takes at most {_MAX_IMAGES} images; you passed {len(value)}.") + # Uploaded together rather than one after another: each entry is a + # presign round trip plus a full-file PUT, and ten keyframes in + # sequence took long enough that a submit looked hung. + prepared[field] = list( + await asyncio.gather(*(_deliver_media(entry, permitted, task_id) for entry in value)) + ) + else: + prepared[field] = await _deliver_media(value, permitted, task_id) + return prepared + + +def _without_media(args: dict) -> dict: + """Drop media fields entirely — for the mode that takes none. + + The gateway ignores them for text-to-video, so stripping here matches its + behaviour while avoiding an upload the caller never needed. + """ + return {k: v for k, v in dict(args or {}).items() if k not in _MEDIA_FIELDS} + + +async def _deliver_media(value, permitted: tuple, task_id: Optional[str]): + """Replace a local path with a ``nous-upload:`` reference; pass URLs through. + + Raises ``ValueError`` with a model-readable sentence when the file cannot + be read or uploaded — the caller turns that into the tool's error payload. + """ + if not isinstance(value, str) or not _looks_like_local_path(value): + return value + + from tools.image_source import ImageResolutionError, ResolveContext, resolve_image_source + + try: + resolved = await resolve_image_source(value, ResolveContext(task_id=task_id), permitted=permitted) + except ImageResolutionError as exc: + raise ValueError(f"Could not read {_display_path(value)}: {exc}") from exc + + endpoints = _endpoints() + uploader = None + if endpoints is not None: + uploader = build_managed_media_uploader(endpoints["base_url"], endpoints["upload_path"]) + if uploader is None: + raise ValueError("Local file uploads are not available in this build; pass a URL instead.") + + try: + return await uploader(resolved.data, resolved.mime) + except Exception as exc: + raise ValueError(f"Could not upload {_display_path(value)}: {exc}") from exc + + +# --------------------------------------------------------------------------- +# Saving the finished clip +# --------------------------------------------------------------------------- + +# Generous: a 50MB clip over a slow link, still well inside the agent's budget. +_DOWNLOAD_READ_TIMEOUT_SECONDS = 300.0 +_DOWNLOAD_CONNECT_TIMEOUT_SECONDS = 15.0 +# A rejection page is a few hundred bytes of XML; a clip is megabytes. Anything +# smaller than this is not the video, whatever the HTTP status said. +_MIN_PLAUSIBLE_VIDEO_BYTES = 64 * 1024 +# Enough collision suffixes to be useful, few enough to fail fast if something +# is generating files in a loop. +_MAX_FILENAME_ATTEMPTS = 50 + + +async def _save_if_ready(raw: str, save_to) -> str: + """Download a finished clip and swap the signed URL for a local path. + + The URL is handled here rather than by the model on purpose. It is long and + percent-encoded, and re-keying it into a shell command dropped characters + often enough to be the main failure mode of this tool — a corrupted + signature is rejected, but `curl` still writes the rejection body to the + output file and exits 0, so it read as success. Passing the exact string + from this response removes the transcription step entirely. + + It also keeps a bearer credential out of the transcript: the signed URL + grants the clip to anyone holding it for the next 15-60 minutes, and + returning it put it in the model's context and the saved conversation. The + gateway already scrubs presigned URLs for *input* media for exactly this + reason; this makes the output side match. + """ + try: + payload = json.loads(raw) + except Exception: + return raw + if not isinstance(payload, dict): + return raw + + details = payload.get("details") + if not isinstance(details, dict) or details.get("status") != "Ready": + return raw + + result = details.get("result") + url = result.get("sample") if isinstance(result, dict) else None + if not isinstance(url, str) or not url.strip(): + return raw + + # Dropped whether or not the save succeeds. A retry re-polls, which mints a + # fresh URL, so nothing is lost by not handing this one to the model. + result.pop("sample", None) + + try: + target, size = await _download_video(url.strip(), save_to) + except Exception as exc: + payload["result"] = ( + f"The clip finished but saving it failed: {type(exc).__name__}: {exc}. " + "Poll this job again to retry the download; the job itself is unaffected." + ) + return json.dumps(payload, ensure_ascii=False) + + details["saved_path"] = str(target) + details["saved_bytes"] = size + payload["result"] = _delivery_lead_in(target) + str(payload.get("result") or "") + return json.dumps(payload, ensure_ascii=False) + + +def _delivery_lead_in(target) -> str: + """Opens the result text, ahead of the gateway's own delivery guidance. + + On a messaging platform the tag is spelled out rather than described. The + model has to reproduce this path exactly or the file is not sent, and the + reply is published either way — the tag is stripped from the text whether + or not it named a real file, so a wrong path reads to the user as a + message that simply forgot the attachment. Handing over the finished line + removes the step where that goes wrong; only this side knows the path. + """ + if _delivers_as_an_attachment(): + return ( + f"Saved to {target}. To deliver it, copy the next line into your reply exactly as " + f"written, alone on its own line, with nothing added around it:\n" + f"MEDIA:{target}\n" + ) + return f"Saved to {target}. " + + +async def _download_video(url: str, save_to) -> tuple: + """Stream the clip to disk, returning (path, bytes). + + SSRF-guarded, for the same reason the upload PUT is: this URL comes from + the vendor by way of the gateway, and it is fetched from the user's own + machine. Real result URLs are public CDN objects, which the guard allows. + """ + import httpx + + from tools.url_safety import create_ssrf_safe_async_client + + target = _resolve_destination(save_to, _filename_from_url(url)) + # Written under a .part name and renamed only once it is complete and + # plausible, so a failed download can never leave something that looks like + # a playable file behind. + partial = target.with_name(target.name + ".part") + timeout = httpx.Timeout(_DOWNLOAD_CONNECT_TIMEOUT_SECONDS, read=_DOWNLOAD_READ_TIMEOUT_SECONDS) + + try: + async with create_ssrf_safe_async_client(timeout=timeout, follow_redirects=True) as client: + async with client.stream("GET", url) as response: + response.raise_for_status() + with partial.open("wb") as handle: + async for chunk in response.aiter_bytes(): + handle.write(chunk) + + size = partial.stat().st_size + if size < _MIN_PLAUSIBLE_VIDEO_BYTES: + raise ValueError(f"the download returned only {size} bytes, which is not a video") + partial.replace(target) + except BaseException: + partial.unlink(missing_ok=True) + raise + + return target, size + + +def _filename_from_url(url: str) -> str: + from pathlib import PurePosixPath + from urllib.parse import unquote, urlsplit + + name = PurePosixPath(unquote(urlsplit(url).path)).name + # The path segment is vendor-controlled, so keep only a plain filename. + name = re.sub(r"[^A-Za-z0-9._-]", "_", name).lstrip(".")[:120] + return name or "flux3-video.mp4" + + +def _delivers_as_an_attachment() -> bool: + """True on a surface where the clip is received rather than opened off disk. + + Deferred to the shared classifier so this tool cannot drift from the rest + of the codebase about what counts as a chat channel. It matters here that + the API server and webhooks are *not* one: they carry a platform value but + no attachment channel, and neither strips an unfulfilled MEDIA: tag out of + the reply, so treating them as messaging puts the literal tag in front of + the caller. + """ + try: + from gateway.session_context import session_is_messaging_surface + + return session_is_messaging_surface() + except Exception: + return False + + +def _default_directory(): + """Where a clip lands when the caller named no location. + + On a messaging platform the user has no filesystem — the only way they + ever see the clip is as an attachment — so it goes to the gateway's own + video cache, which is an unconditionally allowed delivery root. Downloads + is not: an operator running HERMES_MEDIA_DELIVERY_STRICT=1 delivers only + from the cache roots, so a clip saved to Downloads there is dropped on the + way out and the user is shown a reply with nothing attached. + """ + from pathlib import Path + + if _delivers_as_an_attachment(): + try: + from hermes_constants import get_hermes_dir + + return get_hermes_dir("cache/videos", "video_cache") + except Exception: + logger.debug("Could not resolve the video cache dir; using Downloads", exc_info=True) + downloads = Path.home() / "Downloads" + return downloads if downloads.is_dir() else Path.cwd() + + +def _resolve_destination(save_to, filename: str): + """Where to write, honouring an explicit request and never overwriting.""" + from pathlib import Path + + if isinstance(save_to, str) and save_to.strip(): + requested = Path(save_to.strip()).expanduser() + if requested.is_dir() or save_to.rstrip().endswith(("/", "\\")): + directory, name = requested, filename + else: + directory, name = requested.parent, requested.name + else: + directory, name = _default_directory(), filename + + directory.mkdir(parents=True, exist_ok=True) + return _free_path(directory / name) + + +def _free_path(candidate): + """`name.mp4` -> `name-2.mp4` -> `name-3.mp4` … so nothing is clobbered.""" + if not candidate.exists(): + return candidate + for suffix in range(2, _MAX_FILENAME_ATTEMPTS + 2): + sibling = candidate.with_name(f"{candidate.stem}-{suffix}{candidate.suffix}") + if not sibling.exists(): + return sibling + raise ValueError(f"could not find a free filename next to {candidate}") + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + +def _error(message: str) -> str: + return json.dumps({"error": message}, ensure_ascii=False) + + +def _submit_args(mode: str, args: dict) -> dict: + """The wire body: the model's arguments, minus Nones, plus our mode. + + Arguments pass through as the model gave them; the gateway owns validation + and the translation onto the vendor's own fields. + """ + body = {k: v for k, v in dict(args or {}).items() if v is not None} + body["mode"] = mode + return body + + +async def _submit(mode: str, args: dict) -> str: + endpoints = _endpoints() + if endpoints is None: + return _error("BFL video generation is not available in this build.") + return await _call_gateway("POST", f"{endpoints['base_url']}/generations", _submit_args(mode, args)) + + +async def _handle_text_to_video(args: dict, **kwargs) -> str: + return await _submit("text_to_video", _without_media(args)) + + +async def _handle_image_to_video(args: dict, **kwargs) -> str: + try: + prepared = await _prepare_media(args, kwargs.get("task_id")) + except ValueError as exc: + return _error(str(exc)) + return await _submit("image_to_video", prepared) + + +async def _handle_keyframes_to_video(args: dict, **kwargs) -> str: + images = (args or {}).get("input_images") + if not isinstance(images, list) or not images: + return _error("input_images must be a non-empty list of 1-10 images (local paths or URLs).") + try: + prepared = await _prepare_media(args, kwargs.get("task_id")) + except ValueError as exc: + return _error(str(exc)) + return await _submit("keyframes_to_video", prepared) + + +async def _handle_video_continuation(args: dict, **kwargs) -> str: + try: + prepared = await _prepare_media(args, kwargs.get("task_id")) + except ValueError as exc: + return _error(str(exc)) + return await _submit("video_continuation", prepared) + + +async def _handle_get_result(args: dict, **kwargs) -> str: + job_id = (args or {}).get("id") + if not isinstance(job_id, str) or not job_id.strip(): + return _error("id is required: the job id returned by the generate tool.") + endpoints = _endpoints() + if endpoints is None: + return _error("BFL video generation is not available in this build.") + from urllib.parse import quote + + url = f"{endpoints['base_url']}/generations/{quote(job_id.strip(), safe='')}" + save_to = (args or {}).get("save_to") + + raw = await _call_gateway("GET", url) + if _poll_is_finished(raw): + return await _save_if_ready(raw, save_to) + + # Still running, so absorb the wait here instead of asking the model to + # take it. A model has no clock: told to wait it emits "I'll wait a minute" + # and its next action lands immediately, so the guidance produced a burst of + # polls rather than a paced one. Waiting inside the call cannot be skipped, + # needs no shell, and works the same on every platform. One call therefore + # covers a couple of minutes and returns as soon as a look finds it done. + if not await _wait_before_second_look(): + # Interrupted mid-wait: hand back the status we already have rather + # than spending a round trip the user has just asked us to stop for. + return await _save_if_ready(raw, save_to) + raw = await _call_gateway("GET", url) + return await _save_if_ready(raw, save_to) + + +async def _handle_prompting_guide(args: dict, **kwargs) -> str: + return FLUX3_PROMPTING_GUIDE + + +# --------------------------------------------------------------------------- +# Gating +# --------------------------------------------------------------------------- + +def check_bfl_requirements() -> bool: + try: + if _endpoints() is None: + return False + from hermes_cli.nous_account import get_nous_portal_account_info + + info = get_nous_portal_account_info() + return bool(getattr(info, "logged_in", False) and getattr(info, "paid_service_access", False)) + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Pinned schemas +# --------------------------------------------------------------------------- + +_ASPECT_RATIOS = ["auto", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"] +_RESOLUTIONS = ["720p"] + +_GUIDE_POINTER = "Read bfl_flux3_prompting_guide before your first generation. " +_MEDIA_SENTENCE = ( + "Media fields accept a local file path (uploaded automatically to Nous-managed temporary " + "storage and deleted when the generation finishes) or a URL. " +) +_OVERRIDE_SENTENCE = "All guidance is defaults: explicit user instructions override it." + + +def _shared_submit_properties() -> dict: + return { + "prompt": { + "type": "string", + "minLength": 1, + "description": ( + "Generation brief in plain prose (it is interpreted and expanded by a reasoning " + "harness). Order it subject, distinguishing visual specifics, action, camera, " + "lighting, environment, audio, style. Audio is generated by default — say " + '"no music" if unwanted.' + ), + }, + "aspect_ratio": { + "type": "string", + "enum": list(_ASPECT_RATIOS), + "default": "auto", + "description": 'Output aspect ratio. "auto" lets the model choose.', + }, + "duration": { + "oneOf": [ + {"type": "integer", "minimum": 5, "maximum": 20}, + {"type": "string", "const": "auto"}, + ], + "default": "auto", + "description": 'Clip duration in whole seconds (5-20), or "auto".', + }, + "resolution": { + "type": "string", + "enum": list(_RESOLUTIONS), + "default": "720p", + "description": "Output resolution bin.", + }, + "generate_audio": { + "type": "boolean", + "default": True, + "description": "Generate synchronized audio.", + }, + "grounding": { + "type": "boolean", + "default": True, + "description": "Allow a short research pass before generation.", + }, + "seed": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "Optional reproducibility seed.", + }, + "version": { + "type": "string", + "description": 'Model version pin. Defaults to "latest".', + }, + } + + +TEXT_TO_VIDEO_SCHEMA = { + "name": "bfl_flux3_text_to_video", + "description": ( + _GUIDE_POINTER + + "FLUX 3 text-to-video: generates a clip (with audio) from the prompt alone. Nothing but " + "the prompt anchors the subject here, so research anything with a real, checkable " + "appearance before writing it — whatever you leave unspecified is filled in for you. " + "Generation takes several minutes: this returns a job id immediately; poll " + "bfl_flux3_get_result. " + + _OVERRIDE_SENTENCE + ), + "parameters": { + "type": "object", + "properties": _shared_submit_properties(), + "required": ["prompt"], + "additionalProperties": False, + }, +} + +IMAGE_TO_VIDEO_SCHEMA = { + "name": "bfl_flux3_image_to_video", + "description": ( + _GUIDE_POINTER + + "FLUX 3 image-to-video: animates one image as the literal opening frame — those pixels " + "are frame 0 and the clip moves from there. " + + _MEDIA_SENTENCE + + "Returns a job id; poll bfl_flux3_get_result. " + + _OVERRIDE_SENTENCE + ), + "parameters": { + "type": "object", + "properties": { + **_shared_submit_properties(), + "input_image": { + "type": "string", + "minLength": 1, + "description": "Exactly one opening-frame image: a local file path or a URL (PNG/JPEG/WebP, up to 10MB).", + }, + }, + "required": ["prompt", "input_image"], + "additionalProperties": False, + }, +} + +KEYFRAMES_TO_VIDEO_SCHEMA = { + "name": "bfl_flux3_keyframes_to_video", + "description": ( + _GUIDE_POINTER + + "FLUX 3 keyframe video: a storyboard of 1-10 images pinned at chosen frame positions " + "(24fps). Name the subject and describe the motion that carries it between the pins, and " + "keep the subject consistent across every pinned image. " + + _MEDIA_SENTENCE + + "Returns a job id; poll bfl_flux3_get_result. " + + _OVERRIDE_SENTENCE + ), + "parameters": { + "type": "object", + "properties": { + **_shared_submit_properties(), + "input_images": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "minItems": 1, + "maxItems": 10, + "description": "1-10 keyframe images: local file paths or URLs (PNG/JPEG/WebP, up to 10MB each).", + }, + "keyframe_indices": { + "type": "array", + "items": {"type": "integer", "minimum": 0, "maximum": 480}, + "minItems": 1, + "maxItems": 10, + "description": ( + "One unique non-negative frame index per image (24fps). Each must be at most " + 'duration×24, so set an explicit duration rather than "auto" whenever you pin ' + 'indices — "auto" resolves to 5, 10, 15 or 20 seconds and an index past the ' + "length it picks is rejected." + ), + }, + }, + "required": ["prompt", "input_images", "keyframe_indices"], + "additionalProperties": False, + }, +} + +VIDEO_CONTINUATION_SCHEMA = { + "name": "bfl_flux3_video_continuation", + "description": ( + _GUIDE_POINTER + + "FLUX 3 video continuation: the new generation picks up from the input clip's final " + 'frames. Open the prompt with "Continue this video from its final frames:", re-establish ' + "the subject and the moment it ended on, then describe what happens next. input_video " + "must be an mp4 of at most 50MB and 15 seconds, and the generated segment tops out at 15s " + "too — chain a second continuation for a longer sequence. duration is the " + "new segment only. " + + _MEDIA_SENTENCE + + "Returns a job id; poll bfl_flux3_get_result. " + + _OVERRIDE_SENTENCE + ), + "parameters": { + "type": "object", + "properties": { + **_shared_submit_properties(), + "input_video": { + "type": "string", + "minLength": 1, + "description": "The clip to continue: a local file path or a URL. mp4 only, at most 50MB and 15 seconds.", + }, + }, + "required": ["prompt", "input_video"], + "additionalProperties": False, + }, +} + +GET_RESULT_SCHEMA = { + "name": "bfl_flux3_get_result", + "description": ( + "Poll a FLUX 3 video job by the job id a generate tool returned. Generation takes minutes " + "and a long Generating phase is normal. This call waits for you while the job runs, so it " + "may take a couple of minutes; if it returns still generating, just call it again. Do not " + "sleep between calls. " + "On Ready the clip is downloaded for you and the response gives its local path; your only " + "remaining step is to deliver that file as the response describes." + ), + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Job id from a previous bfl_flux3_* generate call.", + }, + "save_to": { + "type": "string", + "description": ( + "Where to save the finished clip: a directory or a full file path. Set this " + "only when the user asked for a particular location; the default is " + "~/Downloads. An existing file is never overwritten." + ), + }, + }, + "required": ["id"], + "additionalProperties": False, + }, +} + +PROMPTING_GUIDE_SCHEMA = { + "name": "bfl_flux3_prompting_guide", + "description": ( + "Read this before your first FLUX 3 generation. The prompting and grounding guide: how " + "to research a subject so it renders as itself, how to assemble a prompt, which generate " + "tool fits, and how to save and deliver the finished clip. Takes no arguments and spends " + "no generation budget." + ), + "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, +} + + +# --------------------------------------------------------------------------- +# Pinned prompting guide (methodology only — policy numbers such as waits and +# limits arrive live in the gateway's tool responses, so they cannot drift) +# --------------------------------------------------------------------------- + +FLUX3_PROMPTING_GUIDE = """# FLUX 3 video generation — how to get the best results + +Everything here is guidance, not policy: the user's explicit instructions +always win. If they say skip the research, save somewhere specific, or deliver +differently — do it their way without arguing. Only the server's own validation +and limits are non-negotiable. + +Start from the user's own wording. The prompt is rewritten by a reasoning +harness before anything is generated, so restyling it yourself just stacks a +second rewrite on top and gives their intent another chance to drift. There are +two reasons to add anything: grounding facts for a subject with a real, +checkable appearance, and the behaviours below, which they had no way to know +about. Absent those, send what they wrote. + +## Grounding + +Grounding is your job, and it is the highest-leverage step in the workflow. +What you specify is preserved; what you leave out is filled in for you. So +when a subject has a real, checkable appearance, research it first and put +what you found into the prompt — that is what makes the result yours rather +than an approximation of it. + +Ground whenever someone who knows the subject could watch the clip and say +"that's not what that is": a named person or place, a landmark, a particular +vehicle or machine, anything technical, anything culturally specific, or a +period setting. Skip it for generics ("a dog on a beach") — there is no fact +to get wrong. + +To ground: search for VISUAL references, where three good photographs beat any +amount of prose. If you can analyze images, analyze what you find. Then put +only what a camera could see into the prompt: silhouette and proportion, +materials and finish, specific colours, distinctive details, era-correct +context. + +## How this model behaves + +The prompt is read by a reasoning harness rather than a tag encoder, so keyword +tricks and word order do nothing. Write plain prose at whatever length the +brief deserves; a single line is a valid prompt. + +Audio is generated by default, whether or not you mention it, so leaving it out +gets you invented sound rather than silence. Name the ambient sound, the music +and any speech separately — each lands as its own layer — and say "no music" +when you do not want it. + +A quoted line becomes speech only if a speaker is visible on camera. Without +one it tends to render as burned-in text instead, so quote the line, describe +the speaker, and add "no on-screen text, no subtitles". + +Multi-shot sequences work inside a single generation: "SHOT ONE ... HARD CUT. +SHOT TWO ..." produces real cuts, and "one continuous unbroken shot" gets an +uncut take. Consecutive shots have to contrast in scale, location or colour or +the cut will not read as one — near-identical coverage blends back into a +continuous take. + +## Choosing a tool + +- No input media -> bfl_flux3_text_to_video. +- Animate one image as the literal opening frame -> + bfl_flux3_image_to_video (those pixels are frame 0, and the clip moves from + there). Name the subject and its distinguishing specifics here as you would + anywhere else: only frame 0 is pinned, every frame after it is generated, + and what you leave out is the model's call rather than yours. +- Several images pinned at chosen moments -> bfl_flux3_keyframes_to_video + (name the subject and describe the motion that carries it between the pins; + keep it consistent across every pin, since a mismatch becomes a visible + morph mid-clip). Pin exact indices and you want an explicit duration too — + every index must fall within duration×24. +- Keep going from where a clip ends, or chain segments -> + bfl_flux3_video_continuation. Open with "Continue this video from its final + frames:", re-establish the subject and the moment the clip ended on, then + describe what happens next; duration is the new segment only, so plan + segments of 15 seconds or less to feed outputs back in. + +## Media inputs + +Pass local file paths directly — never hand-encode file contents into an argument. +URLs also work. Limits per file: +images 10MB (PNG/JPEG/WebP), video one mp4 of at most 50MB and 15 seconds. +Do not pre-shrink files to fit imagined caps; oversized pixel dimensions are +auto-downscaled and output tops out at 720p. + +## Workflow + +Submit returns a job id immediately — the video does not exist yet. Poll +bfl_flux3_get_result with that id; generation takes several minutes and a long +Generating phase is normal, not a stall. Nothing reaches disk before the job is +Ready, so checking folders mid-run tells you nothing. + +The waiting is not yours to do. bfl_flux3_get_result takes the pause itself +while a job is still running, so one call can occupy a couple of minutes and +comes back the moment the job finishes. If it returns still generating, just +call it again — no sleeping, no interval to judge, nothing to time. + +A job survives client restarts: re-poll the same id rather than resubmitting, +which would only spend your budgets on duplicate work. + +## Save and deliver + +bfl_flux3_get_result saves the clip itself and returns saved_path. The download +is not yours to do and no URL is handed to you to fetch. Pass save_to only when +the user named a location; otherwise it lands in ~/Downloads, and an existing +file is never overwritten. If saving fails the response says so — poll the same +job again to retry, which is safe and spends no generation budget. + +Then deliver that file so the clip plays inline. Which markup plays inline is the +host's decision, so check your system prompt or platform instructions and use +exactly the form they give; the common ones are a MEDIA: tag alone on its own +line and a markdown embed. Two things break it. Write the real absolute path, +with ~ expanded. And keep the markup plain: wrapping it in bold, backticks or a +code fence, or rewriting it as a [link](path), turns an inline player into +literal text or a click-target. That is the most common way this step fails, +and it reads as success because the filename is on screen. Where the +instructions call for no delivery markup, or the host has no such mechanism, +follow them and state the absolute path in plain text. + +Report what you did rather than what the job says it did: the echoed prompt +field describes intent and often overstates what the render preserved. +""" + + +# --------------------------------------------------------------------------- +# Registration (auto-discovered: top-level registry.register calls) +# --------------------------------------------------------------------------- + +registry.register( + name="bfl_flux3_text_to_video", + toolset=_TOOLSET, + schema=TEXT_TO_VIDEO_SCHEMA, + handler=_handle_text_to_video, + check_fn=check_bfl_requirements, + requires_env=[], + is_async=True, + emoji="🎬", +) + +registry.register( + name="bfl_flux3_image_to_video", + toolset=_TOOLSET, + schema=IMAGE_TO_VIDEO_SCHEMA, + handler=_handle_image_to_video, + check_fn=check_bfl_requirements, + requires_env=[], + is_async=True, + emoji="🎬", +) + +registry.register( + name="bfl_flux3_keyframes_to_video", + toolset=_TOOLSET, + schema=KEYFRAMES_TO_VIDEO_SCHEMA, + handler=_handle_keyframes_to_video, + check_fn=check_bfl_requirements, + requires_env=[], + is_async=True, + emoji="🎬", +) + +registry.register( + name="bfl_flux3_video_continuation", + toolset=_TOOLSET, + schema=VIDEO_CONTINUATION_SCHEMA, + handler=_handle_video_continuation, + check_fn=check_bfl_requirements, + requires_env=[], + is_async=True, + emoji="🎬", +) + +registry.register( + name="bfl_flux3_get_result", + toolset=_TOOLSET, + schema=GET_RESULT_SCHEMA, + handler=_handle_get_result, + check_fn=check_bfl_requirements, + requires_env=[], + is_async=True, + emoji="🎬", +) + +registry.register( + name="bfl_flux3_prompting_guide", + toolset=_TOOLSET, + schema=PROMPTING_GUIDE_SCHEMA, + handler=_handle_prompting_guide, + check_fn=check_bfl_requirements, + requires_env=[], + is_async=True, + emoji="📖", +) diff --git a/tools/image_source.py b/tools/image_source.py index e8d55cebab..e46651e482 100644 --- a/tools/image_source.py +++ b/tools/image_source.py @@ -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" 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 diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index 2bbc9dcff1..7391081366 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -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:` 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:`` 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 + diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 90fe360e98..1689edbe86 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -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//`` 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:`` 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//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 { diff --git a/toolsets.py b/toolsets.py index 4c7588b0ab..f4bb3c3343 100644 --- a/toolsets.py +++ b/toolsets.py @@ -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 diff --git a/ui-tui/src/__tests__/attachments.test.ts b/ui-tui/src/__tests__/attachments.test.ts new file mode 100644 index 0000000000..50e5088bcc --- /dev/null +++ b/ui-tui/src/__tests__/attachments.test.ts @@ -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)]) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 1a446f69e1..3802769e93 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -7,7 +7,6 @@ import type { BillingCardInfo, BillingMutationResponse, BillingStateResponse, - ImageAttachResponse, SessionCloseResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, @@ -364,6 +363,10 @@ export interface ComposerPasteResult { export type MaybePromise = Promise | 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 + setComposerTokens: StateSetter setHistoryIdx: StateSetter setInput: StateSetter setInputBuf: StateSetter - setPasteSnips: StateSetter 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 queueRef: MutableRefObject submitRef: MutableRefObject<(value: string) => void> + tokensRef: MutableRefObject } 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 - 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 - paste: (quiet?: boolean) => void queueRef: MutableRefObject selection: SelectionApi setInput: StateSetter @@ -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 } diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 00321ecc90..865c9cb5d6 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -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()) }, { diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index a412949019..bc072ec380 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -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('image.attach', { path: arg, session_id: ctx.sid }).then( - ctx.guarded(r => { - ctx.transcript.sys(attachedImageNotice(r)) - - if (r.remainder) { - ctx.composer.setInput(r.remainder) - } - }) - ) - } + run: (arg, ctx) => ctx.composer.attachImagePath(arg) }, { diff --git a/ui-tui/src/app/submissionCore.ts b/ui-tui/src/app/submissionCore.ts index c99eca09f2..aa6cd98db6 100644 --- a/ui-tui/src/app/submissionCore.ts +++ b/ui-tui/src/app/submissionCore.ts @@ -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('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)) diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 40120326a8..188e3c5727 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -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([]) - const [pasteSnips, setPasteSnips] = useState([]) + const [tokens, setTokens] = useState([]) + // 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([]) + + const setInput = useCallback>(next => { + inputRef.current = typeof next === 'function' ? next(inputRef.current) : next + setInputState(inputRef.current) + }, []) + + const setComposerTokens = useCallback>(next => { + tokensRef.current = typeof next === 'function' ? next(tokensRef.current) : next + setTokens(tokensRef.current) + }, []) + const isBlocked = useStore($isBlocked) const { querier } = useStdin() as { querier: Parameters[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 => { + const sid = getUiState().sid + + if (!sid) { + return null + } + + const r = await gw + .request('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): Promise => { + async ({ bracketed, cursor, text, value }: Omit): Promise => { 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 => { + }: PasteEvent): MaybePromise => { 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) => { + 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('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 { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 5766ebb6a7..18277fd663 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) - const clipboardPasteRef = useRef<(quiet?: boolean) => Promise | void>(() => {}) + const sysRef = useRef<(text: string) => void>(() => {}) const submitRef = useRef<(value: string) => void>(() => {}) const terminalHintsShownRef = useRef(new Set()) 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('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>( + 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 diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 4cce57bae5..13dab7ce4c 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -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) }) }, diff --git a/ui-tui/src/app/useSubmission.test.ts b/ui-tui/src/app/useSubmission.test.ts deleted file mode 100644 index 34202104dd..0000000000 --- a/ui-tui/src/app/useSubmission.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 0ced5f0b8a..28ffaff9ca 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -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() - - 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, diff --git a/ui-tui/src/domain/attachments.ts b/ui-tui/src/domain/attachments.ts new file mode 100644 index 0000000000..7894cf170a --- /dev/null +++ b/ui-tui/src/domain/attachments.ts @@ -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() + + 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() +} diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts index 45fbf606c0..b4428520d8 100644 --- a/ui-tui/src/domain/messages.ts +++ b/ui-tui/src/domain/messages.ts @@ -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 diff --git a/uv.lock b/uv.lock index 425c30c6db..2662d6292c 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] diff --git a/web/src/lib/pty-resume-loading.test.ts b/web/src/lib/pty-resume-loading.test.ts new file mode 100644 index 0000000000..72f6e5f779 --- /dev/null +++ b/web/src/lib/pty-resume-loading.test.ts @@ -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); + } + }); +}); diff --git a/web/src/lib/pty-resume-loading.ts b/web/src/lib/pty-resume-loading.ts new file mode 100644 index 0000000000..9a150664d5 --- /dev/null +++ b/web/src/lib/pty-resume-loading.ts @@ -0,0 +1,47 @@ +import type { PtyConnectionState } from "@/lib/pty-reconnect"; + +/** + * Hard cap so a wedged resume (never gets PTY payload) cannot leave the + * wait notice up forever. + */ +export const PTY_RESUME_LOADING_MAX_MS = 30000; + +export const PTY_RESUME_LOADING_MESSAGE = + "Please wait while the conversation loads…"; + +export interface ResumeLoadingOverlayInput { + hasResumeTarget: boolean; + ptyState: PtyConnectionState; + hydrating: boolean; +} + +/** + * Show a wait notice only while a resumed chat is still blank. Once the + * first real PTY payload arrives the terminal has something to show, so + * the notice hides and history can stream in underneath. + * + * Reconnect / ended / closed states keep their own overlays and must not + * stack this one on top. + */ +export function shouldShowResumeLoadingOverlay({ + hasResumeTarget, + ptyState, + hydrating, +}: ResumeLoadingOverlayInput): boolean { + if (!hasResumeTarget || !hydrating) { + return false; + } + if ( + ptyState === "reconnecting" || + ptyState === "closed" || + ptyState === "ended" + ) { + return false; + } + return ptyState === "connecting" || ptyState === "open"; +} + +/** First non-empty PTY chunk means the blank window is over. */ +export function shouldFinishResumeHydrationOnChunk(chunkText: string): boolean { + return chunkText.length > 0; +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 081cccc0c4..529f1d69f9 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -47,6 +47,12 @@ import { shouldBlockPtyInput, shouldReconnectPtyOnPageResume, } from "@/lib/pty-reconnect"; +import { + PTY_RESUME_LOADING_MAX_MS, + PTY_RESUME_LOADING_MESSAGE, + shouldFinishResumeHydrationOnChunk, + shouldShowResumeLoadingOverlay, +} from "@/lib/pty-resume-loading"; import { MOBILE_REPLACEMENT_WINDOW_MS, normalizePtyMobileInput, @@ -204,6 +210,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const [ptyState, setPtyState] = useState("connecting"); const ptyStateRef = useRef("connecting"); + // True until the first real PTY payload arrives for a resumed session. + // Covers the blank terminal + blinking-cursor window so users don't think + // chat is broken; clears as soon as there is something to show. + const [resumeHydrating, setResumeHydrating] = useState(false); const [lastCloseCode, setLastCloseCode] = useState(null); // NS-504: when the agent process exits cleanly (the user typed `/exit`, or // started a new session that ended the current PTY child), the PTY socket @@ -892,12 +902,42 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { let onDataDisposable: { dispose(): void } | null = null; let onResizeDisposable: { dispose(): void } | null = null; let eraseSuppressionTimer: ReturnType | null = null; + let resumeMaxTimer: ReturnType | null = null; const clearEraseSuppressionTimer = () => { if (eraseSuppressionTimer) { clearTimeout(eraseSuppressionTimer); eraseSuppressionTimer = null; } }; + const clearResumeLoadingTimers = () => { + if (resumeMaxTimer) { + clearTimeout(resumeMaxTimer); + resumeMaxTimer = null; + } + }; + const finishResumeHydration = () => { + clearResumeLoadingTimers(); + if (!unmounting) { + setResumeHydrating(false); + } + }; + const noteResumePtyChunk = (chunkText: string) => { + if (!resumeParam || unmounting) { + return; + } + if (shouldFinishResumeHydrationOnChunk(chunkText)) { + finishResumeHydration(); + } + }; + if (resumeParam) { + setResumeHydrating(true); + resumeMaxTimer = setTimeout( + finishResumeHydration, + PTY_RESUME_LOADING_MAX_MS, + ); + } else { + setResumeHydrating(false); + } const forceFresh = forceFreshPtyRef.current; forceFreshPtyRef.current = false; // A connect attempt is now in flight — set synchronously (before the async @@ -1018,7 +1058,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { : decoder.decode(new Uint8Array(ev.data as ArrayBuffer), { stream: true, }); - term.write(resumeParam ? sanitizer.next(text) : text); + // Gate hydration on the payload actually written to xterm. The + // sanitizer can turn a nonempty erase-only / all-newline / partial-CSI + // resume frame into "" (pty-resume-sanitizer.ts); keying off raw `text` + // would hide the wait notice while the terminal is still blank. + const rendered = resumeParam ? sanitizer.next(text) : text; + term.write(rendered); + noteResumePtyChunk(rendered); }; ws.onclose = (ev) => { @@ -1180,6 +1226,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { imageUploadDisposed = true; syncMetricsRef.current = null; clearEraseSuppressionTimer(); + clearResumeLoadingTimers(); + setResumeHydrating(false); onDataDisposable?.dispose(); onResizeDisposable?.dispose(); mobileInputCleanup?.(); @@ -1354,6 +1402,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const visibleBanner = banner ?? reconnectBanner; const showReconnectOverlay = ptyState === "reconnecting" || (ptyState === "closed" && !banner); + const showResumeLoadingOverlay = shouldShowResumeLoadingOverlay({ + hasResumeTarget: Boolean(resumeParam), + ptyState, + hydrating: resumeHydrating, + }); const mobileModelToolsPortal = isActive && narrow && @@ -1488,6 +1541,19 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
)} + {showResumeLoadingOverlay && ( +
+
+ {PTY_RESUME_LOADING_MESSAGE} +
+
+ )} + {/* NS-504: the agent process exited (e.g. `/exit` or a new session). Offer an in-place restart so the user never has to refresh the whole page to get a working chat back. */}