mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge origin/main into feat/hermes-relay-shared-metrics
Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
commit
4fe4b0dca7
192 changed files with 10055 additions and 594 deletions
|
|
@ -1952,8 +1952,12 @@ def init_agent(
|
|||
# parent_session_id chain, no `name #N` renumber). See #38763 and
|
||||
# agent/conversation_compression.py. Consumed by compress_context(), not the
|
||||
# compressor, so it rides on the agent.
|
||||
# Default True must match DEFAULT_CONFIG["compression"]["in_place"]
|
||||
# (#38763). default=False here previously flipped agents into rotation
|
||||
# mode whenever the merged config omitted the key (partial configs,
|
||||
# load_config failure → {}), re-arming the pre-lease drift abort.
|
||||
compression_in_place = is_truthy_value(
|
||||
_compression_cfg.get("in_place"), default=False
|
||||
_compression_cfg.get("in_place"), default=True
|
||||
)
|
||||
codex_app_server_auto_compaction = str(
|
||||
_compression_cfg.get("codex_app_server_auto", "native") or "native"
|
||||
|
|
|
|||
|
|
@ -4381,6 +4381,7 @@ def _try_main_agent_model_fallback(
|
|||
failed_provider: str,
|
||||
task: str = None,
|
||||
reason: str = "error",
|
||||
failed_model: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||
"""Last-resort fallback to the user's main agent provider + model.
|
||||
|
||||
|
|
@ -4389,8 +4390,23 @@ def _try_main_agent_model_fallback(
|
|||
layer: if nothing the user asked for can serve the request, try the
|
||||
main chat model before giving up.
|
||||
|
||||
Skips when the failed provider already IS the main provider (no point
|
||||
retrying the same backend that just failed).
|
||||
``failed_model`` narrows the same-provider skip to the exact
|
||||
(provider, model) pair that just failed, mirroring
|
||||
:func:`_try_configured_fallback_chain`. This matters for self-hosted /
|
||||
custom endpoints serving several models behind one provider label: the
|
||||
aux compression model timing out says nothing about the health of the
|
||||
main agent model deployed on the same URL (real incident: aux
|
||||
``glm-5.2`` hung and timed out while main ``macaron-v1-venti`` on the
|
||||
identical endpoint was serving 448K-token turns fine — the
|
||||
provider-label skip discarded the one fallback that would have worked).
|
||||
|
||||
- Model-specific runtime failures (timeout, connection, rate limit,
|
||||
model-incompatible, invalid response) pass ``failed_model``: skip the
|
||||
main model only when it IS the exact model that failed.
|
||||
- Provider-wide failures (auth 401, payment 402) and legacy callers
|
||||
leave ``failed_model`` as None, keeping the whole-provider skip —
|
||||
the shared credentials/account are broken, so the main model on the
|
||||
same provider cannot help either.
|
||||
|
||||
Returns:
|
||||
(client, model, provider_label) or (None, None, "") if no fallback.
|
||||
|
|
@ -4407,9 +4423,23 @@ def _try_main_agent_model_fallback(
|
|||
if not main_provider or not main_model or main_provider.lower() in {"auto", ""}:
|
||||
return None, None, ""
|
||||
|
||||
skip = (failed_provider or "").lower().strip()
|
||||
if main_provider.lower() == skip:
|
||||
# The thing that failed IS the main model — nothing to fall back to.
|
||||
# Identity + scope semantics owned by agent.backend_identity (#72468):
|
||||
# model-scoped failures skip only the exact deployment that failed;
|
||||
# provider-wide failures (no failed_model) skip the credential surface.
|
||||
from agent.backend_identity import (
|
||||
BackendIdentity,
|
||||
FailureScope,
|
||||
should_skip_candidate,
|
||||
)
|
||||
|
||||
skip_model = (failed_model or "").strip().lower() or None
|
||||
if should_skip_candidate(
|
||||
BackendIdentity.build(provider=main_provider, model=main_model),
|
||||
BackendIdentity.build(provider=failed_provider, model=skip_model),
|
||||
FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL,
|
||||
):
|
||||
# The thing that failed IS the main model (or the failure was
|
||||
# provider-wide) — nothing to fall back to.
|
||||
return None, None, ""
|
||||
if _is_provider_unhealthy(main_provider):
|
||||
_log_skip_unhealthy(main_provider, task)
|
||||
|
|
@ -4519,6 +4549,7 @@ def _try_configured_fallback_chain(
|
|||
task: str,
|
||||
failed_provider: str,
|
||||
reason: str = "error",
|
||||
failed_model: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||
"""Try user-configured fallback_chain for a specific auxiliary task.
|
||||
|
||||
|
|
@ -4526,6 +4557,25 @@ def _try_configured_fallback_chain(
|
|||
entry in order. Each entry must have at least ``provider``; ``model``,
|
||||
``base_url``, and ``api_key`` are optional.
|
||||
|
||||
``failed_model`` narrows the skip check to the exact (provider, model)
|
||||
pair that just failed, rather than the whole provider. Without it every
|
||||
entry sharing the failed provider is skipped (the original behaviour).
|
||||
Callers pass it only when a sibling model on the same provider could
|
||||
plausibly recover:
|
||||
|
||||
- Model-specific runtime failures (timeout, connection, rate limit,
|
||||
model-incompatible, invalid response) pass ``failed_model`` so a
|
||||
chain that intentionally lists several models under the same provider
|
||||
— e.g. two more NVIDIA NIM models after the primary NIM model times
|
||||
out — is not skipped wholesale. Only the exact model that failed is
|
||||
skipped; the siblings still run instead of jumping straight to the
|
||||
main-agent-model safety net.
|
||||
- Provider-wide failures (auth 401, payment 402) and "no client could
|
||||
be built" callers leave ``failed_model`` as None, keeping the whole
|
||||
provider skipped — the shared credentials/account behind every model
|
||||
on that provider are broken, so a sibling can't help and the
|
||||
main-agent-model safety net should be reached instead.
|
||||
|
||||
Returns:
|
||||
(client, model, provider_label) or (None, None, "") if no fallback.
|
||||
"""
|
||||
|
|
@ -4537,7 +4587,24 @@ def _try_configured_fallback_chain(
|
|||
if not chain or not isinstance(chain, list):
|
||||
return None, None, ""
|
||||
|
||||
skip = failed_provider.lower().strip()
|
||||
skip_model = (failed_model or "").strip().lower() or None
|
||||
# Identity + scope semantics owned by agent.backend_identity (#59561,
|
||||
# #72468): a failed_model means the failure was model-scoped (timeout /
|
||||
# connection / rate limit) — only the exact deployment is skipped; no
|
||||
# failed_model means provider-wide (auth/payment) — the whole credential
|
||||
# surface is skipped.
|
||||
from agent.backend_identity import (
|
||||
BackendIdentity,
|
||||
FailureScope,
|
||||
should_skip_candidate,
|
||||
)
|
||||
|
||||
failed_ident = BackendIdentity.build(
|
||||
provider=failed_provider, model=skip_model,
|
||||
)
|
||||
failure_scope = (
|
||||
FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL
|
||||
)
|
||||
tried = []
|
||||
min_ctx = _task_minimum_context_length(task)
|
||||
|
||||
|
|
@ -4545,9 +4612,20 @@ def _try_configured_fallback_chain(
|
|||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fb_provider = str(entry.get("provider", "")).strip()
|
||||
if not fb_provider or fb_provider.lower() == skip:
|
||||
if not fb_provider:
|
||||
continue
|
||||
fb_model = str(entry.get("model", "")).strip() or None
|
||||
fb_model_raw = str(entry.get("model", "")).strip()
|
||||
if should_skip_candidate(
|
||||
BackendIdentity.build(
|
||||
provider=fb_provider,
|
||||
model=fb_model_raw,
|
||||
base_url=str(entry.get("base_url") or ""),
|
||||
),
|
||||
failed_ident,
|
||||
failure_scope,
|
||||
):
|
||||
continue
|
||||
fb_model = fb_model_raw or None
|
||||
|
||||
label = f"fallback_chain[{i}]({fb_provider})"
|
||||
|
||||
|
|
@ -8355,6 +8433,15 @@ def call_llm(
|
|||
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
|
||||
task or "call", reason, resolved_provider, first_err)
|
||||
|
||||
# Narrow the configured-chain skip to the exact model that
|
||||
# failed ONLY for model-specific failures. Auth (401) and
|
||||
# payment (402) errors are provider-wide — the credentials or
|
||||
# account behind every model on that provider are the same — so
|
||||
# a sibling model can't recover; keep skipping the whole
|
||||
# provider so the main-agent-model safety net is still reached.
|
||||
_chain_failed_model = (
|
||||
None if reason in ("auth error", "payment error") else final_model
|
||||
)
|
||||
# Fallback order (#26882, #26803):
|
||||
# 1. User-configured fallback_chain (per-task) if set
|
||||
# 2. For auto: top-level main fallback_providers/fallback_model
|
||||
|
|
@ -8363,7 +8450,8 @@ def call_llm(
|
|||
fb_client, fb_model, fb_label = (None, None, "")
|
||||
if is_auto:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
task, resolved_provider or "auto", reason=reason,
|
||||
failed_model=_chain_failed_model)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
|
|
@ -8372,10 +8460,12 @@ def call_llm(
|
|||
resolved_provider, task, reason=reason)
|
||||
else:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
task, resolved_provider or "auto", reason=reason,
|
||||
failed_model=_chain_failed_model)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_agent_model_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
resolved_provider, task, reason=reason,
|
||||
failed_model=_chain_failed_model)
|
||||
|
||||
if fb_client is not None:
|
||||
fb_resp = _call_fallback_candidate_sync(
|
||||
|
|
@ -8939,6 +9029,15 @@ async def async_call_llm(
|
|||
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
|
||||
task or "call", reason, resolved_provider, first_err)
|
||||
|
||||
# Narrow the configured-chain skip to the exact model that
|
||||
# failed ONLY for model-specific failures. Auth (401) and
|
||||
# payment (402) errors are provider-wide — the credentials or
|
||||
# account behind every model on that provider are the same — so
|
||||
# a sibling model can't recover; keep skipping the whole
|
||||
# provider so the main-agent-model safety net is still reached.
|
||||
_chain_failed_model = (
|
||||
None if reason in ("auth error", "payment error") else final_model
|
||||
)
|
||||
# Fallback order (#26882, #26803):
|
||||
# 1. User-configured fallback_chain (per-task) if set
|
||||
# 2. For auto: top-level main fallback_providers/fallback_model
|
||||
|
|
@ -8947,7 +9046,8 @@ async def async_call_llm(
|
|||
fb_client, fb_model, fb_label = (None, None, "")
|
||||
if is_auto:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
task, resolved_provider or "auto", reason=reason,
|
||||
failed_model=_chain_failed_model)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
|
|
@ -8956,10 +9056,12 @@ async def async_call_llm(
|
|||
resolved_provider, task, reason=reason)
|
||||
else:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
task, resolved_provider or "auto", reason=reason,
|
||||
failed_model=_chain_failed_model)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_agent_model_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
resolved_provider, task, reason=reason,
|
||||
failed_model=_chain_failed_model)
|
||||
|
||||
if fb_client is not None:
|
||||
# Convert sync fallback client to async
|
||||
|
|
|
|||
204
agent/backend_identity.py
Normal file
204
agent/backend_identity.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""Single owner for backend identity and failure-scoped skip decisions.
|
||||
|
||||
Every fallback / dedup / skip / quarantine decision in Hermes ultimately asks
|
||||
one question: **"is this candidate the same backend as the one that failed,
|
||||
along the axis that failure invalidated?"** Before this module, that
|
||||
question was re-implemented inline at six call sites across four subsystems,
|
||||
each comparing whatever string was locally convenient (provider label,
|
||||
provider+model, base_url+model, ...). Each incident fixed one site while the
|
||||
others kept the bug: #22548 (same-shim aliases), #70893 (xai-oauth vs xai —
|
||||
same host, distinct credential), #59561 (aux chain skipped sibling models),
|
||||
#72468 (aux main-model safety net, same bug three weeks later), #62984 /
|
||||
#54250 / #57584 (dedup ignoring base_url strands multi-endpoint pools).
|
||||
|
||||
The root insight: "provider" conflates three independent identity axes, and
|
||||
each failure class invalidates a different one:
|
||||
|
||||
* **credential surface** — auth 401 / payment 402 kill everything sharing the
|
||||
credential (every model, every host reached with that key/token).
|
||||
* **endpoint** — DNS failure / connection refused kill everything behind the
|
||||
URL, regardless of model or credential.
|
||||
* **model deployment** — timeout / overload / rate limit / model-incompatible
|
||||
kill ONE model's deployment. A sibling model behind the same URL is an
|
||||
independent deployment (real incident: aux ``glm-5.2`` hung and timed out
|
||||
while main ``macaron-v1-venti`` on the identical endpoint was serving
|
||||
448K-token turns).
|
||||
|
||||
Call sites should build :class:`BackendIdentity` values, classify the failure
|
||||
with :func:`classify_failure_scope`, and ask :func:`should_skip_candidate`.
|
||||
Do not re-implement any comparison inline — extend THIS module instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FailureScope(Enum):
|
||||
"""Which identity axis a failure invalidates."""
|
||||
|
||||
#: Timeout, overload/429, connection blip, model-incompatible, invalid
|
||||
#: response: evidence against ONE model deployment only.
|
||||
MODEL = "model"
|
||||
#: Auth 401 / payment 402: evidence against the shared credential —
|
||||
#: every model reached with it is equally dead.
|
||||
CREDENTIAL = "credential"
|
||||
#: DNS / connection-refused / unreachable host: evidence against the
|
||||
#: endpoint — every model behind the URL is equally dead.
|
||||
ENDPOINT = "endpoint"
|
||||
|
||||
|
||||
#: Reason strings already used by auxiliary_client's except-chain, mapped to
|
||||
#: scopes. Unknown reasons default to MODEL — the least-invalidating scope —
|
||||
#: so an unrecognized failure never over-skips viable candidates.
|
||||
_REASON_SCOPES = {
|
||||
"auth error": FailureScope.CREDENTIAL,
|
||||
"payment error": FailureScope.CREDENTIAL,
|
||||
"rate limit": FailureScope.MODEL,
|
||||
"model incompatible with route": FailureScope.MODEL,
|
||||
"invalid provider response": FailureScope.MODEL,
|
||||
"connection error": FailureScope.MODEL,
|
||||
"timeout": FailureScope.MODEL,
|
||||
}
|
||||
|
||||
|
||||
def classify_failure_scope(reason: Optional[str]) -> FailureScope:
|
||||
"""Map a human-readable failure reason to the identity axis it kills."""
|
||||
return _REASON_SCOPES.get((reason or "").strip().lower(), FailureScope.MODEL)
|
||||
|
||||
|
||||
def _norm_provider(value: Optional[str]) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _norm_model(value: Optional[str]) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _norm_base_url(value: Optional[str]) -> str:
|
||||
return (value or "").strip().rstrip("/").lower()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendIdentity:
|
||||
"""Normalized identity of one (provider, model, endpoint) deployment.
|
||||
|
||||
Empty fields mean "unknown" — comparisons treat an unknown axis as
|
||||
non-distinguishing (it can neither prove sameness nor difference on its
|
||||
own; the remaining axes decide).
|
||||
"""
|
||||
|
||||
provider: str = ""
|
||||
model: str = ""
|
||||
base_url: str = ""
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> "BackendIdentity":
|
||||
return cls(
|
||||
provider=_norm_provider(provider),
|
||||
model=_norm_model(model),
|
||||
base_url=_norm_base_url(base_url),
|
||||
)
|
||||
|
||||
|
||||
def _both_first_class(a: BackendIdentity, b: BackendIdentity) -> bool:
|
||||
"""True when both providers are distinct registered first-class providers.
|
||||
|
||||
Two different registry providers have distinct credential surfaces even
|
||||
when they share an inference host (xai-oauth vs xai, openai-codex vs
|
||||
openai-api) — #70893. Custom/shim aliases are NOT in the registry, so
|
||||
two aliases pointing at one URL still count as the same backend (#22548).
|
||||
"""
|
||||
if not a.provider or not b.provider or a.provider == b.provider:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
|
||||
return a.provider in PROVIDER_REGISTRY and b.provider in PROVIDER_REGISTRY
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def same_credential_surface(a: BackendIdentity, b: BackendIdentity) -> bool:
|
||||
"""Do two identities share the credential a 401/402 just invalidated?
|
||||
|
||||
Conservative on purpose: an unprovable axis must answer "different"
|
||||
(try the candidate — worst case one wasted RTT) rather than "same"
|
||||
(skip — worst case stranded failover). Two distinct custom labels at
|
||||
one URL may carry different per-entry api_keys, so a shared URL alone
|
||||
never proves a shared credential; it is only used as a weak signal
|
||||
when a provider label is missing entirely.
|
||||
"""
|
||||
if a.provider and b.provider:
|
||||
# Same label = same configured credential. Different labels =
|
||||
# different credential config (first-class registry providers
|
||||
# explicitly so — #70893; custom entries can each carry their own
|
||||
# api_key, so sameness is unprovable and we must not skip).
|
||||
return a.provider == b.provider
|
||||
# Provider unknown on a side: same explicit URL is the best signal left.
|
||||
return bool(a.base_url and a.base_url == b.base_url)
|
||||
|
||||
|
||||
def same_endpoint(a: BackendIdentity, b: BackendIdentity) -> bool:
|
||||
"""Do two identities sit behind the endpoint that just went unreachable?"""
|
||||
if a.base_url and b.base_url:
|
||||
return a.base_url == b.base_url
|
||||
# An unknown base_url inherits the provider default → same provider
|
||||
# label implies the same default endpoint.
|
||||
return bool(a.provider and a.provider == b.provider)
|
||||
|
||||
|
||||
def same_deployment(a: BackendIdentity, b: BackendIdentity) -> bool:
|
||||
"""Are these the exact same model deployment (the thing a timeout kills)?
|
||||
|
||||
Provider+model must match; the base_url axis distinguishes only when BOTH
|
||||
sides carry an explicit URL (#62984: same provider+model on two different
|
||||
explicit URLs is two deployments — a pool). A side with an unknown URL
|
||||
inherits the provider default and cannot prove difference.
|
||||
"""
|
||||
if not (a.provider and b.provider and a.provider == b.provider):
|
||||
# Same-host different-label shims: same URL + same model IS the same
|
||||
# deployment even when the alias labels differ (#22548) — unless both
|
||||
# labels are first-class registry providers (#70893).
|
||||
if (
|
||||
a.base_url
|
||||
and a.base_url == b.base_url
|
||||
and a.model
|
||||
and a.model == b.model
|
||||
and not _both_first_class(a, b)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
if not (a.model and b.model and a.model == b.model):
|
||||
return False
|
||||
if a.base_url and b.base_url and a.base_url != b.base_url:
|
||||
return False # distinct explicit endpoints — a pool, not a dup
|
||||
return True
|
||||
|
||||
|
||||
def should_skip_candidate(
|
||||
candidate: BackendIdentity,
|
||||
failed: BackendIdentity,
|
||||
scope: FailureScope = FailureScope.MODEL,
|
||||
) -> bool:
|
||||
"""THE skip predicate: would trying ``candidate`` just repeat the failure?
|
||||
|
||||
True when the candidate is the same backend as ``failed`` along the axis
|
||||
``scope`` says the failure invalidated. Every fallback/dedup/skip site
|
||||
must call this instead of comparing labels inline.
|
||||
"""
|
||||
if scope is FailureScope.CREDENTIAL:
|
||||
return same_credential_surface(candidate, failed)
|
||||
if scope is FailureScope.ENDPOINT:
|
||||
return same_endpoint(candidate, failed)
|
||||
return same_deployment(candidate, failed)
|
||||
|
|
@ -440,26 +440,56 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
|
|||
|
||||
|
||||
def should_use_direct_api_call(agent) -> bool:
|
||||
"""Whether a cron OpenAI-wire request should skip the interrupt worker.
|
||||
"""Whether an OpenAI-wire request should skip the interrupt worker.
|
||||
|
||||
Issue #62151 is specific to OpenRouter's chat-completions path inside the
|
||||
gateway cron thread stack. Keep native/Codex/Bedrock/MoA transports on their
|
||||
established workers: their cancellation and client ownership differ, and
|
||||
the report provides no evidence that those paths share the pre-HTTP wedge.
|
||||
Two nested-pool contexts wedge before the socket opens when the request
|
||||
is pushed onto yet another daemon worker thread:
|
||||
|
||||
- Gateway cron turns (#62151): gateway asyncio loop → cron thread →
|
||||
interrupt worker. Fixed by running inline.
|
||||
- Delegated children (#60203): gateway loop → async-delegation executor
|
||||
(module-lifetime daemon pool) → per-child timeout executor → interrupt
|
||||
worker. Same fingerprint after multi-day gateway uptime — children hang
|
||||
at their FIRST API call with zero stale-detector output (the worker
|
||||
never reaches dispatch), all providers, restart cures it. The cron fix
|
||||
originally excluded delegation "for lack of evidence"; #60203 is that
|
||||
evidence.
|
||||
|
||||
Running inline drops the deepest thread layer (whose only job is
|
||||
interactive-interrupt responsiveness). Interrupts still work: the inline
|
||||
path registers ``agent._active_request_abort``, which ``interrupt()``
|
||||
invokes cross-thread to shut the active sockets — the same mechanism the
|
||||
async-delegation stall monitor (#72227) relies on.
|
||||
|
||||
Keep native/Codex/Bedrock/MoA transports on their established workers:
|
||||
their cancellation and client ownership differ.
|
||||
"""
|
||||
return (
|
||||
getattr(agent, "platform", None) == "cron"
|
||||
and getattr(agent, "api_mode", None) == "chat_completions"
|
||||
and getattr(agent, "provider", None) != "moa"
|
||||
)
|
||||
if getattr(agent, "api_mode", None) != "chat_completions":
|
||||
return False
|
||||
if getattr(agent, "provider", None) == "moa":
|
||||
return False
|
||||
if getattr(agent, "platform", None) == "cron":
|
||||
return True
|
||||
# Delegated child (delegate_task sync or background) — detected via the
|
||||
# execution ContextVar set by _run_single_child, with the agent's own
|
||||
# platform stamp as a fallback for callers that bypass the runner.
|
||||
try:
|
||||
from agent.delegation_context import is_delegated_child_context
|
||||
|
||||
if is_delegated_child_context():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return getattr(agent, "platform", None) == "subagent"
|
||||
|
||||
|
||||
def direct_api_call(agent, api_kwargs: dict):
|
||||
"""Run a non-streaming LLM call inline on the conversation thread.
|
||||
|
||||
Used when ``should_use_direct_api_call`` is True. Skips the interrupt worker
|
||||
(whose only job is interactive-interrupt responsiveness, which this context
|
||||
does not have) so the nested-pool deadlock (#62151) cannot occur. Because the
|
||||
Used when ``should_use_direct_api_call`` is True (cron turns and
|
||||
delegated children). Skips the interrupt worker (whose only job is
|
||||
interactive-interrupt responsiveness, which these contexts do not have)
|
||||
so the nested-pool deadlock (#62151, #60203) cannot occur. Because the
|
||||
request runs in-flight normally, the per-request OpenAI client's own httpx
|
||||
timeout (provider ``request_timeout_seconds`` / ``HERMES_API_TIMEOUT``) bounds
|
||||
a genuinely hung provider — the same bound interactive calls already rely on.
|
||||
|
|
@ -470,7 +500,7 @@ def direct_api_call(agent, api_kwargs: dict):
|
|||
request_client_lock = threading.Lock()
|
||||
|
||||
def _abort_active_request(reason: str) -> None:
|
||||
"""Abort the inline request from cron's watchdog/interrupt thread."""
|
||||
"""Abort the inline request from a watchdog/interrupt thread."""
|
||||
with request_client_lock:
|
||||
request_client = request_client_holder["client"]
|
||||
if request_client is not None:
|
||||
|
|
@ -1521,45 +1551,6 @@ def _fallback_entry_key(fb: dict) -> tuple[str, str, str]:
|
|||
)
|
||||
|
||||
|
||||
def _fallback_entry_is_same_backend_by_base_url(
|
||||
*,
|
||||
current_provider: str,
|
||||
fb_provider: str,
|
||||
current_base_url: str,
|
||||
fb_base_url: str,
|
||||
current_model: str,
|
||||
fb_model: str,
|
||||
) -> bool:
|
||||
"""True when base_url+model identity means the fallback is the same backend.
|
||||
|
||||
Issue #22548: two ``custom_providers`` aliases that point at the same shim
|
||||
URL with the same model must be skipped, or failover loops on the dead
|
||||
backend. First-class providers that share a host while using different
|
||||
auth (``xai-oauth`` vs ``xai``, ``openai-codex`` vs ``openai-api``) are
|
||||
distinct credential surfaces — skipping them strands configured failover
|
||||
when primary and fallback reuse the same model slug on that host.
|
||||
"""
|
||||
if not (
|
||||
fb_base_url
|
||||
and current_base_url
|
||||
and fb_base_url == current_base_url
|
||||
and fb_model == current_model
|
||||
):
|
||||
return False
|
||||
if fb_provider == current_provider:
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
|
||||
# Both sides are registered first-class providers → different auth
|
||||
# identities even when the inference host matches. Allow failover.
|
||||
if current_provider in PROVIDER_REGISTRY and fb_provider in PROVIDER_REGISTRY:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _fallback_entry_unavailable_without_network(agent, fb: dict) -> Optional[str]:
|
||||
"""Return a skip reason for fallback entries known to be unusable locally."""
|
||||
fb_provider = (fb.get("provider") or "").strip().lower()
|
||||
|
|
@ -1645,33 +1636,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
|||
)
|
||||
return agent._try_activate_fallback(reason)
|
||||
|
||||
# Skip entries that resolve to the current (provider, model) — falling
|
||||
# back to the same backend that just failed loops the failure. Compare
|
||||
# base_url too so two distinct custom_providers entries pointing at the
|
||||
# same shim/proxy URL also dedup. See issue #22548. Do NOT treat
|
||||
# first-class providers that share a host (xai-oauth vs xai) as the same
|
||||
# backend — they use different credentials.
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
current_model = (getattr(agent, "model", "") or "").strip()
|
||||
current_base_url = str(getattr(agent, "base_url", "") or "").rstrip("/").lower()
|
||||
fb_base_url_for_dedup = (fb.get("base_url") or "").strip().rstrip("/").lower()
|
||||
if fb_provider == current_provider and fb_model == current_model:
|
||||
# Skip entries that resolve to the same backend that just failed —
|
||||
# falling back to it loops the failure. Identity semantics (which axes
|
||||
# distinguish two backends, shim aliases, first-class credential
|
||||
# surfaces, multi-endpoint pools) are owned by agent.backend_identity —
|
||||
# see #22548, #70893, #62984. Do not re-implement comparisons here.
|
||||
from agent.backend_identity import BackendIdentity, should_skip_candidate
|
||||
|
||||
current_ident = BackendIdentity.build(
|
||||
provider=getattr(agent, "provider", ""),
|
||||
model=getattr(agent, "model", ""),
|
||||
base_url=str(getattr(agent, "base_url", "") or ""),
|
||||
)
|
||||
fb_ident = BackendIdentity.build(
|
||||
provider=fb_provider,
|
||||
model=fb_model,
|
||||
base_url=(fb.get("base_url") or ""),
|
||||
)
|
||||
if should_skip_candidate(fb_ident, current_ident):
|
||||
logger.warning(
|
||||
"Fallback skip: chain entry %s/%s matches current provider/model",
|
||||
fb_provider, fb_model,
|
||||
)
|
||||
return agent._try_activate_fallback(reason)
|
||||
if _fallback_entry_is_same_backend_by_base_url(
|
||||
current_provider=current_provider,
|
||||
fb_provider=fb_provider,
|
||||
current_base_url=current_base_url,
|
||||
fb_base_url=fb_base_url_for_dedup,
|
||||
current_model=current_model,
|
||||
fb_model=fb_model,
|
||||
):
|
||||
logger.warning(
|
||||
"Fallback skip: chain entry base_url %s matches current backend",
|
||||
fb_base_url_for_dedup,
|
||||
"Fallback skip: chain entry %s/%s resolves to the same backend "
|
||||
"as the current one (%s)",
|
||||
fb_provider, fb_model, current_ident.base_url or current_ident.provider,
|
||||
)
|
||||
return agent._try_activate_fallback(reason)
|
||||
|
||||
|
|
|
|||
|
|
@ -1402,7 +1402,10 @@ def compress_context(
|
|||
# `name #N` renumber, no contextvar/env/logging re-sync, no memory/context-
|
||||
# engine session-switch. The conversation keeps one durable id for life,
|
||||
# eliminating the session-rotation bug cluster. Default True (2107b86024).
|
||||
in_place = bool(getattr(agent, "compression_in_place", False))
|
||||
# Default True matches DEFAULT_CONFIG / #38763. A missing attribute must
|
||||
# NOT fall back to rotation mode — that re-enables the pre-lease drift
|
||||
# path and can wedge busy sessions that never set the flag.
|
||||
in_place = bool(getattr(agent, "compression_in_place", True))
|
||||
# Set True once the in-place DB write actually completes (the DB block can
|
||||
# raise and skip it). Surfaced to the gateway via agent._last_compaction_in_place.
|
||||
compacted_in_place = False
|
||||
|
|
@ -1712,6 +1715,13 @@ def compress_context(
|
|||
# non-destructive — pre-compaction rows are soft-archived (active=0,
|
||||
# compacted=1), stay searchable and recoverable, so snapshot/durable
|
||||
# drift cannot lose data there and must not abort compaction.
|
||||
#
|
||||
# When durable DID grow, ADOPT it and continue rather than aborting.
|
||||
# Aborting returned the stale snapshot unchanged, so busy sessions
|
||||
# (memory review / shared session_id writers) stayed permanently
|
||||
# behind the DB: every /compress and auto-compress saw
|
||||
# "changed before lease acquisition", surfaced as the misleading
|
||||
# "No changes from compression", and never reclaimed tokens.
|
||||
if not in_place and _lock_db is not None and _lock_sid:
|
||||
durable_loader = getattr(
|
||||
type(_lock_db), "get_messages_as_conversation", None
|
||||
|
|
@ -1719,16 +1729,19 @@ def compress_context(
|
|||
if callable(durable_loader):
|
||||
durable_parent = durable_loader(_lock_db, _lock_sid)
|
||||
if isinstance(durable_parent, list) and len(durable_parent) > len(messages):
|
||||
logger.warning(
|
||||
"compression aborted: session=%s changed before lease "
|
||||
"acquisition; preserving newer durable messages",
|
||||
logger.info(
|
||||
"compression: session=%s grew before lease "
|
||||
"(%d → %d msgs); adopting durable snapshot",
|
||||
_lock_sid,
|
||||
len(messages),
|
||||
len(durable_parent),
|
||||
)
|
||||
_release_lock()
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
messages = durable_parent
|
||||
_pre_msg_count = len(messages)
|
||||
# Token estimate was for the stale snapshot; clear it so
|
||||
# the compressor re-derives from the adopted transcript
|
||||
# instead of under-counting the newly visible rows.
|
||||
approx_tokens = 0
|
||||
|
||||
# Notify external memory provider before compression discards context.
|
||||
# The provider's on_pre_compress() may return a string of insights it
|
||||
|
|
|
|||
|
|
@ -5497,6 +5497,14 @@ def run_conversation(
|
|||
args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200]
|
||||
logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview)
|
||||
|
||||
# Uniquify duplicate tool-call ids BEFORE any downstream
|
||||
# consumer (validation error paths, dispatch, history build,
|
||||
# Responses item-id derivation). Models that reuse one id for
|
||||
# different calls in a batch otherwise lose the later call's
|
||||
# result: the pre-API sanitizer keeps only the first
|
||||
# call/result pair per id. See _uniquify_tool_call_ids.
|
||||
agent._uniquify_tool_call_ids(assistant_message.tool_calls)
|
||||
|
||||
# Validate tool call names - detect model hallucinations
|
||||
# Repair mismatched tool names before validating
|
||||
for tc in assistant_message.tool_calls:
|
||||
|
|
@ -6328,7 +6336,28 @@ def run_conversation(
|
|||
". No fallback providers configured.")
|
||||
)
|
||||
|
||||
final_response = "(empty)"
|
||||
# Deliver a labeled reasoning excerpt instead of a bare
|
||||
# "(empty)" when the model DID think but never produced
|
||||
# visible text. This is delivery-only: the persisted
|
||||
# assistant message above keeps the "(empty)" sentinel
|
||||
# (its replay semantics prevent empty-response loops),
|
||||
# and raw chain-of-thought is never promoted to a normal
|
||||
# answer earlier in the ladder — prefill continuation,
|
||||
# empty-content retries, and provider fallback all run
|
||||
# first. Only at this terminal, where the alternative is
|
||||
# returning nothing, is showing the model's own reasoning
|
||||
# (clearly labeled as such) strictly more useful.
|
||||
# Idea credit: PR #48795 (@ligl0325).
|
||||
if reasoning_text:
|
||||
final_response = (
|
||||
"⚠️ The model produced only internal reasoning and "
|
||||
"no final answer, despite retries"
|
||||
+ (" and fallback" if agent._fallback_chain else "")
|
||||
+ ". Its last reasoning, which may contain the "
|
||||
"answer:\n\n" + reasoning_preview
|
||||
)
|
||||
else:
|
||||
final_response = "(empty)"
|
||||
break
|
||||
|
||||
# Reset retry counter/signature on successful content
|
||||
|
|
|
|||
|
|
@ -159,6 +159,14 @@ _RATE_LIMIT_PATTERNS = [
|
|||
"throttlingexception",
|
||||
"too many concurrent requests",
|
||||
"servicequotaexceededexception",
|
||||
# Generic throttle prefix — Bedrock (and some proxies) surface throttling
|
||||
# as "Throttling error: Too many tokens, please wait before trying
|
||||
# again." Without this entry the message falls through to the
|
||||
# context-overflow list (which contains "too many tokens") and the retry
|
||||
# loop compresses a healthy session instead of backing off. Matched
|
||||
# BEFORE _CONTEXT_OVERFLOW_PATTERNS in the message-only path, so the
|
||||
# throttle wins. (port of anomalyco/opencode#37848's exclusion guard)
|
||||
"throttling",
|
||||
]
|
||||
|
||||
# Patterns that indicate provider-side overload, NOT a per-credential rate
|
||||
|
|
@ -212,6 +220,12 @@ _PAYLOAD_TOO_LARGE_PATTERNS = [
|
|||
"request entity too large",
|
||||
"payload too large",
|
||||
"error code: 413",
|
||||
# Anthropic's structured 413 error type. Normally arrives with an HTTP
|
||||
# 413 status (handled by the status path), but aggregators/proxies can
|
||||
# re-wrap it into a plain message with no status attribute — route it to
|
||||
# the same compression recovery. (port of anomalyco/opencode#37848)
|
||||
"request_too_large",
|
||||
"request exceeds the maximum size",
|
||||
]
|
||||
|
||||
# Image-size patterns. Matched against 400 bodies (not 413) because most
|
||||
|
|
@ -298,6 +312,10 @@ _CONTEXT_OVERFLOW_PATTERNS = [
|
|||
"max input token",
|
||||
"input token",
|
||||
"exceeds the maximum number of input tokens",
|
||||
# Together/Fireworks-style: "Input length 131393 exceeds the maximum
|
||||
# allowed input length of 131040 tokens." No other pattern in this list
|
||||
# matches that wording. (port of anomalyco/opencode#37848)
|
||||
"maximum allowed input length",
|
||||
]
|
||||
|
||||
# Model not found patterns
|
||||
|
|
|
|||
|
|
@ -163,6 +163,42 @@ _FREE_TIER_GUIDANCE = (
|
|||
)
|
||||
|
||||
|
||||
def is_standard_key_auth_error(
|
||||
status: int, error_message: str, reason: str = ""
|
||||
) -> bool:
|
||||
"""Return True when a Gemini 401 indicates Google rejected the key TYPE.
|
||||
|
||||
Google began rejecting unrestricted legacy "Standard" Google Cloud API
|
||||
keys on the Gemini API on June 19, 2026, and ALL Standard keys stop
|
||||
working in September 2026. The rejection surfaces as a misleading 401
|
||||
telling the user to supply an OAuth 2 access token ("Request had invalid
|
||||
authentication credentials. Expected OAuth 2 access token, login cookie
|
||||
or other valid authentication credential."), optionally carrying
|
||||
``google.rpc.ErrorInfo`` reason ``ACCESS_TOKEN_TYPE_UNSUPPORTED``.
|
||||
|
||||
Scoped narrowly so a plain bad key (reason ``API_KEY_INVALID``,
|
||||
"API key not valid") keeps its existing message.
|
||||
"""
|
||||
if status != 401:
|
||||
return False
|
||||
if reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED":
|
||||
return True
|
||||
return "expected oauth 2 access token" in (error_message or "").lower()
|
||||
|
||||
|
||||
_STANDARD_KEY_GUIDANCE = (
|
||||
"\n\nGoogle Gemini rejected this API key's type — you do NOT need OAuth. "
|
||||
"Google began rejecting legacy 'Standard' Google Cloud keys for the "
|
||||
"Gemini API on June 19, 2026, and all Standard keys stop working in "
|
||||
"September 2026. Open https://aistudio.google.com/api-keys, check the "
|
||||
"key's type and status, and create a replacement Gemini API key (or, as "
|
||||
"a temporary bridge, restrict the Standard key to "
|
||||
"generativelanguage.googleapis.com). Then update GEMINI_API_KEY / "
|
||||
"GOOGLE_API_KEY in ~/.hermes/.env and restart your session. "
|
||||
"Details: https://ai.google.dev/gemini-api/docs/api-key"
|
||||
)
|
||||
|
||||
|
||||
class GeminiAPIError(Exception):
|
||||
"""Error shape compatible with Hermes retry/error classification."""
|
||||
|
||||
|
|
@ -824,6 +860,12 @@ def gemini_http_error(
|
|||
if status == 429 and is_free_tier_quota_error(err_message or body_text):
|
||||
message = message + _FREE_TIER_GUIDANCE
|
||||
|
||||
# Legacy "Standard" Google Cloud key rejection (June 19, 2026 onward) ->
|
||||
# Google's raw 401 misleadingly tells the user to use OAuth. Append the
|
||||
# actual fix (mint a new Gemini API key in AI Studio).
|
||||
if is_standard_key_auth_error(status, err_message or body_text, reason):
|
||||
message = message + _STANDARD_KEY_GUIDANCE
|
||||
|
||||
return GeminiAPIError(
|
||||
message,
|
||||
code=code,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Dict
|
||||
|
||||
# Gemini's ``FunctionDeclaration.parameters`` field accepts the ``Schema``
|
||||
|
|
@ -76,15 +77,31 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
|
|||
|
||||
# Gemini's Schema validator requires every ``enum`` entry to be a string,
|
||||
# even when the parent ``type`` is ``integer`` / ``number`` / ``boolean``.
|
||||
# OpenAI / OpenRouter / Anthropic accept typed enums (e.g. Discord's
|
||||
# ``auto_archive_duration: {type: integer, enum: [60, 1440, 4320, 10080]}``),
|
||||
# so we only drop the ``enum`` when it would collide with Gemini's rule.
|
||||
# Keeping ``type: integer`` plus the human-readable description gives the
|
||||
# model enough guidance; the tool handler still validates the value.
|
||||
# Preserve those constraints by stringifying scalar values while keeping
|
||||
# the declared type intact; Gemini uses the strings as schema metadata and
|
||||
# still emits typed tool arguments at runtime.
|
||||
enum_val = cleaned.get("enum")
|
||||
type_val = cleaned.get("type")
|
||||
if isinstance(enum_val, list) and type_val in {"integer", "number", "boolean"}:
|
||||
if any(not isinstance(item, str) for item in enum_val):
|
||||
stringified = []
|
||||
for item in enum_val:
|
||||
if isinstance(item, str):
|
||||
value = item
|
||||
elif isinstance(item, bool):
|
||||
value = "true" if item else "false"
|
||||
elif (
|
||||
isinstance(item, (int, float))
|
||||
and not isinstance(item, bool)
|
||||
and math.isfinite(item)
|
||||
):
|
||||
value = str(item)
|
||||
else:
|
||||
continue
|
||||
if value not in stringified:
|
||||
stringified.append(value)
|
||||
if stringified:
|
||||
cleaned["enum"] = stringified
|
||||
else:
|
||||
cleaned.pop("enum", None)
|
||||
|
||||
# Gemini validates ``required`` strictly against the same node's
|
||||
|
|
|
|||
|
|
@ -175,6 +175,85 @@ _YAML_ASSIGN_RE = re.compile(
|
|||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# Word-boundary validation for the mixed/lowercase key patterns above
|
||||
# (_CFG_DOTTED_RE, _CFG_ANCHORED_RE, _YAML_ASSIGN_RE).
|
||||
#
|
||||
# Those key classes allow arbitrary alphanumeric affixes around the secret
|
||||
# keyword so real key names like ``client_secret``, ``clientSecret``, and
|
||||
# ``s3.secret-key`` match. The side effect: ordinary prose/document words that
|
||||
# merely CONTAIN a keyword also matched — ``Secretary: J.Smith`` (secret),
|
||||
# ``tokenizer: cl100k_base`` (token), ``author=Smith`` (auth) — mangling
|
||||
# legitimate content on the surfaces that run these passes (browser snapshots,
|
||||
# log lines, kanban summaries, CLI-echoed command output). Ported from
|
||||
# nearai/ironclaw#6129, where the same substring false positive ("Secretary of
|
||||
# the Treasury" matching the ``secret`` marker) scrubbed legitimate tool
|
||||
# results from the replayed transcript and sent the model into a re-fetch
|
||||
# loop.
|
||||
#
|
||||
# A keyword occurrence only counts when it sits at a word boundary within the
|
||||
# key: at the key's edge, next to a non-letter (``_ - . 3``), or at a
|
||||
# camelCase transition (``clientSecret``, ``secretKey``, ``APIToken``). A
|
||||
# trailing plural ``s`` is treated as part of the keyword (``secrets:``,
|
||||
# ``tokens:``). Common concatenated compounds keep matching via explicit
|
||||
# alternatives (``authtoken`` ngrok, ``authkey`` tailscale, ``secretkey``
|
||||
# minio, ``apikey``). Embedded occurrences inside a larger word
|
||||
# (``secretary``, ``tokenizer``, ``authored``, ``credentialing``) no longer
|
||||
# match. ALL-CAPS keys keep the legacy embedded matching (``MYTOKEN=…``) — an
|
||||
# all-caps key is almost never prose, the same rationale as _ENV_ASSIGN_RE.
|
||||
_KEY_KEYWORD_RE = re.compile(
|
||||
r"(?:api|auth|access|refresh|session|secret)[ _.\-]?(?:key|token)"
|
||||
r"|token|secret|passwd|password|credential|auth",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_word_start(s: str, i: int) -> bool:
|
||||
"""True if position ``i`` in ``s`` begins a word (not mid-word)."""
|
||||
if i == 0:
|
||||
return True
|
||||
prev, cur = s[i - 1], s[i]
|
||||
if not prev.isalpha():
|
||||
return True
|
||||
if cur.isupper() and prev.islower():
|
||||
return True # camelCase: clientSecret
|
||||
# Acronym run ending: APIToken — the 'T' begins a new word when it is
|
||||
# followed by lowercase while the preceding run is uppercase.
|
||||
if cur.isupper() and prev.isupper() and i + 1 < len(s) and s[i + 1].islower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_word_end(s: str, j: int, *, allow_plural: bool = True) -> bool:
|
||||
"""True if position ``j`` (exclusive end) in ``s`` ends a word."""
|
||||
if j >= len(s):
|
||||
return True
|
||||
cur = s[j]
|
||||
if not cur.isalpha():
|
||||
return True
|
||||
if cur.isupper() and s[j - 1].islower():
|
||||
return True # camelCase continuation: secretKey
|
||||
if allow_plural and cur in "sS":
|
||||
return _is_word_end(s, j + 1, allow_plural=False)
|
||||
return False
|
||||
|
||||
|
||||
def _key_has_secret_keyword(key: str) -> bool:
|
||||
"""True if ``key`` contains a secret keyword at a word boundary.
|
||||
|
||||
Post-match validator for _CFG_DOTTED_RE / _CFG_ANCHORED_RE /
|
||||
_YAML_ASSIGN_RE hits — rejects prose words that merely embed a keyword
|
||||
(``secretary``, ``tokenizer``, ``authored``). Safe to call with the
|
||||
_ENV_ASSIGN_RE key too: all-caps keys short-circuit to the legacy
|
||||
embedded-match behavior.
|
||||
"""
|
||||
letters = [c for c in key if c.isalpha()]
|
||||
if letters and all(c.isupper() for c in letters):
|
||||
return True # legacy all-caps behavior (MYTOKEN=…)
|
||||
for m in _KEY_KEYWORD_RE.finditer(key):
|
||||
if _is_word_start(key, m.start()) and _is_word_end(key, m.end()):
|
||||
return True
|
||||
return False
|
||||
|
||||
# JSON field patterns: "apiKey": "value", "token": "value", etc.
|
||||
_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)"
|
||||
_JSON_FIELD_RE = re.compile(
|
||||
|
|
@ -614,6 +693,13 @@ def redact_sensitive_text(
|
|||
# prose/log contexts (issue #2852): ``KEY=os.getenv('X')``.
|
||||
if _ENV_LOOKUP_VALUE_RE.match(value):
|
||||
return m.group(0)
|
||||
# Keyword must sit at a word boundary within the key —
|
||||
# ``author=Smith`` / ``press.secretary=…`` are prose, not
|
||||
# credentials (ported from nearai/ironclaw#6129). All-caps
|
||||
# keys (the _ENV_ASSIGN_RE shape) short-circuit to legacy
|
||||
# embedded matching inside the helper.
|
||||
if not _key_has_secret_keyword(name):
|
||||
return m.group(0)
|
||||
return f"{name}={quote}{_mask_token(value)}{quote}"
|
||||
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
|
||||
# Lowercase/dotted config keys (issue #16413). Skip URLs entirely —
|
||||
|
|
@ -647,6 +733,11 @@ def redact_sensitive_text(
|
|||
# not a leaked secret value.
|
||||
if _ENV_LOOKUP_VALUE_RE.match(value):
|
||||
return m.group(0)
|
||||
# Keyword must sit at a word boundary within the key —
|
||||
# ``Secretary: J.Smith`` / ``tokenizer: cl100k_base`` are
|
||||
# document text, not credentials (nearai/ironclaw#6129).
|
||||
if not _key_has_secret_keyword(key):
|
||||
return m.group(0)
|
||||
return f"{key}{sep}{_mask_token(value)}"
|
||||
text = _YAML_ASSIGN_RE.sub(_redact_yaml, text)
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ emitted by each built-in hook site.
|
|||
child_role – role string of the child agent
|
||||
child_summary – summary of the child's work
|
||||
child_status – exit status string (e.g. "success", "error")
|
||||
tool_call_history – redacted tool name/input summary/byte counts/status list
|
||||
duration_ms – wall-clock time of the child run in milliseconds
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ EXCLUDED_SKILL_DIRS = frozenset(
|
|||
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
|
||||
|
||||
|
||||
def is_excluded_skill_path(path) -> bool:
|
||||
def is_excluded_skill_path(path, *, root: Optional[Path] = None) -> bool:
|
||||
"""True if *path* should be skipped by active skill scanners.
|
||||
|
||||
Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
|
||||
|
|
@ -66,11 +66,11 @@ def is_excluded_skill_path(path) -> bool:
|
|||
from pathlib import PurePath
|
||||
parts = PurePath(str(path)).parts
|
||||
return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path(
|
||||
path
|
||||
path, root=root
|
||||
)
|
||||
|
||||
|
||||
def is_skill_support_path(path) -> bool:
|
||||
def is_skill_support_path(path, *, root: Optional[Path] = None) -> bool:
|
||||
"""True if *path* is under a support dir of an actual skill root.
|
||||
|
||||
``references/``, ``templates/``, ``assets/``, and ``scripts/`` are
|
||||
|
|
@ -92,6 +92,8 @@ def is_skill_support_path(path) -> bool:
|
|||
if part not in SKILL_SUPPORT_DIRS or idx == 0:
|
||||
continue
|
||||
skill_root = Path(*parts[:idx])
|
||||
if root is not None and not path_obj.is_absolute():
|
||||
skill_root = root / skill_root
|
||||
if (skill_root / "SKILL.md").exists():
|
||||
return True
|
||||
return False
|
||||
|
|
|
|||
533
agent/subagent_lifecycle.py
Normal file
533
agent/subagent_lifecycle.py
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
"""Public, plugin-safe lifecycle API for delegated Hermes subagents.
|
||||
|
||||
This module deliberately exposes immutable contracts, not ``AIAgent`` objects.
|
||||
It is the supported boundary for plugins that need to supervise fresh child
|
||||
sessions; plugins must obtain it from ``PluginContext.subagent_lifecycle``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import dataclasses
|
||||
import enum
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import math
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
|
||||
|
||||
PUBLIC_CONTRACT_VERSION = 1
|
||||
_MAX_GOAL_CHARS = 16_000
|
||||
_MAX_CONTEXT_CHARS = 32_000
|
||||
_MAX_METADATA_BYTES = 8_192
|
||||
_MAX_RESULT_CHARS = 32_000
|
||||
_TERMINAL_RETENTION_SECONDS = 3_600
|
||||
|
||||
|
||||
class SubagentLifecycleError(ValueError):
|
||||
"""A request cannot be safely accepted by the public lifecycle API."""
|
||||
|
||||
|
||||
class SubagentState(str, enum.Enum):
|
||||
PENDING = "PENDING"
|
||||
STARTING = "STARTING"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
INTERRUPTED = "INTERRUPTED"
|
||||
CANCEL_REQUESTED = "CANCEL_REQUESTED"
|
||||
CANCELLED = "CANCELLED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentLaunchRequest:
|
||||
goal: str
|
||||
context: Optional[str] = None
|
||||
role: str = "leaf"
|
||||
model: Optional[str] = None
|
||||
allowed_toolsets: Optional[tuple[str, ...]] = None
|
||||
blocked_tools: tuple[str, ...] = ()
|
||||
working_directory: Optional[str] = None
|
||||
parent_session_id: Optional[str] = None
|
||||
correlation_id: Optional[str] = None
|
||||
metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict)
|
||||
timeout_seconds: Optional[float] = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentHandle:
|
||||
contract_version: int
|
||||
subagent_id: str
|
||||
parent_session_id: Optional[str]
|
||||
correlation_id: Optional[str]
|
||||
created_at: float
|
||||
provider: Optional[str]
|
||||
model: Optional[str]
|
||||
role: str
|
||||
depth: int
|
||||
capability: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "SubagentHandle":
|
||||
try:
|
||||
return cls(**dict(value))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SubagentLifecycleError("Malformed subagent handle.") from exc
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentStatus:
|
||||
handle: SubagentHandle
|
||||
state: SubagentState
|
||||
updated_at: float
|
||||
diagnostic: Optional[str] = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentTerminalState:
|
||||
handle: SubagentHandle
|
||||
state: SubagentState
|
||||
completed: bool
|
||||
timed_out: bool = False
|
||||
diagnostic: Optional[str] = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentCancelResult:
|
||||
accepted: bool
|
||||
already_terminal: bool = False
|
||||
unknown_handle: bool = False
|
||||
unsupported: bool = False
|
||||
state: SubagentState = SubagentState.UNKNOWN
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentResult:
|
||||
handle: SubagentHandle
|
||||
terminal_state: SubagentState
|
||||
ready: bool
|
||||
summary: Optional[str] = None
|
||||
structured_payload: Optional[Mapping[str, Any]] = None
|
||||
started_at: Optional[float] = None
|
||||
completed_at: Optional[float] = None
|
||||
error_classification: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
usage_metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict)
|
||||
tool_execution_summary: Mapping[str, Any] = dataclasses.field(default_factory=dict)
|
||||
result_hash: Optional[str] = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentReconnectResult:
|
||||
connected: bool
|
||||
state: SubagentState
|
||||
diagnostic: Optional[str] = None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _Record:
|
||||
handle: SubagentHandle
|
||||
state: SubagentState
|
||||
updated_at: float
|
||||
agent: Any = None
|
||||
future: Optional[Future] = None
|
||||
started_at: Optional[float] = None
|
||||
completed_at: Optional[float] = None
|
||||
result: Optional[SubagentResult] = None
|
||||
|
||||
|
||||
class _Registry:
|
||||
"""Thread-safe terminal-retention registry; never returns live records."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.RLock()
|
||||
self.records: dict[str, _Record] = {}
|
||||
self.correlations: dict[tuple[Optional[str], str], str] = {}
|
||||
|
||||
|
||||
_REGISTRY = _Registry()
|
||||
# Daemon worker pool: a wedged/abandoned child must never block interpreter
|
||||
# exit at atexit-join time (same rationale as _run_single_child's timeout
|
||||
# executor and the async-delegation registry pool).
|
||||
from tools.daemon_pool import DaemonThreadPoolExecutor as _DaemonExecutor
|
||||
|
||||
_EXECUTOR = _DaemonExecutor(max_workers=8, thread_name_prefix="hermes-lifecycle")
|
||||
_SECRET = secrets.token_bytes(32)
|
||||
_ACTIVE_PARENT_AGENT: contextvars.ContextVar[Any] = contextvars.ContextVar(
|
||||
"hermes_subagent_lifecycle_parent", default=None
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_subagent_parent(parent_agent: Any):
|
||||
"""Bind the host-owned parent for the current agent turn."""
|
||||
token = _ACTIVE_PARENT_AGENT.set(parent_agent)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_ACTIVE_PARENT_AGENT.reset(token)
|
||||
|
||||
|
||||
def get_active_subagent_parent() -> Any:
|
||||
"""Return the parent bound to this execution context, if any."""
|
||||
return _ACTIVE_PARENT_AGENT.get()
|
||||
|
||||
|
||||
class SubagentLifecycleService:
|
||||
"""Stable public service returned by :attr:`PluginContext.subagent_lifecycle`.
|
||||
|
||||
Running children are in-process only. Completed results remain available
|
||||
until process exit; ``reconnect`` accurately reports that a serialized
|
||||
handle cannot reconnect after a restart instead of launching work again.
|
||||
"""
|
||||
|
||||
def __init__(self, parent_agent_resolver: Callable[[], Any]) -> None:
|
||||
self._parent_agent_resolver = parent_agent_resolver
|
||||
|
||||
def launch(self, request: SubagentLaunchRequest) -> SubagentHandle:
|
||||
parent = self._parent_agent_resolver()
|
||||
if parent is None:
|
||||
raise SubagentLifecycleError(
|
||||
"No active Hermes parent session is available."
|
||||
)
|
||||
self._validate_request(request, parent)
|
||||
parent_session_id = str(getattr(parent, "session_id", "") or "") or None
|
||||
if request.parent_session_id and request.parent_session_id != parent_session_id:
|
||||
raise SubagentLifecycleError(
|
||||
"parent_session_id does not match the active session."
|
||||
)
|
||||
correlation_key = (parent_session_id, request.correlation_id or "")
|
||||
with _REGISTRY.lock:
|
||||
self._cleanup_locked()
|
||||
if request.correlation_id and correlation_key in _REGISTRY.correlations:
|
||||
raise SubagentLifecycleError(
|
||||
"Duplicate correlation_id for this parent session."
|
||||
)
|
||||
|
||||
# Delegate construction remains internal so plugin code never imports
|
||||
# private delegation helpers or manipulates the active-child registry.
|
||||
from tools.delegate_tool import (
|
||||
_build_child_preserving_parent_tools,
|
||||
DEFAULT_MAX_ITERATIONS,
|
||||
)
|
||||
|
||||
child = _build_child_preserving_parent_tools(
|
||||
task_index=0,
|
||||
goal=request.goal,
|
||||
context=request.context,
|
||||
toolsets=list(request.allowed_toolsets)
|
||||
if request.allowed_toolsets
|
||||
else None,
|
||||
model=request.model,
|
||||
max_iterations=DEFAULT_MAX_ITERATIONS,
|
||||
task_count=1,
|
||||
parent_agent=parent,
|
||||
role=request.role,
|
||||
)
|
||||
subagent_id = str(getattr(child, "_subagent_id", "") or "")
|
||||
if not subagent_id:
|
||||
raise SubagentLifecycleError("Hermes failed to assign a child identity.")
|
||||
created = time.time()
|
||||
handle = SubagentHandle(
|
||||
PUBLIC_CONTRACT_VERSION,
|
||||
subagent_id,
|
||||
parent_session_id,
|
||||
request.correlation_id,
|
||||
created,
|
||||
getattr(child, "provider", None),
|
||||
getattr(child, "model", None),
|
||||
getattr(child, "_delegate_role", request.role),
|
||||
int(getattr(child, "_delegate_depth", 1) or 1),
|
||||
self._capability(subagent_id, parent_session_id, created),
|
||||
)
|
||||
record = _Record(handle, SubagentState.PENDING, created, agent=child)
|
||||
with _REGISTRY.lock:
|
||||
_REGISTRY.records[subagent_id] = record
|
||||
if request.correlation_id:
|
||||
_REGISTRY.correlations[correlation_key] = subagent_id
|
||||
record.future = _EXECUTOR.submit(self._run, record, request.goal, parent)
|
||||
return handle
|
||||
|
||||
def status(self, handle: SubagentHandle) -> SubagentStatus:
|
||||
record = self._record(handle)
|
||||
if record is None:
|
||||
return SubagentStatus(
|
||||
handle, SubagentState.UNKNOWN, time.time(), "UNKNOWN_HANDLE"
|
||||
)
|
||||
with _REGISTRY.lock:
|
||||
return SubagentStatus(record.handle, record.state, record.updated_at)
|
||||
|
||||
def wait(
|
||||
self, handle: SubagentHandle, *, timeout_seconds: Optional[float] = None
|
||||
) -> SubagentTerminalState:
|
||||
record = self._record(handle)
|
||||
if record is None:
|
||||
return SubagentTerminalState(
|
||||
handle, SubagentState.UNKNOWN, True, diagnostic="UNKNOWN_HANDLE"
|
||||
)
|
||||
future = record.future
|
||||
if future is not None:
|
||||
try:
|
||||
future.result(timeout=timeout_seconds)
|
||||
except TimeoutError:
|
||||
return SubagentTerminalState(record.handle, record.state, False, True)
|
||||
except Exception:
|
||||
pass
|
||||
with _REGISTRY.lock:
|
||||
return SubagentTerminalState(
|
||||
record.handle, record.state, record.result is not None
|
||||
)
|
||||
|
||||
def cancel(self, handle: SubagentHandle, *, reason: str) -> SubagentCancelResult:
|
||||
record = self._record(handle)
|
||||
if record is None:
|
||||
return SubagentCancelResult(False, unknown_handle=True)
|
||||
with _REGISTRY.lock:
|
||||
if record.result is not None:
|
||||
return SubagentCancelResult(
|
||||
False, already_terminal=True, state=record.state
|
||||
)
|
||||
agent = record.agent
|
||||
record.state = SubagentState.CANCEL_REQUESTED
|
||||
record.updated_at = time.time()
|
||||
if agent is None or not hasattr(agent, "interrupt"):
|
||||
return SubagentCancelResult(
|
||||
False, unsupported=True, state=SubagentState.CANCEL_REQUESTED
|
||||
)
|
||||
try:
|
||||
agent.interrupt(f"Lifecycle cancellation requested: {reason[:500]}")
|
||||
except Exception:
|
||||
return SubagentCancelResult(
|
||||
False, unsupported=True, state=SubagentState.CANCEL_REQUESTED
|
||||
)
|
||||
return SubagentCancelResult(True, state=SubagentState.CANCEL_REQUESTED)
|
||||
|
||||
def result(self, handle: SubagentHandle) -> SubagentResult:
|
||||
record = self._record(handle)
|
||||
if record is None:
|
||||
return SubagentResult(
|
||||
handle,
|
||||
SubagentState.UNKNOWN,
|
||||
False,
|
||||
error_classification="UNKNOWN_HANDLE",
|
||||
)
|
||||
with _REGISTRY.lock:
|
||||
if record.result is not None:
|
||||
return record.result
|
||||
return SubagentResult(
|
||||
record.handle, record.state, False, error_classification="NOT_READY"
|
||||
)
|
||||
|
||||
def reconnect(self, handle: SubagentHandle) -> SubagentReconnectResult:
|
||||
record = self._record(handle)
|
||||
if record is None:
|
||||
return SubagentReconnectResult(
|
||||
False, SubagentState.UNKNOWN, "RECONNECT_UNAVAILABLE"
|
||||
)
|
||||
with _REGISTRY.lock:
|
||||
return SubagentReconnectResult(True, record.state)
|
||||
|
||||
def _record(self, handle: SubagentHandle) -> Optional[_Record]:
|
||||
if (
|
||||
not isinstance(handle, SubagentHandle)
|
||||
or type(handle.contract_version) is not int
|
||||
or handle.contract_version != PUBLIC_CONTRACT_VERSION
|
||||
):
|
||||
return None
|
||||
if (
|
||||
not isinstance(handle.subagent_id, str)
|
||||
or not handle.subagent_id
|
||||
or (
|
||||
handle.parent_session_id is not None
|
||||
and not isinstance(handle.parent_session_id, str)
|
||||
)
|
||||
or (
|
||||
handle.correlation_id is not None
|
||||
and not isinstance(handle.correlation_id, str)
|
||||
)
|
||||
or isinstance(handle.created_at, bool)
|
||||
or not isinstance(handle.created_at, (int, float))
|
||||
or not math.isfinite(handle.created_at)
|
||||
or (handle.provider is not None and not isinstance(handle.provider, str))
|
||||
or (handle.model is not None and not isinstance(handle.model, str))
|
||||
or not isinstance(handle.role, str)
|
||||
or type(handle.depth) is not int
|
||||
or not isinstance(handle.capability, str)
|
||||
):
|
||||
return None
|
||||
if not hmac.compare_digest(
|
||||
handle.capability,
|
||||
self._capability(
|
||||
handle.subagent_id, handle.parent_session_id, handle.created_at
|
||||
),
|
||||
):
|
||||
return None
|
||||
parent = self._parent_agent_resolver()
|
||||
active_parent_id = str(getattr(parent, "session_id", "") or "") or None
|
||||
if active_parent_id != handle.parent_session_id:
|
||||
return None
|
||||
with _REGISTRY.lock:
|
||||
return _REGISTRY.records.get(handle.subagent_id)
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_locked() -> None:
|
||||
"""Retain terminal snapshots for a bounded period, never live work."""
|
||||
cutoff = time.time() - _TERMINAL_RETENTION_SECONDS
|
||||
expired = [
|
||||
subagent_id
|
||||
for subagent_id, record in _REGISTRY.records.items()
|
||||
if record.result is not None
|
||||
and record.completed_at is not None
|
||||
and record.completed_at < cutoff
|
||||
]
|
||||
for subagent_id in expired:
|
||||
record = _REGISTRY.records.pop(subagent_id)
|
||||
if record.handle.correlation_id:
|
||||
_REGISTRY.correlations.pop(
|
||||
(record.handle.parent_session_id, record.handle.correlation_id),
|
||||
None,
|
||||
)
|
||||
|
||||
def _run(self, record: _Record, goal: str, parent: Any) -> None:
|
||||
with _REGISTRY.lock:
|
||||
if record.state is not SubagentState.CANCEL_REQUESTED:
|
||||
record.state = SubagentState.RUNNING
|
||||
record.started_at = time.time()
|
||||
record.updated_at = record.started_at
|
||||
try:
|
||||
from tools.delegate_tool import _run_child_lifecycle
|
||||
|
||||
raw = _run_child_lifecycle(0, goal, record.agent, parent)
|
||||
status = (
|
||||
str(raw.get("status", "error")) if isinstance(raw, dict) else "error"
|
||||
)
|
||||
if status == "completed":
|
||||
state = SubagentState.SUCCEEDED
|
||||
elif status == "interrupted":
|
||||
state = (
|
||||
SubagentState.CANCELLED
|
||||
if record.state == SubagentState.CANCEL_REQUESTED
|
||||
else SubagentState.INTERRUPTED
|
||||
)
|
||||
else:
|
||||
state = SubagentState.FAILED
|
||||
summary = raw.get("summary") if isinstance(raw, dict) else None
|
||||
summary = str(summary)[:_MAX_RESULT_CHARS] if summary is not None else None
|
||||
error = raw.get("error") if isinstance(raw, dict) else None
|
||||
result = SubagentResult(
|
||||
record.handle,
|
||||
state,
|
||||
True,
|
||||
summary=summary,
|
||||
completed_at=time.time(),
|
||||
started_at=record.started_at,
|
||||
error_classification=None
|
||||
if state == SubagentState.SUCCEEDED
|
||||
else status.upper(),
|
||||
error_message=str(error)[:_MAX_RESULT_CHARS] if error else None,
|
||||
usage_metadata={"api_calls": raw.get("api_calls", 0)}
|
||||
if isinstance(raw, dict)
|
||||
else {},
|
||||
tool_execution_summary={
|
||||
"duration_seconds": raw.get("duration_seconds", 0)
|
||||
}
|
||||
if isinstance(raw, dict)
|
||||
else {},
|
||||
)
|
||||
except Exception as exc:
|
||||
result = SubagentResult(
|
||||
record.handle,
|
||||
SubagentState.FAILED,
|
||||
True,
|
||||
started_at=record.started_at,
|
||||
completed_at=time.time(),
|
||||
error_classification=type(exc).__name__,
|
||||
error_message=str(exc)[:_MAX_RESULT_CHARS],
|
||||
)
|
||||
payload = dataclasses.asdict(result)
|
||||
payload.pop("result_hash", None)
|
||||
result = dataclasses.replace(
|
||||
result,
|
||||
result_hash=hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, default=str).encode()
|
||||
).hexdigest(),
|
||||
)
|
||||
with _REGISTRY.lock:
|
||||
record.agent = None
|
||||
record.result = result
|
||||
record.state = result.terminal_state
|
||||
record.completed_at = result.completed_at
|
||||
record.updated_at = result.completed_at or time.time()
|
||||
|
||||
@staticmethod
|
||||
def _capability(
|
||||
subagent_id: str, parent_session_id: Optional[str], created_at: float
|
||||
) -> str:
|
||||
value = f"{subagent_id}|{parent_session_id or ''}|{created_at:.6f}".encode()
|
||||
return hmac.new(_SECRET, value, hashlib.sha256).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _validate_request(request: SubagentLaunchRequest, parent: Any) -> None:
|
||||
if (
|
||||
not isinstance(request, SubagentLaunchRequest)
|
||||
or not isinstance(request.goal, str)
|
||||
or not request.goal.strip()
|
||||
or len(request.goal) > _MAX_GOAL_CHARS
|
||||
):
|
||||
raise SubagentLifecycleError(
|
||||
"goal must be a non-empty string of at most 16000 characters."
|
||||
)
|
||||
if request.context is not None and (
|
||||
not isinstance(request.context, str)
|
||||
or len(request.context) > _MAX_CONTEXT_CHARS
|
||||
):
|
||||
raise SubagentLifecycleError(
|
||||
"context must be a string of at most 32000 characters."
|
||||
)
|
||||
if request.role not in {"leaf", "orchestrator"}:
|
||||
raise SubagentLifecycleError("role must be 'leaf' or 'orchestrator'.")
|
||||
if request.timeout_seconds is not None:
|
||||
raise SubagentLifecycleError(
|
||||
"Per-launch timeout is not supported; configure delegation timeout explicitly."
|
||||
)
|
||||
if request.working_directory is not None:
|
||||
raise SubagentLifecycleError(
|
||||
"working_directory is not supported because Hermes delegates use isolated task environments."
|
||||
)
|
||||
if request.blocked_tools:
|
||||
raise SubagentLifecycleError(
|
||||
"Per-tool blocking is not supported; use allowed_toolsets. Hermes always blocks unsafe child tools."
|
||||
)
|
||||
try:
|
||||
metadata_bytes = len(
|
||||
json.dumps(dict(request.metadata), sort_keys=True).encode()
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SubagentLifecycleError("metadata must be JSON-serializable.") from exc
|
||||
if metadata_bytes > _MAX_METADATA_BYTES:
|
||||
raise SubagentLifecycleError("metadata exceeds 8192 bytes.")
|
||||
if request.allowed_toolsets:
|
||||
from toolsets import TOOLSETS
|
||||
|
||||
unknown = set(request.allowed_toolsets) - set(TOOLSETS)
|
||||
if unknown:
|
||||
raise SubagentLifecycleError(
|
||||
f"Unknown toolsets: {', '.join(sorted(unknown))}."
|
||||
)
|
||||
enabled = getattr(parent, "enabled_toolsets", None)
|
||||
if enabled is not None and not set(request.allowed_toolsets).issubset(
|
||||
set(enabled)
|
||||
):
|
||||
raise SubagentLifecycleError(
|
||||
"Requested toolsets would broaden parent permissions."
|
||||
)
|
||||
|
|
@ -703,8 +703,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
||||
if not _err and _underlying:
|
||||
if _underlying in _tool_search_scoped_names(agent):
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
# Probe-validate before unwrapping (ironclaw#5149):
|
||||
# missing required args return the parameter schema
|
||||
# instead of dispatching into an opaque failure.
|
||||
_probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args)
|
||||
if _probe_err is not None:
|
||||
_ts_scope_block = _probe_err
|
||||
else:
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
else:
|
||||
_ts_scope_block = (
|
||||
f"'{_underlying}' is not available in this session. "
|
||||
|
|
@ -1353,8 +1360,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
||||
if not _err and _underlying:
|
||||
if _underlying in _tool_search_scoped_names(agent):
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
# Probe-validate before unwrapping (ironclaw#5149):
|
||||
# missing required args return the parameter schema
|
||||
# instead of dispatching into an opaque failure.
|
||||
_probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args)
|
||||
if _probe_err is not None:
|
||||
# This path wraps _block_msg in {"error": ...} —
|
||||
# flatten the probe payload to one plain string.
|
||||
try:
|
||||
_probe = json.loads(_probe_err)
|
||||
_ts_scope_block = (
|
||||
f"{_probe.get('error', '')} Parameters schema: "
|
||||
f"{json.dumps(_probe.get('parameters', {}), ensure_ascii=False)}. "
|
||||
f"{_probe.get('hint', '')}"
|
||||
).strip()
|
||||
except Exception:
|
||||
_ts_scope_block = _probe_err
|
||||
else:
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
else:
|
||||
_ts_scope_block = (
|
||||
f"'{_underlying}' is not available in this session. "
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ class ToolCallGuardrailConfig:
|
|||
no_progress_block_after: int = 5
|
||||
idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES)
|
||||
mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES)
|
||||
loop_caps: "LoopCapConfig" = field(default_factory=lambda: LoopCapConfig())
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfig":
|
||||
|
|
@ -121,6 +122,54 @@ class ToolCallGuardrailConfig:
|
|||
hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")),
|
||||
defaults.no_progress_block_after,
|
||||
),
|
||||
loop_caps=LoopCapConfig.from_mapping(data.get("loop_caps")),
|
||||
)
|
||||
|
||||
|
||||
# Default session-wide caps, matching Claude Code's v2.1.212 runaway-loop
|
||||
# Per-turn (per-agent-loop) caps on runaway-prone tool calls. Counts reset at
|
||||
# the start of every agent loop (reset_for_turn), so the limit is "within a
|
||||
# single turn" rather than cumulative over the whole session. A single loop
|
||||
# issuing dozens of web searches or spawning dozens of subagents is already
|
||||
# pathological, so the defaults are deliberately low.
|
||||
_DEFAULT_MAX_WEB_SEARCHES_PER_TURN = 50
|
||||
_DEFAULT_MAX_SUBAGENTS_PER_TURN = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoopCapConfig:
|
||||
"""Per-turn caps on runaway-prone tool calls.
|
||||
|
||||
Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added caps on
|
||||
WebSearch calls and subagent spawns to stop runaway search / delegation
|
||||
loops. Here the caps count *within a single agent loop* (one turn): the
|
||||
counters reset in ``reset_for_turn`` at the start of every
|
||||
``run_conversation``, so a legitimate multi-turn session is never starved,
|
||||
but a single turn that spirals into an unbounded search / delegation loop
|
||||
is stopped.
|
||||
|
||||
Semantics differ from the per-turn loop *detector* above (which keys on
|
||||
repeated identical/failing calls): these caps are a hard ceiling on the
|
||||
total count of a tool within the turn and fire regardless of
|
||||
``hard_stop_enabled``. A value of ``0`` disables the cap (unlimited).
|
||||
"""
|
||||
|
||||
max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_TURN
|
||||
max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_TURN
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoopCapConfig":
|
||||
"""Build config from the ``tool_loop_guardrails.loop_caps`` section."""
|
||||
if not isinstance(data, Mapping):
|
||||
return cls()
|
||||
defaults = cls()
|
||||
return cls(
|
||||
max_web_searches=_non_negative_int(
|
||||
data.get("max_web_searches"), defaults.max_web_searches
|
||||
),
|
||||
max_subagents=_non_negative_int(
|
||||
data.get("max_subagents"), defaults.max_subagents
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -233,6 +282,11 @@ class ToolCallGuardrailController:
|
|||
self._same_tool_failure_counts: dict[str, int] = {}
|
||||
self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {}
|
||||
self._halt_decision: ToolGuardrailDecision | None = None
|
||||
# Per-turn runaway-loop cap counters. Reset every turn (this method
|
||||
# runs at the start of each run_conversation), so the caps bound a
|
||||
# single agent loop rather than accumulating across the session.
|
||||
self._turn_web_search_count = 0
|
||||
self._turn_subagent_count = 0
|
||||
|
||||
@property
|
||||
def halt_decision(self) -> ToolGuardrailDecision | None:
|
||||
|
|
@ -240,6 +294,17 @@ class ToolCallGuardrailController:
|
|||
|
||||
def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision:
|
||||
signature = ToolCallSignature.from_call(tool_name, _coerce_args(args))
|
||||
|
||||
# ── Per-turn runaway-loop caps ──────────────────────────────────
|
||||
# These are hard ceilings on how many times a runaway-prone tool may
|
||||
# be called within a single agent loop (turn). They apply regardless
|
||||
# of hard_stop_enabled (which only governs the per-turn loop detector).
|
||||
# We block BEFORE the call runs once the count is already at the cap,
|
||||
# then increment for an allowed call so the (cap+1)-th is refused.
|
||||
cap_block = self._check_loop_cap(tool_name, _coerce_args(args), signature)
|
||||
if cap_block is not None:
|
||||
return cap_block
|
||||
|
||||
if not self.config.hard_stop_enabled:
|
||||
return ToolGuardrailDecision(tool_name=tool_name, signature=signature)
|
||||
|
||||
|
|
@ -379,6 +444,68 @@ class ToolCallGuardrailController:
|
|||
return False
|
||||
return tool_name in self.config.idempotent_tools
|
||||
|
||||
def _check_loop_cap(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: Mapping[str, Any],
|
||||
signature: ToolCallSignature,
|
||||
) -> ToolGuardrailDecision | None:
|
||||
"""Enforce and advance the per-turn runaway-loop counters.
|
||||
|
||||
Returns a ``block`` decision when the cap is already reached, otherwise
|
||||
increments the relevant counter for the allowed call and returns
|
||||
``None``. A cap of 0 disables that limit entirely. Counters reset each
|
||||
turn via ``reset_for_turn``.
|
||||
"""
|
||||
caps = self.config.loop_caps
|
||||
|
||||
if tool_name == "web_search":
|
||||
cap = caps.max_web_searches
|
||||
if cap and self._turn_web_search_count >= cap:
|
||||
decision = ToolGuardrailDecision(
|
||||
action="block",
|
||||
code="loop_web_search_cap",
|
||||
message=(
|
||||
f"Blocked web_search: this turn has already made {cap} "
|
||||
"web searches, the per-turn limit. This looks like a "
|
||||
"runaway search loop. Work with the results you already "
|
||||
"have and give the user your answer."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=self._turn_web_search_count,
|
||||
signature=signature,
|
||||
)
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
self._turn_web_search_count += 1
|
||||
return None
|
||||
|
||||
if tool_name == "delegate_task":
|
||||
cap = caps.max_subagents
|
||||
if not cap:
|
||||
return None
|
||||
spawn_count = _subagent_spawn_count(args)
|
||||
if self._turn_subagent_count >= cap:
|
||||
decision = ToolGuardrailDecision(
|
||||
action="block",
|
||||
code="loop_subagent_cap",
|
||||
message=(
|
||||
f"Blocked delegate_task: this turn has already spawned "
|
||||
f"{self._turn_subagent_count} subagents (limit {cap}). "
|
||||
"This looks like a runaway delegation loop. Finish the "
|
||||
"work with the results you have and answer the user."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=self._turn_subagent_count,
|
||||
signature=signature,
|
||||
)
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
self._turn_subagent_count += spawn_count
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def toolguard_synthetic_result(decision: ToolGuardrailDecision) -> str:
|
||||
"""Build a synthetic role=tool content string for a blocked tool call."""
|
||||
|
|
@ -471,6 +598,32 @@ def _positive_int(value: Any, default: int) -> int:
|
|||
return parsed if parsed >= 1 else default
|
||||
|
||||
|
||||
def _non_negative_int(value: Any, default: int) -> int:
|
||||
"""Parse a session-cap value. 0 is a valid (disable) value; negatives and
|
||||
junk fall back to the default."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed if parsed >= 0 else default
|
||||
|
||||
|
||||
def _subagent_spawn_count(args: Mapping[str, Any]) -> int:
|
||||
"""How many subagents a single delegate_task call spawns.
|
||||
|
||||
delegate_task runs in one of two modes: a batch (``tasks`` is a non-empty
|
||||
list, one child per item) or a single task (``goal``). Count the batch size
|
||||
when present, otherwise 1, so the session subagent cap reflects real spawns
|
||||
rather than delegate_task invocations.
|
||||
"""
|
||||
tasks = args.get("tasks") if isinstance(args, Mapping) else None
|
||||
if isinstance(tasks, list) and tasks:
|
||||
return len(tasks)
|
||||
return 1
|
||||
|
||||
|
||||
def _sha256(value: str) -> str:
|
||||
# surrogatepass: tool results scraped from the web can carry unpaired
|
||||
# UTF-16 surrogates (e.g. half of a mathematical-bold pair); a strict
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ import { RealSessionBuilder } from './real-session-builder'
|
|||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const SESSION_TITLE = 'E2E large persisted session'
|
||||
const EXPECTED_TEXT = 'E2E persisted user message 52'
|
||||
// The oldest seeded turn (HISTORY_TURNS[0]). The transcript first paints only
|
||||
// the newest turns (FIRST_PAINT_BUDGET) and backfills the rest in a rAF; a
|
||||
// baseline count taken before that backfill sees a clipped transcript and
|
||||
// falsely reports duplicates once the full list mounts. Waiting for this
|
||||
// oldest row means the baseline reflects the fully-mounted transcript.
|
||||
const OLDEST_SEEDED_TEXT = 'E2E persisted user message 0: audit the compatibility matrix'
|
||||
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
|
||||
const HISTORY_TURNS = Array.from(
|
||||
{ length: 27 },
|
||||
|
|
@ -210,6 +216,16 @@ test.describe('large session resume', () => {
|
|||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
// The transcript first paints only the newest turns (FIRST_PAINT_BUDGET)
|
||||
// and backfills older turns in a rAF. Wait for the oldest seeded row to
|
||||
// mount before taking the baseline so it reflects the full transcript —
|
||||
// otherwise a clipped baseline makes the backfilled rows look like
|
||||
// duplicates of the completed reply.
|
||||
await fixture.page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
OLDEST_SEEDED_TEXT,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY)
|
||||
await submitPrompt(fixture.page, BACKGROUND_PROMPT)
|
||||
await fixture.mock.waitForHeldStream()
|
||||
|
|
|
|||
|
|
@ -4,11 +4,7 @@
|
|||
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server'
|
||||
|
||||
async function send(page: Page, text: string, delay = 15): Promise<void> {
|
||||
|
|
@ -25,12 +21,11 @@ async function pasteAndSend(page: Page, text: string): Promise<void> {
|
|||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
|
||||
async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false,
|
||||
text,
|
||||
{ timeout },
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -62,23 +57,16 @@ test.describe('session compression', () => {
|
|||
await send(page, 'E2E_COMPRESSION_THIRD')
|
||||
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1)
|
||||
|
||||
// Commit the command before typing its argument. This waits for the async
|
||||
// completion request on cold CI workers, then uses the composer's own
|
||||
// keyboard accept path to replace the `/compress` trigger with a command
|
||||
// chip. Clicking a later completion after typing the argument can insert a
|
||||
// second command token (for example `//compress ...`) as plain text.
|
||||
// This test covers compression and continuation, not slash completion.
|
||||
// Insert the complete command atomically and click Send so an async
|
||||
// completion response cannot consume Enter as a picker acceptance.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type('/compress', { delay: 15 })
|
||||
await page.getByText('/compress').first().waitFor({ state: 'visible' })
|
||||
await page.keyboard.press('Enter')
|
||||
await composer.type(' preserve the three test turns', { delay: 15 })
|
||||
await page.keyboard.press('Enter')
|
||||
await page.keyboard.insertText('/compress preserve the three test turns')
|
||||
await expect.poll(() => composer.textContent()).toContain('preserve the three test turns')
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click()
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator('[data-slot="aui_thread-viewport"]').textContent(),
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.poll(() => page.locator('[data-slot="aui_thread-viewport"]').textContent(), { timeout: 90_000 })
|
||||
.toMatch(/Compressed|No changes from compression/)
|
||||
|
||||
// Compression rotates the agent's live session id. A post-compression
|
||||
|
|
@ -105,7 +93,7 @@ auxiliary:
|
|||
provider: custom
|
||||
model: mock-model`,
|
||||
mockServer: {
|
||||
holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.',
|
||||
holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.'
|
||||
}
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
|
|
|||
25
apps/desktop/scripts/diag-code-live.mjs
Normal file
25
apps/desktop/scripts/diag-code-live.mjs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Is the tree-split preview path actually active in the running renderer?
|
||||
// Checks the served source (what vite compiled) rather than guessing.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const out = await cdp.eval(`(async () => {
|
||||
const res = await fetch('/src/components/pane-shell/tree/renderer/tree-split.tsx')
|
||||
const src = await res.text()
|
||||
return JSON.stringify({
|
||||
previewShift: src.includes('previewShift'),
|
||||
adaptiveFloor: (await (await fetch('/src/app/session/hooks/use-message-stream/index.ts')).text()).includes('adaptiveFloor'),
|
||||
structuralSignature: (await (await fetch('/src/components/assistant-ui/thread/list.tsx')).text()).includes('structuralSignature'),
|
||||
sharedRO: (await (await fetch('/src/hooks/use-resize-observer.ts')).text()).includes('sharedObserver'),
|
||||
rootTipProvider: (await (await fetch('/src/main.tsx')).text()).includes('RootTooltipProvider')
|
||||
})
|
||||
})()`)
|
||||
|
||||
console.log(out)
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
47
apps/desktop/scripts/diag-key-latency.mjs
Normal file
47
apps/desktop/scripts/diag-key-latency.mjs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Typing latency, isolated: keystroke -> next paint, with and without an
|
||||
// active stream. Distinguishes "input is slow" from "the frame budget is
|
||||
// consumed by streaming flushes" — the fix differs completely.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
const TYPE = `
|
||||
(async () => {
|
||||
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
|
||||
if (!el) return JSON.stringify({ error: 'no composer' })
|
||||
el.focus()
|
||||
const perKey = []
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const ch = 'abcdefghij'[i % 10]
|
||||
const t0 = performance.now()
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
|
||||
el.textContent += ch
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
perKey.push(performance.now() - t0)
|
||||
// Human-ish 80ms cadence so streaming flushes interleave realistically.
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
}
|
||||
el.textContent = ''
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
|
||||
const sorted = [...perKey].sort((a, b) => a - b)
|
||||
const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
|
||||
const busy = (() => { try { return document.querySelectorAll('[data-status="running"]').length } catch { return -1 } })()
|
||||
return JSON.stringify({
|
||||
keyToPaint_p50: Math.round(pct(0.5) * 10) / 10,
|
||||
keyToPaint_p95: Math.round(pct(0.95) * 10) / 10,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
|
||||
over16: perKey.filter(f => f > 16.7).length,
|
||||
over33: perKey.filter(f => f > 33).length,
|
||||
streamingParts: busy
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
console.log(await cdp.eval(TYPE))
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
21
apps/desktop/scripts/diag-live-state.mjs
Normal file
21
apps/desktop/scripts/diag-live-state.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Quick state probe of the running hgui instance via CDP.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const state = await cdp.eval(`(() => {
|
||||
const rc = !!window.__RENDER_COUNTS__
|
||||
const pl = !!window.__PERF_LIVE__
|
||||
const tiles = window.__HERMES_SESSION_TILES__ ? Object.keys(window.__HERMES_SESSION_TILES__.states()).length : -1
|
||||
const gw = document.querySelector('[data-slot="statusbar"]')?.textContent?.slice(0, 120) ?? '(no statusbar)'
|
||||
const sidebarRows = document.querySelectorAll('[data-slot="sidebar"] [data-session-id], [data-tree-group] a').length
|
||||
return JSON.stringify({ rc, pl, tiles, gw, sidebarRows, title: document.title, url: location.href.slice(0, 80) })
|
||||
})()`)
|
||||
|
||||
console.log(state)
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
149
apps/desktop/scripts/diag-real-loop.mjs
Normal file
149
apps/desktop/scripts/diag-real-loop.mjs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// The real-app perf loop: drive HER hgui instance (real profile, real
|
||||
// sessions) through the three interactions that matter — session switch,
|
||||
// sidebar drag, composer typing — and report honest single-clock numbers.
|
||||
//
|
||||
// node scripts/diag-real-loop.mjs [--port 9222] [--switches 6]
|
||||
//
|
||||
// Unlike the synthetic scenarios this clicks REAL sidebar rows, so session
|
||||
// switching is measured as the user feels it: click -> transcript painted.
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const SWITCHES = Number(arg('switches', 6))
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session switch: click a sidebar session row, await the transcript settling.
|
||||
// Measures click -> first paint of the new transcript AND click -> settled
|
||||
// (two rAFs with no further DOM mutation in the thread viewport).
|
||||
// ---------------------------------------------------------------------------
|
||||
const SWITCH = swaps => `
|
||||
(async () => {
|
||||
const rows = [...document.querySelectorAll('[data-slot="row-button"]')]
|
||||
.filter(el => el.offsetParent && (el.textContent ?? '').trim())
|
||||
if (rows.length < 2) return JSON.stringify({ error: 'need 2+ visible session rows, found ' + rows.length })
|
||||
|
||||
const results = []
|
||||
for (let i = 0; i < ${swaps}; i++) {
|
||||
const row = rows[i % Math.min(rows.length, 4)]
|
||||
const viewport = () => document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const t0 = performance.now()
|
||||
row.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1 }))
|
||||
row.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 }))
|
||||
row.click()
|
||||
|
||||
// First paint: next two rAFs after the click.
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
const firstPaint = performance.now() - t0
|
||||
|
||||
// Settled: no mutations in the viewport for 2 consecutive frames, capped 3s.
|
||||
let lastMutation = performance.now()
|
||||
const target = viewport() ?? document.body
|
||||
const mo = new MutationObserver(() => { lastMutation = performance.now() })
|
||||
mo.observe(target, { childList: true, subtree: true, characterData: true })
|
||||
const deadline = performance.now() + 3000
|
||||
while (performance.now() < deadline) {
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
if (performance.now() - lastMutation > 120) break
|
||||
}
|
||||
mo.disconnect()
|
||||
results.push({ firstPaint: Math.round(firstPaint), settled: Math.round(performance.now() - t0 - 120) })
|
||||
await new Promise(r => setTimeout(r, 250))
|
||||
}
|
||||
return JSON.stringify(results)
|
||||
})()
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drag the first visible sash, single-clock frames.
|
||||
// ---------------------------------------------------------------------------
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const handle = [...document.querySelectorAll('[role="separator"]')].find(el => el.offsetParent || el.getBoundingClientRect().width > 0)
|
||||
if (!handle) return JSON.stringify({ error: 'no sash' })
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...o, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 60; i++) {
|
||||
x += (i < 30 ? 2 : -2)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...o, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
const now = performance.now(); frames.push(now - last); last = now
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...o, buttons: 0, clientX: x, clientY: y }))
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
return JSON.stringify({
|
||||
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
|
||||
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
|
||||
slow33: frames.filter(f => f > 33).length
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type into the composer, single-clock frames (one mark per keystroke frame).
|
||||
// ---------------------------------------------------------------------------
|
||||
const TYPE = `
|
||||
(async () => {
|
||||
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
|
||||
if (!el) return JSON.stringify({ error: 'no composer' })
|
||||
el.focus()
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const ch = 'the quick brown fox '[i % 20]
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
|
||||
el.textContent += ch
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
const now = performance.now(); frames.push(now - last); last = now
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
}
|
||||
// Clear what we typed.
|
||||
el.textContent = ''
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
|
||||
const moving = frames
|
||||
const total = moving.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...moving].sort((a, b) => a - b)
|
||||
return JSON.stringify({
|
||||
fps: Math.round((moving.length / total) * 1000 * 10) / 10,
|
||||
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
|
||||
slow33: moving.filter(f => f > 33).length
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
console.log('== SESSION SWITCH (click -> paint / settled ms) ==')
|
||||
console.log(await cdp.eval(SWITCH(SWITCHES)))
|
||||
|
||||
await sleep(500)
|
||||
console.log('\n== SIDEBAR DRAG ==')
|
||||
console.log(await cdp.eval(DRAG))
|
||||
|
||||
await sleep(500)
|
||||
console.log('\n== COMPOSER TYPING ==')
|
||||
console.log(await cdp.eval(TYPE))
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
27
apps/desktop/scripts/diag-sidebar-dom.mjs
Normal file
27
apps/desktop/scripts/diag-sidebar-dom.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Dump the sidebar's actual DOM shape so selectors stop being guesses.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const out = await cdp.eval(`(() => {
|
||||
const sidebar = document.querySelector('[data-slot="sidebar"]') ?? document.querySelector('aside')
|
||||
if (!sidebar) return '(no sidebar el)'
|
||||
// Find clickable rows: anchors or buttons with text, depth-limited sample.
|
||||
const clickables = [...sidebar.querySelectorAll('a, button, [role="button"], [data-slot]')].slice(0, 60)
|
||||
const rows = clickables.map(el => ({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
slot: el.getAttribute('data-slot') ?? '',
|
||||
cls: (el.className?.baseVal ?? el.className ?? '').toString().slice(0, 40),
|
||||
text: (el.textContent ?? '').trim().slice(0, 30),
|
||||
visible: !!el.offsetParent
|
||||
})).filter(r => r.text)
|
||||
return JSON.stringify(rows.slice(0, 30), null, 1)
|
||||
})()`)
|
||||
|
||||
console.log(out)
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
52
apps/desktop/scripts/diag-switch-autopsy.mjs
Normal file
52
apps/desktop/scripts/diag-switch-autopsy.mjs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// Session-switch autopsy: click between the two heaviest rows repeatedly,
|
||||
// recording per-switch (a) settled ms, (b) React commits, (c) top rendered
|
||||
// components — so slow switches name themselves.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const ROUNDS = Number(arg('rounds', 8))
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
const SWITCH_ONE = index => `
|
||||
(async () => {
|
||||
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
|
||||
if (rows.length < 2) return JSON.stringify({ error: 'rows' })
|
||||
const row = rows[${index} % 2]
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
rc.start()
|
||||
const t0 = performance.now()
|
||||
row.click()
|
||||
let lastMutation = performance.now()
|
||||
const mo = new MutationObserver(() => { lastMutation = performance.now() })
|
||||
mo.observe(document.body, { childList: true, subtree: true, characterData: true })
|
||||
const deadline = performance.now() + 4000
|
||||
while (performance.now() < deadline) {
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
if (performance.now() - lastMutation > 150) break
|
||||
}
|
||||
mo.disconnect()
|
||||
rc.stop()
|
||||
const settled = Math.round(performance.now() - t0 - 150)
|
||||
const report = rc.report(6).map(r => r.name + ':' + r.renders + '(' + Math.round(r.totalMs) + 'ms)')
|
||||
return JSON.stringify({ label: (row.textContent ?? '').slice(0, 24), settled, commits: rc.commits(), top: report })
|
||||
})()
|
||||
`
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
for (let i = 0; i < ROUNDS; i++) {
|
||||
console.log(await cdp.eval(SWITCH_ONE(i)))
|
||||
await sleep(400)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
74
apps/desktop/scripts/diag-switch-trace.mjs
Normal file
74
apps/desktop/scripts/diag-switch-trace.mjs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// What happens during a SLOW session switch? Click a heavy row with tracing
|
||||
// on, dump the style/layout/script split plus top callsites.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
const CLICK_HEAVIEST = `
|
||||
(() => {
|
||||
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
|
||||
if (rows.length < 2) return 'need rows'
|
||||
// Alternate between the first two rows so every run actually switches.
|
||||
const current = location.hash
|
||||
const target = rows.find(r => !r.getAttribute('data-active')) ?? rows[1]
|
||||
target.click()
|
||||
return 'clicked: ' + (target.textContent ?? '').slice(0, 40)
|
||||
})()
|
||||
`
|
||||
|
||||
const events = []
|
||||
let complete = false
|
||||
cdp.on('Tracing.dataCollected', p => events.push(...(p.value ?? [])))
|
||||
cdp.on('Tracing.tracingComplete', () => {
|
||||
complete = true
|
||||
})
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
await cdp.send('Tracing.start', {
|
||||
transferMode: 'ReportEvents',
|
||||
traceConfig: { includedCategories: ['devtools.timeline'] }
|
||||
})
|
||||
|
||||
console.log(await cdp.eval(CLICK_HEAVIEST))
|
||||
await sleep(2500)
|
||||
console.log(await cdp.eval(CLICK_HEAVIEST))
|
||||
await sleep(2500)
|
||||
|
||||
await cdp.send('Tracing.end')
|
||||
|
||||
for (let w = 0; !complete && w < 10000; w += 200) {
|
||||
await sleep(200)
|
||||
}
|
||||
|
||||
const totals = new Map()
|
||||
const byFn = new Map()
|
||||
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || typeof e.dur !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
totals.set(e.name, (totals.get(e.name) ?? 0) + e.dur / 1000)
|
||||
|
||||
if (e.name === 'FunctionCall') {
|
||||
const d = e.args?.data ?? {}
|
||||
const key = `${d.functionName || '(anon)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
|
||||
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const style = totals.get('UpdateLayoutTree') ?? 0
|
||||
const layout = totals.get('Layout') ?? 0
|
||||
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
|
||||
console.log(`\nVERDICT over 2 switches: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms paint=${(totals.get('Paint') ?? 0).toFixed(0)}ms`)
|
||||
console.log('\nTOP CALLSITES:')
|
||||
|
||||
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)) {
|
||||
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
290
apps/desktop/scripts/live-drive.mjs
Normal file
290
apps/desktop/scripts/live-drive.mjs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
// Live-drive harness for the REAL hgui instance on :9222.
|
||||
//
|
||||
// node scripts/live-drive.mjs status — targets, session count, perf-live armed?
|
||||
// node scripts/live-drive.mjs fps [seconds] — raw rAF fps over N seconds (default 4)
|
||||
// node scripts/live-drive.mjs drag — drag the sidebar sash, report fps + LoAF
|
||||
// node scripts/live-drive.mjs type — type into composer, report fps + LoAF
|
||||
// node scripts/live-drive.mjs switch — cycle through sidebar sessions, per-switch ms
|
||||
// node scripts/live-drive.mjs send "msg" — submit a prompt in the focused session
|
||||
// node scripts/live-drive.mjs eval "expr" — arbitrary page eval
|
||||
//
|
||||
// Attaches to the page target directly (no perf-harness deps) so it works on
|
||||
// the app Brooklyn actually runs, with her profile, her sessions, her layout.
|
||||
|
||||
import { WebSocket } from 'ws'
|
||||
|
||||
const PORT = Number(process.env.CDP_PORT ?? 9222)
|
||||
|
||||
async function attach() {
|
||||
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()
|
||||
const page = list.find(t => t.type === 'page' && !/devtools/.test(t.url))
|
||||
|
||||
if (!page) {
|
||||
throw new Error('no page target on :' + PORT)
|
||||
}
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 })
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.once('open', resolve)
|
||||
ws.once('error', reject)
|
||||
})
|
||||
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.on('message', raw => {
|
||||
const msg = JSON.parse(raw)
|
||||
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result)
|
||||
}
|
||||
})
|
||||
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const mid = ++id
|
||||
pending.set(mid, { resolve, reject })
|
||||
ws.send(JSON.stringify({ id: mid, method, params }))
|
||||
})
|
||||
|
||||
await send('Runtime.enable')
|
||||
|
||||
const evaluate = async expression => {
|
||||
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
|
||||
|
||||
if (r.exceptionDetails) {
|
||||
throw new Error(r.exceptionDetails.exception?.description ?? 'eval failed')
|
||||
}
|
||||
|
||||
return r.result?.value
|
||||
}
|
||||
|
||||
return { evaluate, close: () => ws.close(), send }
|
||||
}
|
||||
|
||||
const FPS = seconds => `
|
||||
(async () => {
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
const end = last + ${seconds * 1000}
|
||||
while (performance.now() < end) {
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
const now = performance.now()
|
||||
frames.push(now - last)
|
||||
last = now
|
||||
}
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
|
||||
return {
|
||||
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
|
||||
p95: Math.round(pct(0.95) * 10) / 10,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
|
||||
slow33: frames.filter(f => f > 33).length,
|
||||
n: frames.length
|
||||
}
|
||||
})()
|
||||
`
|
||||
|
||||
// LoAF recorder for a window of work driven inside `body`.
|
||||
const WITH_LOAF = body => `
|
||||
(async () => {
|
||||
const lofs = []
|
||||
const po = new PerformanceObserver(list => {
|
||||
for (const e of list.getEntries()) {
|
||||
lofs.push({
|
||||
ms: Math.round(e.duration),
|
||||
block: Math.round(e.blockingDuration ?? 0),
|
||||
style: e.styleAndLayoutStart ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) : 0,
|
||||
scripts: (e.scripts ?? []).filter(s => s.duration >= 5).map(s =>
|
||||
(s.invokerType ?? '') + ':' + (s.invoker ?? s.sourceFunctionName ?? '?') + '@' +
|
||||
((s.sourceURL ?? '').split('/').pop() ?? '') + ' ' + Math.round(s.duration) + 'ms')
|
||||
})
|
||||
}
|
||||
})
|
||||
po.observe({ type: 'long-animation-frame', buffered: false })
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
let stop = false
|
||||
const tick = () => {
|
||||
if (stop) return
|
||||
const now = performance.now()
|
||||
frames.push(now - last)
|
||||
last = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
${body}
|
||||
stop = true
|
||||
po.disconnect()
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
|
||||
return {
|
||||
fps: total ? Math.round((frames.length / total) * 1000 * 10) / 10 : 0,
|
||||
p95: Math.round(pct(0.95) * 10) / 10,
|
||||
worst: sorted.length ? Math.round(sorted[sorted.length - 1] * 10) / 10 : 0,
|
||||
slow33: frames.filter(f => f > 33).length,
|
||||
longFrames: lofs.slice(0, 10)
|
||||
}
|
||||
})()
|
||||
`
|
||||
|
||||
const DRAG_BODY = `
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) throw new Error('no sash')
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 60; i++) {
|
||||
x += (i < 30 ? 3 : -3)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
`
|
||||
|
||||
const TYPE_BODY = `
|
||||
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]'))
|
||||
if (!el) throw new Error('no composer')
|
||||
el.focus()
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const ch = 'the quick brown fox '[i % 20]
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
|
||||
el.textContent += ch
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
}
|
||||
`
|
||||
|
||||
// Session switching: click each sidebar session row, time route->settled.
|
||||
const SWITCH = `
|
||||
(async () => {
|
||||
const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')]
|
||||
.filter(el => el.offsetParent !== null).slice(0, 8)
|
||||
if (pick().length < 2) {
|
||||
throw new Error('found ' + pick().length + ' session rows')
|
||||
}
|
||||
const times = []
|
||||
const labels = []
|
||||
for (let i = 0; i < Math.min(pick().length, 6); i++) {
|
||||
// Re-query each iteration: a switch can re-render the sidebar and
|
||||
// detach the previously captured nodes.
|
||||
const row = pick()[i]
|
||||
if (!row) break
|
||||
labels.push((row.textContent || '').slice(0, 24))
|
||||
const t0 = performance.now()
|
||||
row.click()
|
||||
let calm = 0
|
||||
while (calm < 2 && performance.now() - t0 < 5000) {
|
||||
const f0 = performance.now()
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
const dt = performance.now() - f0
|
||||
calm = dt < 20 ? calm + 1 : 0
|
||||
}
|
||||
times.push(Math.round(performance.now() - t0))
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
}
|
||||
return { switches: times, labels, avg: Math.round(times.reduce((a, b) => a + b, 0) / times.length) }
|
||||
})()
|
||||
`
|
||||
|
||||
const cmd = process.argv[2] ?? 'status'
|
||||
const arg = process.argv[3]
|
||||
const { evaluate, close } = await attach()
|
||||
|
||||
try {
|
||||
if (cmd === 'status') {
|
||||
const r = await evaluate(`JSON.stringify({
|
||||
url: location.hash,
|
||||
perfLive: typeof window.__PERF_LIVE__ !== 'undefined',
|
||||
renderCounts: typeof window.__RENDER_COUNTS__ !== 'undefined',
|
||||
tiles: document.querySelectorAll('[data-tree-group]').length,
|
||||
sessions: document.querySelectorAll('[data-slot*="session-row"], [data-session-row]').length,
|
||||
composers: [...document.querySelectorAll('[contenteditable="true"]')].length
|
||||
})`)
|
||||
console.log(r)
|
||||
} else if (cmd === 'fps') {
|
||||
console.log(JSON.stringify(await evaluate(FPS(Number(arg ?? 4)))))
|
||||
} else if (cmd === 'drag') {
|
||||
const r = await evaluate(WITH_LOAF(DRAG_BODY))
|
||||
console.log('drag', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 }))
|
||||
for (const lf of r.longFrames) {
|
||||
console.log(` ⏱ ${lf.ms}ms block=${lf.block} style=${lf.style} → ${lf.scripts.join(' | ') || '(no script ≥5ms)'}`)
|
||||
}
|
||||
} else if (cmd === 'type') {
|
||||
const r = await evaluate(WITH_LOAF(TYPE_BODY))
|
||||
console.log('type', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 }))
|
||||
for (const lf of r.longFrames) {
|
||||
console.log(` ⏱ ${lf.ms}ms block=${lf.block} style=${lf.style} → ${lf.scripts.join(' | ') || '(no script ≥5ms)'}`)
|
||||
}
|
||||
} else if (cmd === 'switch') {
|
||||
console.log(JSON.stringify(await evaluate(SWITCH)))
|
||||
} else if (cmd === 'eval') {
|
||||
console.log(JSON.stringify(await evaluate(arg)))
|
||||
} else if (cmd === 'profile') {
|
||||
// CPU-profile one session switch via CDP Profiler (Document Policy blocks
|
||||
// the in-page Profiler API, CDP is exempt). arg = row label prefix.
|
||||
await send('Profiler.enable')
|
||||
await send('Profiler.setSamplingInterval', { interval: 200 })
|
||||
await send('Profiler.start')
|
||||
const r = await evaluate(`
|
||||
(async () => {
|
||||
const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent !== null)
|
||||
const row = pick().find(el => (el.textContent || '').startsWith(${JSON.stringify(arg ?? 'GUI')}))
|
||||
if (!row) return 'row not found'
|
||||
const t0 = performance.now()
|
||||
row.click()
|
||||
let calm = 0
|
||||
while (calm < 3 && performance.now() - t0 < 6000) {
|
||||
const f0 = performance.now()
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
calm = (performance.now() - f0) < 20 ? calm + 1 : 0
|
||||
}
|
||||
return Math.round(performance.now() - t0)
|
||||
})()
|
||||
`)
|
||||
const { profile } = await send('Profiler.stop')
|
||||
// Self-time per function.
|
||||
const hitById = new Map()
|
||||
for (let i = 0; i < profile.samples.length; i++) {
|
||||
const id = profile.samples[i]
|
||||
const dt = profile.timeDeltas[i] ?? 0
|
||||
hitById.set(id, (hitById.get(id) ?? 0) + dt)
|
||||
}
|
||||
const rows = []
|
||||
for (const node of profile.nodes) {
|
||||
const us = hitById.get(node.id)
|
||||
if (!us || us < 5000) continue
|
||||
const f = node.callFrame
|
||||
rows.push([Math.round(us / 1000), `${f.functionName || '(anon)'} @ ${(f.url || '').split('/').pop()}:${f.lineNumber}`])
|
||||
}
|
||||
rows.sort((a, b) => b[0] - a[0])
|
||||
console.log('switch took', r, 'ms — top self-time:')
|
||||
for (const [ms, name] of rows.slice(0, 18)) {
|
||||
console.log(` ${String(ms).padStart(6)}ms ${name}`)
|
||||
}
|
||||
} else if (cmd === 'send') {
|
||||
const r = await evaluate(`
|
||||
(async () => {
|
||||
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]'))
|
||||
if (!el) return 'no composer'
|
||||
el.focus()
|
||||
document.execCommand('insertText', false, ${JSON.stringify(arg ?? 'hello')})
|
||||
await new Promise(r => setTimeout(r, 120))
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter' }))
|
||||
return 'sent'
|
||||
})()
|
||||
`)
|
||||
console.log(r)
|
||||
} else {
|
||||
console.log('unknown cmd', cmd)
|
||||
}
|
||||
} finally {
|
||||
close()
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
|
||||
import { isFocusWithin } from '@/lib/keybinds/combo'
|
||||
import { $artifactTabs } from '@/store/artifacts'
|
||||
import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
|
||||
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri
|
|||
// file tabs remain (the rail UI falls back to tabs[0]). Gating only on
|
||||
// `$filePreviewTarget` made ⌘W fall through to closeWorkspaceTab() and look
|
||||
// broken with a file tab still on screen.
|
||||
if ($previewTarget.get() || $filePreviewTabs.get().length > 0) {
|
||||
if ($previewTarget.get() || $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0) {
|
||||
return closeActiveRightRailTab()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
|||
onCancel: () => Promise<void> | void
|
||||
onAddContextRef: (refText: string, label?: string, detail?: string) => void
|
||||
onAddUrl: (url: string) => void
|
||||
onBranchInNewChat: (messageId: string) => void
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
maxVoiceRecordingSeconds?: number
|
||||
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
|
||||
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
|
||||
|
|
|
|||
222
apps/desktop/src/app/chat/right-rail/artifact-pane.tsx
Normal file
222
apps/desktop/src/app/chat/right-rail/artifact-pane.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { artifactDownloadName } from '@/lib/artifact-detect'
|
||||
import { downloadTextFile } from '@/lib/download-text'
|
||||
import { ChevronLeft, ChevronRight, Download, ExternalLink } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$artifactRegistry,
|
||||
$artifactVersionSelection,
|
||||
type ArtifactRecord,
|
||||
selectArtifactVersion
|
||||
} from '@/store/artifacts'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { ArtifactLivePreview, ArtifactSourceView, composeArtifactHtml } from './artifact-renderers'
|
||||
import { PreviewEmptyState } from './preview-file'
|
||||
|
||||
type ArtifactViewMode = 'preview' | 'source'
|
||||
|
||||
const MIME_BY_KIND = { code: 'text/plain', html: 'text/html', svg: 'image/svg+xml' } as const
|
||||
|
||||
const HEADER_BUTTON_CLASS =
|
||||
'flex h-5 items-center gap-1 rounded-md px-1 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
|
||||
|
||||
/** Write the composed document to a real temp file through the existing
|
||||
* buffer-save IPC, then hand it to the OS browser. A blob/data URL can't
|
||||
* cross into the OS default browser, so a file on disk is the honest path. */
|
||||
async function openHtmlInBrowser(content: string): Promise<void> {
|
||||
const bridge = window.hermesDesktop
|
||||
|
||||
if (!bridge?.saveImageBuffer || !bridge.openExternal) {
|
||||
throw new Error('Desktop bridge unavailable')
|
||||
}
|
||||
|
||||
const bytes = new TextEncoder().encode(composeArtifactHtml(content))
|
||||
const path = await bridge.saveImageBuffer(bytes, '.html')
|
||||
|
||||
if (!path) {
|
||||
throw new Error('Could not write artifact file')
|
||||
}
|
||||
|
||||
const fileUrl = `file://${path.startsWith('/') ? '' : '/'}${path.replace(/\\/g, '/')}`
|
||||
|
||||
if (bridge.openPreviewInBrowser) {
|
||||
await bridge.openPreviewInBrowser(fileUrl)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await bridge.openExternal(fileUrl)
|
||||
}
|
||||
|
||||
function VersionStepper({
|
||||
current,
|
||||
onSelect,
|
||||
total
|
||||
}: {
|
||||
current: number
|
||||
onSelect: (index: number) => void
|
||||
total: number
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.artifactPane
|
||||
|
||||
if (total < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 text-[0.625rem] font-bold text-muted-foreground">
|
||||
<Tip label={copy.olderVersion}>
|
||||
<button
|
||||
aria-label={copy.olderVersion}
|
||||
className={HEADER_BUTTON_CLASS}
|
||||
disabled={current === 0}
|
||||
onClick={() => onSelect(current - 1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
<span className="tabular-nums">{copy.versionOf(current + 1, total)}</span>
|
||||
<Tip label={copy.newerVersion}>
|
||||
<button
|
||||
aria-label={copy.newerVersion}
|
||||
className={HEADER_BUTTON_CLASS}
|
||||
disabled={current === total - 1}
|
||||
onClick={() => onSelect(current + 1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ArtifactPane({ artifactId }: { artifactId: string }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.artifactPane
|
||||
const registry = useStore($artifactRegistry)
|
||||
const versionSelection = useStore($artifactVersionSelection)
|
||||
// View mode is per-pane, ephemeral: renderable artifacts open in preview.
|
||||
const [userMode, setUserMode] = useState<ArtifactViewMode | null>(null)
|
||||
|
||||
// Reset the explicit mode when the pane is reused for another artifact.
|
||||
useEffect(() => {
|
||||
setUserMode(null)
|
||||
}, [artifactId])
|
||||
|
||||
const record = useMemo<ArtifactRecord | null>(() => {
|
||||
for (const records of Object.values(registry)) {
|
||||
const found = records.find(candidate => candidate.id === artifactId)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [artifactId, registry])
|
||||
|
||||
if (!record) {
|
||||
return <PreviewEmptyState body={copy.missingBody} title={copy.missingTitle} />
|
||||
}
|
||||
|
||||
const isRenderable = record.kind === 'html' || record.kind === 'svg'
|
||||
const versionIndex = Math.min(versionSelection[artifactId] ?? record.versions.length - 1, record.versions.length - 1)
|
||||
const version = record.versions[versionIndex]!
|
||||
const isCurrentVersion = versionIndex >= record.versions.length - 1
|
||||
const mode: ArtifactViewMode = isRenderable ? (userMode ?? 'preview') : 'source'
|
||||
const downloadName = artifactDownloadName(record.kind, record.language, record.title)
|
||||
|
||||
const modeLabel: Record<ArtifactViewMode, string> = {
|
||||
preview: copy.modePreview,
|
||||
source: copy.modeSource
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-transparent">
|
||||
<div className="flex h-7 shrink-0 items-center gap-3 border-b border-border/40 px-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<VersionStepper
|
||||
current={versionIndex}
|
||||
onSelect={index => selectArtifactVersion(artifactId, index)}
|
||||
total={record.versions.length}
|
||||
/>
|
||||
{!isCurrentVersion && (
|
||||
<button
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/25 underline-offset-4 transition-colors hover:text-foreground"
|
||||
onClick={() => selectArtifactVersion(artifactId, record.versions.length - 1)}
|
||||
type="button"
|
||||
>
|
||||
{copy.latest}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isRenderable &&
|
||||
(['preview', 'source'] as const).map(candidate => (
|
||||
<button
|
||||
className={cn(
|
||||
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
|
||||
candidate === mode
|
||||
? 'text-foreground underline decoration-current/30'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
key={candidate}
|
||||
onClick={() => setUserMode(candidate)}
|
||||
type="button"
|
||||
>
|
||||
{modeLabel[candidate]}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="h-5 px-1 opacity-70 hover:opacity-100"
|
||||
iconClassName="size-3"
|
||||
label={copy.copyContent}
|
||||
showLabel={false}
|
||||
text={version.content}
|
||||
/>
|
||||
<Tip label={copy.download}>
|
||||
<button
|
||||
aria-label={copy.download}
|
||||
className={HEADER_BUTTON_CLASS}
|
||||
onClick={() => downloadTextFile(downloadName, version.content, MIME_BY_KIND[record.kind])}
|
||||
type="button"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
{record.kind === 'html' && window.hermesDesktop && (
|
||||
<Tip label={copy.openInBrowser}>
|
||||
<button
|
||||
aria-label={copy.openInBrowser}
|
||||
className={HEADER_BUTTON_CLASS}
|
||||
onClick={() =>
|
||||
void openHtmlInBrowser(version.content).catch(error => notifyError(error, copy.openInBrowserFailed))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{mode === 'preview' && isRenderable ? (
|
||||
<ArtifactLivePreview content={version.content} kind={record.kind} title={record.title} />
|
||||
) : (
|
||||
<ArtifactSourceView language={record.kind === 'html' ? 'html' : record.language} text={version.content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx
Normal file
107
apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import DOMPurify from 'dompurify'
|
||||
import { useMemo } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
|
||||
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
|
||||
import type { ArtifactKind } from '@/lib/artifact-detect'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
const SOURCE_CHUNK_LINES = 200
|
||||
const SOURCE_LINE_PX = 20
|
||||
const SOURCE_OVERSCAN_LINES = 400
|
||||
|
||||
/** Windowed, Shiki-highlighted source view for artifact content. Same fixed-row
|
||||
* windowing as the file preview's SourceView so a 5k-line artifact scrolls
|
||||
* smoothly, minus the gutter drag/selection machinery (artifact content has no
|
||||
* on-disk path to reference lines against). */
|
||||
export function ArtifactSourceView({ language, text }: { language: string; text: string }) {
|
||||
const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text])
|
||||
const lastChunk = chunks.at(-1)
|
||||
const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0
|
||||
|
||||
const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({
|
||||
overscanRows: SOURCE_OVERSCAN_LINES,
|
||||
rowPx: SOURCE_LINE_PX,
|
||||
rowsPerChunk: SOURCE_CHUNK_LINES,
|
||||
totalRows: totalLines
|
||||
})
|
||||
|
||||
const visibleChunks = chunks.slice(startChunk, endChunk + 1)
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto" onScroll={onScroll} ref={scrollerRef}>
|
||||
<div className="min-w-max px-3 py-2 font-mono text-[0.7rem] leading-relaxed" data-selectable-text="true">
|
||||
{beforeRows > 0 && <div aria-hidden style={{ height: beforeRows * SOURCE_LINE_PX }} />}
|
||||
{visibleChunks.map(chunk => (
|
||||
<div className="[&_pre]:m-0" key={chunk.start}>
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{chunk.text}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
))}
|
||||
{afterRows > 0 && <div aria-hidden style={{ height: afterRows * SOURCE_LINE_PX }} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrap an HTML fragment in a minimal document shell; full documents pass
|
||||
* through untouched. Keeps generated fragments (no <html>/<body>) rendering
|
||||
* with sane defaults instead of quirks-mode soup. */
|
||||
export function composeArtifactHtml(content: string): string {
|
||||
if (/<html[\s>]|<!doctype\s+html/i.test(content)) {
|
||||
return content
|
||||
}
|
||||
|
||||
return [
|
||||
'<!doctype html>',
|
||||
'<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
'<style>body{margin:0;font-family:system-ui,sans-serif}</style></head><body>',
|
||||
content,
|
||||
'</body></html>'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandboxed live renderer for html/svg artifact content.
|
||||
*
|
||||
* HTML runs in an `<iframe sandbox="allow-scripts">` — scripts execute in an
|
||||
* opaque origin with no same-origin access, no top navigation, no popups, no
|
||||
* form submission out of the frame. The parent app is unreachable. SVG is
|
||||
* DOMPurify-sanitized with the same profile as the inline ```svg embed.
|
||||
*/
|
||||
export function ArtifactLivePreview({ content, kind, title }: { content: string; kind: ArtifactKind; title: string }) {
|
||||
const svgClean = useMemo(
|
||||
() => (kind === 'svg' ? DOMPurify.sanitize(content, { USE_PROFILES: { svg: true, svgFilters: true } }) : ''),
|
||||
[content, kind]
|
||||
)
|
||||
|
||||
if (kind === 'svg') {
|
||||
return (
|
||||
<div className="grid h-full place-items-center overflow-auto bg-background p-4 [&_svg]:h-auto [&_svg]:max-h-full [&_svg]:w-auto [&_svg]:max-w-full">
|
||||
<div dangerouslySetInnerHTML={{ __html: svgClean }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="block size-full border-0 bg-white"
|
||||
sandbox="allow-scripts"
|
||||
srcDoc={composeArtifactHtml(content)}
|
||||
// Deliberately raw white + forced light scheme: the frame hosts foreign
|
||||
// generated HTML that assumes a light canvas, so it renders deterministically
|
||||
// light in both app themes instead of inheriting theme tokens it can't see.
|
||||
style={{ colorScheme: 'light' }}
|
||||
title={title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { Tip } from '@/components/ui/tooltip'
|
|||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $openArtifacts, artifactIdFromTabId } from '@/store/artifacts'
|
||||
import {
|
||||
$panesFlipped,
|
||||
$rightRailActiveTabId,
|
||||
|
|
@ -34,6 +35,7 @@ import {
|
|||
} from '@/store/preview'
|
||||
import { $dirtyPreviewUrls } from '@/store/preview-edit'
|
||||
|
||||
import { ArtifactPane } from './artifact-pane'
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
export const PREVIEW_RAIL_MIN_WIDTH = '18rem'
|
||||
|
|
@ -44,11 +46,9 @@ interface ChatPreviewRailProps {
|
|||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
interface RailTab {
|
||||
id: RightRailTabId
|
||||
label: string
|
||||
target: PreviewTarget
|
||||
}
|
||||
type RailTab =
|
||||
| { id: RightRailTabId; kind: 'preview'; label: string; target: PreviewTarget; tooltip: string }
|
||||
| { artifactId: string; id: RightRailTabId; kind: 'artifact'; label: string; tooltip: string }
|
||||
|
||||
function tabLabelFor(target: PreviewTarget): string {
|
||||
const value = target.label || target.path || target.source || target.url
|
||||
|
|
@ -65,15 +65,43 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
const filePreviewTabs = useStore($filePreviewTabs)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const dirtyPreviewUrls = useStore($dirtyPreviewUrls)
|
||||
const openArtifacts = useStore($openArtifacts)
|
||||
|
||||
const tabs = useMemo<readonly RailTab[]>(
|
||||
() => [
|
||||
...(previewTarget
|
||||
? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab]
|
||||
? [
|
||||
{
|
||||
id: RIGHT_RAIL_PREVIEW_TAB_ID,
|
||||
kind: 'preview',
|
||||
label: t.preview.tab,
|
||||
target: previewTarget,
|
||||
tooltip: previewTarget.path || previewTarget.url || t.preview.tab
|
||||
} as RailTab
|
||||
]
|
||||
: []),
|
||||
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
|
||||
...filePreviewTabs.map(
|
||||
({ id, target }) =>
|
||||
({
|
||||
id,
|
||||
kind: 'preview',
|
||||
label: tabLabelFor(target),
|
||||
target,
|
||||
tooltip: target.path || target.url || tabLabelFor(target)
|
||||
}) as RailTab
|
||||
),
|
||||
...openArtifacts.map(
|
||||
({ record, tabId }) =>
|
||||
({
|
||||
artifactId: record.id,
|
||||
id: tabId,
|
||||
kind: 'artifact',
|
||||
label: record.title || t.artifactPane.tabFallback,
|
||||
tooltip: record.title || t.artifactPane.tabFallback
|
||||
}) as RailTab
|
||||
)
|
||||
],
|
||||
[filePreviewTabs, previewTarget, t.preview.tab]
|
||||
[filePreviewTabs, openArtifacts, previewTarget, t.artifactPane.tabFallback, t.preview.tab]
|
||||
)
|
||||
|
||||
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
|
||||
|
|
@ -117,13 +145,13 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
const active = tab.id === activeTab.id
|
||||
const hasOthers = tabs.length > 1
|
||||
const hasTabsToRight = index < tabs.length - 1
|
||||
const dirty = Boolean(dirtyPreviewUrls[tab.target.url])
|
||||
const dirty = tab.kind === 'preview' && Boolean(dirtyPreviewUrls[tab.target.url])
|
||||
|
||||
return (
|
||||
<ContextMenu key={tab.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<PaneTab active={active} dirty={dirty} onClose={() => closeRightRailTab(tab.id)}>
|
||||
<Tip label={tab.target.path || tab.target.url || tab.label}>
|
||||
<Tip label={tab.tooltip}>
|
||||
<PaneTabLabel
|
||||
aria-selected={active}
|
||||
as="button"
|
||||
|
|
@ -132,6 +160,9 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{tab.kind === 'artifact' && (
|
||||
<Codicon className="mr-1 shrink-0 text-[0.6875rem] opacity-70" name="sparkle" />
|
||||
)}
|
||||
{tab.label}
|
||||
</PaneTabLabel>
|
||||
</Tip>
|
||||
|
|
@ -166,13 +197,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<PreviewPane
|
||||
embedded
|
||||
onRestartServer={isPreview ? onRestartServer : undefined}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={activeTab.target}
|
||||
/>
|
||||
{activeTab.kind === 'artifact' ? (
|
||||
<ArtifactPane artifactId={artifactIdFromTabId(activeTab.id) ?? activeTab.artifactId} />
|
||||
) : (
|
||||
<PreviewPane
|
||||
embedded
|
||||
onRestartServer={isPreview ? onRestartServer : undefined}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={activeTab.target}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -170,7 +170,6 @@ function TileChat({
|
|||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||
onAttachDroppedItems={composer.attachDroppedItems}
|
||||
onAttachImageBlob={composer.attachImageBlob}
|
||||
onBranchInNewChat={() => undefined}
|
||||
onCancel={actions.cancelRun}
|
||||
onDeleteSelectedSession={() => undefined}
|
||||
onDismissError={actions.dismissError}
|
||||
|
|
|
|||
|
|
@ -205,7 +205,16 @@ export function SidebarSessionsSection({
|
|||
// The flat recents/pinned list is the only place sessions reorder by hand;
|
||||
// grouped/tree views always sort by creation date and never drag.
|
||||
const sessionsDraggable = sortable && !!onReorderSessions
|
||||
const displayEntries = useMemo(() => flattenSessionsWithBranches(sessions), [sessions])
|
||||
// Pinned and manual-drag lists pass sessions already in the caller's order.
|
||||
// Default recents stay dateGrouped and still re-sort roots by group recency
|
||||
// so partition buckets stay truthful — but NEVER for pins, where a turn
|
||||
// finishing was floating background tasks over the user's fixed ranking.
|
||||
const preserveInputOrder = pinned || (sessionsDraggable && !dateGrouped)
|
||||
|
||||
const displayEntries = useMemo(
|
||||
() => flattenSessionsWithBranches(sessions, { preserveOrder: preserveInputOrder }),
|
||||
[sessions, preserveInputOrder]
|
||||
)
|
||||
|
||||
const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => {
|
||||
const rowProps = {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
|||
import { LayoutDashboard } from '@/lib/icons'
|
||||
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||
import { Codecs, persistentAtom } from '@/lib/persisted'
|
||||
import { $artifactTabs } from '@/store/artifacts'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
|
|
@ -51,7 +52,7 @@ import {
|
|||
SIDEBAR_DEFAULT_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH
|
||||
} from '@/store/layout'
|
||||
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
|
||||
import { $filePreviewTabs, $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
|
||||
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
|
||||
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
|
||||
import { watchSessionPins } from '@/store/session-pin-sync'
|
||||
|
|
@ -552,9 +553,10 @@ bindPaneCollapse(
|
|||
// Preview EXISTS only while something is previewed (old-shell semantics:
|
||||
// closing the last preview tab closes the pane; a new target opens + fronts
|
||||
// it). Same visibility binding as every other self-managed surface, driven
|
||||
// by the live targets instead of a toggle.
|
||||
const $previewVisible = computed([$previewTarget, $filePreviewTarget], (target, fileTarget) =>
|
||||
Boolean(target || fileTarget)
|
||||
// by the live targets (and open artifact tabs) instead of a toggle.
|
||||
const $previewVisible = computed(
|
||||
[$previewTarget, $filePreviewTabs, $artifactTabs],
|
||||
(target, fileTabs, artifactTabs) => Boolean(target) || fileTabs.length > 0 || artifactTabs.length > 0
|
||||
)
|
||||
|
||||
bindPaneVisibility('preview', $previewVisible, closeRightRail)
|
||||
|
|
@ -603,6 +605,17 @@ const revealPreview = () => {
|
|||
|
||||
$previewTarget.listen(target => target && revealPreview())
|
||||
$filePreviewTarget.listen(target => target && revealPreview())
|
||||
// Artifact reveal keys on tab OPENS (length grows), not list identity — closing
|
||||
// one of two artifact tabs must not re-front the pane.
|
||||
let lastArtifactTabCount = $artifactTabs.get().length
|
||||
$artifactTabs.listen(tabs => {
|
||||
const grew = tabs.length > lastArtifactTabCount
|
||||
lastArtifactTabCount = tabs.length
|
||||
|
||||
if (grew) {
|
||||
revealPreview()
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function latestChatActions(actions: ChatActions): ChatActions {
|
|||
onAddUrl: (...args) => actions.onAddUrl(...args),
|
||||
onAttachDroppedItems: (...args) => actions.onAttachDroppedItems(...args),
|
||||
onAttachImageBlob: (...args) => actions.onAttachImageBlob(...args),
|
||||
onBranchInNewChat: (...args) => actions.onBranchInNewChat(...args),
|
||||
onBranchInNewChat: latestOptional(() => actions.onBranchInNewChat),
|
||||
onCancel: (...args) => actions.onCancel(...args),
|
||||
onDeleteSelectedSession: (...args) => actions.onDeleteSelectedSession(...args),
|
||||
onDismissError: latestOptional(() => actions.onDismissError),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { registry } from '@/contrib/registry'
|
|||
import { getLogs } from '@/hermes'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $openArtifacts } from '@/store/artifacts'
|
||||
import { $filePreviewTarget, $previewTarget, setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
|
|
@ -74,9 +75,10 @@ export const $restartPreviewServer = atom<((url: string, context?: string) => Pr
|
|||
export function PreviewRailPane() {
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const fileTarget = useStore($filePreviewTarget)
|
||||
const openArtifacts = useStore($openArtifacts)
|
||||
const restartPreviewServer = useStore($restartPreviewServer)
|
||||
|
||||
if (!previewTarget && !fileTarget) {
|
||||
if (!previewTarget && !fileTarget && openArtifacts.length === 0) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center px-4 text-center">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ import {
|
|||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { clearActiveSessionTodos } from '@/store/todos'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import { reportInstallMethodWarning } from '@/store/updates'
|
||||
|
|
@ -439,7 +439,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
}
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
clearSessionSubagents(sessionId)
|
||||
pruneFinishedSessionSubagents(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
nativeSubagentSessionsRef.current.delete(sessionId)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { setSessionTodos } from '@/store/todos'
|
|||
import type { ClientSessionState } from '../../../types'
|
||||
|
||||
import { useGatewayEventHandler } from './gateway-event'
|
||||
import { completionErrorText, delegateTaskPayloads, STREAM_DELTA_FLUSH_MS } from './utils'
|
||||
import { completionErrorText, delegateTaskPayloads, MAX_STREAM_FLUSH_GAP_MS, STREAM_DELTA_FLUSH_MS } from './utils'
|
||||
|
||||
interface MessageStreamOptions {
|
||||
activeGatewayProfile?: string
|
||||
|
|
@ -184,6 +184,9 @@ export function useMessageStream({
|
|||
const queuedDeltasRef = useRef<Map<string, QueuedStreamDeltas>>(new Map())
|
||||
const flushHandleRef = useRef<number | null>(null)
|
||||
const lastFlushAtRef = useRef<number>(0)
|
||||
// What the previous flush cost on the main thread — drives the adaptive
|
||||
// flush floor in scheduleDeltaFlush so multi-stream load yields to input.
|
||||
const lastFlushCostRef = useRef<number>(0)
|
||||
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
|
||||
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
|
||||
const compactedTurnRef = useRef<Set<string>>(new Set())
|
||||
|
|
@ -243,12 +246,29 @@ export function useMessageStream({
|
|||
// length. With this floor, slower streams still coalesce ~2 tokens per
|
||||
// commit and the synthetic harness shows longtask counts drop from ~5/5s
|
||||
// to ~1/5s on big sessions (see scripts/profile-typing-lag.md).
|
||||
//
|
||||
// ADAPTIVE: the floor scales with what the last flush actually cost.
|
||||
// With several sessions streaming at once (split tiles), one flush carries
|
||||
// every stream's commit + markdown re-parse; when that work approaches or
|
||||
// exceeds the fixed 33ms budget, back-to-back flushes leave the main
|
||||
// thread no idle frames and every interaction (typing, resize, hover)
|
||||
// stutters even though no render is wasted. Yielding 3x the measured cost
|
||||
// keeps the thread ~75% idle for input at any load: cheap flushes stay at
|
||||
// 30fps of text growth, expensive multi-stream flushes degrade text fps
|
||||
// instead of interactivity — capped so text never updates slower than 4/s.
|
||||
const sinceLast = performance.now() - lastFlushAtRef.current
|
||||
|
||||
const adaptiveFloor = Math.min(
|
||||
Math.max(STREAM_DELTA_FLUSH_MS, lastFlushCostRef.current * 3),
|
||||
MAX_STREAM_FLUSH_GAP_MS
|
||||
)
|
||||
|
||||
const runFlush = () => {
|
||||
flushHandleRef.current = null
|
||||
lastFlushAtRef.current = performance.now()
|
||||
const startedAt = performance.now()
|
||||
lastFlushAtRef.current = startedAt
|
||||
flushQueuedDeltas()
|
||||
lastFlushCostRef.current = performance.now() - startedAt
|
||||
}
|
||||
|
||||
// Always a timer, never requestAnimationFrame. Chromium pauses rAF for a
|
||||
|
|
@ -265,7 +285,7 @@ export function useMessageStream({
|
|||
// 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.
|
||||
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast))
|
||||
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, adaptiveFloor - sinceLast))
|
||||
}, [flushQueuedDeltas])
|
||||
|
||||
const queueDelta = useCallback(
|
||||
|
|
|
|||
|
|
@ -66,6 +66,12 @@ export function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boole
|
|||
// `scripts/profile-typing-lag.md` for the measurement work behind this.
|
||||
export const STREAM_DELTA_FLUSH_MS = 33
|
||||
|
||||
// Ceiling for the ADAPTIVE flush gap (see scheduleDeltaFlush). Under heavy
|
||||
// multi-stream load the gap stretches to 3x the measured flush cost so the
|
||||
// main thread stays responsive to input; this cap guarantees streaming text
|
||||
// still visibly updates at least ~4x per second no matter the load.
|
||||
export const MAX_STREAM_FLUSH_GAP_MS = 250
|
||||
|
||||
// Gateway/provider failures sometimes arrive as message.complete text instead
|
||||
// of an explicit error event. Treat matches as inline assistant errors so they
|
||||
// persist like real error events and don't get erased by hydrate fallback.
|
||||
|
|
|
|||
|
|
@ -929,6 +929,51 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
|
|||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('executes /approvals against the focused profile session and persists its mode', async () => {
|
||||
const focusedProfile = 'work'
|
||||
const focusedSessionId = 'work-runtime-session'
|
||||
const persistedModes = new Map<string, string>()
|
||||
const sessionProfiles = new Map([[focusedSessionId, focusedProfile]])
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'slash.exec') {
|
||||
const sessionId = String(params?.session_id ?? '')
|
||||
const profile = sessionProfiles.get(sessionId)
|
||||
const command = String(params?.command ?? '')
|
||||
|
||||
if (profile && command === 'approvals off') {
|
||||
persistedModes.set(profile, 'off')
|
||||
}
|
||||
|
||||
return { output: 'Approval mode: off (persistent profile setting).' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
|
||||
render(
|
||||
<Harness
|
||||
activeSessionId={focusedSessionId}
|
||||
activeSessionIdRef={{ current: focusedSessionId }}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={focusedSessionId}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/approvals off')
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('slash.exec', {
|
||||
command: 'approvals off',
|
||||
session_id: focusedSessionId
|
||||
})
|
||||
expect(persistedModes.get(focusedProfile)).toBe('off')
|
||||
expect(persistedModes.has('default')).toBe(false)
|
||||
})
|
||||
|
||||
it('submits /goal send directives returned directly by slash.exec instead of rendering no output', async () => {
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const states: Record<string, unknown>[] = []
|
||||
|
|
|
|||
|
|
@ -977,19 +977,23 @@ describe('resumeSession failure recovery', () => {
|
|||
})
|
||||
|
||||
function BranchHarness({
|
||||
activeSessionId = null,
|
||||
navigate = vi.fn(),
|
||||
onCurrentReady,
|
||||
onReady,
|
||||
requestGateway
|
||||
}: {
|
||||
activeSessionId?: string | null
|
||||
navigate?: ReturnType<typeof vi.fn>
|
||||
onCurrentReady?: (branchCurrentSession: (messageId?: string) => Promise<boolean>) => void
|
||||
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
|
||||
|
||||
const actions = useSessionActions({
|
||||
activeSessionId: null,
|
||||
activeSessionIdRef: ref<string | null>(null),
|
||||
activeSessionId,
|
||||
activeSessionIdRef: ref<string | null>(activeSessionId),
|
||||
busyRef: ref(false),
|
||||
creatingSessionRef: ref(false),
|
||||
ensureSessionState: () => ({}) as ClientSessionState,
|
||||
|
|
@ -1008,7 +1012,8 @@ function BranchHarness({
|
|||
|
||||
useEffect(() => {
|
||||
onReady(actions.branchStoredSession)
|
||||
}, [actions.branchStoredSession, onReady])
|
||||
onCurrentReady?.(actions.branchCurrentSession)
|
||||
}, [actions.branchCurrentSession, actions.branchStoredSession, onCurrentReady, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -1090,6 +1095,56 @@ describe('branchStoredSession desktop source tagging', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('branches an open live chat via session.branch with a trimmed message count (bug #1/#3 fix)', async () => {
|
||||
let branchParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.branch') {
|
||||
branchParams = params
|
||||
|
||||
return {
|
||||
session_id: 'branch-runtime',
|
||||
stored_session_id: 'branch-stored',
|
||||
title: 'Branch',
|
||||
message_count: 2,
|
||||
messages: [],
|
||||
info: {}
|
||||
} as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
setMessages([
|
||||
{ id: 'q1', role: 'user', parts: [{ type: 'text', text: 'question one' }] },
|
||||
{ id: 'a1', role: 'assistant', parts: [{ type: 'text', text: 'answer one' }] },
|
||||
{ id: 'q2', role: 'user', parts: [{ type: 'text', text: 'question two' }] },
|
||||
{ id: 'a2', role: 'assistant', parts: [{ type: 'text', text: 'answer two' }] }
|
||||
])
|
||||
|
||||
let branchCurrentSession: ((messageId?: string) => Promise<boolean>) | null = null
|
||||
render(
|
||||
<BranchHarness
|
||||
activeSessionId="live-parent"
|
||||
onCurrentReady={branch => (branchCurrentSession = branch)}
|
||||
onReady={() => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(branchCurrentSession).not.toBeNull())
|
||||
|
||||
// Branch from the FIRST assistant reply ("a1"), not the last message <20>
|
||||
// this is exactly the scenario that used to drop the question (bug #1):
|
||||
// only the clicked message survived instead of everything up to it.
|
||||
await expect(branchCurrentSession!('a1')).resolves.toBe(true)
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.branch', {
|
||||
session_id: 'live-parent',
|
||||
count: 2
|
||||
})
|
||||
expect(branchParams).toEqual({ session_id: 'live-parent', count: 2 })
|
||||
})
|
||||
|
||||
// #67603: right-clicking a session outside the paginated sidebar window is a
|
||||
// cache miss. Resolve its owning profile (cache → active → cross-profile) and
|
||||
// swap to it before reading the transcript / creating the branch, so the fork
|
||||
|
|
|
|||
|
|
@ -1102,6 +1102,7 @@ export function useSessionActions({
|
|||
const forkBranch = useCallback(
|
||||
async (
|
||||
branchMessages: BranchMessage[],
|
||||
sourceSessionId: null | string,
|
||||
parentStoredId: null | string,
|
||||
cwd?: string,
|
||||
profile?: null | string
|
||||
|
|
@ -1119,14 +1120,19 @@ export function useSessionActions({
|
|||
await ensureGatewayProfile(profile)
|
||||
|
||||
// No title: the backend auto-names the branch from its parent's lineage.
|
||||
const branched = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
...(cwd && { cwd }),
|
||||
...(profile ? { profile } : {}),
|
||||
messages: branchMessages.map(({ content, role }) => ({ content, role })),
|
||||
...(parentStoredId && { parent_session_id: parentStoredId })
|
||||
})
|
||||
const branched = sourceSessionId
|
||||
? await requestGateway<SessionCreateResponse>('session.branch', {
|
||||
session_id: sourceSessionId,
|
||||
count: branchMessages.length
|
||||
})
|
||||
: await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
...(cwd && { cwd }),
|
||||
...(profile ? { profile } : {}),
|
||||
messages: branchMessages.map(({ content, role }) => ({ content, role })),
|
||||
...(parentStoredId && { parent_session_id: parentStoredId })
|
||||
})
|
||||
|
||||
const routedSessionId = branched.stored_session_id ?? branched.session_id
|
||||
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
|
||||
|
|
@ -1212,7 +1218,7 @@ export function useSessionActions({
|
|||
? messages.findIndex(message => message.id === messageId)
|
||||
: messages.findLastIndex(message => message.role === 'assistant' || message.role === 'user')
|
||||
|
||||
const start = at >= 0 ? at : Math.max(messages.length - 1, 0)
|
||||
const start = 0
|
||||
const end = at >= 0 ? at + 1 : messages.length
|
||||
const branchMessages = toBranchMessages(messages.slice(start, end))
|
||||
|
||||
|
|
@ -1229,7 +1235,13 @@ export function useSessionActions({
|
|||
// must stay on that thread's backend (cache hit for an open session).
|
||||
const profile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
|
||||
|
||||
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim(), profile)
|
||||
return forkBranch(
|
||||
branchMessages,
|
||||
activeSessionIdRef.current,
|
||||
selectedStoredSessionIdRef.current,
|
||||
$currentCwd.get().trim(),
|
||||
profile
|
||||
)
|
||||
},
|
||||
[activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef]
|
||||
)
|
||||
|
|
@ -1261,7 +1273,7 @@ export function useSessionActions({
|
|||
return false
|
||||
}
|
||||
|
||||
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile)
|
||||
return await forkBranch(branchMessages, null, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile)
|
||||
} catch (err) {
|
||||
notifyError(err, copy.branchFailed)
|
||||
|
||||
|
|
|
|||
|
|
@ -493,6 +493,7 @@ export function useStatusbarItems({
|
|||
id: 'running-timer',
|
||||
label: copy.turnRunning,
|
||||
title: copy.currentTurnElapsed,
|
||||
toggleLabel: copy.toggleRunningTimer,
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
|
|
@ -511,6 +512,7 @@ export function useStatusbarItems({
|
|||
/>
|
||||
),
|
||||
title: copy.openContextUsage,
|
||||
toggleLabel: copy.toggleContextUsage,
|
||||
variant: 'menu'
|
||||
},
|
||||
{
|
||||
|
|
@ -519,6 +521,7 @@ export function useStatusbarItems({
|
|||
id: 'session-timer',
|
||||
label: copy.session,
|
||||
title: copy.runtimeSessionElapsed,
|
||||
toggleLabel: copy.toggleSessionTimer,
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -96,4 +96,25 @@ describe('statusbar item visibility', () => {
|
|||
|
||||
expect(screen.getByText('Plugin thing')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('starts the per-turn session readouts hidden and restores them from the menu', async () => {
|
||||
const statusbar = bar([
|
||||
item('running-timer', 'Turn timer', { variant: 'text' }),
|
||||
item('context-usage', 'Context meter', { variant: 'menu' }),
|
||||
item('session-timer', 'Session timer', { variant: 'text' }),
|
||||
item('gateway-health', 'Gateway')
|
||||
])
|
||||
|
||||
for (const label of ['Turn timer', 'Context meter', 'Session timer']) {
|
||||
expect(screen.queryByText(label)).toBeNull()
|
||||
}
|
||||
|
||||
openContextMenu(statusbar)
|
||||
|
||||
const row = await screen.findByRole('menuitemcheckbox', { name: 'Session timer' })
|
||||
fireEvent.click(row)
|
||||
|
||||
expect($statusbarHiddenIds.get()).not.toContain('session-timer')
|
||||
expect(within(statusbar).getByText('Session timer')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
145
apps/desktop/src/components/assistant-ui/artifact-card.tsx
Normal file
145
apps/desktop/src/components/assistant-ui/artifact-card.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
'use client'
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { CodeCardIcon } from '@/components/chat/code-card'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ArtifactDetection } from '@/lib/artifact-detect'
|
||||
import { codiconForLanguage } from '@/lib/markdown-code'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$artifactRegistry,
|
||||
artifactsForSession,
|
||||
openArtifactTab,
|
||||
selectArtifactVersion,
|
||||
upsertArtifact
|
||||
} from '@/store/artifacts'
|
||||
|
||||
interface ArtifactCardProps {
|
||||
code: string
|
||||
detection: ArtifactDetection
|
||||
streaming?: boolean
|
||||
}
|
||||
|
||||
const KIND_ICON: Record<ArtifactDetection['kind'], string> = {
|
||||
code: 'code',
|
||||
html: 'browser',
|
||||
svg: 'symbol-color'
|
||||
}
|
||||
|
||||
function detectionIcon(detection: ArtifactDetection): string {
|
||||
return detection.kind === 'code' ? codiconForLanguage(detection.language) : KIND_ICON[detection.kind]
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcript stand-in for a fenced block that was promoted to an artifact.
|
||||
* Replaces the wall of code with a compact, openable card: icon, title, kind,
|
||||
* version badge. While the fence is still streaming
|
||||
* it shows a shimmer + line count instead of the growing source.
|
||||
*
|
||||
* Registration is automatic on completion (so version history accumulates
|
||||
* even if the user never opens the card) but opening the rail is strictly
|
||||
* click-driven — a background stream never steals the pane.
|
||||
*/
|
||||
export function ArtifactCard({ code, detection, streaming = false }: ArtifactCardProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.artifactCard
|
||||
const view = useSessionView()
|
||||
const runtimeId = useStore(view.$runtimeId)
|
||||
const storedId = useStore(view.$storedId)
|
||||
const registry = useStore($artifactRegistry)
|
||||
const sessionId = storedId || runtimeId || ''
|
||||
|
||||
const trimmed = code.trim()
|
||||
|
||||
// Register/version the artifact once its fence has finished streaming.
|
||||
// upsertArtifact dedupes on content hash, so re-renders and transcript
|
||||
// replays are no-ops.
|
||||
useEffect(() => {
|
||||
if (!streaming && sessionId && trimmed) {
|
||||
upsertArtifact(sessionId, detection, trimmed)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- detection derives from code
|
||||
}, [detection.kind, detection.language, detection.title, sessionId, streaming, trimmed])
|
||||
|
||||
const record = useMemo(() => {
|
||||
void registry
|
||||
|
||||
const slugMatch = artifactsForSession(sessionId).find(
|
||||
candidate => candidate.kind === detection.kind && candidate.versions.some(v => v.content === trimmed)
|
||||
)
|
||||
|
||||
return slugMatch ?? null
|
||||
}, [detection.kind, registry, sessionId, trimmed])
|
||||
|
||||
const lineCount = useMemo(() => trimmed.split('\n').length, [trimmed])
|
||||
const kindLabel = copy.kind[detection.kind]
|
||||
const versionCount = record?.versions.length ?? 0
|
||||
const title = (record?.title || detection.title || kindLabel).trim() || kindLabel
|
||||
|
||||
const open = () => {
|
||||
if (streaming || !sessionId || !trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the registry row exists even if the completion effect hasn't
|
||||
// fired yet (e.g. clicked in the same frame the stream sealed).
|
||||
const result = upsertArtifact(sessionId, detection, trimmed)
|
||||
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
|
||||
openArtifactTab(result.artifactId)
|
||||
|
||||
// An older card opens at ITS version, not silently the newest — the user
|
||||
// clicked this specific iteration. openArtifactTab resets to newest, so
|
||||
// re-pin when this card's content is a historical version.
|
||||
const versionIndex = result.record.versions.findIndex(version => version.content === trimmed)
|
||||
|
||||
if (versionIndex !== -1 && versionIndex < result.record.versions.length - 1) {
|
||||
selectArtifactVersion(result.artifactId, versionIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group/artifact my-1.5 flex w-full max-w-md items-center gap-2.5 overflow-hidden rounded-[0.625rem] border border-border px-3 py-2.5 text-left transition-colors',
|
||||
streaming ? 'cursor-default' : 'cursor-pointer hover:bg-accent/40'
|
||||
)}
|
||||
data-slot="aui_artifact-card"
|
||||
disabled={streaming}
|
||||
onClick={open}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground">
|
||||
<CodeCardIcon className="text-[1rem]" name={detectionIcon(detection)} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground',
|
||||
streaming && 'shimmer text-foreground/55'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span className="block truncate text-[length:var(--conversation-tool-font-size)] text-muted-foreground">
|
||||
{streaming
|
||||
? copy.generating(lineCount)
|
||||
: versionCount > 1
|
||||
? `${kindLabel} · ${copy.versionBadge(versionCount)}`
|
||||
: kindLabel}
|
||||
</span>
|
||||
</span>
|
||||
{!streaming && (
|
||||
<span className="shrink-0 text-[length:var(--conversation-tool-font-size)] font-medium text-muted-foreground opacity-0 transition-opacity group-hover/artifact:opacity-100">
|
||||
{copy.open}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $artifactTabs, artifactsForSession, clearArtifactRegistry } from '@/store/artifacts'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
|
||||
|
||||
import { MarkdownTextContent } from './markdown-text'
|
||||
|
||||
const HTML_DOC = `<!doctype html>
|
||||
<html>
|
||||
<head><title>Pomodoro Timer</title></head>
|
||||
<body>
|
||||
<h1>Pomodoro</h1>
|
||||
<p>A tiny focus timer that counts down twenty-five minutes.</p>
|
||||
<script>let seconds = 25 * 60; setInterval(() => { seconds -= 1 }, 1000)</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const SMALL_SNIPPET = 'const x = 1'
|
||||
|
||||
function fenced(language: string, body: string): string {
|
||||
return `Here you go:\n\n\`\`\`${language}\n${body}\n\`\`\`\n`
|
||||
}
|
||||
|
||||
// End-to-end for the artifact path: a substantial ```html fence in assistant
|
||||
// markdown must come out of preprocessMarkdown -> Streamdown -> SyntaxHighlighter
|
||||
// as an artifact card (registered in the store), while small fences keep the
|
||||
// plain code-card path.
|
||||
describe('MarkdownTextContent artifacts', () => {
|
||||
beforeEach(() => {
|
||||
$activeSessionId.set('session-artifacts')
|
||||
$selectedStoredSessionId.set(null)
|
||||
window.localStorage.clear()
|
||||
clearArtifactRegistry()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$activeSessionId.set(null)
|
||||
$selectedStoredSessionId.set(null)
|
||||
clearArtifactRegistry()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('renders a substantial html fence as an artifact card and registers it', async () => {
|
||||
render(<MarkdownTextContent isRunning={false} text={fenced('html', HTML_DOC)} />)
|
||||
|
||||
const card = await screen.findByText('Pomodoro Timer')
|
||||
|
||||
expect(card.closest('button')?.dataset.slot).toBe('aui_artifact-card')
|
||||
expect(artifactsForSession('session-artifacts')).toHaveLength(1)
|
||||
expect(artifactsForSession('session-artifacts')[0]?.kind).toBe('html')
|
||||
// Registration alone must not open the rail (offer, don't hijack).
|
||||
expect($artifactTabs.get()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps a small fence as a plain code block', async () => {
|
||||
const { container } = render(<MarkdownTextContent isRunning={false} text={fenced('js', SMALL_SNIPPET)} />)
|
||||
|
||||
// The code card mounts synchronously; Shiki may split tokens into spans,
|
||||
// so assert on the card slots rather than text content.
|
||||
expect(container.querySelector('[data-slot="code-card"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-slot="aui_artifact-card"]')).toBeNull()
|
||||
expect(artifactsForSession('session-artifacts')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not register while the message is still streaming', async () => {
|
||||
render(<MarkdownTextContent isRunning text={fenced('html', HTML_DOC)} />)
|
||||
|
||||
await screen.findByText('Pomodoro Timer')
|
||||
|
||||
expect(artifactsForSession('session-artifacts')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@ import { ExpandableBlock } from '@/components/chat/expandable-block'
|
|||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { detectArtifact } from '@/lib/artifact-detect'
|
||||
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
|
||||
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
|
||||
import { parseMarkdownIntoBlocksCached } from '@/lib/markdown-blocks'
|
||||
|
|
@ -33,6 +34,7 @@ import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
|||
import { sessionRefFromMarkdownHref } from '@/lib/session-refs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { ArtifactCard } from './artifact-card'
|
||||
import { SessionRefLink } from './directive-text'
|
||||
import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
|
||||
|
||||
|
|
@ -355,6 +357,9 @@ interface MarkdownTextSurfaceProps {
|
|||
containerClassName?: string
|
||||
containerProps?: ComponentProps<'div'>
|
||||
defer?: boolean
|
||||
/** Disable artifact-card promotion for fenced blocks (reasoning text — a
|
||||
* model's scratchpad draft must not register artifact versions). */
|
||||
disableArtifacts?: boolean
|
||||
}
|
||||
|
||||
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
|
||||
|
|
@ -403,7 +408,12 @@ function HugeTextFallback({ containerClassName, text }: { containerClassName?: s
|
|||
)
|
||||
}
|
||||
|
||||
function MarkdownTextSurface({ containerClassName, containerProps, defer }: MarkdownTextSurfaceProps) {
|
||||
function MarkdownTextSurface({
|
||||
containerClassName,
|
||||
containerProps,
|
||||
defer,
|
||||
disableArtifacts
|
||||
}: MarkdownTextSurfaceProps) {
|
||||
const { status, text } = useMessagePartText()
|
||||
const isStreaming = status.type === 'running'
|
||||
|
||||
|
|
@ -508,18 +518,28 @@ function MarkdownTextSurface({ containerClassName, containerProps, defer }: Mark
|
|||
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
|
||||
),
|
||||
img: MarkdownImage,
|
||||
// ```mermaid / ```svg fences route to their lazy renderers; every other
|
||||
// language falls back to the Shiki-highlighted code block.
|
||||
SyntaxHighlighter: (props: SyntaxHighlighterProps) => (
|
||||
<RichCodeBlock
|
||||
code={props.code}
|
||||
fallback={<SyntaxHighlighter {...props} defer={isStreaming} />}
|
||||
language={props.language}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
)
|
||||
// ```mermaid / ```svg fences route to their lazy renderers; substantial
|
||||
// html/svg/code fences promote to an artifact card that opens in the
|
||||
// right rail; every other language falls back to the Shiki-highlighted
|
||||
// code block.
|
||||
SyntaxHighlighter: (props: SyntaxHighlighterProps) => {
|
||||
const artifact = disableArtifacts ? null : detectArtifact(props.language, props.code)
|
||||
|
||||
if (artifact) {
|
||||
return <ArtifactCard code={props.code} detection={artifact} streaming={isStreaming} />
|
||||
}
|
||||
|
||||
return (
|
||||
<RichCodeBlock
|
||||
code={props.code}
|
||||
fallback={<SyntaxHighlighter {...props} defer={isStreaming} />}
|
||||
language={props.language}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}) as StreamdownTextComponents,
|
||||
[isStreaming]
|
||||
[disableArtifacts, isStreaming]
|
||||
)
|
||||
|
||||
if (text.length > MAX_MARKDOWN_CHARS) {
|
||||
|
|
|
|||
|
|
@ -13,8 +13,13 @@ const isTransientLookupError = (error: unknown): boolean =>
|
|||
error instanceof Error && /(useClientLookup|tapClient(Lookup|Resource)).*out of bounds/.test(error.message)
|
||||
|
||||
interface Props {
|
||||
// Changes whenever the message list mutates; remounting clears the caught
|
||||
// error so the next consistent render recovers silently.
|
||||
// Changes whenever the message list mutates STRUCTURALLY (ids/roles/count);
|
||||
// remounting clears the caught error so the next consistent render recovers
|
||||
// silently. Deliberately NOT the per-token signature: this prop reaches
|
||||
// every turn's boundary, so a value that ticks with content length would
|
||||
// re-render every boundary — and reconcile every turn's subtree — on every
|
||||
// streamed token (measured: 540 wasted Block renders per explain() sample
|
||||
// with two threads streaming).
|
||||
resetKey: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { MarkdownTextContent } from './markdown-text'
|
|||
|
||||
const openSessionTile = vi.fn()
|
||||
|
||||
vi.mock('@/store/session-states', () => ({
|
||||
vi.mock('@/store/session-states', async importOriginal => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
openSessionTile: (...args: unknown[]) => openSessionTile(...args)
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
// Bug #2: the Branch-in-new-chat button used to render unconditionally even
|
||||
// when its handler was a no-op (session-tile.tsx passed `() => undefined`
|
||||
// for branched/tiled chats, where nested branching isn't supported). That
|
||||
// left a visibly clickable button that silently did nothing. The fix makes
|
||||
// AssistantMessage's action bar hide the button entirely when no handler is
|
||||
// supplied, matching how onDismissError/onRestoreToMessage already behave.
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Thread } from '.'
|
||||
|
||||
const createdAt = new Date('2026-05-01T00:00:00.000Z')
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
vi.stubGlobal('CSS', { escape: (str: string) => str })
|
||||
|
||||
Element.prototype.scrollTo = function scrollTo() {}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
function userMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'question one' }],
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function Harness({ onBranchInNewChat }: { onBranchInNewChat?: (messageId: string) => void }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [userMessage(), assistantMessage()],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread onBranchInNewChat={onBranchInNewChat} />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AssistantMessage branch button visibility (bug #2 fix)', () => {
|
||||
it('shows the Branch in new chat button when a handler is provided (open chat)', async () => {
|
||||
render(<Harness onBranchInNewChat={() => undefined} />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Branch in new chat' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides the Branch in new chat button when no handler is provided (session-tile / branched chat)', async () => {
|
||||
render(<Harness />)
|
||||
|
||||
// Wait for the assistant message to actually mount before asserting
|
||||
// absence, so a missing button isn't just a false negative from an
|
||||
// unrendered message.
|
||||
await screen.findByText('done')
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Branch in new chat' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -151,15 +151,17 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
|
|||
data-slot="aui_msg-actions"
|
||||
>
|
||||
<MessageAge />
|
||||
<TooltipIconButton
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onBranchInNewChat?.(messageId)
|
||||
}}
|
||||
tooltip={copy.branchNewChat}
|
||||
>
|
||||
<GitForkIcon className="size-3.5" />
|
||||
</TooltipIconButton>
|
||||
{onBranchInNewChat && (
|
||||
<TooltipIconButton
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onBranchInNewChat(messageId)
|
||||
}}
|
||||
tooltip={copy.branchNewChat}
|
||||
>
|
||||
<GitForkIcon className="size-3.5" />
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
|
||||
<ReadAloudButton getText={getMessageText} messageId={messageId} />
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type FC, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { AssistantMessage } from '@/components/assistant-ui/thread/assistant-message'
|
||||
import { ThreadMessageList } from '@/components/assistant-ui/thread/list'
|
||||
|
|
@ -16,7 +16,7 @@ import { notifyError } from '@/store/notifications'
|
|||
|
||||
type ThreadLoadingState = 'response' | 'session'
|
||||
|
||||
export const Thread: FC<{
|
||||
interface ThreadProps {
|
||||
clampToComposer?: boolean
|
||||
cwd?: string | null
|
||||
gateway?: HermesGateway | null
|
||||
|
|
@ -28,7 +28,16 @@ export const Thread: FC<{
|
|||
onRestoreToMessage?: (messageId: string, target?: RestoreMessageTarget) => Promise<void> | void
|
||||
sessionId?: string | null
|
||||
sessionKey?: string | null
|
||||
}> = ({
|
||||
}
|
||||
|
||||
// memo'd on purpose, and load-bearing for session-switch cost. ChatView
|
||||
// re-renders on every route change (it reads `location`), and this subtree is
|
||||
// the entire transcript — without a bail-out here the router's context update
|
||||
// rebuilds every message of the OUTGOING thread before it is replaced. The
|
||||
// props above are all stable across a plain re-render (see the component-map
|
||||
// and loadingIndicator memos below), so the only thing that gets through is a
|
||||
// genuine change.
|
||||
export const Thread = memo(function Thread({
|
||||
clampToComposer = false,
|
||||
cwd = null,
|
||||
gateway = null,
|
||||
|
|
@ -40,7 +49,7 @@ export const Thread: FC<{
|
|||
onRestoreToMessage,
|
||||
sessionId = null,
|
||||
sessionKey
|
||||
}) => {
|
||||
}: ThreadProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
|
||||
|
|
@ -77,9 +86,21 @@ export const Thread: FC<{
|
|||
// deps. Only their definedness stays a dep — it gates UI (the user
|
||||
// Stop button, the restore-confirm affordance). Assigned during render
|
||||
// (the useStoreSelector pattern) so the ref never lags a render.
|
||||
//
|
||||
// cwd / gateway / sessionId ride the same ref for the same reason, and it
|
||||
// is load-bearing on the hot path: all three change on EVERY session
|
||||
// switch, so listing them as deps re-minted these types mid-switch and
|
||||
// remounted the entire OUTGOING transcript — thousands of renders of a
|
||||
// thread that was about to be replaced, all of it before the resume RPC
|
||||
// had even been sent. They are read inside the edit composer (which only
|
||||
// exists while a message is being edited), never during a plain render,
|
||||
// so a ref read is always current by the time it matters.
|
||||
const callbacksRef = useRef({ onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage })
|
||||
callbacksRef.current = { onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage }
|
||||
|
||||
const editContextRef = useRef({ cwd, gateway, sessionId })
|
||||
editContextRef.current = { cwd, gateway, sessionId }
|
||||
|
||||
const hasBranchInNewChat = Boolean(onBranchInNewChat)
|
||||
const hasCancel = Boolean(onCancel)
|
||||
const hasDismissError = Boolean(onDismissError)
|
||||
|
|
@ -96,7 +117,11 @@ export const Thread: FC<{
|
|||
/>
|
||||
),
|
||||
SystemMessage,
|
||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||
UserEditComposer: () => {
|
||||
const { cwd: editCwd, gateway: editGateway, sessionId: editSessionId } = editContextRef.current
|
||||
|
||||
return <UserEditComposer cwd={editCwd} gateway={editGateway} sessionId={editSessionId} />
|
||||
},
|
||||
UserMessage: () => (
|
||||
<UserMessage
|
||||
onCancel={hasCancel ? () => callbacksRef.current.onCancel?.() : undefined}
|
||||
|
|
@ -104,16 +129,7 @@ export const Thread: FC<{
|
|||
/>
|
||||
)
|
||||
}),
|
||||
[
|
||||
cwd,
|
||||
gateway,
|
||||
hasBranchInNewChat,
|
||||
hasCancel,
|
||||
hasDismissError,
|
||||
hasRestoreToMessage,
|
||||
requestRestoreConfirm,
|
||||
sessionId
|
||||
]
|
||||
[hasBranchInNewChat, hasCancel, hasDismissError, hasRestoreToMessage, requestRestoreConfirm]
|
||||
)
|
||||
|
||||
const emptyPlaceholder = intro ? (
|
||||
|
|
@ -122,13 +138,20 @@ export const Thread: FC<{
|
|||
</div>
|
||||
) : undefined
|
||||
|
||||
// Stable element identity, for the same reason the component map above is
|
||||
// memoized: this is a prop of the memo'd ThreadMessageList, so a fresh
|
||||
// element every render defeats the bail-out and drags the whole transcript
|
||||
// into the switch's render pass. It takes no props, so one element is
|
||||
// always correct.
|
||||
const loadingIndicator = useMemo(() => <BackgroundResumeNotice />, [])
|
||||
|
||||
return (
|
||||
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
|
||||
<ThreadMessageList
|
||||
clampToComposer={clampToComposer}
|
||||
components={messageComponents}
|
||||
emptyPlaceholder={emptyPlaceholder}
|
||||
loadingIndicator={<BackgroundResumeNotice />}
|
||||
loadingIndicator={loadingIndicator}
|
||||
sessionKey={sessionKey}
|
||||
/>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
|
|
@ -144,4 +167,4 @@ export const Thread: FC<{
|
|||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
|
@ -46,7 +47,14 @@ const RENDER_BUDGET = 300
|
|||
// the user actually sees after scroll-to-bottom), then bump to the full budget
|
||||
// in a requestAnimationFrame — defers the heavy markdown+syntax-highlight render
|
||||
// past the initial commit, so the switch feels instant.
|
||||
const FIRST_PAINT_BUDGET = 60
|
||||
//
|
||||
// 20, down from 60: the first-paint commit is synchronous and uninterruptible,
|
||||
// and at 60 parts it measured 627ms on a real session (LoAF: block=575ms, no
|
||||
// attributed script — pure commit). A viewport after scroll-to-bottom shows
|
||||
// 1-2 turns ≈ 10-20 parts; the transition backfill below fills the rest
|
||||
// interruptibly, so the only thing a smaller budget changes is how much work
|
||||
// blocks the click-to-paint path.
|
||||
const FIRST_PAINT_BUDGET = 20
|
||||
|
||||
interface ThreadMessageListProps {
|
||||
clampToComposer: boolean
|
||||
|
|
@ -141,14 +149,25 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
loadingIndicator,
|
||||
sessionKey
|
||||
}) => {
|
||||
const messageSignature = useAuiState(s =>
|
||||
s.thread.messages
|
||||
.map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`)
|
||||
.join('\n')
|
||||
// TWO signatures, deliberately split. The STRUCTURAL one (ids/roles/count)
|
||||
// changes only when messages are added/removed/swapped — it keys the error
|
||||
// boundaries and the row identity. The WEIGHT one (per-message part counts)
|
||||
// ticks while a streaming turn appends parts — it feeds only the render
|
||||
// budget. Folding weights into the structural key handed every boundary a
|
||||
// new resetKey per appended part, which reconciled every turn's subtree on
|
||||
// every tick (measured: 540 wasted Block renders per explain() sample with
|
||||
// two threads streaming).
|
||||
const structuralSignature = useAuiState(s =>
|
||||
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
|
||||
)
|
||||
|
||||
const weightSignature = useAuiState(s => s.thread.messages.map(message => message.content?.length ?? 1).join(','))
|
||||
|
||||
const { t } = useI18n()
|
||||
const groups = buildGroups(messageSignature)
|
||||
// Row structure is memoized on the STRUCTURAL signature only, so streaming
|
||||
// part-appends can't churn group identity (that would defeat the rows memo
|
||||
// below on every tick). Weights are folded in separately for the budget.
|
||||
const groups = useMemo(() => buildGroups(structuralSignature), [structuralSignature])
|
||||
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
|
||||
|
||||
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
|
||||
|
|
@ -213,7 +232,22 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
return () => cancelAnimationFrame(rafId)
|
||||
}, [renderBudget])
|
||||
|
||||
const hiddenCount = firstVisibleGroupIndex(groups, renderBudget)
|
||||
// Weights (per-message part counts) fold into the BUDGET only. Group
|
||||
// identity stays structural, so a streaming append re-runs this cheap sum —
|
||||
// not the row JSX. Weighted the same way the old combined signature was.
|
||||
const weightedGroups = useMemo(() => {
|
||||
const weights = weightSignature.split(',').map(w => Number(w) || 1)
|
||||
|
||||
return groups.map(group => ({
|
||||
...group,
|
||||
weight:
|
||||
group.kind === 'turn'
|
||||
? group.indices.reduce((sum, index) => sum + (weights[index] ?? 1), 0)
|
||||
: (weights[group.index] ?? 1)
|
||||
}))
|
||||
}, [groups, weightSignature])
|
||||
|
||||
const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget)
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
const restoreFromBottomRef = useRef<number | null>(null)
|
||||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
|
|
@ -331,6 +365,59 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
}
|
||||
}, [scrollRef, renderBudget])
|
||||
|
||||
// The row array is memoized on the inputs the rows actually read. This
|
||||
// component re-renders on every isAtBottom flip — and use-stick-to-bottom
|
||||
// flips it from a ResizeObserver, so a sidebar DRAG re-renders this list per
|
||||
// frame. Without the memo, the inline .map() rebuilt every row's JSX each
|
||||
// time, and rebuilt children re-render their whole subtree even when nothing
|
||||
// changed (measured live: 865 wasted Block renders in one drag, walked to
|
||||
// "MessageRenderBoundary (children only)" by explain()). With it, React
|
||||
// bails out on element identity and a scroll flip re-renders nothing below.
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
visibleGroups.map((group, indexInVisible) => (
|
||||
// content-visibility:auto — off-screen turns skip style recalc,
|
||||
// layout, and paint. On a long transcript this is what keeps
|
||||
// UNRELATED UI fast: any dialog/popover mount (Radix Presence
|
||||
// reads getComputedStyle) forces a whole-document style recalc,
|
||||
// measured ~650-730ms per open on a 1300-message session and
|
||||
// ~100-200ms with this on. contain-intrinsic-size keeps a
|
||||
// placeholder height for never-rendered turns (auto: remembered
|
||||
// real size once rendered), so scrollbar/anchoring stay stable.
|
||||
// Sticky human bubbles are unaffected — their turn is rendered
|
||||
// whenever any part of it intersects the viewport.
|
||||
//
|
||||
// The live tail (newest turns) is exempt: virtualizing a turn
|
||||
// whose final size hasn't been remembered yet snaps it to a stale
|
||||
// height when it scrolls off, drifting stick-to-bottom up over old
|
||||
// turns. See isVirtualizedGroup.
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
|
||||
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
|
||||
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
|
||||
)}
|
||||
key={group.id}
|
||||
>
|
||||
<MessageRenderBoundary resetKey={structuralSignature}>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
</MessageRenderBoundary>
|
||||
</div>
|
||||
)),
|
||||
[visibleGroups, components, structuralSignature]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
|
|
@ -380,46 +467,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
{t.assistant.thread.showEarlier}
|
||||
</button>
|
||||
)}
|
||||
{visibleGroups.map((group, indexInVisible) => (
|
||||
// content-visibility:auto — off-screen turns skip style recalc,
|
||||
// layout, and paint. On a long transcript this is what keeps
|
||||
// UNRELATED UI fast: any dialog/popover mount (Radix Presence
|
||||
// reads getComputedStyle) forces a whole-document style recalc,
|
||||
// measured ~650-730ms per open on a 1300-message session and
|
||||
// ~100-200ms with this on. contain-intrinsic-size keeps a
|
||||
// placeholder height for never-rendered turns (auto: remembered
|
||||
// real size once rendered), so scrollbar/anchoring stay stable.
|
||||
// Sticky human bubbles are unaffected — their turn is rendered
|
||||
// whenever any part of it intersects the viewport.
|
||||
//
|
||||
// The live tail (newest turns) is exempt: virtualizing a turn
|
||||
// whose final size hasn't been remembered yet snaps it to a stale
|
||||
// height when it scrolls off, drifting stick-to-bottom up over old
|
||||
// turns. See isVirtualizedGroup.
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
|
||||
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
|
||||
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
|
||||
)}
|
||||
key={group.id}
|
||||
>
|
||||
<MessageRenderBoundary resetKey={messageSignature}>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
</MessageRenderBoundary>
|
||||
</div>
|
||||
))}
|
||||
{rows}
|
||||
{loadingIndicator}
|
||||
{clampToComposer && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -87,8 +87,20 @@ const ThinkingDisclosure: FC<{
|
|||
return
|
||||
}
|
||||
|
||||
const pin = () => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
// Height-gated: the observer also fires when the container's WIDTH changes
|
||||
// (sidebar sash drag resizes every message), and pinning there forces a
|
||||
// scrollHeight read+write per preview per frame. Only actual content
|
||||
// growth needs the pin; the height rides the RO entry, reflow-free.
|
||||
let lastHeight = -1
|
||||
|
||||
const pin = (entries: readonly ResizeObserverEntry[]) => {
|
||||
const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1
|
||||
const grew = height < 0 || height > lastHeight
|
||||
lastHeight = height
|
||||
|
||||
if (grew) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
// No sync pin(): the observer's guaranteed initial delivery runs it with
|
||||
|
|
@ -198,6 +210,7 @@ const ReasoningTextPart: ReasoningMessagePartComponent = () => {
|
|||
<MarkdownTextContent
|
||||
containerClassName="text-xs leading-snug text-muted-foreground/85"
|
||||
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
|
||||
disableArtifacts
|
||||
isRunning={status.type === 'running' || messageRunning}
|
||||
text={text.trimStart()}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -755,7 +755,25 @@ function useToolWindow(enabled: boolean) {
|
|||
return
|
||||
}
|
||||
|
||||
const pin = () => {
|
||||
// Track the content's HEIGHT and only pin when it grows. The observer also
|
||||
// fires for width changes — a sidebar sash drag resizes every tool window
|
||||
// once per frame — and pinning there is (a) pointless, the list didn't
|
||||
// grow, and (b) expensive: `pin` writes scrollTop then `syncFade` reads it
|
||||
// back, a write->read forced reflow per tool group per frame. Measured on
|
||||
// a real session while dragging the sash: 927ms of `pin` script plus
|
||||
// 2.7s of style recalc across one 60-frame drag. Reading the height off
|
||||
// the RO entry keeps the check reflow-free.
|
||||
let lastHeight = -1
|
||||
|
||||
const pin = (entries: readonly ResizeObserverEntry[]) => {
|
||||
const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1
|
||||
const grew = height < 0 || height > lastHeight
|
||||
lastHeight = height
|
||||
|
||||
if (!grew) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stickRef.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useState } from 'react'
|
|||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
|
||||
import { modelSearchText } from '@/lib/model-search-text'
|
||||
import { currentPickerSelection } from '@/lib/model-status-label'
|
||||
import { normalize } from '@/lib/text'
|
||||
import type { ModelOptionProvider, ModelPricing } from '@/types/hermes'
|
||||
|
|
@ -172,7 +173,7 @@ function ModelResults({
|
|||
|
||||
const matches = (provider: ModelOptionProvider, model: string) =>
|
||||
!q ||
|
||||
model.toLowerCase().includes(q) ||
|
||||
modelSearchText(model).toLowerCase().includes(q) ||
|
||||
provider.name.toLowerCase().includes(q) ||
|
||||
provider.slug.toLowerCase().includes(q)
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,31 @@ function sameRect(a: Rect | null, b: Rect | null) {
|
|||
// Workspace-edge CSS vars
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- Sash-drag deferral ------------------------------------------------------
|
||||
// The tree sash sets this for the duration of a resize gesture (pointerdown to
|
||||
// pointerup). While set, `publishWorkspaceGeometry` skips its :root custom
|
||||
// property writes: each one invalidates computed style for the whole document,
|
||||
// and the sash's ResizeObserver fires per frame. Measured live (LoAF, real
|
||||
// session): drag frames of ~68ms with style+layout=67ms and no script ≥5ms;
|
||||
// suppressing the writes recovered 14fps → 51fps. The vars only align titlebar
|
||||
// chrome — republishing once on release is visually identical.
|
||||
let sashDragDepth = 0
|
||||
let onSashDragEnd: null | (() => void) = null
|
||||
|
||||
export function beginSashDrag() {
|
||||
sashDragDepth += 1
|
||||
}
|
||||
|
||||
export function endSashDrag() {
|
||||
sashDragDepth = Math.max(0, sashDragDepth - 1)
|
||||
|
||||
if (sashDragDepth === 0) {
|
||||
onSashDragEnd?.()
|
||||
}
|
||||
}
|
||||
|
||||
const sashDragging = () => sashDragDepth > 0
|
||||
|
||||
/**
|
||||
* Publish the workspace zone's viewport edges as root CSS vars:
|
||||
* --workspace-left : px from the viewport's left to the main zone
|
||||
|
|
@ -177,6 +202,12 @@ export function publishWorkspaceGeometry(): () => void {
|
|||
const ro = new ResizeObserver(() => measure())
|
||||
|
||||
const measure = () => {
|
||||
// DEFER during a sash drag (see beginSashDrag above) — republished once on
|
||||
// release via the onSashDragEnd hook registered below.
|
||||
if (sashDragging()) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = document.querySelector<HTMLElement>('[data-session-anchor="workspace"]')
|
||||
|
||||
if (next !== el) {
|
||||
|
|
@ -216,11 +247,14 @@ export function publishWorkspaceGeometry(): () => void {
|
|||
// frame later, after the DOM committed. RO covers width changes (sash drags,
|
||||
// side collapses); window resize covers the rest.
|
||||
const unsubTree = $layoutTree.listen(() => requestAnimationFrame(measure))
|
||||
// Drag released → publish the final geometry the deferral above skipped.
|
||||
onSashDragEnd = () => requestAnimationFrame(measure)
|
||||
window.addEventListener('resize', measure)
|
||||
measure()
|
||||
|
||||
return () => {
|
||||
unsubTree()
|
||||
onSashDragEnd = null
|
||||
window.removeEventListener('resize', measure)
|
||||
ro.disconnect()
|
||||
root.style.removeProperty('--workspace-left')
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'
|
||||
|
||||
import { beginSashDrag, endSashDrag } from '@/components/pane-shell/geometry'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { rafCoalesce } from '@/lib/raf-coalesce'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -234,9 +235,33 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
|
|||
|
||||
document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
// Suppress :root geometry-var writes for the gesture (see geometry.ts —
|
||||
// each one restyles the whole document; they republish on release).
|
||||
beginSashDrag()
|
||||
|
||||
// pointermove outpaces 60fps and each write relayouts the whole pane tree,
|
||||
// so coalesce to one apply per frame (rafCoalesce commits on cleanup).
|
||||
//
|
||||
// During the gesture the store is NOT written. setTreeSplitWeights /
|
||||
// setPaneWidthOverride each mint a new tree/pane-state object, and the
|
||||
// resulting commit walks every mounted pane — measured live on a real
|
||||
// 2-session layout: 31 commits across a 58-frame drag, 20.7fps, with
|
||||
// TreeNode at 490ms and Block/Ct re-parsing markdown for 620ms. The
|
||||
// store is written ONCE on release; during the drag the seam is
|
||||
// previewed with inline styles on the same wrappers React sizes.
|
||||
//
|
||||
// Preview rules (learned the hard way — a wrong shape here left a
|
||||
// phantom gap where a hidden sidebar lived):
|
||||
// - a FIXED side gets ONLY a flex-basis override. Its wrapper renders
|
||||
// as `flex: 0 1 <track>`, so basis is the whole difference; grow and
|
||||
// shrink stay React's. Crucially the flex partner is left untouched,
|
||||
// so it keeps absorbing the remainder and no leftover gap can open.
|
||||
// - a flex-vs-flex seam pins both sides to `0 1 <px>`. Their combined
|
||||
// px is constant, so sibling flex tracks see the same leftover.
|
||||
// - cleanup: a real drag commits the store once, and React's re-render
|
||||
// rewrites the `flex` shorthand, which clears the overrides (writing
|
||||
// the shorthand resets the longhands). A no-movement click restores
|
||||
// the captured style attribute instead, since nothing re-renders.
|
||||
const applyShift = (shiftPx: number) => {
|
||||
if (a.fixed) {
|
||||
a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx)))
|
||||
|
|
@ -255,14 +280,58 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
|
|||
}
|
||||
}
|
||||
|
||||
const resize = rafCoalesce(applyShift)
|
||||
const styleA = kidA.getAttribute('style')
|
||||
const styleB = kidB.getAttribute('style')
|
||||
|
||||
const previewSide = (el: HTMLElement, fixed: boolean, px: number) => {
|
||||
if (fixed) {
|
||||
el.style.flexBasis = `${px}px`
|
||||
} else if (!a.fixed && !b.fixed) {
|
||||
el.style.flex = `0 1 ${px}px`
|
||||
}
|
||||
// Mixed seam, flex side: untouched — it absorbs what the fixed side
|
||||
// gives up, exactly as the track model would render it.
|
||||
}
|
||||
|
||||
const previewShift = (shiftPx: number) => {
|
||||
previewSide(kidA, a.fixed, a0px + shiftPx)
|
||||
previewSide(kidB, b.fixed, b0px - shiftPx)
|
||||
}
|
||||
|
||||
const resize = rafCoalesce(previewShift)
|
||||
let lastShift: null | number = null
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
resize.push(Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)))
|
||||
lastShift = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))
|
||||
resize.push(lastShift)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
resize.finish()
|
||||
|
||||
if (lastShift !== null) {
|
||||
// One store commit; the re-render rewrites `flex` and clears the
|
||||
// preview overrides.
|
||||
applyShift(lastShift)
|
||||
} else {
|
||||
// Click without movement: nothing will re-render, so put the
|
||||
// wrappers' inline styles back exactly as React last wrote them.
|
||||
if (styleA === null) {
|
||||
kidA.removeAttribute('style')
|
||||
} else {
|
||||
kidA.setAttribute('style', styleA)
|
||||
}
|
||||
|
||||
if (styleB === null) {
|
||||
kidB.removeAttribute('style')
|
||||
} else {
|
||||
kidB.setAttribute('style', styleB)
|
||||
}
|
||||
}
|
||||
|
||||
// Geometry vars re-enable AFTER the final store commit above, so the
|
||||
// release publishes exactly one fresh measurement.
|
||||
endSashDrag()
|
||||
document.body.style.cursor = restoreCursor
|
||||
document.body.style.userSelect = restoreSelect
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@
|
|||
// with no changed input, and stores that published a value equal to the last.
|
||||
|
||||
import './render-counter'
|
||||
// Live interaction profiler — arms on real resize/typing so we can measure the
|
||||
// app under REAL sessions instead of a synthetic scenario's toy transcripts.
|
||||
// window.__PERF_LIVE__.on() in the console, then just use the app.
|
||||
import './perf-live'
|
||||
|
||||
import { watchSessionAtoms } from './watched-atoms'
|
||||
|
||||
|
|
|
|||
306
apps/desktop/src/debug/perf-live.ts
Normal file
306
apps/desktop/src/debug/perf-live.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// Live interaction profiler — for driving the app by hand and seeing what the
|
||||
// app actually does under YOUR sessions, not a synthetic scenario's.
|
||||
//
|
||||
// The synthetic scenarios seed toy transcripts (short prose, no tool calls, no
|
||||
// code blocks). A real session is heavier in ways that matter, so a scenario
|
||||
// can report 57fps on a gesture that visibly lags in the real app. This closes
|
||||
// that gap by measuring the real thing.
|
||||
//
|
||||
// It arms itself on any pointerdown on a resize handle and on composer typing,
|
||||
// records frames + render attribution for the duration of the interaction, and
|
||||
// prints a table when the interaction ends. Idle cost is zero: nothing is
|
||||
// observed until an interaction starts.
|
||||
//
|
||||
// window.__PERF_LIVE__.on() start watching (also: ?perflive=1)
|
||||
// window.__PERF_LIVE__.off()
|
||||
// window.__PERF_LIVE__.last() the most recent report as an object
|
||||
//
|
||||
// Dev-only; the whole debug/ graph is aliased out of production builds.
|
||||
|
||||
interface Sample {
|
||||
kind: string
|
||||
ms: number
|
||||
frames: number
|
||||
fps: number
|
||||
p95: number
|
||||
worst: number
|
||||
slow33: number
|
||||
commits: number
|
||||
top: Array<{ name: string; renders: number; wasted: number; totalMs: number }>
|
||||
longFrames: LongFrame[]
|
||||
}
|
||||
|
||||
/** One Long Animation Frame, attributed. `styleMs` is the engine's style+layout
|
||||
* time inside the frame; `scripts` names who ran JS and for how long. This is
|
||||
* the half the render counter cannot see — a frame can cost 900ms with almost
|
||||
* no React in it, and only LoAF says whether that was layout, a ResizeObserver
|
||||
* callback loop, or some timer. */
|
||||
interface LongFrame {
|
||||
ms: number
|
||||
styleMs: number
|
||||
blockingMs: number
|
||||
scripts: Array<{ invoker: string; ms: number; src: string }>
|
||||
}
|
||||
|
||||
const RESIZE_SELECTOR =
|
||||
'[role="separator"], [data-slot="pane-resize-handle"], [class*="cursor-col-resize"], [class*="cursor-row-resize"]'
|
||||
|
||||
const TYPING_SELECTOR = '[contenteditable="true"], textarea, input[type="text"]'
|
||||
|
||||
// A gesture is "over" once this long passes with no further input events.
|
||||
const IDLE_END_MS = 350
|
||||
// Don't report trivial blips (a click, a single keypress).
|
||||
const MIN_FRAMES = 6
|
||||
|
||||
let watching = false
|
||||
|
||||
let active: null | {
|
||||
kind: string
|
||||
startedAt: number
|
||||
frames: number[]
|
||||
last: number
|
||||
raf: number
|
||||
endTimer: ReturnType<typeof setTimeout> | null
|
||||
} = null
|
||||
|
||||
let lastReport: null | Sample = null
|
||||
|
||||
// Long Animation Frames observed while a gesture is active. LoAF entries name
|
||||
// the scripts inside each long frame and split out style/layout time — the
|
||||
// half of a frame the render counter cannot see.
|
||||
let longFrames: LongFrame[] = []
|
||||
|
||||
const loafObserver =
|
||||
typeof PerformanceObserver !== 'undefined' &&
|
||||
PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')
|
||||
? new PerformanceObserver(list => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of list.getEntries()) {
|
||||
const e = entry as PerformanceEntry & {
|
||||
blockingDuration?: number
|
||||
styleAndLayoutStart?: number
|
||||
renderStart?: number
|
||||
scripts?: Array<{
|
||||
duration: number
|
||||
invoker?: string
|
||||
invokerType?: string
|
||||
sourceURL?: string
|
||||
sourceFunctionName?: string
|
||||
}>
|
||||
}
|
||||
|
||||
longFrames.push({
|
||||
blockingMs: Math.round(e.blockingDuration ?? 0),
|
||||
ms: Math.round(e.duration),
|
||||
scripts: (e.scripts ?? [])
|
||||
.filter(s => s.duration >= 5)
|
||||
.map(s => ({
|
||||
invoker: `${s.invokerType ?? ''}:${s.invoker ?? s.sourceFunctionName ?? '?'}`,
|
||||
ms: Math.round(s.duration),
|
||||
src: (s.sourceURL ?? '').split('/').pop() ?? ''
|
||||
})),
|
||||
// styleAndLayoutStart -> frame end is the engine's style+layout tail.
|
||||
styleMs: e.styleAndLayoutStart ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) : 0
|
||||
})
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
const pct = (sorted: number[], p: number) =>
|
||||
sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
|
||||
|
||||
function finish() {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
const { kind, startedAt, frames, raf } = active
|
||||
active = null
|
||||
cancelAnimationFrame(raf)
|
||||
loafObserver?.disconnect()
|
||||
const capturedLongFrames = longFrames
|
||||
longFrames = []
|
||||
|
||||
const counter = window.__RENDER_COUNTS__
|
||||
const commits = counter?.commits() ?? 0
|
||||
|
||||
const top = (counter?.report(12) ?? []).map(r => ({
|
||||
name: r.name,
|
||||
renders: r.renders,
|
||||
wasted: r.wasted,
|
||||
totalMs: r.totalMs
|
||||
}))
|
||||
|
||||
counter?.stop()
|
||||
|
||||
if (frames.length < MIN_FRAMES) {
|
||||
return
|
||||
}
|
||||
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
|
||||
const report: Sample = {
|
||||
commits,
|
||||
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
|
||||
frames: frames.length,
|
||||
kind,
|
||||
longFrames: capturedLongFrames,
|
||||
ms: Math.round(performance.now() - startedAt),
|
||||
p95: Math.round(pct(sorted, 0.95) * 10) / 10,
|
||||
slow33: frames.filter(f => f > 33).length,
|
||||
top,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10
|
||||
}
|
||||
|
||||
lastReport = report
|
||||
|
||||
const headline =
|
||||
`%c${kind}%c ${report.fps}fps · ${report.frames} frames in ${report.ms}ms · ` +
|
||||
`p95 ${report.p95}ms worst ${report.worst}ms · ${report.slow33} slow · ${commits} commits`
|
||||
|
||||
console.log(
|
||||
headline,
|
||||
`background:${report.fps < 45 ? '#c0392b' : '#27ae60'};color:#fff;padding:1px 6px;border-radius:3px`,
|
||||
'color:inherit'
|
||||
)
|
||||
|
||||
if (top.length) {
|
||||
console.table(top)
|
||||
}
|
||||
|
||||
// The frame-engine side: what each long frame actually spent its time on.
|
||||
for (const lf of capturedLongFrames.slice(0, 8)) {
|
||||
const scripts = lf.scripts.map(s => `${s.invoker}@${s.src} ${s.ms}ms`).join(' | ') || '(no script ≥5ms)'
|
||||
console.log(` ⏱ longframe ${lf.ms}ms style+layout ${lf.styleMs}ms block ${lf.blockingMs}ms → ${scripts}`)
|
||||
}
|
||||
}
|
||||
|
||||
function begin(kind: string) {
|
||||
if (active) {
|
||||
// Same gesture continuing — just push the end deadline out.
|
||||
if (active.endTimer) {
|
||||
clearTimeout(active.endTimer)
|
||||
}
|
||||
|
||||
active.endTimer = setTimeout(finish, IDLE_END_MS)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
window.__RENDER_COUNTS__?.start()
|
||||
|
||||
try {
|
||||
// Buffered so a long frame already in flight when the gesture starts is
|
||||
// still attributed to it.
|
||||
loafObserver?.observe({ buffered: true, type: 'long-animation-frame' })
|
||||
} catch {
|
||||
// Older runtime without LoAF — headline still works, attribution is empty.
|
||||
}
|
||||
|
||||
const now = performance.now()
|
||||
|
||||
const state = {
|
||||
endTimer: setTimeout(finish, IDLE_END_MS) as ReturnType<typeof setTimeout> | null,
|
||||
frames: [] as number[],
|
||||
kind,
|
||||
last: now,
|
||||
raf: 0,
|
||||
startedAt: now
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (active !== state) {
|
||||
return
|
||||
}
|
||||
|
||||
const t = performance.now()
|
||||
state.frames.push(t - state.last)
|
||||
state.last = t
|
||||
state.raf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
active = state
|
||||
state.raf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
const target = event.target as Element | null
|
||||
|
||||
if (target?.closest?.(RESIZE_SELECTOR)) {
|
||||
begin('resize')
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove() {
|
||||
if (active?.kind === 'resize') {
|
||||
begin('resize')
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const target = event.target as Element | null
|
||||
|
||||
// Ignore pure modifiers and navigation — we want real text entry.
|
||||
if (event.key.length !== 1 && event.key !== 'Backspace') {
|
||||
return
|
||||
}
|
||||
|
||||
if (target?.closest?.(TYPING_SELECTOR)) {
|
||||
begin('typing')
|
||||
}
|
||||
}
|
||||
|
||||
function on() {
|
||||
if (watching) {
|
||||
return 'already watching'
|
||||
}
|
||||
|
||||
watching = true
|
||||
window.addEventListener('pointerdown', onPointerDown, true)
|
||||
window.addEventListener('pointermove', onPointerMove, true)
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
|
||||
console.log(
|
||||
'%cperf-live%c armed — resize a pane or type in the composer; a report prints when you stop.',
|
||||
'background:#5a7db0;color:#fff;padding:1px 6px;border-radius:3px',
|
||||
'color:inherit'
|
||||
)
|
||||
|
||||
return 'watching'
|
||||
}
|
||||
|
||||
function off() {
|
||||
watching = false
|
||||
window.removeEventListener('pointerdown', onPointerDown, true)
|
||||
window.removeEventListener('pointermove', onPointerMove, true)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
finish()
|
||||
|
||||
return 'stopped'
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PERF_LIVE__?: {
|
||||
on: () => string
|
||||
off: () => string
|
||||
last: () => null | Sample
|
||||
watching: () => boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && !window.__PERF_LIVE__) {
|
||||
window.__PERF_LIVE__ = { last: () => lastReport, off, on, watching: () => watching }
|
||||
|
||||
// Opt in for a whole session with ?perflive=1 so a reload keeps measuring.
|
||||
if (new URLSearchParams(window.location.search).get('perflive') === '1') {
|
||||
on()
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
|
@ -46,6 +46,12 @@ const counts = new Map<string, RenderRecord>()
|
|||
let commits = 0
|
||||
let recording = false
|
||||
|
||||
// explain() state: while set, every wasted render of this component walks up
|
||||
// the fiber tree to find the ancestor whose props/state/context actually
|
||||
// changed — the origin of the cascade.
|
||||
let explainTarget: null | string = null
|
||||
const explainCauses = new Map<string, number>()
|
||||
|
||||
const blank = (): RenderRecord => ({
|
||||
contextChanged: 0,
|
||||
propsChanged: 0,
|
||||
|
|
@ -76,19 +82,51 @@ function propsChanged(fiber: Fiber): boolean {
|
|||
/** Did any hook's memoizedState change? Covers useState, useSyncExternalStore
|
||||
* (so nanostores `useStore`), useMemo, and useReducer alike. */
|
||||
function stateChanged(fiber: Fiber): boolean {
|
||||
return changedHookIndices(fiber).length > 0
|
||||
}
|
||||
|
||||
/** Indices (source order) of the hooks whose memoizedState changed. The index
|
||||
* maps straight onto the component's hook call order, so "hook #3 changed"
|
||||
* identifies the exact useStore/useState line without guessing. */
|
||||
function changedHookIndices(fiber: Fiber): number[] {
|
||||
let next: Fiber['memoizedState'] | null | undefined = fiber.memoizedState
|
||||
let prev: Fiber['memoizedState'] | null | undefined = fiber.alternate?.memoizedState
|
||||
const changed: number[] = []
|
||||
let index = 0
|
||||
|
||||
while (next && prev) {
|
||||
if (!Object.is(next.memoizedState, prev.memoizedState)) {
|
||||
return true
|
||||
changed.push(index)
|
||||
}
|
||||
|
||||
next = next.next
|
||||
prev = prev.next
|
||||
index += 1
|
||||
}
|
||||
|
||||
return false
|
||||
return changed
|
||||
}
|
||||
|
||||
/** Names of the props whose identity changed — the cascade origin's smoking
|
||||
* gun. Used by explain() so the answer is "Streamdown (props: children)" and
|
||||
* not just "Streamdown (props)". */
|
||||
function changedPropKeys(fiber: Fiber): string[] {
|
||||
const prev = fiber.alternate?.memoizedProps as Record<string, unknown> | null | undefined
|
||||
const next = fiber.memoizedProps as Record<string, unknown> | null | undefined
|
||||
|
||||
if (!prev || !next) {
|
||||
return []
|
||||
}
|
||||
|
||||
const keys: string[] = []
|
||||
|
||||
for (const key of Object.keys(next)) {
|
||||
if (!Object.is(prev[key], next[key])) {
|
||||
keys.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Did any consumed context value change? A `memo()` cannot block a re-render
|
||||
|
|
@ -139,6 +177,46 @@ function record(fiber: Fiber) {
|
|||
|
||||
if (!props && !state && !context) {
|
||||
entry.wasted += 1
|
||||
|
||||
// explain() support: walk UP from a wasted render to the TOP of the
|
||||
// cascade — the highest ancestor that also rendered this commit. That
|
||||
// fiber is the origin; its own changed props/state is the reason.
|
||||
// Stopping at the first ancestor with changed props is wrong: JSX rebuilt
|
||||
// by a parent makes every intermediate node report "children changed",
|
||||
// which is the symptom cascading down, not the cause.
|
||||
if (explainTarget && name === explainTarget) {
|
||||
let origin: Fiber = fiber
|
||||
let cursor = fiber.return
|
||||
let hops = 0
|
||||
|
||||
while (cursor && hops < 80) {
|
||||
if (isCompositeFiber(cursor) && didFiberRender(cursor)) {
|
||||
origin = cursor
|
||||
}
|
||||
|
||||
cursor = cursor.return
|
||||
hops += 1
|
||||
}
|
||||
|
||||
const originName = getDisplayName(origin) ?? '?'
|
||||
const changed = changedPropKeys(origin).filter(k => k !== 'children')
|
||||
|
||||
const why =
|
||||
origin === fiber
|
||||
? 'self'
|
||||
: stateChanged(origin)
|
||||
? `state (hooks #${changedHookIndices(origin).slice(0, 5).join(',#')})`
|
||||
: changed.length
|
||||
? `props: ${changed.slice(0, 4).join(',')}`
|
||||
: contextChanged(origin)
|
||||
? 'context'
|
||||
: changedPropKeys(origin).length
|
||||
? 'children only'
|
||||
: 'no visible change (external store?)'
|
||||
|
||||
const key = `${originName} (${why})`
|
||||
explainCauses.set(key, (explainCauses.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
counts.set(name, entry)
|
||||
|
|
@ -168,6 +246,12 @@ declare global {
|
|||
report: (limit?: number) => Array<RenderRecord & { name: string }>
|
||||
/** Attribution for one component by display name. */
|
||||
get: (name: string) => RenderRecord | undefined
|
||||
/**
|
||||
* Name a component (its displayName, e.g. 'Block'), interact, then call
|
||||
* with no argument to get the tally of which CHANGED ancestor each of
|
||||
* its wasted renders cascaded from. The origin, walked — not guessed.
|
||||
*/
|
||||
explain: (name?: null | string) => Record<string, number> | string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -195,6 +279,20 @@ if (typeof window !== 'undefined' && !window.__RENDER_COUNTS__) {
|
|||
},
|
||||
commits: () => commits,
|
||||
counts,
|
||||
explain: name => {
|
||||
if (name !== undefined) {
|
||||
explainTarget = name
|
||||
explainCauses.clear()
|
||||
|
||||
if (name && !recording) {
|
||||
recording = true
|
||||
}
|
||||
|
||||
return name ? `explaining ${name} — interact, then call explain() to read` : 'explain off'
|
||||
}
|
||||
|
||||
return Object.fromEntries([...explainCauses.entries()].sort((x, y) => y[1] - x[1]))
|
||||
},
|
||||
get: name => counts.get(name),
|
||||
recording: () => recording,
|
||||
report,
|
||||
|
|
|
|||
|
|
@ -1495,6 +1495,29 @@ export const ar = defineLocale({
|
|||
copyUrl: 'نسخ الرابط',
|
||||
copyPath: 'نسخ المسار'
|
||||
},
|
||||
|
||||
artifactCard: {
|
||||
kind: { code: 'كود', html: 'صفحة تفاعلية', svg: 'رسم' },
|
||||
generating: lines => `جارٍ الإنشاء… ${lines} سطرًا`,
|
||||
versionBadge: count => `${count} إصدارات`,
|
||||
open: 'فتح'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: 'ناتج',
|
||||
modePreview: 'معاينة',
|
||||
modeSource: 'المصدر',
|
||||
versionOf: (current, total) => `الإصدار ${current} من ${total}`,
|
||||
olderVersion: 'إصدار أقدم',
|
||||
newerVersion: 'إصدار أحدث',
|
||||
latest: 'الأحدث',
|
||||
copyContent: 'نسخ المحتوى',
|
||||
download: 'تنزيل',
|
||||
openInBrowser: 'فتح في المتصفح',
|
||||
openInBrowserFailed: 'تعذّر الفتح في المتصفح',
|
||||
missingTitle: 'الناتج غير متاح',
|
||||
missingBody: 'لم يعد هذا الناتج موجودًا في السجل المحلي.'
|
||||
},
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': 'جلسة جديدة',
|
||||
|
|
|
|||
|
|
@ -1757,6 +1757,29 @@ export const en: Translations = {
|
|||
copyPath: 'Copy path'
|
||||
},
|
||||
|
||||
artifactCard: {
|
||||
kind: { code: 'Code', html: 'Interactive page', svg: 'Graphic' },
|
||||
generating: lines => `Generating… ${lines} lines`,
|
||||
versionBadge: count => `${count} versions`,
|
||||
open: 'Open'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: 'Artifact',
|
||||
modePreview: 'PREVIEW',
|
||||
modeSource: 'SOURCE',
|
||||
versionOf: (current, total) => `v${current} of ${total}`,
|
||||
olderVersion: 'Older version',
|
||||
newerVersion: 'Newer version',
|
||||
latest: 'Latest',
|
||||
copyContent: 'Copy content',
|
||||
download: 'Download',
|
||||
openInBrowser: 'Open in browser',
|
||||
openInBrowserFailed: 'Could not open in browser',
|
||||
missingTitle: 'Artifact unavailable',
|
||||
missingBody: 'This artifact is no longer in the local registry.'
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': 'New session',
|
||||
|
|
@ -2407,6 +2430,9 @@ export const en: Translations = {
|
|||
toggleApprovalMode: 'Approvals',
|
||||
toggleBackendVersion: 'Backend version',
|
||||
toggleCommandCenter: 'Command Center',
|
||||
toggleContextUsage: 'Context meter',
|
||||
toggleRunningTimer: 'Turn timer',
|
||||
toggleSessionTimer: 'Session timer',
|
||||
toggleTerminal: 'Terminal',
|
||||
toggleVersion: 'Version & updates',
|
||||
toggleWorkspace: 'Workspace',
|
||||
|
|
|
|||
|
|
@ -1621,6 +1621,29 @@ export const ja = defineLocale({
|
|||
copyPath: 'パスをコピー'
|
||||
},
|
||||
|
||||
artifactCard: {
|
||||
kind: { code: 'コード', html: 'インタラクティブページ', svg: 'グラフィック' },
|
||||
generating: lines => `生成中… ${lines} 行`,
|
||||
versionBadge: count => `${count} 個のバージョン`,
|
||||
open: '開く'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: 'アーティファクト',
|
||||
modePreview: 'プレビュー',
|
||||
modeSource: 'ソース',
|
||||
versionOf: (current, total) => `${total} 中 v${current}`,
|
||||
olderVersion: '前のバージョン',
|
||||
newerVersion: '次のバージョン',
|
||||
latest: '最新',
|
||||
copyContent: 'コンテンツをコピー',
|
||||
download: 'ダウンロード',
|
||||
openInBrowser: 'ブラウザで開く',
|
||||
openInBrowserFailed: 'ブラウザで開けませんでした',
|
||||
missingTitle: 'アーティファクトを利用できません',
|
||||
missingBody: 'このアーティファクトはローカルレジストリに存在しません。'
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': '新しいセッション',
|
||||
|
|
|
|||
|
|
@ -1466,6 +1466,29 @@ export interface Translations {
|
|||
copyPath: string
|
||||
}
|
||||
|
||||
artifactCard: {
|
||||
kind: Record<'code' | 'html' | 'svg', string>
|
||||
generating: (lines: number) => string
|
||||
versionBadge: (count: number) => string
|
||||
open: string
|
||||
}
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: string
|
||||
modePreview: string
|
||||
modeSource: string
|
||||
versionOf: (current: number, total: number) => string
|
||||
olderVersion: string
|
||||
newerVersion: string
|
||||
latest: string
|
||||
copyContent: string
|
||||
download: string
|
||||
openInBrowser: string
|
||||
openInBrowserFailed: string
|
||||
missingTitle: string
|
||||
missingBody: string
|
||||
}
|
||||
|
||||
sidebar: {
|
||||
nav: Record<string, string>
|
||||
searchAria: string
|
||||
|
|
@ -2013,6 +2036,9 @@ export interface Translations {
|
|||
toggleApprovalMode: string
|
||||
toggleBackendVersion: string
|
||||
toggleCommandCenter: string
|
||||
toggleContextUsage: string
|
||||
toggleRunningTimer: string
|
||||
toggleSessionTimer: string
|
||||
toggleTerminal: string
|
||||
toggleVersion: string
|
||||
toggleWorkspace: string
|
||||
|
|
|
|||
|
|
@ -1569,6 +1569,29 @@ export const zhHant = defineLocale({
|
|||
copyPath: '複製路徑'
|
||||
},
|
||||
|
||||
artifactCard: {
|
||||
kind: { code: '程式碼', html: '互動頁面', svg: '圖形' },
|
||||
generating: lines => `產生中… ${lines} 行`,
|
||||
versionBadge: count => `${count} 個版本`,
|
||||
open: '開啟'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: '產物',
|
||||
modePreview: '預覽',
|
||||
modeSource: '原始碼',
|
||||
versionOf: (current, total) => `第 ${current}/${total} 版`,
|
||||
olderVersion: '較舊版本',
|
||||
newerVersion: '較新版本',
|
||||
latest: '最新',
|
||||
copyContent: '複製內容',
|
||||
download: '下載',
|
||||
openInBrowser: '在瀏覽器中開啟',
|
||||
openInBrowserFailed: '無法在瀏覽器中開啟',
|
||||
missingTitle: '產物無法使用',
|
||||
missingBody: '此產物已不在本機註冊表中。'
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': '新工作階段',
|
||||
|
|
|
|||
|
|
@ -1947,6 +1947,29 @@ export const zh: Translations = {
|
|||
copyPath: '复制路径'
|
||||
},
|
||||
|
||||
artifactCard: {
|
||||
kind: { code: '代码', html: '交互页面', svg: '图形' },
|
||||
generating: lines => `生成中… ${lines} 行`,
|
||||
versionBadge: count => `${count} 个版本`,
|
||||
open: '打开'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: '产物',
|
||||
modePreview: '预览',
|
||||
modeSource: '源码',
|
||||
versionOf: (current, total) => `第 ${current}/${total} 版`,
|
||||
olderVersion: '较旧版本',
|
||||
newerVersion: '较新版本',
|
||||
latest: '最新',
|
||||
copyContent: '复制内容',
|
||||
download: '下载',
|
||||
openInBrowser: '在浏览器中打开',
|
||||
openInBrowserFailed: '无法在浏览器中打开',
|
||||
missingTitle: '产物不可用',
|
||||
missingBody: '此产物已不在本地注册表中。'
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': '新建会话',
|
||||
|
|
@ -2583,6 +2606,9 @@ export const zh: Translations = {
|
|||
toggleApprovalMode: '审批',
|
||||
toggleBackendVersion: '后端版本',
|
||||
toggleCommandCenter: '命令中心',
|
||||
toggleContextUsage: '上下文用量',
|
||||
toggleRunningTimer: '回合计时',
|
||||
toggleSessionTimer: '会话计时',
|
||||
toggleTerminal: '终端',
|
||||
toggleVersion: '版本与更新',
|
||||
toggleWorkspace: '工作区',
|
||||
|
|
|
|||
121
apps/desktop/src/lib/artifact-detect.test.ts
Normal file
121
apps/desktop/src/lib/artifact-detect.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { artifactContentHash, artifactDownloadName, artifactSlug, detectArtifact } from './artifact-detect'
|
||||
|
||||
const HTML_DOC = `<!doctype html>
|
||||
<html>
|
||||
<head><title>Pomodoro Timer</title></head>
|
||||
<body>
|
||||
<h1>Pomodoro</h1>
|
||||
<script>let t = 25 * 60; setInterval(() => { t -= 1 }, 1000)</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
function longCode(lines: number): string {
|
||||
return Array.from(
|
||||
{ length: lines },
|
||||
(_, i) => `export function helper${i}(value: number) { return value * ${i} }`
|
||||
).join('\n')
|
||||
}
|
||||
|
||||
describe('detectArtifact', () => {
|
||||
it('promotes a full html document', () => {
|
||||
const detection = detectArtifact('html', HTML_DOC)
|
||||
|
||||
expect(detection).not.toBeNull()
|
||||
expect(detection?.kind).toBe('html')
|
||||
expect(detection?.title).toBe('Pomodoro Timer')
|
||||
})
|
||||
|
||||
it('falls back to h1 when the document has no title tag', () => {
|
||||
const doc = `<!doctype html><html><body><h1>Budget <em>Dashboard</em></h1>${'<div>x</div>'.repeat(30)}</body></html>`
|
||||
|
||||
expect(detectArtifact('html', doc)?.title).toBe('Budget Dashboard')
|
||||
})
|
||||
|
||||
it('ignores a small html snippet', () => {
|
||||
expect(detectArtifact('html', '<div class="chip">hello</div>')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores small svg fences (inline embed owns them)', () => {
|
||||
expect(detectArtifact('svg', '<svg viewBox="0 0 10 10"><rect width="10" height="10"/></svg>')).toBeNull()
|
||||
})
|
||||
|
||||
it('promotes a large svg', () => {
|
||||
const svg = `<svg viewBox="0 0 100 100"><title>Org Chart</title>${'<rect x="1" y="2" width="3" height="4"/>'.repeat(80)}</svg>`
|
||||
const detection = detectArtifact('svg', svg)
|
||||
|
||||
expect(detection?.kind).toBe('svg')
|
||||
expect(detection?.title).toBe('Org Chart')
|
||||
})
|
||||
|
||||
it('keeps short code inline', () => {
|
||||
expect(detectArtifact('python', 'print("hi")')).toBeNull()
|
||||
})
|
||||
|
||||
it('promotes long code and derives a declaration title', () => {
|
||||
const code = `export function buildDashboard(config: Config) {\n${longCode(60)}\n}`
|
||||
const detection = detectArtifact('typescript', code)
|
||||
|
||||
expect(detection?.kind).toBe('code')
|
||||
expect(detection?.title).toBe('buildDashboard')
|
||||
})
|
||||
|
||||
it('prefers a filename comment for the title', () => {
|
||||
const code = `# server.py\n${longCode(60)
|
||||
.replace(/export function/g, 'def')
|
||||
.replace(/\{|\}/g, '')}`
|
||||
|
||||
const detection = detectArtifact('python', code)
|
||||
|
||||
expect(detection?.kind).toBe('code')
|
||||
expect(detection?.title).toBe('server.py')
|
||||
})
|
||||
|
||||
it('never promotes prose-ish or terminal fences', () => {
|
||||
expect(detectArtifact('text', longCode(80))).toBeNull()
|
||||
expect(detectArtifact('diff', longCode(80))).toBeNull()
|
||||
expect(detectArtifact('markdown', longCode(80))).toBeNull()
|
||||
expect(detectArtifact('mermaid', longCode(80))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('artifactSlug', () => {
|
||||
it('is stable across regenerations of the same artifact', () => {
|
||||
const a = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' })
|
||||
const b = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' })
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(a).toContain('html')
|
||||
})
|
||||
|
||||
it('distinguishes different titles', () => {
|
||||
expect(artifactSlug({ kind: 'html', language: 'html', title: 'Timer' })).not.toBe(
|
||||
artifactSlug({ kind: 'html', language: 'html', title: 'Dashboard' })
|
||||
)
|
||||
})
|
||||
|
||||
it('handles empty/symbol-only titles', () => {
|
||||
expect(artifactSlug({ kind: 'code', language: 'ts', title: '!!!' })).toBe('code:ts:untitled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('artifactContentHash', () => {
|
||||
it('is deterministic and content-sensitive', () => {
|
||||
expect(artifactContentHash('abc')).toBe(artifactContentHash('abc'))
|
||||
expect(artifactContentHash('abc')).not.toBe(artifactContentHash('abd'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('artifactDownloadName', () => {
|
||||
it('keeps an existing extension', () => {
|
||||
expect(artifactDownloadName('code', 'python', 'server.py')).toBe('server.py')
|
||||
})
|
||||
|
||||
it('appends by kind and language', () => {
|
||||
expect(artifactDownloadName('html', 'html', 'Pomodoro Timer')).toBe('Pomodoro-Timer.html')
|
||||
expect(artifactDownloadName('svg', 'svg', 'Org Chart')).toBe('Org-Chart.svg')
|
||||
expect(artifactDownloadName('code', 'typescript', 'buildDashboard')).toBe('buildDashboard.ts')
|
||||
expect(artifactDownloadName('code', 'unknownlang', '')).toBe('artifact.txt')
|
||||
})
|
||||
})
|
||||
232
apps/desktop/src/lib/artifact-detect.ts
Normal file
232
apps/desktop/src/lib/artifact-detect.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
|
||||
/**
|
||||
* Artifact detection — decides when a fenced block in an assistant message is
|
||||
* substantial, self-contained content that deserves an artifact card (opening
|
||||
* in the right rail) instead of an inline code block.
|
||||
*
|
||||
* Pure and cheap: it runs per streaming delta on the growing fence body, so
|
||||
* everything here is a bounded regex scan or a line count. No store access.
|
||||
*/
|
||||
|
||||
export type ArtifactKind = 'code' | 'html' | 'svg'
|
||||
|
||||
export interface ArtifactDetection {
|
||||
kind: ArtifactKind
|
||||
/** Sanitized fence language ('' possible for html/svg detected by shape). */
|
||||
language: string
|
||||
/** Human title derived from the content (html <title>, svg <title>, a named
|
||||
* declaration for code). Falls back to a kind/language label. */
|
||||
title: string
|
||||
}
|
||||
|
||||
// A fence only becomes an artifact once it is unambiguously a document (html),
|
||||
// a large standalone graphic (svg), or long enough that inlining it would
|
||||
// drown the conversation (code). Small snippets stay inline code cards.
|
||||
const HTML_DOC_RE = /<!doctype\s+html|<html[\s>]|<head[\s>]|<body[\s>]/i
|
||||
const HTML_TAG_RE = /<[a-z][a-z0-9-]*(\s[^>]*)?>/i
|
||||
const HTML_DOC_MIN_CHARS = 160
|
||||
const HTML_FRAGMENT_MIN_CHARS = 1200
|
||||
const SVG_MIN_CHARS = 2000
|
||||
const CODE_MIN_LINES = 48
|
||||
const CODE_MIN_CHARS = 3000
|
||||
|
||||
const HTML_LANGUAGES = new Set(['html', 'htm', 'xhtml'])
|
||||
|
||||
// Languages whose fences are never artifacts: prose-ish, terminal output, and
|
||||
// the fences already owned by richer renderers (mermaid diagrams, small svg).
|
||||
const NON_ARTIFACT_LANGUAGES = new Set([
|
||||
'',
|
||||
'console',
|
||||
'diff',
|
||||
'log',
|
||||
'logs',
|
||||
'markdown',
|
||||
'md',
|
||||
'mermaid',
|
||||
'output',
|
||||
'patch',
|
||||
'plain',
|
||||
'plaintext',
|
||||
'shell-session',
|
||||
'stdout',
|
||||
'text',
|
||||
'txt'
|
||||
])
|
||||
|
||||
function countLines(text: string): number {
|
||||
let lines = 1
|
||||
let index = text.indexOf('\n')
|
||||
|
||||
while (index !== -1) {
|
||||
lines += 1
|
||||
index = text.indexOf('\n', index + 1)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function stripTags(value: string): string {
|
||||
return value
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function titleFromTag(content: string, tag: 'h1' | 'title'): string {
|
||||
const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i').exec(content)
|
||||
|
||||
return match ? stripTags(match[1] || '').slice(0, 80) : ''
|
||||
}
|
||||
|
||||
const CODE_DECLARATION_RE =
|
||||
/(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|struct|interface|enum|trait|impl|def|fn)\s+([A-Za-z_$][\w$]*)/
|
||||
|
||||
// `// app.py`, `# server.ts`, `<!-- index.html -->`, `/* main.rs */` on the
|
||||
// first meaningful line — the de-facto LLM convention for naming a file.
|
||||
const FILENAME_COMMENT_RE = /^\s*(?:\/\/|#|--|<!--|\/\*)\s*([\w./-]+\.[a-z0-9]{1,8})\b/i
|
||||
|
||||
function codeTitle(language: string, content: string): string {
|
||||
const head = content.slice(0, 2000)
|
||||
const fileName = FILENAME_COMMENT_RE.exec(head)?.[1]
|
||||
|
||||
if (fileName) {
|
||||
return fileName
|
||||
}
|
||||
|
||||
const declaration = CODE_DECLARATION_RE.exec(head)?.[1]
|
||||
|
||||
if (declaration) {
|
||||
return declaration
|
||||
}
|
||||
|
||||
return language
|
||||
}
|
||||
|
||||
export function detectArtifact(language: string | undefined, code: string | undefined): ArtifactDetection | null {
|
||||
const trimmed = (code ?? '').trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const clean = sanitizeLanguageTag(language || '')
|
||||
|
||||
if (HTML_LANGUAGES.has(clean)) {
|
||||
const isDocument = HTML_DOC_RE.test(trimmed)
|
||||
|
||||
if (
|
||||
(isDocument && trimmed.length >= HTML_DOC_MIN_CHARS) ||
|
||||
(!isDocument && trimmed.length >= HTML_FRAGMENT_MIN_CHARS && HTML_TAG_RE.test(trimmed))
|
||||
) {
|
||||
return {
|
||||
kind: 'html',
|
||||
language: clean,
|
||||
title: titleFromTag(trimmed, 'title') || titleFromTag(trimmed, 'h1') || 'HTML'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (clean === 'svg') {
|
||||
// Small svg fences keep their inline embed (svg-embed.tsx); only a large
|
||||
// standalone graphic graduates into an artifact tab.
|
||||
if (trimmed.length >= SVG_MIN_CHARS && /<svg[\s>]/i.test(trimmed)) {
|
||||
return { kind: 'svg', language: clean, title: titleFromTag(trimmed, 'title') || 'SVG' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (NON_ARTIFACT_LANGUAGES.has(clean)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (trimmed.length < CODE_MIN_CHARS && countLines(trimmed) < CODE_MIN_LINES) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isLikelyProseCodeBlock(clean, trimmed)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { kind: 'code', language: clean, title: codeTitle(clean, trimmed) }
|
||||
}
|
||||
|
||||
/** Stable identity slug for versioning: the same (kind, title, language) in a
|
||||
* session is treated as one artifact the model iterates on. */
|
||||
export function artifactSlug(detection: Pick<ArtifactDetection, 'kind' | 'language' | 'title'>): string {
|
||||
const title = detection.title
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48)
|
||||
|
||||
return `${detection.kind}:${detection.language}:${title || 'untitled'}`
|
||||
}
|
||||
|
||||
/** Tiny non-cryptographic content hash (FNV-1a) for version dedupe. */
|
||||
export function artifactContentHash(content: string): string {
|
||||
let hash = 0x811c9dc5
|
||||
|
||||
for (let i = 0; i < content.length; i += 1) {
|
||||
hash ^= content.charCodeAt(i)
|
||||
hash = Math.imul(hash, 0x01000193)
|
||||
}
|
||||
|
||||
return (hash >>> 0).toString(36)
|
||||
}
|
||||
|
||||
const DOWNLOAD_EXTENSION_BY_LANGUAGE: Record<string, string> = {
|
||||
bash: '.sh',
|
||||
c: '.c',
|
||||
cpp: '.cpp',
|
||||
csharp: '.cs',
|
||||
css: '.css',
|
||||
go: '.go',
|
||||
htm: '.html',
|
||||
html: '.html',
|
||||
java: '.java',
|
||||
javascript: '.js',
|
||||
js: '.js',
|
||||
json: '.json',
|
||||
jsx: '.jsx',
|
||||
kotlin: '.kt',
|
||||
php: '.php',
|
||||
py: '.py',
|
||||
python: '.py',
|
||||
rb: '.rb',
|
||||
rs: '.rs',
|
||||
ruby: '.rb',
|
||||
rust: '.rs',
|
||||
sh: '.sh',
|
||||
sql: '.sql',
|
||||
svg: '.svg',
|
||||
swift: '.swift',
|
||||
toml: '.toml',
|
||||
ts: '.ts',
|
||||
tsx: '.tsx',
|
||||
typescript: '.ts',
|
||||
xhtml: '.html',
|
||||
xml: '.xml',
|
||||
yaml: '.yaml',
|
||||
yml: '.yaml'
|
||||
}
|
||||
|
||||
export function artifactDownloadName(kind: ArtifactKind, language: string, title: string): string {
|
||||
const base =
|
||||
title
|
||||
.replace(/[^\p{L}\p{N}._ -]+/gu, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 60) || 'artifact'
|
||||
|
||||
if (/\.[a-z0-9]{1,8}$/i.test(base)) {
|
||||
return base
|
||||
}
|
||||
|
||||
const ext = kind === 'html' ? '.html' : kind === 'svg' ? '.svg' : DOWNLOAD_EXTENSION_BY_LANGUAGE[language] || '.txt'
|
||||
|
||||
return `${base}${ext}`
|
||||
}
|
||||
|
|
@ -21,6 +21,9 @@ describe('desktop slash command curation', () => {
|
|||
expect(isDesktopSlashSuggestion('/version')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/yolo')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/yolo')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/approvals')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/approvals')).toBe(true)
|
||||
expect(resolveDesktopCommand('/approvals')?.surface).toEqual({ kind: 'exec' })
|
||||
})
|
||||
|
||||
it('surfaces skill and quick commands (extensions) in suggestions and lets them run', () => {
|
||||
|
|
|
|||
|
|
@ -190,6 +190,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
|||
// them, /steer falls back to a next-turn prompt, and /usage is a formatted
|
||||
// live report. Keep them on slash.exec until their RPC contracts are fully
|
||||
// equivalent.
|
||||
{
|
||||
name: '/approvals',
|
||||
description: 'Show or set approval mode [manual|smart|off]',
|
||||
surface: exec(),
|
||||
args: true
|
||||
},
|
||||
{
|
||||
name: '/agents',
|
||||
description: 'Show active desktop sessions and running tasks',
|
||||
|
|
|
|||
16
apps/desktop/src/lib/download-text.ts
Normal file
16
apps/desktop/src/lib/download-text.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** Save generated text content to a file via a blob download. Electron's
|
||||
* default will-download behavior shows the OS save dialog, so this works
|
||||
* without a dedicated IPC handler (same pattern as use-image-download's
|
||||
* browser fallback). */
|
||||
export function downloadTextFile(name: string, content: string, mimeType = 'text/plain') {
|
||||
const blobUrl = URL.createObjectURL(new Blob([content], { type: `${mimeType};charset=utf-8` }))
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = blobUrl
|
||||
link.download = name
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
}
|
||||
30
apps/desktop/src/lib/model-search-text.ts
Normal file
30
apps/desktop/src/lib/model-search-text.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Extra tokens used only for model-picker search ranking.
|
||||
*
|
||||
* Wire IDs stay unchanged — some providers report short or brand-less ids
|
||||
* (Kimi Coding's flagship is literally `k3`) that users still search for by
|
||||
* the familiar `kimi-…` naming of sibling models.
|
||||
*
|
||||
* Keep in sync with ui-tui/src/lib/model-search-text.ts,
|
||||
* web/src/lib/model-search-text.ts, and hermes_cli/model_search.py.
|
||||
*/
|
||||
const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = {
|
||||
k3: ['kimi-k3', 'kimi']
|
||||
}
|
||||
|
||||
/** Haystack for fuzzy/substring model search; never changes the wire id. */
|
||||
export function modelSearchText(model: string): string {
|
||||
const id = model.trim()
|
||||
|
||||
if (!id) {
|
||||
return model
|
||||
}
|
||||
|
||||
const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()]
|
||||
|
||||
if (!aliases?.length) {
|
||||
return id
|
||||
}
|
||||
|
||||
return `${id} ${aliases.join(' ')}`
|
||||
}
|
||||
|
|
@ -50,4 +50,34 @@ describe('flattenSessionsWithBranches', () => {
|
|||
|
||||
expect(flattenSessionsWithBranches([branch])).toEqual([{ session: branch }])
|
||||
})
|
||||
|
||||
it('re-sorts roots by group recency by default (pinned-style jumps without preserveOrder)', () => {
|
||||
// Stale important chat first in the caller's array; a recently-active
|
||||
// background task second. Default path must lift the fresher root — that
|
||||
// is what was scrambling the Pinned section before preserveOrder.
|
||||
const important = session('important', { last_active: 10 })
|
||||
const background = session('background', { last_active: 99 })
|
||||
|
||||
expect(flattenSessionsWithBranches([important, background]).map(e => e.session.id)).toEqual([
|
||||
'background',
|
||||
'important'
|
||||
])
|
||||
})
|
||||
|
||||
it("preserveOrder keeps the caller's root order even when activity is newer lower down", () => {
|
||||
const important = session('important', { last_active: 10 })
|
||||
const background = session('background', { last_active: 99 })
|
||||
const branch = session('branch', { last_active: 50, parent_session_id: 'important' })
|
||||
|
||||
expect(
|
||||
flattenSessionsWithBranches([important, background, branch], { preserveOrder: true }).map(e => ({
|
||||
id: e.session.id,
|
||||
stem: e.branchStem
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: 'important', stem: undefined },
|
||||
{ id: 'branch', stem: '└─ ' },
|
||||
{ id: 'background', stem: undefined }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,10 +5,23 @@ export interface SidebarSessionEntry {
|
|||
session: SessionInfo
|
||||
}
|
||||
|
||||
export interface FlattenSessionsOptions {
|
||||
/**
|
||||
* Keep the input root order instead of re-sorting by group recency.
|
||||
* Use for hand-ordered surfaces (pinned ids, manual recents drag) so a
|
||||
* turn completing can't float a row. Branch children still nest under
|
||||
* their parent; sibling branches stay ordered by their own recency.
|
||||
*/
|
||||
preserveOrder?: boolean
|
||||
}
|
||||
|
||||
const recency = (session: SessionInfo): number => session.last_active || session.started_at || 0
|
||||
|
||||
/** Flat list with branch/fork sessions nested visually under their parent. */
|
||||
export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): SidebarSessionEntry[] {
|
||||
export function flattenSessionsWithBranches(
|
||||
sessions: readonly SessionInfo[],
|
||||
options: FlattenSessionsOptions = {}
|
||||
): SidebarSessionEntry[] {
|
||||
if (sessions.length < 2) {
|
||||
return sessions.map(session => ({ session }))
|
||||
}
|
||||
|
|
@ -53,6 +66,7 @@ export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): S
|
|||
// A group sorts by its freshest member, so activity on any branch lifts the
|
||||
// whole parent→branches cluster together instead of stranding the parent at
|
||||
// its own stale timestamp. Memoized — each subtree is folded at most once.
|
||||
// Skipped when preserveOrder is set: the caller already chose positions.
|
||||
const groupRecencyMemo = new Map<string, number>()
|
||||
|
||||
const groupRecency = (session: SessionInfo): number => {
|
||||
|
|
@ -92,11 +106,13 @@ export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): S
|
|||
children?.forEach((child, index) => emit(child, index === children.length - 1 ? '└─ ' : '├─ '))
|
||||
}
|
||||
|
||||
sessions
|
||||
.filter(session => !nestedIds.has(session.id))
|
||||
.map((session, index) => ({ index, session }))
|
||||
.sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index)
|
||||
.forEach(({ session }) => emit(session))
|
||||
const roots = sessions.filter(session => !nestedIds.has(session.id)).map((session, index) => ({ index, session }))
|
||||
|
||||
if (!options.preserveOrder) {
|
||||
roots.sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index)
|
||||
}
|
||||
|
||||
roots.forEach(({ session }) => emit(session))
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!seen.has(session.id)) {
|
||||
|
|
|
|||
170
apps/desktop/src/store/artifacts.test.ts
Normal file
170
apps/desktop/src/store/artifacts.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ArtifactDetection } from '@/lib/artifact-detect'
|
||||
|
||||
import {
|
||||
$artifactRegistry,
|
||||
$artifactTabs,
|
||||
$artifactVersionSelection,
|
||||
artifactsForSession,
|
||||
artifactTabId,
|
||||
clearArtifactRegistry,
|
||||
closeArtifactTab,
|
||||
getArtifact,
|
||||
openArtifactTab,
|
||||
selectArtifactVersion,
|
||||
upsertArtifact
|
||||
} from './artifacts'
|
||||
import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
|
||||
import { $paneOpen } from './panes'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||
|
||||
const HTML_DETECTION: ArtifactDetection = { kind: 'html', language: 'html', title: 'Pomodoro Timer' }
|
||||
|
||||
describe('artifacts store', () => {
|
||||
beforeEach(() => {
|
||||
$activeSessionId.set('session-1')
|
||||
$selectedStoredSessionId.set(null)
|
||||
window.localStorage.clear()
|
||||
clearArtifactRegistry()
|
||||
$rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$activeSessionId.set(null)
|
||||
$selectedStoredSessionId.set(null)
|
||||
clearArtifactRegistry()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('registers a new artifact with one version', () => {
|
||||
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
|
||||
|
||||
expect(result?.versionAdded).toBe(true)
|
||||
expect(artifactsForSession('session-1')).toHaveLength(1)
|
||||
expect(getArtifact(result!.artifactId)?.versions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('dedupes identical content by hash (streaming replays are no-ops)', () => {
|
||||
const first = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
|
||||
const replay = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
|
||||
|
||||
expect(replay?.versionAdded).toBe(false)
|
||||
expect(replay?.artifactId).toBe(first?.artifactId)
|
||||
expect(getArtifact(first!.artifactId)?.versions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('appends a version when the same artifact regenerates with new content', () => {
|
||||
const first = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
|
||||
const second = upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
|
||||
|
||||
expect(second?.versionAdded).toBe(true)
|
||||
expect(second?.artifactId).toBe(first?.artifactId)
|
||||
|
||||
const record = getArtifact(first!.artifactId)
|
||||
|
||||
expect(record?.versions).toHaveLength(2)
|
||||
expect(record?.versions.at(-1)?.content).toBe('<html>v2</html>')
|
||||
expect(artifactsForSession('session-1')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps different titles as separate artifacts', () => {
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>timer</html>')
|
||||
upsertArtifact('session-1', { ...HTML_DETECTION, title: 'Budget Dashboard' }, '<html>budget</html>')
|
||||
|
||||
expect(artifactsForSession('session-1')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('scopes artifacts per session', () => {
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>a</html>')
|
||||
upsertArtifact('session-2', HTML_DETECTION, '<html>b</html>')
|
||||
|
||||
expect(artifactsForSession('session-1')).toHaveLength(1)
|
||||
expect(artifactsForSession('session-2')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('persists the registry to localStorage', () => {
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>persisted</html>')
|
||||
|
||||
expect(window.localStorage.getItem('hermes.desktop.artifacts.v1')).toContain('persisted')
|
||||
})
|
||||
|
||||
it('rejects empty sessions and empty content', () => {
|
||||
expect(upsertArtifact('', HTML_DETECTION, '<html>x</html>')).toBeNull()
|
||||
expect(upsertArtifact('session-1', HTML_DETECTION, ' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens and closes artifact tabs, driving the rail selection', () => {
|
||||
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
|
||||
const tabId = artifactTabId(result.artifactId)
|
||||
|
||||
openArtifactTab(result.artifactId)
|
||||
|
||||
expect($artifactTabs.get()).toEqual([tabId])
|
||||
expect($rightRailActiveTabId.get()).toBe(tabId)
|
||||
expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true)
|
||||
|
||||
closeArtifactTab(tabId)
|
||||
|
||||
expect($artifactTabs.get()).toEqual([])
|
||||
expect($rightRailActiveTabId.get()).toBe(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
})
|
||||
|
||||
it('does not duplicate a tab when the same artifact opens twice', () => {
|
||||
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
|
||||
|
||||
openArtifactTab(result.artifactId)
|
||||
openArtifactTab(result.artifactId)
|
||||
|
||||
expect($artifactTabs.get()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('tracks version selection and snaps back to latest', () => {
|
||||
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
|
||||
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>v3</html>')
|
||||
|
||||
selectArtifactVersion(result.artifactId, 0)
|
||||
|
||||
expect($artifactVersionSelection.get()[result.artifactId]).toBe(0)
|
||||
|
||||
// Selecting the newest version clears the pin (absent = newest).
|
||||
selectArtifactVersion(result.artifactId, 2)
|
||||
|
||||
expect(result.artifactId in $artifactVersionSelection.get()).toBe(false)
|
||||
|
||||
// Out-of-range clamps.
|
||||
selectArtifactVersion(result.artifactId, -5)
|
||||
|
||||
expect($artifactVersionSelection.get()[result.artifactId]).toBe(0)
|
||||
})
|
||||
|
||||
it('reopening an artifact lands on the newest version', () => {
|
||||
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
|
||||
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
|
||||
selectArtifactVersion(result.artifactId, 0)
|
||||
openArtifactTab(result.artifactId)
|
||||
|
||||
expect(result.artifactId in $artifactVersionSelection.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('survives a registry reload round-trip', () => {
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
|
||||
|
||||
const raw = window.localStorage.getItem('hermes.desktop.artifacts.v1')!
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown[]>
|
||||
|
||||
expect(parsed['session-1']).toHaveLength(1)
|
||||
|
||||
// Simulate a fresh boot: hydrate a clean registry from the persisted JSON.
|
||||
$artifactRegistry.set(JSON.parse(raw))
|
||||
|
||||
const record = artifactsForSession('session-1')[0]
|
||||
|
||||
expect(record?.versions).toHaveLength(2)
|
||||
expect(record?.versions.at(-1)?.content).toBe('<html>v2</html>')
|
||||
})
|
||||
})
|
||||
386
apps/desktop/src/store/artifacts.ts
Normal file
386
apps/desktop/src/store/artifacts.ts
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { artifactContentHash, type ArtifactDetection, type ArtifactKind, artifactSlug } from '@/lib/artifact-detect'
|
||||
import { persistentAtom } from '@/lib/persisted'
|
||||
|
||||
import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID, selectRightRailTab } from './layout'
|
||||
import { setPaneOpen } from './panes'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||
|
||||
/**
|
||||
* ARTIFACT REGISTRY — substantial generated content (HTML pages, large SVGs,
|
||||
* long code) produced in the transcript, promoted out of the message flow into
|
||||
* versioned, openable artifacts. The renderer owns this state: artifacts are a
|
||||
* presentation of message content the backend already persists, so the store
|
||||
* is a cache keyed by session with bounded history.
|
||||
*
|
||||
* Identity: one artifact = one (session, slug) pair, where the slug derives
|
||||
* from kind + language + title. When the model regenerates "the dashboard"
|
||||
* three times in a session, that is ONE artifact with three versions, exactly
|
||||
* like a document the user keeps refining — not three cards.
|
||||
*/
|
||||
|
||||
export interface ArtifactVersion {
|
||||
content: string
|
||||
createdAt: number
|
||||
hash: string
|
||||
}
|
||||
|
||||
export interface ArtifactRecord {
|
||||
createdAt: number
|
||||
id: string
|
||||
kind: ArtifactKind
|
||||
language: string
|
||||
sessionId: string
|
||||
slug: string
|
||||
title: string
|
||||
updatedAt: number
|
||||
/** Oldest → newest. The last entry is the current version. */
|
||||
versions: ArtifactVersion[]
|
||||
}
|
||||
|
||||
type ArtifactRegistry = Record<string, ArtifactRecord[]>
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.artifacts.v1'
|
||||
const MAX_ARTIFACTS_PER_SESSION = 24
|
||||
const MAX_VERSIONS_PER_ARTIFACT = 20
|
||||
const MAX_SESSIONS = 40
|
||||
// localStorage is ~5MB; artifacts carry full content, so cap the persisted
|
||||
// bytes per artifact aggressively. Oversized artifacts survive in memory for
|
||||
// the app's lifetime but persist only their newest version(s) that fit.
|
||||
const MAX_PERSISTED_CHARS_PER_ARTIFACT = 120_000
|
||||
|
||||
export type ArtifactTabId = `artifact:${string}`
|
||||
|
||||
export function artifactTabId(artifactId: string): ArtifactTabId {
|
||||
return `artifact:${artifactId}`
|
||||
}
|
||||
|
||||
export function artifactIdFromTabId(tabId: string): string | null {
|
||||
return tabId.startsWith('artifact:') ? tabId.slice('artifact:'.length) : null
|
||||
}
|
||||
|
||||
function isArtifactVersion(value: unknown): value is ArtifactVersion {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
return typeof r.content === 'string' && typeof r.createdAt === 'number' && typeof r.hash === 'string'
|
||||
}
|
||||
|
||||
function isArtifactRecord(value: unknown): value is ArtifactRecord {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
return (
|
||||
typeof r.createdAt === 'number' &&
|
||||
typeof r.id === 'string' &&
|
||||
(r.kind === 'code' || r.kind === 'html' || r.kind === 'svg') &&
|
||||
typeof r.language === 'string' &&
|
||||
typeof r.sessionId === 'string' &&
|
||||
typeof r.slug === 'string' &&
|
||||
typeof r.title === 'string' &&
|
||||
typeof r.updatedAt === 'number' &&
|
||||
Array.isArray(r.versions) &&
|
||||
r.versions.length > 0 &&
|
||||
r.versions.every(isArtifactVersion)
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeRegistry(value: unknown): ArtifactRegistry {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const out: ArtifactRegistry = {}
|
||||
|
||||
for (const [sessionId, records] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!Array.isArray(records)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const valid = records.filter(isArtifactRecord)
|
||||
|
||||
if (valid.length > 0) {
|
||||
out[sessionId] = valid
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function persistedVersions(record: ArtifactRecord): ArtifactVersion[] {
|
||||
const kept: ArtifactVersion[] = []
|
||||
let budget = MAX_PERSISTED_CHARS_PER_ARTIFACT
|
||||
|
||||
// Newest first; always keep at least the current version even if oversized.
|
||||
for (let i = record.versions.length - 1; i >= 0; i -= 1) {
|
||||
const version = record.versions[i]!
|
||||
|
||||
if (kept.length > 0 && version.content.length > budget) {
|
||||
break
|
||||
}
|
||||
|
||||
budget -= version.content.length
|
||||
kept.unshift(version)
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
|
||||
function pruneRegistry(registry: ArtifactRegistry): ArtifactRegistry {
|
||||
const entries = Object.entries(registry)
|
||||
.map(([sessionId, records]) => {
|
||||
const trimmed = [...records]
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, MAX_ARTIFACTS_PER_SESSION)
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
|
||||
return [sessionId, trimmed] as const
|
||||
})
|
||||
.filter(([, records]) => records.length > 0)
|
||||
.sort(([, a], [, b]) => {
|
||||
const latest = (records: readonly ArtifactRecord[]) => Math.max(...records.map(record => record.updatedAt))
|
||||
|
||||
return latest(b) - latest(a)
|
||||
})
|
||||
.slice(0, MAX_SESSIONS)
|
||||
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
export const $artifactRegistry = persistentAtom<ArtifactRegistry>(
|
||||
STORAGE_KEY,
|
||||
{},
|
||||
{
|
||||
decode: raw => sanitizeRegistry(JSON.parse(raw) as unknown),
|
||||
encode: registry =>
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(pruneRegistry(registry)).map(([sessionId, records]) => [
|
||||
sessionId,
|
||||
records.map(record => ({ ...record, versions: persistedVersions(record) }))
|
||||
])
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
/** Artifact tabs open in the right rail (ids into the registry). */
|
||||
export const $artifactTabs = atom<ArtifactTabId[]>([])
|
||||
|
||||
/** Per-tab selected version index; absent = newest. Ephemeral by design: a
|
||||
* reopened artifact always lands on its current version. */
|
||||
export const $artifactVersionSelection = atom<Record<string, number>>({})
|
||||
|
||||
function currentArtifactSessionId(): string {
|
||||
return $selectedStoredSessionId.get() || $activeSessionId.get() || ''
|
||||
}
|
||||
|
||||
export function getArtifact(artifactId: string): ArtifactRecord | null {
|
||||
for (const records of Object.values($artifactRegistry.get())) {
|
||||
const found = records.find(record => record.id === artifactId)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const $openArtifacts = computed([$artifactRegistry, $artifactTabs], (registry, tabs) => {
|
||||
const byId = new Map<string, ArtifactRecord>()
|
||||
|
||||
for (const records of Object.values(registry)) {
|
||||
for (const record of records) {
|
||||
byId.set(record.id, record)
|
||||
}
|
||||
}
|
||||
|
||||
return tabs
|
||||
.map(tabId => {
|
||||
const id = artifactIdFromTabId(tabId)
|
||||
const record = id ? byId.get(id) : undefined
|
||||
|
||||
return record ? { record, tabId } : null
|
||||
})
|
||||
.filter((entry): entry is { record: ArtifactRecord; tabId: ArtifactTabId } => entry !== null)
|
||||
})
|
||||
|
||||
export function artifactsForSession(sessionId: string | null | undefined): ArtifactRecord[] {
|
||||
const id = sessionId?.trim()
|
||||
|
||||
if (!id) {
|
||||
return []
|
||||
}
|
||||
|
||||
return $artifactRegistry.get()[id] ?? []
|
||||
}
|
||||
|
||||
interface UpsertResult {
|
||||
artifactId: string
|
||||
record: ArtifactRecord
|
||||
/** True when this call appended a NEW version (vs. deduped/no-op). */
|
||||
versionAdded: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Register (or version) an artifact for a session. Same slug + same content
|
||||
* hash is a no-op (streaming remounts and transcript re-renders call this
|
||||
* repeatedly); same slug + new content appends a version.
|
||||
*/
|
||||
export function upsertArtifact(
|
||||
sessionId: string | null | undefined,
|
||||
detection: ArtifactDetection,
|
||||
content: string
|
||||
): UpsertResult | null {
|
||||
const id = sessionId?.trim()
|
||||
const trimmed = content.trim()
|
||||
|
||||
if (!id || !trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const slug = artifactSlug(detection)
|
||||
const hash = artifactContentHash(trimmed)
|
||||
const registry = $artifactRegistry.get()
|
||||
const records = registry[id] ?? []
|
||||
const existing = records.find(record => record.slug === slug)
|
||||
const now = Date.now()
|
||||
|
||||
if (existing) {
|
||||
const known = existing.versions.some(version => version.hash === hash)
|
||||
|
||||
if (known) {
|
||||
return { artifactId: existing.id, record: existing, versionAdded: false }
|
||||
}
|
||||
|
||||
const versions = [...existing.versions, { content: trimmed, createdAt: now, hash }].slice(
|
||||
-MAX_VERSIONS_PER_ARTIFACT
|
||||
)
|
||||
|
||||
const next: ArtifactRecord = {
|
||||
...existing,
|
||||
// A regenerated artifact may carry a sharper title (html <title> arrives
|
||||
// late in the stream); prefer the newest non-generic one.
|
||||
title: detection.title || existing.title,
|
||||
updatedAt: now,
|
||||
versions
|
||||
}
|
||||
|
||||
$artifactRegistry.set(
|
||||
pruneRegistry({
|
||||
...registry,
|
||||
[id]: records.map(record => (record.id === existing.id ? next : record))
|
||||
})
|
||||
)
|
||||
|
||||
return { artifactId: existing.id, record: next, versionAdded: true }
|
||||
}
|
||||
|
||||
const record: ArtifactRecord = {
|
||||
createdAt: now,
|
||||
id: `${id}:${slug}`,
|
||||
kind: detection.kind,
|
||||
language: detection.language,
|
||||
sessionId: id,
|
||||
slug,
|
||||
title: detection.title,
|
||||
updatedAt: now,
|
||||
versions: [{ content: trimmed, createdAt: now, hash }]
|
||||
}
|
||||
|
||||
$artifactRegistry.set(pruneRegistry({ ...registry, [id]: [...records, record] }))
|
||||
|
||||
return { artifactId: record.id, record, versionAdded: true }
|
||||
}
|
||||
|
||||
export function upsertCurrentSessionArtifact(detection: ArtifactDetection, content: string): UpsertResult | null {
|
||||
return upsertArtifact(currentArtifactSessionId(), detection, content)
|
||||
}
|
||||
|
||||
/** Open an artifact tab in the right rail and select it. User-initiated only
|
||||
* (card click) — never called from streaming, per the no-hijack rule. */
|
||||
export function openArtifactTab(artifactId: string) {
|
||||
const tabId = artifactTabId(artifactId)
|
||||
const current = $artifactTabs.get()
|
||||
|
||||
if (!current.includes(tabId)) {
|
||||
$artifactTabs.set([...current, tabId])
|
||||
}
|
||||
|
||||
// Land on the newest version whenever (re)opened.
|
||||
const selection = $artifactVersionSelection.get()
|
||||
|
||||
if (artifactId in selection) {
|
||||
const { [artifactId]: _dropped, ...rest } = selection
|
||||
$artifactVersionSelection.set(rest)
|
||||
}
|
||||
|
||||
setPaneOpen(PREVIEW_PANE_ID, true)
|
||||
selectRightRailTab(tabId)
|
||||
}
|
||||
|
||||
export function closeArtifactTab(tabId: ArtifactTabId): boolean {
|
||||
const current = $artifactTabs.get()
|
||||
const index = current.indexOf(tabId)
|
||||
|
||||
if (index === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = current.filter(id => id !== tabId)
|
||||
|
||||
$artifactTabs.set(next)
|
||||
|
||||
const artifactId = artifactIdFromTabId(tabId)
|
||||
|
||||
if (artifactId) {
|
||||
const { [artifactId]: _dropped, ...rest } = $artifactVersionSelection.get()
|
||||
$artifactVersionSelection.set(rest)
|
||||
}
|
||||
|
||||
if ($rightRailActiveTabId.get() === tabId) {
|
||||
selectRightRailTab(next[Math.min(index, next.length - 1)] ?? RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function selectArtifactVersion(artifactId: string, versionIndex: number) {
|
||||
const record = getArtifact(artifactId)
|
||||
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(record.versions.length - 1, versionIndex))
|
||||
const selection = $artifactVersionSelection.get()
|
||||
|
||||
if (clamped === record.versions.length - 1) {
|
||||
if (artifactId in selection) {
|
||||
const { [artifactId]: _dropped, ...rest } = selection
|
||||
$artifactVersionSelection.set(rest)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$artifactVersionSelection.set({ ...selection, [artifactId]: clamped })
|
||||
}
|
||||
|
||||
export function closeAllArtifactTabs() {
|
||||
$artifactTabs.set([])
|
||||
$artifactVersionSelection.set({})
|
||||
}
|
||||
|
||||
export function clearArtifactRegistry() {
|
||||
$artifactRegistry.set({})
|
||||
closeAllArtifactTabs()
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { atom } from 'nanostores'
|
|||
import { resetLiveRuntimeTracking } from '@/app/contrib/hooks/use-background-sync'
|
||||
import { resetSidebarBatchCapability } from '@/hermes'
|
||||
import { invalidateProfileScopedQueries } from '@/lib/query-client'
|
||||
import { closeAllArtifactTabs } from '@/store/artifacts'
|
||||
import { resetSessionsLimit } from '@/store/layout'
|
||||
import {
|
||||
$unreadFinishedSessionIds,
|
||||
|
|
@ -61,6 +62,10 @@ export function wipeSessionListsForGatewaySwitch(): void {
|
|||
setMessages([])
|
||||
setFreshDraftReady(true)
|
||||
|
||||
// Artifact tabs reference sessions on the previous backend; the registry
|
||||
// itself survives (it's local presentation state) but open tabs must not.
|
||||
closeAllArtifactTabs()
|
||||
|
||||
// Narrowed: account/marketplace/onboarding caches are global, not gateway-
|
||||
// scoped, so a mode swap must not refetch them.
|
||||
invalidateProfileScopedQueries()
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export const FILE_BROWSER_PANE_ID = 'file-browser'
|
|||
export const PREVIEW_PANE_ID = 'preview'
|
||||
export const RIGHT_RAIL_PREVIEW_TAB_ID = 'preview'
|
||||
|
||||
export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `file:${string}`
|
||||
export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `artifact:${string}` | `file:${string}`
|
||||
|
||||
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
|
||||
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { atom, computed } from 'nanostores'
|
|||
import { persistentAtom } from '@/lib/persisted'
|
||||
import { normalize } from '@/lib/text'
|
||||
|
||||
import { $artifactTabs, type ArtifactTabId, closeAllArtifactTabs, closeArtifactTab } from './artifacts'
|
||||
import {
|
||||
$rightRailActiveTabId,
|
||||
PREVIEW_PANE_ID,
|
||||
|
|
@ -79,10 +80,12 @@ export const $filePreviewTabs = persistentAtom<FilePreviewTab[]>(TABS_STORAGE_KE
|
|||
})
|
||||
|
||||
// Drop a restored active file-tab that didn't survive validation so the rail
|
||||
// never points at a tab that isn't there.
|
||||
// never points at a tab that isn't there. Artifact tabs are ephemeral (never
|
||||
// restored), so a persisted `artifact:` active id is always stale too.
|
||||
if (
|
||||
$rightRailActiveTabId.get().startsWith('file:') &&
|
||||
!$filePreviewTabs.get().some(tab => tab.id === $rightRailActiveTabId.get())
|
||||
($rightRailActiveTabId.get().startsWith('file:') &&
|
||||
!$filePreviewTabs.get().some(tab => tab.id === $rightRailActiveTabId.get())) ||
|
||||
$rightRailActiveTabId.get().startsWith('artifact:')
|
||||
) {
|
||||
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
|
|
@ -424,10 +427,10 @@ export function dismissPreviewTarget() {
|
|||
$previewTarget.set(null)
|
||||
|
||||
if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) {
|
||||
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? $artifactTabs.get()[0] ?? RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
|
||||
setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0)
|
||||
setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0)
|
||||
}
|
||||
|
||||
function closeFilePreviewTab(tabId: RightRailTabId) {
|
||||
|
|
@ -447,10 +450,12 @@ function closeFilePreviewTab(tabId: RightRailTabId) {
|
|||
$filePreviewTabs.set(next)
|
||||
|
||||
if ($rightRailActiveTabId.get() === tabId) {
|
||||
selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
selectRightRailTab(
|
||||
next[Math.min(index, next.length - 1)]?.id ?? $artifactTabs.get()[0] ?? RIGHT_RAIL_PREVIEW_TAB_ID
|
||||
)
|
||||
}
|
||||
|
||||
if (next.length === 0 && !$previewTarget.get()) {
|
||||
if (next.length === 0 && !$previewTarget.get() && $artifactTabs.get().length === 0) {
|
||||
setPaneOpen(PREVIEW_PANE_ID, false)
|
||||
}
|
||||
}
|
||||
|
|
@ -464,6 +469,16 @@ export function closeRightRailTab(tabId: RightRailTabId) {
|
|||
return
|
||||
}
|
||||
|
||||
if (tabId.startsWith('artifact:')) {
|
||||
closeArtifactTab(tabId as ArtifactTabId)
|
||||
|
||||
if (!$previewTarget.get() && $filePreviewTabs.get().length === 0 && $artifactTabs.get().length === 0) {
|
||||
setPaneOpen(PREVIEW_PANE_ID, false)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
closeFilePreviewTab(tabId)
|
||||
}
|
||||
|
||||
|
|
@ -474,7 +489,7 @@ export function closeActiveRightRailTab(): boolean {
|
|||
let tabId = $rightRailActiveTabId.get()
|
||||
|
||||
if (tabId === RIGHT_RAIL_PREVIEW_TAB_ID && !$previewTarget.get()) {
|
||||
const fallback = $filePreviewTabs.get()[0]?.id
|
||||
const fallback = $filePreviewTabs.get()[0]?.id ?? $artifactTabs.get()[0]
|
||||
|
||||
if (!fallback) {
|
||||
return false
|
||||
|
|
@ -493,6 +508,16 @@ export function closeActiveRightRailTab(): boolean {
|
|||
return true
|
||||
}
|
||||
|
||||
if (tabId.startsWith('artifact:')) {
|
||||
if (!$artifactTabs.get().includes(tabId as ArtifactTabId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
closeRightRailTab(tabId)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (!$filePreviewTabs.get().some(tab => tab.id === tabId)) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -503,7 +528,7 @@ export function closeActiveRightRailTab(): boolean {
|
|||
}
|
||||
|
||||
// The rail's visible tab order: the live preview tab (when present) first, then
|
||||
// the file tabs in their stored order. Mirrors `ChatPreviewRail`'s `tabs` memo
|
||||
// the file tabs, then artifact tabs. Mirrors `ChatPreviewRail`'s `tabs` memo
|
||||
// so "close others / to the right" act on what the user actually sees.
|
||||
function rightRailTabOrder(): RightRailTabId[] {
|
||||
const ids: RightRailTabId[] = []
|
||||
|
|
@ -516,6 +541,10 @@ function rightRailTabOrder(): RightRailTabId[] {
|
|||
ids.push(tab.id)
|
||||
}
|
||||
|
||||
for (const tabId of $artifactTabs.get()) {
|
||||
ids.push(tabId)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
|
|
@ -544,13 +573,14 @@ export function closeRightRailTabsToRight(tabId: RightRailTabId) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Dismisses the active preview + every file tab so the rail pane unmounts. */
|
||||
/** Dismisses the active preview + every file and artifact tab so the rail pane unmounts. */
|
||||
export function closeRightRail() {
|
||||
if ($previewTarget.get()) {
|
||||
dismissPreviewTarget()
|
||||
}
|
||||
|
||||
$filePreviewTabs.set([])
|
||||
closeAllArtifactTabs()
|
||||
setPaneOpen(PREVIEW_PANE_ID, false)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,16 @@ const STATUSBAR_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden'
|
|||
// Items the bar hides until the user turns them on from its context menu. The
|
||||
// bar's job is to answer "is the backend healthy, where am I, what's it doing" —
|
||||
// route shortcuts (cron/webhooks/agents), the terminal toggle, and the approval
|
||||
// pill are navigation, not status, so they start out of the way.
|
||||
// pill are navigation, not status, so they start out of the way. The per-turn
|
||||
// session readouts (running/session timers, context meter) are diagnostics most
|
||||
// users don't watch, so they start hidden too and the bar stays quiet mid-turn.
|
||||
export const STATUSBAR_HIDDEN_BY_DEFAULT: readonly string[] = [
|
||||
'agents',
|
||||
'approval-mode',
|
||||
'context-usage',
|
||||
'cron',
|
||||
'running-timer',
|
||||
'session-timer',
|
||||
'terminal',
|
||||
'webhooks'
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
clearSessionSubagents,
|
||||
failedSubagentCount,
|
||||
pruneDelegateFallbackSubagents,
|
||||
pruneFinishedSessionSubagents,
|
||||
upsertSubagent
|
||||
} from './subagents'
|
||||
|
||||
|
|
@ -131,4 +132,62 @@ describe('subagent store', () => {
|
|||
expect($subagentsBySession.get().s1).toBeUndefined()
|
||||
expect($subagentsBySession.get().s2).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Regression test for #64015: still-RUNNING background subagents must survive
|
||||
// the per-turn wipe that previously dropped them at message.start. The fix
|
||||
// replaces clearSessionSubagents() with pruneFinishedSessionSubagents() at
|
||||
// the use-message-stream message.start handler, so only terminal-status rows
|
||||
// get filtered out.
|
||||
it('pruneFinishedSessionSubagents keeps running/queued and drops terminal rows', () => {
|
||||
upsertSubagent('s1', { goal: 'live-a', status: 'running', subagent_id: 'live-a', task_index: 0 })
|
||||
upsertSubagent('s1', { goal: 'live-b', status: 'queued', subagent_id: 'live-b', task_index: 1 })
|
||||
upsertSubagent('s1', { goal: 'done', status: 'completed', subagent_id: 'done', task_index: 2 })
|
||||
upsertSubagent('s1', { goal: 'broken', status: 'failed', subagent_id: 'broken', task_index: 3 })
|
||||
upsertSubagent('s1', { goal: 'cancelled', status: 'interrupted', subagent_id: 'cancelled', task_index: 4 })
|
||||
|
||||
pruneFinishedSessionSubagents('s1')
|
||||
|
||||
const ids = listFor('s1')
|
||||
.map(item => item.id)
|
||||
.sort()
|
||||
|
||||
expect(ids).toEqual(['live-a', 'live-b'])
|
||||
expect(activeSubagentCount(listFor('s1'))).toBe(2)
|
||||
})
|
||||
|
||||
// Companion test: after prune, a late `subagent.complete` event for a
|
||||
// surviving live row must still be accepted by upsertSubagent (the wipe
|
||||
// path previously silently dropped these).
|
||||
it('surviving live subagents still accept createIfMissing=false completion', () => {
|
||||
upsertSubagent('s1', { goal: 'live', status: 'running', subagent_id: 'live', task_index: 0 })
|
||||
|
||||
pruneFinishedSessionSubagents('s1')
|
||||
|
||||
upsertSubagent(
|
||||
's1',
|
||||
{ status: 'completed', subagent_id: 'live', task_index: 0, summary: 'finished later' },
|
||||
false,
|
||||
'subagent.complete'
|
||||
)
|
||||
|
||||
const item = listFor('s1')[0]
|
||||
expect(item?.status).toBe('completed')
|
||||
expect(item?.summary).toBe('finished later')
|
||||
})
|
||||
|
||||
it('pruneFinishedSessionSubagents leaves other sessions untouched', () => {
|
||||
upsertSubagent('s1', { goal: 'live', status: 'running', subagent_id: 'a', task_index: 0 })
|
||||
upsertSubagent('s1', { goal: 'done', status: 'completed', subagent_id: 'b', task_index: 1 })
|
||||
upsertSubagent('s2', { goal: 'live', status: 'running', subagent_id: 'c', task_index: 0 })
|
||||
upsertSubagent('s2', { goal: 'done', status: 'completed', subagent_id: 'd', task_index: 1 })
|
||||
|
||||
pruneFinishedSessionSubagents('s1')
|
||||
|
||||
expect(listFor('s1').map(item => item.id)).toEqual(['a'])
|
||||
expect(
|
||||
listFor('s2')
|
||||
.map(item => item.id)
|
||||
.sort()
|
||||
).toEqual(['c', 'd'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -189,6 +189,35 @@ export function clearSessionSubagents(sid: string) {
|
|||
$subagentsBySession.set(rest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune terminal-status subagent rows for a session, leaving running/queued
|
||||
* entries untouched. Used at the `message.start` boundary in the desktop
|
||||
* message-stream hook so that the *previous* turn's finished rows get flushed
|
||||
* from the display while background subagents that outlived the spawning turn
|
||||
* remain visible (and still accept late progress/complete events).
|
||||
*
|
||||
* Distinct from `clearSessionSubagents` (used by the Stop action, which
|
||||
* genuinely cancels running subagents and so should drop them all) and from
|
||||
* `pruneDelegateFallbackSubagents` (which filters by id prefix to remove
|
||||
* placeholder rows once the real native event arrives).
|
||||
*/
|
||||
export function pruneFinishedSessionSubagents(sid: string) {
|
||||
const map = $subagentsBySession.get()
|
||||
const list = map[sid]
|
||||
|
||||
if (!list?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = list.filter(item => item.status === 'running' || item.status === 'queued')
|
||||
|
||||
if (next.length === list.length) {
|
||||
return
|
||||
}
|
||||
|
||||
$subagentsBySession.set({ ...map, [sid]: next })
|
||||
}
|
||||
|
||||
export function pruneDelegateFallbackSubagents(sid: string) {
|
||||
const map = $subagentsBySession.get()
|
||||
const list = map[sid]
|
||||
|
|
|
|||
2
cli.py
2
cli.py
|
|
@ -9708,6 +9708,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
self._handle_footer_command(cmd_original)
|
||||
elif canonical == "yolo":
|
||||
self._toggle_yolo()
|
||||
elif canonical == "approvals":
|
||||
self._handle_approvals_command(cmd_original)
|
||||
elif canonical == "reasoning":
|
||||
self._handle_reasoning_command(cmd_original)
|
||||
elif canonical == "fast":
|
||||
|
|
|
|||
1
contributors/emails/kshitijkapoor0611@gmail.com
Normal file
1
contributors/emails/kshitijkapoor0611@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
kshitijk4poor
|
||||
1
contributors/emails/okalentiev@gmail.com
Normal file
1
contributors/emails/okalentiev@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
okalentiev
|
||||
1
contributors/emails/ruslan.vasylev.vfx@gmail.com
Normal file
1
contributors/emails/ruslan.vasylev.vfx@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
ruslanvasylev
|
||||
1
contributors/emails/sophia@hermes.local
Normal file
1
contributors/emails/sophia@hermes.local
Normal file
|
|
@ -0,0 +1 @@
|
|||
knoal
|
||||
|
|
@ -287,6 +287,21 @@ if [ -d "$HERMES_HOME/cron" ]; then
|
|||
chown_hermes_tree "$HERMES_HOME/cron"
|
||||
fi
|
||||
|
||||
# Always ensure logs/gateways is hermes-owned (#45258). Formerly healed by
|
||||
# restartable gateway log/run chown — removed due to symlink TOCTOU
|
||||
# (CWE-59/367). The targeted data-volume chown above only runs when the
|
||||
# top-level $HERMES_HOME is mis-owned, so a warm volume with hermes-owned
|
||||
# HERMES_HOME but root-owned logs/gateways would otherwise leave
|
||||
# s6-setuidgid hermes mkdir failing with Permission denied. Non-recursive:
|
||||
# profile leaf dirs are each created/owned by their own log/run as hermes.
|
||||
if [ -d "$HERMES_HOME/logs/gateways" ]; then
|
||||
if refuse_symlinked_path "chown" "$HERMES_HOME/logs/gateways"; then
|
||||
:
|
||||
else
|
||||
chown hermes:hermes "$HERMES_HOME/logs/gateways" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Always reset ownership of pairing data on every boot, same docker-exec/
|
||||
# root-write reason as profiles/ and cron/. `docker exec <container>
|
||||
# hermes pairing approve …` defaults to uid=0 and writes 0600 root-owned
|
||||
|
|
|
|||
|
|
@ -221,7 +221,11 @@ Subagent hooks describe delegated child-agent work:
|
|||
and `child_goal`.
|
||||
|
||||
`subagent_stop` fields include parent/child session IDs, role/status fields,
|
||||
`child_summary`, and `duration_ms`.
|
||||
`child_summary`, `duration_ms`, and a metadata-only `tool_call_history`. Each
|
||||
history entry contains the tool name, argument names, bounded side-effect
|
||||
targets, input/output byte counts, and outcome. URL query strings and fragments
|
||||
are removed; raw arguments, prompts, commands, contents, headers, and results
|
||||
are intentionally excluded.
|
||||
|
||||
Observers can use these hooks to model nested trajectories while keeping child
|
||||
agent execution linked to the parent turn that spawned it.
|
||||
|
|
|
|||
|
|
@ -5975,7 +5975,54 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
"timestamp": ts,
|
||||
"text": preview or "",
|
||||
})
|
||||
# _thinking and subagent_progress are intentionally not forwarded
|
||||
elif event_type in {"subagent.start", "subagent.complete"}:
|
||||
event = {
|
||||
"event": event_type,
|
||||
"run_id": run_id,
|
||||
"timestamp": ts,
|
||||
}
|
||||
if preview is not None:
|
||||
event["preview"] = redact_sensitive_text(
|
||||
str(preview), force=True
|
||||
)
|
||||
for key in (
|
||||
"goal",
|
||||
"task_count",
|
||||
"task_index",
|
||||
"subagent_id",
|
||||
"child_session_id",
|
||||
"parent_id",
|
||||
"depth",
|
||||
"model",
|
||||
"tool_count",
|
||||
"status",
|
||||
"summary",
|
||||
"duration_seconds",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"reasoning_tokens",
|
||||
"api_calls",
|
||||
"cost_usd",
|
||||
"files_read",
|
||||
"files_written",
|
||||
"output_tail",
|
||||
):
|
||||
value = kwargs.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
# Free-text fields can carry child terminal/tool output —
|
||||
# force the same secret redaction the API applies to error
|
||||
# text before it leaves the process on a public stream.
|
||||
if key in ("goal", "summary", "output_tail") and isinstance(
|
||||
value, str
|
||||
):
|
||||
value = redact_sensitive_text(value, force=True)
|
||||
event[key] = value
|
||||
_push(event)
|
||||
# _thinking, subagent.tool, and subagent_progress are intentionally
|
||||
# not forwarded on the /v1/runs stream: they are high-volume UI
|
||||
# noise. Lifecycle boundaries (start/complete) still need to land
|
||||
# so clients can observe delegate_task timeouts and failures.
|
||||
|
||||
return _callback
|
||||
|
||||
|
|
|
|||
|
|
@ -2693,6 +2693,30 @@ class BasePlatformAdapter(ABC):
|
|||
"""
|
||||
return len
|
||||
|
||||
def max_message_length_for_chat(self, chat_id: str) -> int:
|
||||
"""Per-chat max message length, in ``message_len_fn_for_chat`` units.
|
||||
|
||||
Default: the adapter-scalar ``MAX_MESSAGE_LENGTH`` (4096 when absent) —
|
||||
for a native adapter every chat lives on the same platform so the
|
||||
scalar is already correct. The relay adapter overrides this: one relay
|
||||
adapter fronts N platforms with different caps (Discord 2000 vs
|
||||
Telegram 4096 vs Slack 39000), and the right cap depends on which
|
||||
platform the chat's inbound arrived from.
|
||||
"""
|
||||
try:
|
||||
return int(getattr(self, "MAX_MESSAGE_LENGTH", 4096) or 4096)
|
||||
except (TypeError, ValueError):
|
||||
return 4096
|
||||
|
||||
def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]:
|
||||
"""Per-chat length function (companion to max_message_length_for_chat).
|
||||
|
||||
Default: the adapter-wide ``message_len_fn``. The relay adapter
|
||||
overrides it so a Telegram-fronted chat measures UTF-16 units while a
|
||||
Discord-fronted chat on the same adapter measures codepoints.
|
||||
"""
|
||||
return self.message_len_fn
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Whether this adapter gates inbound access before dispatch.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from typing import Any, Callable, Dict, Optional, cast
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
|
||||
|
|
@ -123,6 +123,42 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
def message_len_fn(self) -> Callable[[str], int]:
|
||||
return _LEN_FNS.get(self.descriptor.len_unit, len)
|
||||
|
||||
# ── per-chat capability resolution (Phase 1.5 multi-platform) ─────────
|
||||
def _descriptor_for_chat(self, chat_id: str) -> CapabilityDescriptor:
|
||||
"""The capability descriptor governing a specific chat.
|
||||
|
||||
A multi-platform gateway fronts N platforms on ONE adapter, but the
|
||||
scalar `descriptor`/`MAX_MESSAGE_LENGTH` surface can only carry one
|
||||
platform's profile (the primary identity's). Platform caps genuinely
|
||||
differ — Discord 2000 / Telegram 4096 / Slack 39000 — so applying the
|
||||
primary's cap to every chat either fragments needlessly (small primary)
|
||||
or over-sends into a platform 400 (large primary; the live bug: 2,543
|
||||
and 2,641-char sends rejected by Discord). Resolve the chat's platform
|
||||
from what we saw inbound (`_platform_by_chat`, the same map per-frame
|
||||
egress uses) and look up that platform's negotiated descriptor on the
|
||||
transport. Falls back to the scalar descriptor when the chat's platform
|
||||
is unknown (never saw inbound) or the transport predates the map.
|
||||
"""
|
||||
platform = self._platform_by_chat.get(str(chat_id))
|
||||
if platform and self._transport is not None:
|
||||
resolve = getattr(self._transport, "descriptor_for_platform", None)
|
||||
if callable(resolve):
|
||||
try:
|
||||
per_platform = cast(
|
||||
Optional[CapabilityDescriptor], resolve(platform)
|
||||
)
|
||||
except Exception: # noqa: BLE001 - capability lookup must never break a send
|
||||
per_platform = None
|
||||
if per_platform is not None:
|
||||
return per_platform
|
||||
return self.descriptor
|
||||
|
||||
def max_message_length_for_chat(self, chat_id: str) -> int:
|
||||
return self._descriptor_for_chat(chat_id).max_message_length
|
||||
|
||||
def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]:
|
||||
return _LEN_FNS.get(self._descriptor_for_chat(chat_id).len_unit, len)
|
||||
|
||||
def supports_draft_streaming(
|
||||
self,
|
||||
chat_type: Optional[str] = None,
|
||||
|
|
|
|||
|
|
@ -404,6 +404,11 @@ class WebSocketRelayTransport:
|
|||
self._reader: Optional[asyncio.Task[None]] = None
|
||||
self._inbound: Optional[InboundHandler] = None
|
||||
self._descriptor: Optional[CapabilityDescriptor] = None
|
||||
# Phase 1.5 multi-platform: descriptors keyed by the underlying platform
|
||||
# (one per hello'd identity). `_descriptor` above stays the FIRST
|
||||
# (primary-identity) descriptor for back-compat; this map is the
|
||||
# per-platform capability surface read via `descriptor_for_platform`.
|
||||
self._descriptors_by_platform: Dict[str, CapabilityDescriptor] = {}
|
||||
self._descriptor_ready: asyncio.Future[CapabilityDescriptor] | None = None
|
||||
# requestId -> future awaiting the matching outbound_result.
|
||||
self._pending: Dict[str, asyncio.Future[Dict[str, Any]]] = {}
|
||||
|
|
@ -433,8 +438,10 @@ class WebSocketRelayTransport:
|
|||
loop = asyncio.get_running_loop()
|
||||
self._descriptor_ready = loop.create_future()
|
||||
# A fresh handshake is coming; clear any stale descriptor so handshake()
|
||||
# awaits the new one (matters on a re-dial).
|
||||
# awaits the new one (matters on a re-dial). The per-platform map resets
|
||||
# with it — a reconnected connector re-sends one descriptor per hello.
|
||||
self._descriptor = None
|
||||
self._descriptors_by_platform = {}
|
||||
# scale-to-zero (D12): a successful (re-)dial ends any dormant state — we
|
||||
# are live again, so a subsequent UNEXPECTED close should reconnect on the
|
||||
# normal fast backoff, not the dormant cadence.
|
||||
|
|
@ -520,6 +527,18 @@ class WebSocketRelayTransport:
|
|||
raise RuntimeError("handshake() called before connect()")
|
||||
return await asyncio.wait_for(self._descriptor_ready, timeout=self._connect_timeout_s)
|
||||
|
||||
def descriptor_for_platform(self, platform: str) -> Optional[CapabilityDescriptor]:
|
||||
"""The negotiated descriptor for one fronted platform, or None.
|
||||
|
||||
Phase 1.5 multi-platform: the connector replies one descriptor per
|
||||
hello'd identity; they accumulate here keyed by the descriptor's own
|
||||
``platform`` field. Callers (RelayAdapter) use this to resolve PER-CHAT
|
||||
capabilities — e.g. Discord's 2000-char max_message_length vs
|
||||
Telegram's 4096 — instead of applying the primary identity's scalar
|
||||
descriptor to every platform this gateway fronts.
|
||||
"""
|
||||
return self._descriptors_by_platform.get(platform)
|
||||
|
||||
@property
|
||||
def auth_revoked(self) -> bool:
|
||||
"""True once the connector closed the socket with 4401 AFTER a prior
|
||||
|
|
@ -795,7 +814,19 @@ class WebSocketRelayTransport:
|
|||
ftype = frame.get("type")
|
||||
if ftype == "descriptor":
|
||||
descriptor = CapabilityDescriptor.from_json(json.dumps(frame.get("descriptor", {})))
|
||||
self._descriptor = descriptor
|
||||
# Phase 1.5 multi-platform: one descriptor frame arrives per hello'd
|
||||
# identity. Accumulate them keyed by the descriptor's own platform so
|
||||
# the adapter can resolve PER-CHAT capabilities (e.g. Discord's 2000
|
||||
# vs Telegram's 4096 max_message_length) instead of collapsing N
|
||||
# platforms onto whichever descriptor arrived last.
|
||||
if descriptor.platform:
|
||||
self._descriptors_by_platform[descriptor.platform] = descriptor
|
||||
# The FIRST descriptor of this connection generation is the session
|
||||
# default (the primary identity's) — later arrivals must NOT
|
||||
# overwrite it, or the scalar capability surface silently becomes
|
||||
# last-writer-wins across platforms.
|
||||
if self._descriptor is None:
|
||||
self._descriptor = descriptor
|
||||
# Phase 7 Unit 7d-B: a received descriptor means the WS upgrade auth
|
||||
# passed and the connector accepted us — record that we've handshaked
|
||||
# at least once, so a LATER 4401 close is read as a revocation
|
||||
|
|
|
|||
|
|
@ -11975,6 +11975,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if canonical == "yolo":
|
||||
return await self._handle_yolo_command(event)
|
||||
|
||||
if canonical == "approvals":
|
||||
return await self._handle_approvals_command(event)
|
||||
|
||||
if canonical == "model":
|
||||
return await self._handle_model_command(event)
|
||||
|
||||
|
|
@ -21018,6 +21021,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
_raw_progress_limit = int(getattr(adapter, "MAX_MESSAGE_LENGTH", 4000) or 4000)
|
||||
except Exception:
|
||||
_raw_progress_limit = 4000
|
||||
# Per-chat resolution (relay adapter fronting N platforms): the cap
|
||||
# and length unit follow the chat's underlying platform. Native
|
||||
# adapters return their scalar/property unchanged.
|
||||
if isinstance(adapter, BasePlatformAdapter):
|
||||
try:
|
||||
_raw_progress_limit = int(
|
||||
adapter.max_message_length_for_chat(source.chat_id) or 4000
|
||||
)
|
||||
_progress_len_fn = adapter.message_len_fn_for_chat(source.chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Leave a little room for platform quirks / formatting. For tiny
|
||||
# test adapters keep the limit usable instead of clamping to 500+.
|
||||
_PROGRESS_TEXT_LIMIT = max(
|
||||
|
|
|
|||
|
|
@ -3648,6 +3648,23 @@ class GatewaySlashCommandsMixin:
|
|||
|
||||
return _apply_fast_selection(args, persist=persist_global)
|
||||
|
||||
async def _handle_approvals_command(self, event: MessageEvent) -> str:
|
||||
"""Show or persist the profile-wide dangerous-command approval mode."""
|
||||
from gateway.slash_access import policy_for_source
|
||||
from hermes_cli.approval_mode import run_approval_mode_command
|
||||
|
||||
requested = event.get_command_args().strip() or None
|
||||
# This mutates profile-wide security policy. The central slash gate can
|
||||
# allow selected commands to non-admin users, so enforce admin again at
|
||||
# this side-effect boundary. Unconfigured policies remain unrestricted.
|
||||
policy = policy_for_source(self.config, event.source)
|
||||
if requested and not policy.is_admin(event.source.user_id):
|
||||
return "Only gateway admins can change the persistent approval mode."
|
||||
result = run_approval_mode_command(requested)
|
||||
# Approval checks load config dynamically; do not evict the cached agent
|
||||
# or alter its system prompt/tool schema (prompt-cache prefix is sacred).
|
||||
return result.message
|
||||
|
||||
async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
|
||||
"""Handle /yolo — toggle dangerous command approval bypass for this session only."""
|
||||
from tools.approval import (
|
||||
|
|
|
|||
|
|
@ -675,10 +675,13 @@ class GatewayStreamConsumer:
|
|||
# Platform message length limit — leave room for cursor + formatting.
|
||||
# Use the adapter's length function (e.g. utf16_len for Telegram) so
|
||||
# overflow detection matches what the platform actually enforces.
|
||||
# Both resolve PER-CHAT (max_message_length_for_chat): a relay adapter
|
||||
# fronting N platforms has different caps per chat (Discord 2000 vs
|
||||
# Telegram 4096); native adapters return their scalar unchanged.
|
||||
# Gate on isinstance(BasePlatformAdapter) so test MagicMocks (whose
|
||||
# auto-attributes return mock objects, not callables) fall back to len.
|
||||
_len_fn: "Callable[[str], int]" = (
|
||||
self.adapter.message_len_fn
|
||||
self.adapter.message_len_fn_for_chat(self.chat_id)
|
||||
if isinstance(self.adapter, _BasePlatformAdapter)
|
||||
else len
|
||||
)
|
||||
|
|
@ -1359,6 +1362,15 @@ class GatewayStreamConsumer:
|
|||
if isinstance(self.adapter, _BasePlatformAdapter)
|
||||
else len
|
||||
)
|
||||
# Per-chat resolution (relay adapter fronting N platforms): the cap and
|
||||
# length unit follow the chat's underlying platform, not the adapter
|
||||
# scalar. Native adapters return their scalar/property unchanged.
|
||||
if isinstance(self.adapter, _BasePlatformAdapter):
|
||||
try:
|
||||
raw_limit = self.adapter.max_message_length_for_chat(self.chat_id)
|
||||
_len_fn = self.adapter.message_len_fn_for_chat(self.chat_id)
|
||||
except Exception as e:
|
||||
logger.debug("per-chat limit resolution failed: %s", e)
|
||||
safe_limit = max(500, raw_limit - 100)
|
||||
chunks = self._split_text_chunks(continuation, safe_limit, len_fn=_len_fn)
|
||||
|
||||
|
|
@ -1744,8 +1756,11 @@ class GatewayStreamConsumer:
|
|||
"""Per-message length budget (in the adapter's ``message_len_fn`` units)
|
||||
before the consumer splits an overflowing reply.
|
||||
|
||||
Adapters with a richer send/draft path (e.g. Telegram rich messages)
|
||||
can raise this above ``MAX_MESSAGE_LENGTH`` via
|
||||
Resolved PER-CHAT via ``max_message_length_for_chat`` — a relay adapter
|
||||
fronting N platforms has a different cap per chat (Discord 2000 vs
|
||||
Telegram 4096 vs Slack 39000); native adapters return their scalar
|
||||
``MAX_MESSAGE_LENGTH`` unchanged. Adapters with a richer send/draft
|
||||
path (e.g. Telegram rich messages) can raise this above the base via
|
||||
``streaming_overflow_limit`` so a reply that fits one rich message isn't
|
||||
fragmented at the legacy edit limit. Falls back to
|
||||
``MAX_MESSAGE_LENGTH`` (4096 default) for everyone else.
|
||||
|
|
@ -1754,6 +1769,10 @@ class GatewayStreamConsumer:
|
|||
# isinstance gate: MagicMock adapters return mock objects (truthy, not
|
||||
# ints) for arbitrary attribute access — keep them on the base limit.
|
||||
if isinstance(self.adapter, _BasePlatformAdapter):
|
||||
try:
|
||||
base = self.adapter.max_message_length_for_chat(self.chat_id)
|
||||
except Exception as e:
|
||||
logger.debug("max_message_length_for_chat failed: %s", e)
|
||||
try:
|
||||
cap = self.adapter.streaming_overflow_limit()
|
||||
except Exception as e:
|
||||
|
|
|
|||
87
hermes_cli/approval_mode.py
Normal file
87
hermes_cli/approval_mode.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Shared persistent approval-mode command logic.
|
||||
|
||||
Approval mode is profile-scoped configuration, not conversation state. Changing
|
||||
it affects subsequent terminal guard checks immediately because approval.py
|
||||
loads config on each check; it must not rebuild a live agent or mutate its
|
||||
system prompt/tool schema, preserving the prompt-cache prefix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from dataclasses import dataclass
|
||||
from io import StringIO
|
||||
from typing import Optional
|
||||
|
||||
VALID_APPROVAL_MODES = ("manual", "smart", "off")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApprovalModeResult:
|
||||
ok: bool
|
||||
mode: str
|
||||
changed: bool
|
||||
message: str
|
||||
|
||||
|
||||
def _effective_mode() -> str:
|
||||
"""Return the exact mode enforced by the terminal approval guard."""
|
||||
from tools.approval import _get_approval_mode
|
||||
|
||||
return _get_approval_mode()
|
||||
|
||||
|
||||
def run_approval_mode_command(requested_mode: Optional[str]) -> ApprovalModeResult:
|
||||
"""Inspect or persist ``approvals.mode`` through canonical config APIs."""
|
||||
current = _effective_mode()
|
||||
requested = (requested_mode or "").strip().lower()
|
||||
|
||||
if not requested:
|
||||
return ApprovalModeResult(
|
||||
True,
|
||||
current,
|
||||
False,
|
||||
f"Approval mode: {current} (persistent profile setting).",
|
||||
)
|
||||
if requested not in VALID_APPROVAL_MODES:
|
||||
return ApprovalModeResult(
|
||||
False,
|
||||
current,
|
||||
False,
|
||||
"Usage: /approvals [manual|smart|off]",
|
||||
)
|
||||
|
||||
# set_config_value is the canonical managed-scope/write-safety chokepoint.
|
||||
# It reports managed policy through stderr + SystemExit, so capture that for
|
||||
# slash-command output instead of terminating the interactive worker.
|
||||
from hermes_cli.config import set_config_value
|
||||
|
||||
output = StringIO()
|
||||
try:
|
||||
with redirect_stdout(output), redirect_stderr(output):
|
||||
set_config_value("approvals.mode", requested)
|
||||
except SystemExit:
|
||||
detail = output.getvalue().strip() or "Approval mode is managed and cannot be changed."
|
||||
return ApprovalModeResult(False, current, False, detail)
|
||||
except Exception as exc:
|
||||
return ApprovalModeResult(
|
||||
False,
|
||||
current,
|
||||
False,
|
||||
f"Failed to save approval mode: {exc}",
|
||||
)
|
||||
|
||||
effective = _effective_mode()
|
||||
if effective != requested:
|
||||
return ApprovalModeResult(
|
||||
False,
|
||||
effective,
|
||||
False,
|
||||
f"Approval mode remains {effective}; the requested value did not become effective.",
|
||||
)
|
||||
return ApprovalModeResult(
|
||||
True,
|
||||
effective,
|
||||
effective != current,
|
||||
f"Approval mode: {effective} (persistent profile setting).",
|
||||
)
|
||||
|
|
@ -7341,6 +7341,22 @@ def _prompt_model_selection(
|
|||
desc_lines.append(f" ── {unavailable_footer} ──")
|
||||
description = "\n".join(desc_lines) if desc_lines else None
|
||||
|
||||
# Search haystacks keep pricing labels visible while adding aliases
|
||||
# for brand-less wire ids (e.g. Kimi Coding `k3` ↔ query "kimi").
|
||||
from hermes_cli.model_search import model_search_text
|
||||
|
||||
model_search_labels = []
|
||||
for mid in ordered:
|
||||
label = _label(mid)
|
||||
haystack = model_search_text(mid)
|
||||
# model_search_text always starts with the wire id; only append when
|
||||
# aliases add tokens beyond the bare id already in the label.
|
||||
model_search_labels.append(
|
||||
label if haystack == mid else f"{label} {haystack}"
|
||||
)
|
||||
model_search_labels.append("Enter custom model name")
|
||||
model_search_labels.append("Skip (keep current)")
|
||||
|
||||
idx = curses_radiolist(
|
||||
"Select default model:",
|
||||
choices,
|
||||
|
|
@ -7348,6 +7364,7 @@ def _prompt_model_selection(
|
|||
cancel_returns=-1,
|
||||
description=description,
|
||||
searchable=True,
|
||||
search_labels=model_search_labels,
|
||||
)
|
||||
if idx < 0:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -2778,6 +2778,16 @@ class CLICommandsMixin:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def _handle_approvals_command(self, cmd_original: str) -> None:
|
||||
"""Show or persist the profile-wide dangerous-command approval mode."""
|
||||
from cli import _cprint
|
||||
from hermes_cli.approval_mode import run_approval_mode_command
|
||||
|
||||
parts = (cmd_original or "").strip().split(None, 1)
|
||||
requested = parts[1] if len(parts) > 1 else None
|
||||
result = run_approval_mode_command(requested)
|
||||
_cprint(f" {result.message}")
|
||||
|
||||
def _handle_footer_command(self, cmd_original: str) -> None:
|
||||
"""Toggle or inspect ``display.runtime_footer.enabled`` from the CLI.
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
subcommands=("on", "off", "status")),
|
||||
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
|
||||
"Configuration"),
|
||||
CommandDef("approvals", "Show or set the persistent dangerous-command approval mode",
|
||||
"Configuration", args_hint="[manual|smart|off]",
|
||||
subcommands=("manual", "smart", "off")),
|
||||
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
||||
args_hint="[level|show|hide|full|clamp] [--global]",
|
||||
subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp", "--global")),
|
||||
|
|
@ -1187,10 +1190,15 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
|
|||
# /init clamps /version off the native list and breaks Telegram parity.
|
||||
# - version: low-frequency info command; reachable as /hermes version on
|
||||
# Slack. Demoted when /context claimed a native slot (context is a
|
||||
# recurring inspection surface; version is a one-off lookup).
|
||||
# recurring inspection surface; version is a one-off lookup); the demotion
|
||||
# also absorbs the native slot /approvals now consumes at the 50-cap.
|
||||
# - diff: git working-tree diff; reached via /hermes diff on Slack so it
|
||||
# doesn't displace an existing native slash at the 50-command cap.
|
||||
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff"})
|
||||
# - update: low-frequency self-update maintenance command; reached via
|
||||
# /hermes update on Slack. Demoted to free the native slot /approvals now
|
||||
# claims — without this entry /approvals tips the registry past the 50-cap
|
||||
# and silently clamps /update off, breaking Telegram parity.
|
||||
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update"})
|
||||
|
||||
|
||||
def _sanitize_slack_name(raw: str) -> str:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue