fix(delegation): compression_threshold_tokens is opt-in (default 0); keep the value validation
Slimmed after review: the 200K default is dropped. Children compact at the same 0.50 x window ratio trigger as their parent (500K on a 1M model). Reasons: - the run this came from happened at 0.85 (850K); main was already at 0.50, so the real delta against main was 500K -> 200K, not 850K -> 200K; - a replay of the run's 22,489 logged calls (evals/postmortem, cap sweep) put 200K-400K caps within 5% of each other in cost once cache prefixes are intact, because the write price dominates and the cap only trims read volume; - every compaction is a chance to lose detail, and the accuracy side was never measured; at 500K a 1M child compacts roughly never. What stays: the reviewer's finding that the value was coerced, not validated (YAML true -> int 1 -> a one-token trigger; "200k" -> silently off). Values are validated: int >= 16000 enables the cap, 0/false/null/unset = off, anything else is warned and ignored. Docs and config comment restated accordingly.
This commit is contained in:
@@ -533,3 +533,12 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# GOOGLE_CHAT_ALLOW_ALL_USERS=false # Set true to skip the allowlist
|
||||
# GOOGLE_CHAT_HOME_CHANNEL= # Default space (spaces/XXXX) for cron delivery
|
||||
# GOOGLE_CHAT_HOME_CHANNEL_NAME= # Display name for the home channel
|
||||
|
||||
# =============================================================================
|
||||
# reddit-reading skill (optional) — app-only credentials, NOT a user login
|
||||
# =============================================================================
|
||||
# The skill works with no credentials via Reddit's public feeds (~1 request/minute).
|
||||
# For faster access with scores and nested comments, register a free "script" app
|
||||
# at https://www.reddit.com/prefs/apps and paste its id and secret here.
|
||||
# REDDIT_CLIENT_ID=
|
||||
# REDDIT_CLIENT_SECRET=
|
||||
|
||||
@@ -564,6 +564,10 @@ _SESSION_STATE: Dict[str, Any] = {
|
||||
# prefix, kept separately only to place an early cache marker.
|
||||
"_cached_system_prompt": None,
|
||||
"_cached_system_prompt_static": None,
|
||||
# ``(cwd, workspace_block)`` pinned on the first build: the git/workspace snapshot is
|
||||
# probed once per session and replayed on every rebuild, so a moving repo can't push the
|
||||
# prefix-cache divergence point ahead of the volatile band at a compaction boundary.
|
||||
"_frozen_workspace_snapshot": None,
|
||||
# Whether close() also closes _session_db. False: a caller-supplied handle is usually the
|
||||
# SHARED launch handle; callers handing over a DEDICATED handle set True.
|
||||
"_owns_session_db": False,
|
||||
|
||||
@@ -72,6 +72,25 @@ def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) ->
|
||||
)
|
||||
|
||||
|
||||
|
||||
_DEEPSEEK_THINKING_MODEL_PREFIXES = (
|
||||
"deepseek-r", "deepseek-v4", "deepseek_v4", "deepseek-pro",
|
||||
"deepseek_pro", "deepseek-flash", "deepseek_flash",
|
||||
)
|
||||
|
||||
|
||||
def _model_name_is_deepseek_thinking(model: str | None) -> bool:
|
||||
"""Known DeepSeek thinking families behind an Anthropic-compatible relay.
|
||||
|
||||
Strip vendor namespaces, but do not treat arbitrary DeepSeek chat/distill
|
||||
names as evidence of the thinking replay contract.
|
||||
"""
|
||||
if not isinstance(model, str):
|
||||
return False
|
||||
name = model.strip().lower().rsplit("/", 1)[-1]
|
||||
return bool(name) and name.startswith(_DEEPSEEK_THINKING_MODEL_PREFIXES)
|
||||
|
||||
|
||||
def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
"""DeepSeek's ``/anthropic`` route. In thinking mode DeepSeek requires prior-turn ``thinking``
|
||||
blocks to round-trip while the generic third-party path strips them; its blocks are unsigned,
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.anthropic_endpoints import (
|
||||
_is_deepseek_anthropic_endpoint, _is_kimi_family_endpoint, _is_nous_portal_endpoint,
|
||||
_is_third_party_anthropic_endpoint,
|
||||
_is_third_party_anthropic_endpoint, _model_name_is_deepseek_thinking,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -565,7 +565,9 @@ def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | No
|
||||
"""
|
||||
is_third_party = _is_third_party_anthropic_endpoint(base_url) and not _is_nous_portal_endpoint(base_url)
|
||||
is_kimi = _is_kimi_family_endpoint(base_url, model)
|
||||
is_deepseek = _is_deepseek_anthropic_endpoint(base_url)
|
||||
is_deepseek = _is_deepseek_anthropic_endpoint(base_url) or (
|
||||
is_third_party and _model_name_is_deepseek_thinking(model)
|
||||
)
|
||||
last_assistant_idx = next((i for i in range(len(result) - 1, -1, -1) if result[i].get("role") == "assistant"), None)
|
||||
for idx, m in _assistant_block_lists(result):
|
||||
if is_kimi:
|
||||
|
||||
@@ -2957,7 +2957,8 @@ def _contains_any(text: str, needles: Tuple[str, ...]) -> bool:
|
||||
|
||||
|
||||
# Billing-body markers (credit exhaustion wrapped in 402/403/404/429 bodies), plus daily/weekly quota
|
||||
# exhaustion (functionally credit exhaustion; "resource exhausted" is the Vertex/gRPC quota phrasing).
|
||||
# exhaustion (functionally credit exhaustion; "resource exhausted" is the Vertex/gRPC quota phrasing —
|
||||
# also serialized by SDK wrappers and NIM as RESOURCE_EXHAUSTED / ResourceExhausted / resource-exhausted).
|
||||
_PAYMENT_KEYWORDS = (
|
||||
"credits", "insufficient funds", "can only afford", "billing", "payment required",
|
||||
"out of funds", "run out of funds", "balance_depleted", "no usable credits",
|
||||
@@ -2965,6 +2966,7 @@ _PAYMENT_KEYWORDS = (
|
||||
"requires a subscription", "upgrade for access", "upgrade for higher limits",
|
||||
"reached your session usage limit", "quota exceeded", "quota_exceeded",
|
||||
"too many tokens per day", "daily limit", "tokens per day", "daily quota", "resource exhausted",
|
||||
"resource_exhausted", "resource-exhausted", "resourceexhausted",
|
||||
"weekly usage limit", "weekly limit",
|
||||
)
|
||||
|
||||
|
||||
@@ -378,15 +378,24 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]:
|
||||
|
||||
|
||||
def _provider_preferences_for_agent(agent) -> Dict[str, Any]:
|
||||
"""Build the validated provider-routing object shared by request paths."""
|
||||
preferences: Dict[str, Any] = {}
|
||||
for key, value in (("only", agent.providers_allowed), ("ignore", agent.providers_ignored),
|
||||
("order", agent.providers_order), ("sort", _validated_openrouter_provider_sort(agent.provider_sort)),
|
||||
("require_parameters", True if agent.provider_require_parameters else None),
|
||||
("data_collection", agent.provider_data_collection)):
|
||||
if value:
|
||||
preferences[key] = value
|
||||
return preferences
|
||||
"""Build the validated provider-routing object shared by request paths.
|
||||
|
||||
``provider_routing.models.<id>`` overlays the flat constructor values for the CURRENT
|
||||
``agent.model`` (so ``/model`` switches, fallbacks, and delegated children on another
|
||||
model each get their own pins without any surface re-plumbing the kwargs)."""
|
||||
flat = {"only": agent.providers_allowed, "ignore": agent.providers_ignored, "order": agent.providers_order,
|
||||
"sort": agent.provider_sort, "require_parameters": agent.provider_require_parameters,
|
||||
"data_collection": agent.provider_data_collection}
|
||||
per_model = {}
|
||||
with contextlib.suppress(Exception):
|
||||
from hermes_cli.config import load_config_readonly
|
||||
from hermes_constants import resolve_per_model_provider_routing
|
||||
_pr = load_config_readonly().get("provider_routing")
|
||||
per_model = resolve_per_model_provider_routing(agent.model, (_pr or {}).get("models") if isinstance(_pr, dict) else None)
|
||||
merged = {**flat, **{k: v for k, v in per_model.items() if k in flat}}
|
||||
merged["sort"] = _validated_openrouter_provider_sort(merged["sort"])
|
||||
merged["require_parameters"] = True if merged["require_parameters"] else None
|
||||
return {key: value for key, value in merged.items() if value}
|
||||
|
||||
|
||||
def _prompt_cache_scope_for_agent(agent) -> "str | None":
|
||||
@@ -1720,6 +1729,19 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
|
||||
value = getattr(assistant_message, attr, None)
|
||||
if value:
|
||||
msg[attr] = value
|
||||
if attr == "codex_reasoning_items":
|
||||
from agent.codex_responses_adapter import (
|
||||
has_replayable_native_compaction_checkpoint,
|
||||
)
|
||||
|
||||
note_checkpoint = getattr(
|
||||
agent.context_compressor, "note_native_compaction_checkpoint", None
|
||||
)
|
||||
if (
|
||||
callable(note_checkpoint)
|
||||
and has_replayable_native_compaction_checkpoint(agent, [msg])
|
||||
):
|
||||
note_checkpoint()
|
||||
|
||||
if assistant_tool_calls:
|
||||
msg["tool_calls"] = [_assistant_tool_call_dict(agent, tc, i) for i, tc in enumerate(assistant_tool_calls)]
|
||||
@@ -1797,8 +1819,10 @@ def _fallback_reason_text(reason: "FailoverReason | None") -> str:
|
||||
|
||||
|
||||
def _is_anthropic_wire_url(url: str) -> bool:
|
||||
"""Same host match as determine_api_mode() / _detect_api_mode_for_url()."""
|
||||
return url.rstrip("/").lower().endswith("/anthropic") or base_url_hostname(url) == "api.anthropic.com"
|
||||
"""Same Messages-only host match as determine_api_mode() / _detect_api_mode_for_url(): api.anthropic.com,
|
||||
a /anthropic suffix, or Kimi Code's api.kimi.com/coding (its /chat/completions 404s — #77256)."""
|
||||
from hermes_cli.providers import host_mandated_api_mode
|
||||
return host_mandated_api_mode(url) == "anthropic_messages"
|
||||
|
||||
|
||||
def _fallback_api_mode_hint(fb: dict, fb_provider: str, fb_base_url_hint: Optional[str]) -> tuple[bool, str]:
|
||||
|
||||
@@ -541,16 +541,10 @@ def classify_responses_route(agent: Any) -> ResponsesRouteFlags:
|
||||
)
|
||||
|
||||
|
||||
def estimate_native_responses_preflight_tokens(
|
||||
agent: Any, messages: List[Dict[str, Any]], *, system_prompt: str = "", tools: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Optional[int]:
|
||||
"""Estimate tokens for the checkpoint-pruned Responses payload (the full transcript overstates a natively compacted
|
||||
session and fires local compression needlessly). None when native compaction is not proven eligible or conversion fails.
|
||||
|
||||
Automatic preflight previously counted the full durable transcript. On a natively compacted Codex
|
||||
session that overstates the wire by several times and fires local compression against history the main
|
||||
request will never send (#96155).
|
||||
"""
|
||||
def _native_responses_replay_items(
|
||||
agent: Any, messages: List[Dict[str, Any]]
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Build the native-compaction-eligible wire items, or ``None`` when ineligible."""
|
||||
if getattr(agent, "api_mode", None) != "codex_responses" or not isinstance(messages, list):
|
||||
return None
|
||||
route = classify_responses_route(agent)._asdict()
|
||||
@@ -565,7 +559,37 @@ def estimate_native_responses_preflight_tokens(
|
||||
native_compaction_eligible=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("native Responses preflight conversion failed; falling back to generic estimate", exc_info=True)
|
||||
logger.debug(
|
||||
"native Responses replay conversion failed; using the generic fallback",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
return items
|
||||
|
||||
|
||||
def has_replayable_native_compaction_checkpoint(
|
||||
agent: Any, messages: List[Dict[str, Any]]
|
||||
) -> bool:
|
||||
"""Whether the current route would replay a persisted native checkpoint."""
|
||||
items = _native_responses_replay_items(agent, messages)
|
||||
if items is None:
|
||||
return False
|
||||
from agent.native_compaction import has_compaction_checkpoint
|
||||
return has_compaction_checkpoint(items)
|
||||
|
||||
|
||||
def estimate_native_responses_preflight_tokens(
|
||||
agent: Any, messages: List[Dict[str, Any]], *, system_prompt: str = "", tools: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Optional[int]:
|
||||
"""Estimate tokens for the checkpoint-pruned Responses payload (the full transcript overstates a natively compacted
|
||||
session and fires local compression needlessly). None when native compaction is not proven eligible or conversion fails.
|
||||
|
||||
Automatic preflight previously counted the full durable transcript. On a natively compacted Codex
|
||||
session that overstates the wire by several times and fires local compression against history the main
|
||||
request will never send (#96155).
|
||||
"""
|
||||
items = _native_responses_replay_items(agent, messages)
|
||||
if items is None:
|
||||
return None
|
||||
from agent.model_metadata import estimate_request_tokens_rough
|
||||
return estimate_request_tokens_rough(items, system_prompt=system_prompt or "", tools=tools)
|
||||
|
||||
+11
-7
@@ -5,7 +5,8 @@ frozen :class:`RuntimeMode` built from a :class:`ContextProfile` (pure data). Th
|
||||
system prompt reads ``system_prompt_parts()``; the toolset collapses ONLY under opt-in
|
||||
``focus`` (never strips a user-enabled toolset). ``agent.coding_context``: ``auto``
|
||||
(default, prompt-only) / ``focus`` / ``on`` / ``off``. Resolved once, immutable; the
|
||||
workspace snapshot is never re-probed per turn (cache safety).
|
||||
workspace snapshot is probed once per session and replayed through
|
||||
``system_prompt_parts(workspace_block=...)`` on every later build (cache safety).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -322,13 +323,15 @@ class RuntimeMode:
|
||||
return None
|
||||
return [self.profile.toolset, *_enabled_mcp_servers(config)]
|
||||
|
||||
def system_prompt_parts(self, valid_tool_names=None) -> tuple[list[str], list[str], list[str]]:
|
||||
def system_prompt_parts(self, valid_tool_names=None, workspace_block: Optional[str] = None) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Return (prefix, workspace, trailing) posture blocks in the historical flat order —
|
||||
brief, snapshot, operator instructions — so prompt assembly can put a cache boundary
|
||||
before the snapshot without changing persisted bytes. The brief carries the model-family
|
||||
edit-format nudge (one cached string); ``valid_tool_names`` drops the ``todo_list``
|
||||
sentence when that tool isn't loaded; operator instructions ride their own block so
|
||||
the brief stays byte-stable."""
|
||||
the brief stays byte-stable. ``workspace_block`` replays a snapshot the caller already
|
||||
pinned at session start (``""`` = no workspace) instead of re-running the git probe;
|
||||
``None`` probes."""
|
||||
if not self.is_coding:
|
||||
return [], [], []
|
||||
prefix: list[str] = []
|
||||
@@ -340,7 +343,7 @@ class RuntimeMode:
|
||||
if family is not None:
|
||||
brief = f"{brief}\n{_EDIT_FORMAT_GUIDANCE[family][1]}"
|
||||
prefix.append(brief)
|
||||
workspace = build_coding_workspace_block(self.cwd)
|
||||
workspace = build_coding_workspace_block(self.cwd) if workspace_block is None else workspace_block
|
||||
trailing = [f"Operator instructions (from config):\n{self.instructions}"] if self.instructions else []
|
||||
return prefix, [workspace] if workspace else [], trailing
|
||||
|
||||
@@ -395,11 +398,12 @@ def coding_selection(*, platform: Optional[str] = None, cwd: Optional[str | Path
|
||||
|
||||
def coding_system_prompt_parts(
|
||||
*, platform: Optional[str] = None, cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None,
|
||||
model: Optional[str] = None, valid_tool_names=None,
|
||||
model: Optional[str] = None, valid_tool_names=None, workspace_block: Optional[str] = None,
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Return coding prefix, workspace snapshot, and trailing guidance."""
|
||||
"""Return coding prefix, workspace snapshot, and trailing guidance. ``workspace_block``
|
||||
replays the caller's pinned session-start snapshot instead of probing git again."""
|
||||
mode = resolve_runtime_mode(platform=platform, cwd=cwd, config=config, model=model)
|
||||
return mode.system_prompt_parts(valid_tool_names=valid_tool_names)
|
||||
return mode.system_prompt_parts(valid_tool_names=valid_tool_names, workspace_block=workspace_block)
|
||||
|
||||
|
||||
def coding_compact_skill_categories(*, platform: Optional[str] = None, cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None) -> frozenset[str]:
|
||||
|
||||
@@ -2416,6 +2416,20 @@ class ContextCompressor(MicroCompactionMixin, ContextEngine):
|
||||
except (TypeError, ValueError):
|
||||
self._pending_request_rough_tokens = 0
|
||||
|
||||
def note_native_compaction_checkpoint(self) -> None:
|
||||
"""Wait for real usage before trusting a newly checkpointed request.
|
||||
|
||||
Native Responses compaction replaces durable history with an opaque
|
||||
encrypted checkpoint. Its serialized size is unrelated to the token
|
||||
count billed by the provider, so the first rough estimate after capture
|
||||
can jump by more than the whole context window. Reuse the one-response
|
||||
compaction latch and discard any stale local-compression baseline; the
|
||||
next provider response then pairs its real usage with the rough estimate
|
||||
for the checkpointed request.
|
||||
"""
|
||||
self.awaiting_real_usage_after_compression = True
|
||||
self.last_compression_rough_tokens = 0
|
||||
|
||||
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
|
||||
"""Return True when a high rough preflight estimate is known-noisy.
|
||||
Projects real usage as ``last_real + (rough_now - rough_at_last_real)`` and fires only when the
|
||||
@@ -2426,7 +2440,8 @@ class ContextCompressor(MicroCompactionMixin, ContextEngine):
|
||||
re-runs with the aligned basis."""
|
||||
if rough_tokens < self.threshold_tokens:
|
||||
return False
|
||||
# After compaction last_real_prompt_tokens is STALE (above threshold); defer one turn until real usage arrives.
|
||||
# After local or native compaction, last_real_prompt_tokens is STALE
|
||||
# (above threshold); defer one turn until real usage arrives.
|
||||
if self.awaiting_real_usage_after_compression:
|
||||
return True
|
||||
if self.last_real_prompt_tokens <= 0 or self.last_real_prompt_tokens >= self.threshold_tokens:
|
||||
|
||||
+57
-19
@@ -195,7 +195,13 @@ async def preprocess_context_references_async(
|
||||
# Expand concurrently (each ref is independent; several @url: refs would otherwise
|
||||
# serialize web_extract round-trips). gather preserves order, so warnings/blocks
|
||||
# are assembled in ref order; the token-budget check runs once afterwards.
|
||||
tasks = (_expand_reference(ref, cwd_path, url_fetcher=url_fetcher, allowed_root=allowed_root_path) for ref in refs)
|
||||
hard_limit = max(1, int(context_length * 0.50))
|
||||
soft_limit = max(1, int(context_length * 0.25))
|
||||
tasks = (
|
||||
_expand_reference(ref, cwd_path, url_fetcher=url_fetcher, allowed_root=allowed_root_path,
|
||||
max_inline_tokens=hard_limit)
|
||||
for ref in refs
|
||||
)
|
||||
expanded = await asyncio.gather(*tasks)
|
||||
warnings = [warning for warning, _ in expanded if warning]
|
||||
blocks = [block for _, block in expanded if block]
|
||||
@@ -204,8 +210,6 @@ async def preprocess_context_references_async(
|
||||
message=message, original_message=message, references=refs, warnings=warnings, injected_tokens=injected_tokens
|
||||
)
|
||||
|
||||
hard_limit = max(1, int(context_length * 0.50))
|
||||
soft_limit = max(1, int(context_length * 0.25))
|
||||
if injected_tokens > hard_limit:
|
||||
warnings.append(f"@ context injection refused: {injected_tokens} tokens exceeds the 50% hard limit ({hard_limit}).")
|
||||
result.blocked = True
|
||||
@@ -235,11 +239,12 @@ _GIT_REFERENCE_ARGS: dict[str, Callable[[ContextReference], list[str]]] = {
|
||||
|
||||
|
||||
async def _expand_reference(
|
||||
ref: ContextReference, cwd: Path, *, url_fetcher: UrlFetcher = None, allowed_root: Path | None = None
|
||||
ref: ContextReference, cwd: Path, *, url_fetcher: UrlFetcher = None, allowed_root: Path | None = None,
|
||||
max_inline_tokens: int | None = None,
|
||||
) -> Expansion:
|
||||
try:
|
||||
if ref.kind in ("file", "folder"):
|
||||
return _expand_path_reference(ref, cwd, allowed_root=allowed_root)
|
||||
return _expand_path_reference(ref, cwd, allowed_root=allowed_root, max_inline_tokens=max_inline_tokens)
|
||||
if ref.kind in _GIT_REFERENCE_ARGS:
|
||||
git_args = _GIT_REFERENCE_ARGS[ref.kind](ref)
|
||||
return _expand_git_reference(ref, cwd, git_args, "git " + " ".join(git_args))
|
||||
@@ -261,7 +266,8 @@ async def _expand_reference(
|
||||
return f"{ref.raw}: unsupported reference type", None
|
||||
|
||||
|
||||
def _expand_path_reference(ref: ContextReference, cwd: Path, *, allowed_root: Path | None = None) -> Expansion:
|
||||
def _expand_path_reference(ref: ContextReference, cwd: Path, *, allowed_root: Path | None = None,
|
||||
max_inline_tokens: int | None = None) -> Expansion:
|
||||
"""``@file:`` / ``@folder:``: resolve, allow-check, then inline text / binary stub / listing."""
|
||||
is_folder = ref.kind == "folder"
|
||||
path = _resolve_path(cwd, ref.target, allowed_root=allowed_root)
|
||||
@@ -281,7 +287,16 @@ def _expand_path_reference(ref: ContextReference, cwd: Path, *, allowed_root: Pa
|
||||
if ref.line_start is not None:
|
||||
text = "\n".join(text.splitlines()[max(ref.line_start - 1, 0):ref.line_end or ref.line_start])
|
||||
lang = _FENCE_LANGUAGES.get(path.suffix.lower(), "")
|
||||
return None, f"📄 {ref.raw} ({estimate_tokens_rough(text)} tokens)\n```{lang}\n{text}\n```"
|
||||
text_tokens = estimate_tokens_rough(text)
|
||||
# Check BEFORE building the fenced block: an oversized file is not going to be
|
||||
# inlined, so don't build a second MB-scale string just to discard it.
|
||||
if max_inline_tokens is not None and text_tokens > max_inline_tokens:
|
||||
# One oversized file used to poison the aggregate check and refuse the whole
|
||||
# turn (#61987); the file stays readable via the agent's tools instead. The
|
||||
# block alone carries the message (same shape as the binary path) — a warning
|
||||
# would duplicate it in "--- Context Warnings ---".
|
||||
return None, _oversized_text_reference_block(ref, path, text_tokens)
|
||||
return None, f"📄 {ref.raw} ({text_tokens} tokens)\n```{lang}\n{text}\n```"
|
||||
|
||||
|
||||
def _run_quiet(cmd: list[str], cwd: Path, timeout: int, env: dict | None = None) -> subprocess.CompletedProcess:
|
||||
@@ -425,12 +440,7 @@ def _iter_visible_entries(path: Path, cwd: Path, limit: int) -> list[Path]:
|
||||
return output
|
||||
|
||||
|
||||
def _binary_reference_block(ref: ContextReference, path: Path) -> str:
|
||||
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
try:
|
||||
size = format_bytes(path.stat().st_size)
|
||||
except OSError:
|
||||
size = "unknown size"
|
||||
def _agent_visible_path(path: Path) -> str:
|
||||
# Under a container backend the host path dangles inside the sandbox: translate staged
|
||||
# files to their auto-mounted cache path; fall back to the host path (local backend /
|
||||
# translation failure). Run the idempotent TERMINAL_ENV bridge first so in-process
|
||||
@@ -439,14 +449,42 @@ def _binary_reference_block(ref: ContextReference, path: Path) -> str:
|
||||
from tools.terminal_tool import _ensure_terminal_env_bridged
|
||||
_ensure_terminal_env_bridged()
|
||||
from tools.credential_files import to_agent_visible_cache_path
|
||||
visible = to_agent_visible_cache_path(str(path))
|
||||
return to_agent_visible_cache_path(str(path))
|
||||
except Exception:
|
||||
visible = str(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
def _on_disk_reference_block(ref: ContextReference, path: Path, descriptor: str, reason: str, guidance: str) -> str:
|
||||
"""Shared 📎 shape: the file was not inlined, but it IS on disk where the agent's
|
||||
tools run — hand the model the path and a nudge instead of a dead-end warning."""
|
||||
try:
|
||||
size = format_bytes(path.stat().st_size)
|
||||
except OSError:
|
||||
size = "unknown size"
|
||||
return (
|
||||
f"📎 {ref.raw} ({mime}, {size}) — binary file, not inlined as text. "
|
||||
f"It is available on disk at `{visible}`. Use your tools to work with it "
|
||||
f"(read or convert it, extract its text, or view/render it as needed); "
|
||||
f"do not tell the user the file type is unsupported."
|
||||
f"📎 {ref.raw} ({descriptor}, {size}) — {reason} "
|
||||
f"It is available on disk at `{_agent_visible_path(path)}`. {guidance}"
|
||||
)
|
||||
|
||||
|
||||
def _binary_reference_block(ref: ContextReference, path: Path) -> str:
|
||||
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
return _on_disk_reference_block(
|
||||
ref, path,
|
||||
descriptor=mime,
|
||||
reason="binary file, not inlined as text.",
|
||||
guidance="Use your tools to work with it (read or convert it, extract its text, "
|
||||
"or view/render it as needed); do not tell the user the file type is unsupported.",
|
||||
)
|
||||
|
||||
|
||||
def _oversized_text_reference_block(ref: ContextReference, path: Path, text_tokens: int) -> str:
|
||||
return _on_disk_reference_block(
|
||||
ref, path,
|
||||
descriptor=f"text file, approximately {text_tokens} tokens",
|
||||
reason="too large to inline safely.",
|
||||
guidance="Use read_file with a narrow line range, search_files, or terminal/code tools "
|
||||
"to inspect only the relevant parts; do not load the entire file into context.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -791,7 +791,10 @@ def _borrowed_single_use_pool_root() -> Optional[Path]:
|
||||
return None
|
||||
|
||||
|
||||
def _update_root_pool_rows(provider: str, payloads: List[Dict[str, Any]], global_path: Path) -> None:
|
||||
def _update_root_pool_rows(
|
||||
provider: str, payloads: List[Dict[str, Any]], global_path: Path,
|
||||
*, status_cleared_ids: Optional[Iterable[str]] = None,
|
||||
) -> None:
|
||||
"""UPDATE-ONLY merge of *payloads* into the root store's rows for *provider*.
|
||||
|
||||
A borrower may refresh the root's rows (rotation, cooldown state) but
|
||||
@@ -808,6 +811,7 @@ def _update_root_pool_rows(provider: str, payloads: List[Dict[str, Any]], global
|
||||
existing = pool.get(provider)
|
||||
existing_list = existing if isinstance(existing, list) else []
|
||||
incoming_by_id = {p.get("id"): p for p in payloads if isinstance(p, dict) and p.get("id")}
|
||||
cleared = {cid for cid in (status_cleared_ids or ()) if cid}
|
||||
merged: List[Dict[str, Any]] = []
|
||||
changed = False
|
||||
for disk_entry in existing_list:
|
||||
@@ -816,7 +820,10 @@ def _update_root_pool_rows(provider: str, payloads: List[Dict[str, Any]], global
|
||||
if incoming is None:
|
||||
merged.append(disk_entry)
|
||||
continue
|
||||
updated = auth_mod._merge_disk_cooldown_state(incoming, disk_entry, provider)
|
||||
# A deliberately cleared entry has no disk cooldown worth keeping.
|
||||
updated = auth_mod._merge_disk_cooldown_state(
|
||||
incoming, None if did in cleared else disk_entry, provider,
|
||||
)
|
||||
if updated != disk_entry:
|
||||
changed = True
|
||||
merged.append(updated)
|
||||
@@ -830,6 +837,7 @@ def persist_pool_entries(
|
||||
payloads: List[Dict[str, Any]],
|
||||
*,
|
||||
removed_ids: Optional[Iterable[str]] = None,
|
||||
status_cleared_ids: Optional[Iterable[str]] = None,
|
||||
) -> None:
|
||||
"""Persist a provider's pool rows to the store that OWNS them.
|
||||
|
||||
@@ -845,7 +853,10 @@ def persist_pool_entries(
|
||||
global_path = _borrowed_single_use_pool_root()
|
||||
if global_path is not None:
|
||||
try:
|
||||
_update_root_pool_rows(provider, payloads, global_path)
|
||||
_update_root_pool_rows(
|
||||
provider, payloads, global_path,
|
||||
status_cleared_ids=status_cleared_ids,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Fail closed on the FORK, not on the save: never fall back to
|
||||
# writing a local copy (that IS the bug). The in-memory pool
|
||||
@@ -856,7 +867,9 @@ def persist_pool_entries(
|
||||
provider, exc,
|
||||
)
|
||||
return
|
||||
write_credential_pool(provider, payloads, removed_ids=removed_ids)
|
||||
write_credential_pool(
|
||||
provider, payloads, removed_ids=removed_ids, status_cleared_ids=status_cleared_ids,
|
||||
)
|
||||
|
||||
|
||||
# --- Per-provider singleton refresh plumbing -------------------------------
|
||||
@@ -1018,13 +1031,19 @@ class CredentialPool:
|
||||
self._entries[idx] = new
|
||||
return
|
||||
|
||||
def _persist(self, *, removed_ids: Optional[List[str]] = None) -> None:
|
||||
def _persist(
|
||||
self,
|
||||
*,
|
||||
removed_ids: Optional[List[str]] = None,
|
||||
status_cleared_ids: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
# Self-locking: snapshotting self._entries must not race a rotation.
|
||||
with self._lock:
|
||||
persist_pool_entries(
|
||||
self.provider,
|
||||
[entry.to_dict() for entry in self._entries],
|
||||
removed_ids=removed_ids,
|
||||
status_cleared_ids=status_cleared_ids,
|
||||
)
|
||||
|
||||
def _adopt(self, entry: PooledCredential, *, persist: bool = True, **updates: Any) -> PooledCredential:
|
||||
@@ -2139,14 +2158,29 @@ class CredentialPool:
|
||||
return refreshed
|
||||
|
||||
def reset_statuses(self) -> int:
|
||||
"""Clear exhaustion state on every entry. Returns how many were cleared.
|
||||
|
||||
``failure_reason`` lives in ``extra``, not a dataclass field, so it is
|
||||
stripped explicitly. The persist declares the cleared ids because the
|
||||
disk-recency merge reads a cleared ``last_status_at`` (None -> epoch 0)
|
||||
as a stale snapshot and would copy a still-binding cooldown back.
|
||||
"""
|
||||
with self._lock:
|
||||
stale = [e for e in self._entries if e.last_status or e.last_status_at or e.last_error_code]
|
||||
stale = [
|
||||
e for e in self._entries
|
||||
if e.last_status or e.last_status_at or e.last_error_code or e.failure_reason
|
||||
]
|
||||
if stale:
|
||||
stale_ids = {e.id for e in stale}
|
||||
self._entries = [
|
||||
replace(e, **_CLEAR_STATUS) if e.id in stale_ids else e for e in self._entries
|
||||
replace(
|
||||
e, **_CLEAR_STATUS,
|
||||
extra={k: v for k, v in e.extra.items() if k != "failure_reason"},
|
||||
)
|
||||
if e.id in stale_ids else e
|
||||
for e in self._entries
|
||||
]
|
||||
self._persist()
|
||||
self._persist(status_cleared_ids=list(stale_ids))
|
||||
return len(stale)
|
||||
|
||||
def remove_index(self, index: int) -> Optional[PooledCredential]:
|
||||
|
||||
@@ -113,7 +113,8 @@ _BILLING_ERROR_CODES = frozenset({
|
||||
# contains an overflow phrase; rate limit is matched first so throttle wins.
|
||||
_RATE_LIMIT_PATTERNS = (
|
||||
"rate limit", "rate_limit", "too many requests", "throttled", "requests per minute",
|
||||
"tokens per minute", "requests per day", "try again in", "please retry after", "resource_exhausted",
|
||||
"tokens per minute", "requests per day", "try again in", "please retry after",
|
||||
"resource exhausted", "resource_exhausted", "resource-exhausted", "resourceexhausted",
|
||||
"rate increased too quickly", "throttlingexception", "too many concurrent requests",
|
||||
"servicequotaexceededexception", "throttling",
|
||||
)
|
||||
@@ -486,6 +487,13 @@ def _provider_special_cases(c: _Ctx) -> Optional[Verdict]:
|
||||
# to format_error and a status-less block isn't left retryable (#18028).
|
||||
if any(p in msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS):
|
||||
return _V_CONTENT_BLOCKED
|
||||
# ChatGPT Codex masks a rejected encrypted-reasoning replay behind the same bare
|
||||
# ``invalid_prompt: Request blocked.`` it uses for real blocks (#92353). Exact envelope
|
||||
# + provider only. The verdict keeps format_error's abort-and-fallback hints; the one
|
||||
# extra thing it buys is turn_recovery's replay strip, which still requires cached
|
||||
# ``codex_reasoning_items`` — a genuine block with nothing to strip behaves as before.
|
||||
if _is_codex_masked_replay_rejection(c):
|
||||
return _v(_R.invalid_encrypted_content, **_ABORT_FALLBACK)
|
||||
# Anthropic thinking-block 400s (signature mismatch after transcript
|
||||
# mutation). Not gated on provider — OpenRouter proxies Anthropic errors.
|
||||
if status == 400 and "thinking" in msg and any(p in msg for p in _THINKING_MUTATION_WORDS):
|
||||
@@ -777,6 +785,22 @@ def _is_server_injected_param_rejection(error_msg: str, provider: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_CODEX_MASKED_REPLAY_MESSAGE = "request blocked."
|
||||
|
||||
|
||||
def _is_codex_masked_replay_rejection(c: "_Ctx") -> bool:
|
||||
"""HTTP 400 / status-less ``{code: invalid_prompt, message: "Request blocked."}`` from
|
||||
``openai-codex`` — as an SDK error body, a Responses ``error`` SSE frame, or the
|
||||
``response.failed`` text ``"invalid_prompt: Request blocked."``."""
|
||||
if c.provider_slug != "openai-codex" or c.status_code not in (None, 400):
|
||||
return False
|
||||
# The OpenAI SDK unwraps ``body["error"]`` on status errors; stream frames keep the envelope.
|
||||
body_msg = next((str(m).strip().lower() for m in _body_message_candidates(c.body or {}) if m), "")
|
||||
return (c.code == "invalid_prompt" and body_msg == _CODEX_MASKED_REPLAY_MESSAGE) or (
|
||||
c.msg.strip() == f"invalid_prompt: {_CODEX_MASKED_REPLAY_MESSAGE}"
|
||||
)
|
||||
|
||||
|
||||
def _error_obj(body: Any) -> dict:
|
||||
"""``body["error"]`` when it is a dict, else ``{}``."""
|
||||
err = body.get("error") if isinstance(body, dict) else None
|
||||
|
||||
@@ -9,6 +9,7 @@ import threading
|
||||
from typing import Optional
|
||||
|
||||
from agent.interrupt_compat import request_hard_interrupt
|
||||
from tools.interrupt import request_yield as _request_yield
|
||||
from tools.interrupt import set_interrupt as _set_interrupt
|
||||
|
||||
# Same logger name as the origin module so log records / caplog filters are unchanged.
|
||||
@@ -245,8 +246,20 @@ class InterruptControlMixin:
|
||||
return False
|
||||
|
||||
# Never kill a tool to deliver guidance; the steer drain puts it on the final tool result.
|
||||
# A foreground terminal command would park that delivery until it exits (a 5-minute
|
||||
# `sleep` poller, a build), so ask the tool workers to YIELD: terminal hands the live
|
||||
# process to the background registry and returns; tools that don't yield are unaffected.
|
||||
if getattr(self, "_executing_tools", False):
|
||||
return self.steer(cleaned)
|
||||
accepted = self.steer(cleaned)
|
||||
if accepted:
|
||||
tracker = getattr(self, "_tool_worker_threads", None)
|
||||
tracker_lock = getattr(self, "_tool_worker_threads_lock", None)
|
||||
if tracker is not None and tracker_lock is not None:
|
||||
with tracker_lock:
|
||||
worker_tids = list(tracker)
|
||||
for tid in worker_tids:
|
||||
_request_yield(tid)
|
||||
return accepted
|
||||
|
||||
_model_active = getattr(self, "_model_request_active", None)
|
||||
with _ic_lock(self, "_pending_redirect_lock"):
|
||||
|
||||
@@ -328,7 +328,12 @@ def is_native_compaction_rejection(error: Any, status_code: Any = None) -> bool:
|
||||
def has_compaction_checkpoint(items: Any) -> bool:
|
||||
"""Does this ``codex_reasoning_items`` sidecar carry a compaction checkpoint? A checkpoint is
|
||||
cumulative context living in exactly one place: rewrite/discard the sidecar only after asking."""
|
||||
return isinstance(items, list) and any(_is_compaction_item(item) for item in items)
|
||||
return isinstance(items, list) and any(
|
||||
_is_compaction_item(item)
|
||||
and isinstance(item.get("encrypted_content"), str)
|
||||
and bool(item["encrypted_content"].strip())
|
||||
for item in items
|
||||
)
|
||||
|
||||
|
||||
def merge_interim_reasoning_items(prior_items: Any, new_items: Any) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Nous Portal ``anthropic/*`` wire selection when ``nous.anthropic_wire`` is ``auto``.
|
||||
|
||||
Portal serves Claude two ways and Hermes cannot tell which from the request: an OpenRouter
|
||||
passthrough (today, for every ``anthropic/*`` id) or GMI/Vertex (planned once GMI is back). The
|
||||
native Messages wire is the better transport, but on the OpenRouter path it re-writes the previous
|
||||
turn's prompt cache on 14-20% of consecutive calls in concurrent tool loops (measured 2026-09-06;
|
||||
NousResearch/api#227), so the session must ride chat/completions there. On GMI that is untested,
|
||||
and until it is measured ``auto`` never promotes to native.
|
||||
|
||||
The upstream IS visible in the first RESPONSE: OpenRouter stamps ``provider`` (chat wire) and
|
||||
mints ``gen-<unix>-<rand>`` ids; GMI/Vertex responses carry neither. So ``auto`` starts every
|
||||
session on chat (safe on both upstreams), reads the first response, and switches the session to
|
||||
native only when the upstream is GMI and native has been cleared for GMI. One decision per
|
||||
session, at call 1, before there is a cache to lose; later calls never flip.
|
||||
|
||||
``classify_upstream`` is pure and unit-tested; ``maybe_switch_wire_after_first_response`` is
|
||||
the single hook, called from the usage recorder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Flip to True only after the 20x6 concurrency probe (evals/postmortem/live_ab) is clean on a
|
||||
# GMI-served anthropic/* id on the native wire. Until then ``auto`` is chat everywhere.
|
||||
GMI_NATIVE_WIRE_CLEARED = False
|
||||
|
||||
_OPENROUTER_ID = re.compile(r"^gen-\d{9,}-[A-Za-z0-9_-]{8,}$")
|
||||
|
||||
|
||||
def classify_upstream(response: Any) -> Optional[str]:
|
||||
"""``"openrouter"`` / ``"gmi"`` / ``None`` (unknown) from a Portal response object.
|
||||
|
||||
Works on both wires: the OpenAI SDK object exposes ``.provider`` (OpenRouter's upstream name,
|
||||
e.g. ``"Anthropic"``, ``"Amazon Bedrock"``) and an OpenRouter-minted ``.id``; the Anthropic SDK
|
||||
object has ``.id`` only. GMI/Vertex responses have Anthropic-native ``msg_…`` ids and no
|
||||
``provider``. Anything else is unknown, and unknown never triggers a switch.
|
||||
"""
|
||||
if response is None:
|
||||
return None
|
||||
if isinstance(getattr(response, "provider", None), str) and getattr(response, "provider"):
|
||||
return "openrouter"
|
||||
rid = getattr(response, "id", None)
|
||||
if isinstance(rid, str):
|
||||
if _OPENROUTER_ID.match(rid):
|
||||
return "openrouter"
|
||||
if rid.startswith("msg_"):
|
||||
return "gmi"
|
||||
return None
|
||||
|
||||
|
||||
def wire_for_upstream(upstream: Optional[str]) -> str:
|
||||
"""The api_mode ``auto`` wants once the upstream is known. Chat unless GMI and cleared."""
|
||||
if upstream == "gmi" and GMI_NATIVE_WIRE_CLEARED:
|
||||
return "anthropic_messages"
|
||||
return "chat_completions"
|
||||
|
||||
|
||||
def maybe_switch_wire_after_first_response(agent: Any, response: Any, api_call_count: int) -> bool:
|
||||
"""Decide the session's wire from its first response; the switch itself is applied at the
|
||||
start of the next iteration (``apply_pending_wire_switch``), never while a response is being
|
||||
consumed. Returns True when a switch was scheduled.
|
||||
|
||||
Only for provider=nous, anthropic/* models, ``nous.anthropic_wire: auto``, and only on the
|
||||
session's first API call.
|
||||
"""
|
||||
if api_call_count != 1 or getattr(agent, "_nous_wire_decided", False):
|
||||
return False
|
||||
if (getattr(agent, "provider", "") or "").lower() != "nous":
|
||||
return False
|
||||
model = str(getattr(agent, "model", "") or "")
|
||||
if not model.lower().startswith("anthropic/"):
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.providers import _nous_anthropic_wire
|
||||
if _nous_anthropic_wire() != "auto":
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
agent._nous_wire_decided = True # one decision per session, whatever it is
|
||||
upstream = classify_upstream(response)
|
||||
want = wire_for_upstream(upstream)
|
||||
if want == getattr(agent, "api_mode", None):
|
||||
logger.debug("nous wire auto: upstream=%s, staying on %s", upstream, want)
|
||||
return False
|
||||
agent._nous_wire_pending = (want, upstream)
|
||||
return True
|
||||
|
||||
|
||||
def apply_pending_wire_switch(agent: Any) -> bool:
|
||||
"""At iteration start, with no response in flight: perform the switch scheduled by
|
||||
``maybe_switch_wire_after_first_response``. Reuses ``switch_model`` (same model/provider, new
|
||||
api_mode) so client rebuild, cache policy and ``_primary_runtime`` stay consistent. A failure
|
||||
is logged and the session stays on its current wire."""
|
||||
pending = getattr(agent, "_nous_wire_pending", None)
|
||||
if not pending:
|
||||
return False
|
||||
agent._nous_wire_pending = None
|
||||
want, upstream = pending
|
||||
try:
|
||||
from agent.agent_runtime_helpers import switch_model
|
||||
switch_model(agent, agent.model, "nous", api_key=getattr(agent, "api_key", "") or "",
|
||||
base_url=getattr(agent, "base_url", "") or "", api_mode=want)
|
||||
except Exception as exc: # never let wire selection break a turn
|
||||
logger.warning("nous wire auto: switch to %s failed (%s); staying on %s", want, exc, agent.api_mode)
|
||||
return False
|
||||
logger.info("nous wire auto: upstream=%s -> %s for the rest of session %s", upstream, want,
|
||||
getattr(agent, "session_id", "?"))
|
||||
return True
|
||||
+21
-18
@@ -96,7 +96,15 @@ def _scan_context_content(content: str, filename: str) -> str:
|
||||
def _find_git_root(start: Path) -> Optional[Path]:
|
||||
"""Nearest ancestor (or *start* itself) containing ``.git``, else None."""
|
||||
current = start.resolve()
|
||||
return next((p for p in (current, *current.parents) if (p / ".git").exists()), None)
|
||||
# A parent the process may not stat (locked-down /home on shared hosts) is "no .git here", not a crash.
|
||||
return next((p for p in (current, *current.parents) if _exists_or_denied(p / ".git")), None)
|
||||
|
||||
|
||||
def _exists_or_denied(path: Path) -> bool:
|
||||
try:
|
||||
return path.exists()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _find_hermes_md(cwd: Path) -> Optional[Path]:
|
||||
@@ -845,13 +853,6 @@ def _tenv_read(name: str, default: str = "") -> str:
|
||||
|
||||
_BACKEND_IMAGE_KEYS = {b: f"{b}_image" for b in ("docker", "singularity", "modal", "daytona")}
|
||||
# (config key, default) pairs forwarded to _create_environment's container_config.
|
||||
_CONTAINER_CONFIG_DEFAULTS = (
|
||||
("container_cpu", 1), ("container_memory", 5120), ("container_disk", 51200), ("container_persistent", True),
|
||||
("modal_mode", "auto"), ("docker_volumes", []), ("docker_mount_cwd_to_workspace", False),
|
||||
("docker_forward_env", []), ("docker_env", {}), ("docker_run_as_host_user", False), ("docker_extra_args", []),
|
||||
("docker_shm_size", "1g"), ("docker_persist_across_processes", True), ("docker_shared_container_key", ""),
|
||||
("docker_orphan_reaper", True),
|
||||
)
|
||||
# Single-line POSIX probe; `2>/dev/null` keeps a missing binary from polluting output.
|
||||
_BACKEND_PROBE_CMD = (
|
||||
"printf 'os=%s\\nkernel=%s\\nhome=%s\\ncwd=%s\\nuser=%s\\n' \"$(uname -s 2>/dev/null || echo unknown)\" "
|
||||
@@ -862,31 +863,33 @@ _BACKEND_PROBE_CMD = (
|
||||
|
||||
def _run_backend_probe(env_type: str, terminal_tool) -> str:
|
||||
"""Execute the probe command inside a freshly built backend; "" when it yields nothing."""
|
||||
from tools.terminal_tool_backends import _create_environment, _ssh_config_from_config
|
||||
from tools.terminal_tool_backends import _container_config_from_config, _create_environment, _ssh_config_from_config
|
||||
from tools.terminal_tool_lifecycle import _cleanup_env
|
||||
|
||||
config = terminal_tool._get_env_config()
|
||||
# Mirrors tools/terminal_tool.py's live-command assembly (`_create_environment` is the factory).
|
||||
# Same container_config shaper as the live terminal path: a private copy of the key table here
|
||||
# drifted (no docker_network) and gave the probe a bridge-networked container under lockdown.
|
||||
env = _create_environment(
|
||||
env_type=env_type, image=config.get(_BACKEND_IMAGE_KEYS[env_type], "") if env_type in _BACKEND_IMAGE_KEYS else "", cwd=config.get("cwd", ""),
|
||||
timeout=config.get("timeout", 180),
|
||||
ssh_config=_ssh_config_from_config(config) if env_type == "ssh" else None,
|
||||
container_config=({k: config.get(k, d) for k, d in _CONTAINER_CONFIG_DEFAULTS}
|
||||
container_config=(_container_config_from_config(config)
|
||||
if terminal_tool._is_container_backend(env_type) else None),
|
||||
task_id="prompt-backend-probe", host_cwd=config.get("host_cwd"),
|
||||
# Only ssh honors this: an isolated ControlMaster socket and no remote dir setup / file sync /
|
||||
# snapshot. A normal SSHEnvironment would upload the whole ~/.hermes tree just to run `uname`,
|
||||
# and its later __del__ would sync_back() and close the master shared with the agent's own env.
|
||||
probe_only=True,
|
||||
)
|
||||
try:
|
||||
result = env.execute(_BACKEND_PROBE_CMD, timeout=4)
|
||||
finally:
|
||||
# One-shot `uname`; without teardown the backend leaves a second idle sandbox
|
||||
# (task_id="prompt-backend-probe") running for the whole process next to the agent's own.
|
||||
# ssh is left alone: no task-scoped sandbox, and its cleanup() closes a ControlMaster socket
|
||||
# (keyed by user@host:port) shared with the agent's real environment; ControlPersist expires it.
|
||||
if env_type != "ssh":
|
||||
try:
|
||||
_cleanup_env(env, force_remove=True)
|
||||
except Exception:
|
||||
logger.debug("Backend probe cleanup failed", exc_info=True)
|
||||
try:
|
||||
_cleanup_env(env, force_remove=True)
|
||||
except Exception:
|
||||
logger.debug("Backend probe cleanup failed", exc_info=True)
|
||||
if result.get("returncode") != 0:
|
||||
logger.debug("Backend probe returned non-zero: %r", result)
|
||||
return ""
|
||||
|
||||
@@ -4,6 +4,7 @@ only (notification, timeout, kill and retry policy live elsewhere). Provenance i
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from enum import Enum
|
||||
@@ -45,6 +46,21 @@ def normalize_activity_provenance(provenance: Optional[ActivityProvenance | str]
|
||||
return ActivityProvenance.UNKNOWN
|
||||
|
||||
|
||||
def format_iteration_progress(api_call_count: Any, max_iterations: Any) -> str:
|
||||
"""``iteration N/M`` for user-facing status lines, or ``iteration N`` when the cap is unbounded.
|
||||
|
||||
``AIAgent.max_iterations`` defaults to ``sys.maxsize`` (unlimited), so printing the pair verbatim
|
||||
shows ``iteration 3/9223372036854775807`` in busy acks, heartbeats and timeout diagnostics (#102806).
|
||||
"""
|
||||
try:
|
||||
cap = int(max_iterations)
|
||||
except (TypeError, ValueError):
|
||||
cap = sys.maxsize
|
||||
if cap >= sys.maxsize:
|
||||
return f"iteration {api_call_count}"
|
||||
return f"iteration {api_call_count}/{cap}"
|
||||
|
||||
|
||||
def reset_session_activity_persist_window(agent: Any) -> None:
|
||||
"""Clear the persist rate-limit so the next stamp writes through (terminal compression labels must not stick on mid-compress text)."""
|
||||
with suppress(Exception):
|
||||
|
||||
+20
-4
@@ -538,12 +538,28 @@ def _alibaba_identity_part(agent: Any) -> List[str]:
|
||||
|
||||
def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]:
|
||||
"""``(prefix, workspace, trailing)`` coding-posture blocks; all empty
|
||||
without tools or when probing fails (it must never block prompt build)."""
|
||||
without tools or when probing fails (it must never block prompt build).
|
||||
|
||||
The workspace block is a live git probe that leads the context tier, ahead of the whole
|
||||
volatile band; re-probing at the compaction rebuild re-emits different bytes for any
|
||||
repo that moved and defeats the keep-prompt fast path. So the bytes are pinned per
|
||||
session on the agent, keyed by the resolved cwd (a gateway serves many cwds), and
|
||||
replayed on rebuilds; ``reset_session_state`` drops the pin at a session boundary.
|
||||
"""
|
||||
try:
|
||||
from agent.coding_context import coding_system_prompt_parts
|
||||
if agent.valid_tool_names:
|
||||
return coding_system_prompt_parts(platform=agent.platform, cwd=resolve_context_cwd(),
|
||||
model=agent.model, valid_tool_names=agent.valid_tool_names)
|
||||
if not agent.valid_tool_names:
|
||||
return [], [], []
|
||||
cwd = resolve_context_cwd()
|
||||
cwd_key = str(cwd) if cwd is not None else ""
|
||||
pinned = getattr(agent, "_frozen_workspace_snapshot", None)
|
||||
# "" is a real pinned value (no workspace here) — only a cwd mismatch re-probes.
|
||||
replay = pinned[1] if pinned is not None and pinned[0] == cwd_key else None
|
||||
parts = coding_system_prompt_parts(platform=agent.platform, cwd=cwd, model=agent.model,
|
||||
valid_tool_names=agent.valid_tool_names, workspace_block=replay)
|
||||
if replay is None:
|
||||
agent._frozen_workspace_snapshot = (cwd_key, parts[1][0] if parts[1] else "")
|
||||
return parts
|
||||
except Exception:
|
||||
pass
|
||||
return [], [], []
|
||||
|
||||
@@ -210,17 +210,14 @@ def _do_upload(jsonl: str, *, token: str, session_id: str, dataset_name: str = D
|
||||
def load_session_messages(session_id: str, db_path=None) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
||||
"""``(messages, meta)`` from SQLite; ``meta`` is ``{}`` when the session row is missing (a live, untitled
|
||||
session may still have messages)."""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB(db_path=db_path) if db_path else SessionDB()
|
||||
from hermes_state_registry import acquire, release_or_close
|
||||
db = acquire(db_path or None)
|
||||
try:
|
||||
resolved = db.resolve_session_id(session_id) or session_id
|
||||
meta = db.get_session(resolved) or {}
|
||||
return db.get_messages_as_conversation(resolved), meta
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close trace-upload SessionDB", exc_info=True)
|
||||
release_or_close(db)
|
||||
|
||||
|
||||
def upload_session_trace(
|
||||
|
||||
@@ -146,8 +146,10 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
|
||||
return thinking_config
|
||||
if effort not in {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"}:
|
||||
effort = "medium"
|
||||
# Gemini 3 Flash documents low/medium/high; Gemini 3 Pro only low/high.
|
||||
if normalized_model.startswith(("gemini-3", "gemini-3.1")):
|
||||
# Gemini 3 Flash documents low/medium/high thinking levels; Gemini 3 Pro
|
||||
# is stricter (low/high). Clamp Hermes' wider effort set to what each
|
||||
# family accepts so we never forward an undocumented level verbatim.
|
||||
if normalized_model.startswith("gemini-3"):
|
||||
if "flash" in normalized_model:
|
||||
thinking_config["thinkingLevel"] = (
|
||||
"low" if effort in {"minimal", "low"} else "high" if effort in _HIGH_EFFORTS else "medium"
|
||||
|
||||
+26
-2
@@ -525,13 +525,37 @@ def _stage_turn_user_message(
|
||||
|
||||
|
||||
def _hydrate_from_history(agent: Any, conversation_history: Optional[List[Any]]) -> None:
|
||||
"""Hydrate the todo store and per-session nudge counters from persisted history."""
|
||||
"""Hydrate process-local state from persisted history on the first resumed turn."""
|
||||
if not conversation_history:
|
||||
return
|
||||
if not agent._todo_store.has_items():
|
||||
agent._hydrate_todo_store(conversation_history)
|
||||
# Hydrate per-session nudge counters from persisted history.
|
||||
# A live native checkpoint arms this latch while its response is captured. A
|
||||
# restarted agent must recover the same one-response deferral before turn-start
|
||||
# compression can rewrite the restored opaque checkpoint. Reuse the adapter's
|
||||
# exact route/issuer/replay filtering and tolerate plugin compressors without the
|
||||
# optional hook.
|
||||
if agent._user_turn_count == 0:
|
||||
note_checkpoint = getattr(
|
||||
getattr(agent, "context_compressor", None),
|
||||
"note_native_compaction_checkpoint",
|
||||
None,
|
||||
)
|
||||
if callable(note_checkpoint):
|
||||
try:
|
||||
from agent.codex_responses_adapter import (
|
||||
has_replayable_native_compaction_checkpoint,
|
||||
)
|
||||
|
||||
if has_replayable_native_compaction_checkpoint(
|
||||
agent, conversation_history
|
||||
):
|
||||
note_checkpoint()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"restored native checkpoint hydration skipped", exc_info=True
|
||||
)
|
||||
# Hydrate per-session nudge counters from persisted history.
|
||||
prior_user_turns = sum(1 for m in conversation_history if m.get("role") == "user")
|
||||
if prior_user_turns > 0:
|
||||
agent._user_turn_count = prior_user_turns
|
||||
|
||||
@@ -156,6 +156,11 @@ def _idle_compaction(
|
||||
if _idle_gap < _idle_after:
|
||||
return
|
||||
_compressor = agent.context_compressor
|
||||
# A live or restored native checkpoint must reach its issuer once so real usage,
|
||||
# rather than an opaque ciphertext estimate, decides whether local compression is
|
||||
# still needed. Threshold and post-tool preflight honor the same latch.
|
||||
if bool(getattr(_compressor, "awaiting_real_usage_after_compression", False)):
|
||||
return
|
||||
# Route-aware pressure: on compacted native-Codex sessions the durable figure
|
||||
# overstates the wire, so reuse the preflight estimator.
|
||||
_idle_tokens = _tc._preflight_request_tokens(
|
||||
|
||||
@@ -39,6 +39,12 @@ def prepare_iteration(agent: Any,*, messages: Any, api_call_count: Any) -> Itera
|
||||
_INTERRUPT_SCAFFOLD_MARKER, _maybe_inject_run_budget_wrapup
|
||||
)
|
||||
|
||||
# nous.anthropic_wire=auto: a wire switch decided from the previous response lands here,
|
||||
# before this iteration's request is built and with nothing in flight.
|
||||
if getattr(agent, "_nous_wire_pending", None):
|
||||
from agent.nous_wire import apply_pending_wire_switch
|
||||
apply_pending_wire_switch(agent)
|
||||
|
||||
# Fire step_callback for gateway hooks (agent:step event).
|
||||
if agent.step_callback is not None:
|
||||
try:
|
||||
|
||||
@@ -297,6 +297,9 @@ def compress_after_tool_results(
|
||||
if (
|
||||
agent.compression_enabled
|
||||
and compression_attempts < max_compression_attempts
|
||||
and not bool(
|
||||
getattr(_compressor, "awaiting_real_usage_after_compression", False)
|
||||
)
|
||||
and _compressor.should_compress(_real_tokens)
|
||||
):
|
||||
compression_attempts += 1
|
||||
|
||||
+45
-21
@@ -892,7 +892,8 @@ def log_api_error_attempt(
|
||||
|
||||
if agent._is_openrouter_url() and "support tool use" in error_msg:
|
||||
_blines(agent, f" 💡 No OpenRouter providers for {_model} support tool calling with your current settings.")
|
||||
if agent.providers_allowed:
|
||||
from agent.chat_completion_helpers import _provider_preferences_for_agent
|
||||
if _provider_preferences_for_agent(agent).get("only"):
|
||||
_blines(
|
||||
agent,
|
||||
" Your provider_routing.only restriction is filtering out tool-capable providers.",
|
||||
@@ -973,30 +974,44 @@ def compute_error_backoff(
|
||||
agent: Any, api_error: Exception, *, retry_count: int, max_retries: int, is_rate_limited: bool,
|
||||
is_zai_coding_overload: bool, base_url: Any, model: Any,
|
||||
) -> float:
|
||||
"""Pick the wait before the next API retry and announce it. Retry-After wins for rate
|
||||
limits (capped at 600s: Anthropic Tier 1 buckets reset in ~171s, so a 120s cap re-tripped
|
||||
the limit); otherwise jittered backoff, replaced by the adaptive policy for 429s / Z.AI
|
||||
overloads. Normal retries are buffered; long Z.AI Coding waits surface immediately."""
|
||||
"""Pick the wait before the next API retry and announce it. Retry-After wins for
|
||||
rate limits and any other retryable error (capped at 600s: Anthropic Tier 1 buckets
|
||||
reset in ~171s, so a 120s cap re-tripped the limit); otherwise jittered backoff,
|
||||
replaced by the adaptive policy for 429s / Z.AI overloads. Normal retries are
|
||||
buffered; long Z.AI Coding waits surface immediately."""
|
||||
# Imported lazily so tests that patch ``agent.retry_utils.jittered_backoff`` /
|
||||
# ``adaptive_rate_limit_backoff`` (incl. the run_agent conftest fast-backoff fixture) intercept.
|
||||
from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff
|
||||
from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff, parse_retry_after_seconds
|
||||
|
||||
_retry_after = None
|
||||
_resp_headers = getattr(getattr(api_error, "response", None), "headers", None) if is_rate_limited else None
|
||||
if _resp_headers and hasattr(_resp_headers, "get"):
|
||||
_ra_raw = _resp_headers.get("retry-after") or _resp_headers.get("Retry-After")
|
||||
if _ra_raw:
|
||||
try:
|
||||
# Cap at 10 minutes. Anthropic Tier 1 input-token buckets reset in ~171s, so a 120s cap
|
||||
# caused us to retry before the actual reset window and re-trip the limit. 600s covers all
|
||||
# realistic provider reset windows while still rejecting pathological values. (#26293)
|
||||
_retry_after = min(float(_ra_raw), 600)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
|
||||
# Respect Retry-After on every retryable provider error, not just 429s. Retryable
|
||||
# 5xx responses (e.g. Cloudflare 520/524) also carry the header or a structured
|
||||
# ``retry_after`` problem-detail body field; ignoring either turns an origin
|
||||
# outage into a retry storm.
|
||||
_retry_after = parse_retry_after_seconds(
|
||||
getattr(getattr(api_error, "response", None), "headers", None)
|
||||
)
|
||||
if _retry_after is None:
|
||||
_error_body = getattr(api_error, "body", None)
|
||||
if isinstance(_error_body, dict):
|
||||
# Some providers nest it as error.retry_after (the same unwrap
|
||||
# extract_api_error_context uses), others put it at the top level.
|
||||
_nested = _error_body.get("error")
|
||||
_payload = _nested if isinstance(_nested, dict) else _error_body
|
||||
_retry_after = parse_retry_after_seconds(_payload.get("retry_after"))
|
||||
if _retry_after is not None:
|
||||
# Cap at 10 minutes. Anthropic Tier 1 input-token buckets reset in ~171s, so a 120s cap
|
||||
# caused us to retry before the actual reset window and re-trip the limit. 600s covers all
|
||||
# realistic provider reset windows while still rejecting pathological values. (#26293)
|
||||
_retry_after = min(_retry_after, 600)
|
||||
if _retry_after <= 0:
|
||||
# A zero/expired cooldown (retry-after: 0, or an HTTP-date in the
|
||||
# past, which the parser clamps to 0.0) carries no usable wait —
|
||||
# treat it as absent so we never hot-loop the provider.
|
||||
_retry_after = None
|
||||
wait_time = _retry_after if _retry_after is not None else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
|
||||
_backoff_policy = None
|
||||
_adaptive = is_rate_limited or is_zai_coding_overload
|
||||
if _adaptive and not _retry_after:
|
||||
if _adaptive and _retry_after is None:
|
||||
wait_time, _backoff_policy = adaptive_rate_limit_backoff(
|
||||
retry_count, base_url=str(base_url), model=model, error=api_error, default_wait=wait_time,
|
||||
)
|
||||
@@ -1009,7 +1024,16 @@ def compute_error_backoff(
|
||||
else:
|
||||
agent._buffer_status(_rate_limit_status)
|
||||
else:
|
||||
agent._buffer_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...")
|
||||
_retry_status = (
|
||||
f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})..."
|
||||
)
|
||||
if _retry_after is not None and _retry_after > 60:
|
||||
# A 5xx Retry-After can now reach the 600s cap; buffering that wait
|
||||
# would leave the user silent for minutes, so surface long provider
|
||||
# cooldowns immediately (mirrors the zai_coding_overload_long path).
|
||||
agent._emit_status(_retry_status)
|
||||
else:
|
||||
agent._buffer_status(_retry_status)
|
||||
logger.warning(
|
||||
"Retrying API call in %ss (attempt %s/%s) %s policy=%s error=%s",
|
||||
wait_time, retry_count, max_retries, agent._client_log_context(),
|
||||
|
||||
@@ -181,6 +181,11 @@ def record_response_usage(
|
||||
prompt_tokens, completion_tokens, total_tokens,
|
||||
api_duration, _cache_pct,
|
||||
)
|
||||
# nous.anthropic_wire=auto: the session's wire is decided once, from this first response.
|
||||
if agent.session_api_calls == 1 and (agent.provider or "") == "nous":
|
||||
with suppress(Exception):
|
||||
from agent.nous_wire import maybe_switch_wire_after_first_response
|
||||
maybe_switch_wire_after_first_response(agent, response, agent.session_api_calls)
|
||||
|
||||
# MoA: agent.model/provider are the virtual preset/"moa" with no pricing entry, silently
|
||||
# dropping aggregator spend. Price at the REAL model/provider from the aggregator slot.
|
||||
|
||||
@@ -191,6 +191,9 @@ _SNAPSHOTS: tuple[tuple[str, Optional[str], str, dict], ...] = (
|
||||
("deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash"): ("0.14", "0.28", "0.0028"),
|
||||
"deepseek-v4-pro": ("0.435", "0.87", "0.003625"),
|
||||
}),
|
||||
("google", "https://ai.google.dev/gemini-api/docs/pricing", "google-pricing-2026-09-02", {
|
||||
("gemini-3.8-flash", "gemini-3.7-flash"): ("0.75", "3.75", "0.075"),
|
||||
}),
|
||||
("google", "https://ai.google.dev/gemini-api/docs/pricing", "google-pricing-2026-07-28", {
|
||||
"gemini-3.6-flash": ("1.50", "7.50", "0.15"), "gemini-3.5-flash-lite": ("0.30", "2.50", "0.03"),
|
||||
}),
|
||||
|
||||
+48
-1
@@ -182,6 +182,30 @@ def _run_start_phase(
|
||||
return ReadinessResult(url, ready, status, time.monotonic() - started, error, _tail(output))
|
||||
|
||||
|
||||
def _compose_live_state_reason(root: Path) -> str | None:
|
||||
"""Why ``docker compose build``/``up`` must not run at *root*, or ``None`` to proceed.
|
||||
|
||||
Read-only ``docker compose ps`` probe. Only a missing docker binary proceeds -- the
|
||||
build phase would fail the same way, so there is nothing to protect. A hung daemon
|
||||
or a non-zero probe refuses: containers may be live and unobservable, which is
|
||||
exactly the #103567 loss window.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "ps", "--status", "running", "--format", "{{.Name}}"],
|
||||
cwd=root, capture_output=True, text=True, timeout=15, stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
return "docker compose ps timed out after 15s; live containers cannot be ruled out"
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "").strip().splitlines()
|
||||
return f"docker compose ps failed (exit {result.returncode}): {detail[-1] if detail else 'no output'}"
|
||||
names = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
return f"this compose project already has running container(s): {', '.join(names)}" if names else None
|
||||
|
||||
|
||||
def run_verify(
|
||||
root: Path, recipe: Recipe, phases: tuple[str, ...] | list[str] | None = None,
|
||||
phase_timeout: float = DEFAULT_PHASE_TIMEOUT, ready_timeout: float = DEFAULT_READY_TIMEOUT,
|
||||
@@ -189,11 +213,34 @@ def run_verify(
|
||||
on_output: Callable[[str], None] | None = None,
|
||||
) -> VerifyResult:
|
||||
"""Run the selected command phases sequentially, then (unless ``skip_start`` or a
|
||||
phase failed) boot ``recipe.start``, poll readiness, and tear the process group down."""
|
||||
phase failed) boot ``recipe.start``, poll readiness, and tear the process group down.
|
||||
|
||||
A ``compose`` recipe refuses outright when the project already has running
|
||||
containers: ``docker compose build`` + ``up`` replaces them on an image-hash
|
||||
change, destroying any container-local state they carry -- this has caused a
|
||||
real state-loss incident (#103567). The check is best-effort and read-only
|
||||
(``docker compose ps``); when it cannot run at all, verify proceeds rather than
|
||||
blocking on an unrelated environment gap, matching every other recipe kind's
|
||||
behavior when its own tooling is unavailable."""
|
||||
root = Path(root)
|
||||
selected = tuple(phases) if phases else PHASE_ORDER + ("start",)
|
||||
result = VerifyResult(recipe_name=recipe.name)
|
||||
|
||||
mutating = ("build" in selected) or ("start" in selected and not skip_start)
|
||||
if recipe.kind == "compose" and mutating:
|
||||
reason = _compose_live_state_reason(root)
|
||||
if reason:
|
||||
result.phases.append(PhaseResult(
|
||||
phase="build", command=recipe.build[0] if recipe.build else "docker compose build",
|
||||
exit_code=1, duration=0.0, output_tail=(
|
||||
f"Refusing to run: {reason}. `docker compose build` + `up` would replace "
|
||||
"live containers on an image-hash change, destroying any container-local "
|
||||
"state they carry. If you intend to rebuild this live deployment, run "
|
||||
"`docker compose build`/`up` yourself."
|
||||
),
|
||||
))
|
||||
return result
|
||||
|
||||
for phase in PHASE_ORDER:
|
||||
if phase not in selected:
|
||||
continue
|
||||
|
||||
@@ -126,10 +126,10 @@ describe('main.ts wiring for #90812', () => {
|
||||
it('routes the profile-scoped dial IPC through the single-owner claim', () => {
|
||||
const handlerStart = mainSource.indexOf("ipcMain.handle('hermes:connection', ")
|
||||
expect(handlerStart).toBeGreaterThan(-1)
|
||||
const body = mainSource.slice(handlerStart, handlerStart + 900)
|
||||
const body = mainSource.slice(handlerStart, handlerStart + 1200)
|
||||
|
||||
expect(body).toContain('backendDialClaims.run(')
|
||||
expect(body).toContain('ensureBackend(profile)')
|
||||
expect(body).toContain('ensureBackend(profile, { spawnPriority })')
|
||||
})
|
||||
|
||||
it('routes the registry-scoped dial IPC through the claim keyed by backendScopeKey(connectionId, profile)', () => {
|
||||
@@ -137,8 +137,9 @@ describe('main.ts wiring for #90812', () => {
|
||||
expect(handlerStart).toBeGreaterThan(-1)
|
||||
const body = mainSource.slice(handlerStart, handlerStart + 1_200)
|
||||
|
||||
expect(body).toContain('backendDialClaims.run(backendScopeKey(id, profile)')
|
||||
expect(body).toContain('ensureRegistryBackend(id, profile)')
|
||||
expect(body).toContain('const scopeKey = backendScopeKey(id, profile)')
|
||||
expect(body).toContain('backendDialClaims.run(scopeKey, ')
|
||||
expect(body).toContain("ensureRegistryBackend(id, profile, '', { spawnPriority })")
|
||||
})
|
||||
|
||||
// The four IPC/probe surfaces below call ensureRegistryBackend()/ensureBackend()
|
||||
|
||||
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { normalizeRegistry, REGISTRY_VERSION } from './connection-registry'
|
||||
import { resolveDesktopRemoteRoute } from './desktop-remote-route'
|
||||
import { backendScopeKey } from './connection-registry'
|
||||
import { resolveDesktopRemoteRoute, v1SshTerminalPoolKey } from './desktop-remote-route'
|
||||
|
||||
const tokenA = { encoding: 'plain', value: 'token-a' }
|
||||
const tokenB = { encoding: 'plain', value: 'token-b' }
|
||||
@@ -153,6 +154,46 @@ test('global SSH treats an omitted port as 22 and checks the primary route', ()
|
||||
assert.equal(route?.connectionId, 'ssh-primary')
|
||||
})
|
||||
|
||||
test('v1 settings SSH pool key ignores registry identity tags', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'ssh', remote: { mode: 'ssh', host: 'box.test', user: 'hermes' } },
|
||||
profile: 'worker',
|
||||
registry: registry('ssh-primary', [
|
||||
{ id: 'ssh-primary', kind: 'ssh', label: 'SSH primary', host: 'box.test', user: 'hermes', port: 22 }
|
||||
])
|
||||
})
|
||||
|
||||
assert.ok(route)
|
||||
assert.equal(route.kind, 'ssh')
|
||||
assert.equal(route.connectionId, 'ssh-primary')
|
||||
assert.equal(v1SshTerminalPoolKey(route, 'worker'), '')
|
||||
assert.notEqual(v1SshTerminalPoolKey(route, 'worker'), backendScopeKey(route.connectionId, 'worker'))
|
||||
})
|
||||
|
||||
test('v1 profile SSH pool key is the profile, not conn:id::profile', () => {
|
||||
const ssh = {
|
||||
mode: 'ssh',
|
||||
host: 'box.test',
|
||||
user: 'hermes',
|
||||
port: 2222,
|
||||
keyPath: '/keys/a',
|
||||
remoteHermesPath: '/srv/hermes',
|
||||
remoteProfile: 'worker'
|
||||
}
|
||||
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'local', profiles: { worker: ssh } },
|
||||
profile: 'worker',
|
||||
registry: registry('local', [{ id: 'worker-ssh', kind: 'ssh', label: 'Worker SSH', ...ssh }])
|
||||
})
|
||||
|
||||
assert.ok(route)
|
||||
assert.equal(route.kind, 'ssh')
|
||||
assert.equal(route.connectionId, 'worker-ssh')
|
||||
assert.equal(v1SshTerminalPoolKey(route, 'worker'), 'worker')
|
||||
assert.notEqual(v1SshTerminalPoolKey(route, 'worker'), backendScopeKey(route.connectionId, 'worker'))
|
||||
})
|
||||
|
||||
test('profile route omits identity when two registry entries match exactly', () => {
|
||||
const block = { mode: 'remote', url: 'https://worker.test', authMode: 'token', token: tokenA }
|
||||
|
||||
|
||||
@@ -51,6 +51,22 @@ function withConnectionId<T extends object>(route: T, connectionId?: string): T
|
||||
return connectionId ? { ...route, connectionId } : route
|
||||
}
|
||||
|
||||
/**
|
||||
* Pool key used by v1 settings/profile SSH (`sshConnections`).
|
||||
*
|
||||
* Bootstrap stores the tunnel under sshScopeKey(profile or null).
|
||||
* resolveDesktopRemoteRoute may also stamp a registry connectionId when
|
||||
* the host matches a v2 row. That id is identity, not the pool key.
|
||||
* Looking up backendScopeKey(connectionId, profile) misses the live tunnel.
|
||||
*/
|
||||
export function v1SshTerminalPoolKey(route: { source: string }, profile?: null | string): string {
|
||||
if (route.source === 'profile') {
|
||||
return connectionScopeKey(profile) || ''
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Select one remote route with the existing precedence and freeze any exact
|
||||
* registry identity before I/O. A null result means the profile resolves
|
||||
|
||||
+154
-24
@@ -158,7 +158,7 @@ import { describeCrashReason, installCrashForensics } from './crash-forensics'
|
||||
import { adoptServedDashboardToken } from './dashboard-token'
|
||||
import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation'
|
||||
import { formatDesktopLogLine } from './desktop-log-line'
|
||||
import { resolveDesktopRemoteRoute } from './desktop-remote-route'
|
||||
import { resolveDesktopRemoteRoute, v1SshTerminalPoolKey } from './desktop-remote-route'
|
||||
import {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
@@ -287,7 +287,9 @@ import {
|
||||
import { selectPoolEvictions } from './pool-eviction'
|
||||
import { clampPoolLimits, parsePoolLimits, POOL_LIMITS_DEFAULTS } from './pool-limits'
|
||||
import {
|
||||
isBackgroundSlotWaitTimeout,
|
||||
LocalBackendSpawnCoordinator,
|
||||
type LocalBackendSpawnPriority,
|
||||
type LocalBackendSpawnRequest,
|
||||
releaseLocalBackendSlotAfterExit
|
||||
} from './pool-spawn-coordinator'
|
||||
@@ -411,7 +413,11 @@ import { isHermesOwnedVenvDaemon } from './venv-holder-select'
|
||||
import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace'
|
||||
import { createWakeIndicatorWindowController } from './wake-indicator-window'
|
||||
import { enumerateWindowsFrontToBack, enumerationFailed, readWindowBelow } from './window-below'
|
||||
import { registrySshScopeForWindowRoute, WindowConnectionRouteRegistry } from './window-connection-route'
|
||||
import {
|
||||
registrySshPoolScopeByConnectionId,
|
||||
registrySshScopeForWindowRoute,
|
||||
WindowConnectionRouteRegistry
|
||||
} from './window-connection-route'
|
||||
import { createWindowOpenHandler } from './window-open-policy'
|
||||
import { installWindowRendererLifecycle } from './window-renderer-lifecycle'
|
||||
import { createWindowRevealController } from './window-reveal'
|
||||
@@ -1488,6 +1494,70 @@ const localBackendSpawnCoordinator = new LocalBackendSpawnCoordinator(poolLimits
|
||||
// the queued ticket fails before the renderer does and the user sees why.
|
||||
const POOL_SLOT_WAIT_MS = 30_000
|
||||
|
||||
function spawnPriorityFrom(value: unknown): LocalBackendSpawnPriority {
|
||||
return value === 'foreground' ? 'foreground' : 'background'
|
||||
}
|
||||
|
||||
// Foreground intent for a dial whose pool entry does not exist yet: a user
|
||||
// click that joins an in-flight backendDialClaims claim never re-enters
|
||||
// ensureBackend(), and the claim owner may still be awaiting poolStopper /
|
||||
// registry resolution before backendPool.set(). The local spawn takes the mark
|
||||
// right before its slot request; the IPC handler that set it clears it once
|
||||
// the claim settles, so a dial that never reaches a slot request (primary
|
||||
// route, remote scope, a guard rejection) cannot leave it for a later
|
||||
// hydration spawn of the same key to pick up.
|
||||
const pendingForegroundSpawns = new Set<string>()
|
||||
|
||||
function takeForegroundSpawn(...poolKeys: string[]): boolean {
|
||||
let marked = false
|
||||
|
||||
for (const poolKey of poolKeys) {
|
||||
marked = pendingForegroundSpawns.delete(poolKey) || marked
|
||||
}
|
||||
|
||||
return marked
|
||||
}
|
||||
|
||||
// Upgrade a pooled entry (running, spawning, or queued for a slot) to
|
||||
// foreground so a queued slot wait can take the reserved foreground slot.
|
||||
function promotePoolEntry(entry: any): void {
|
||||
entry.spawnPriority = 'foreground'
|
||||
entry.localBackendSpawnRequest?.promote?.('foreground')
|
||||
}
|
||||
|
||||
// Land a spawn failure in desktop.log. A background slot-wait timeout is
|
||||
// routine under a saturated pool (the next hydration pass retries), so it is
|
||||
// logged as such instead of as a backend-start failure.
|
||||
function logPoolSpawnFailure(label: string, error: unknown): void {
|
||||
if (isBackgroundSlotWaitTimeout(error)) {
|
||||
rememberLog(`Profile backend ${label} slot wait timed out (background); will retry on the next hydration`)
|
||||
} else {
|
||||
rememberLog(
|
||||
`Hermes backend for profile ${label} failed to start: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply foreground intent to the dial claim for `scopeKey`: an entry already
|
||||
// in the pool is promoted directly, otherwise the intent is marked for the
|
||||
// spawn the claim owner is about to start. Returns the cleanup that clears a
|
||||
// mark the dial never consumed.
|
||||
function applySpawnPriority(scopeKey: string, spawnPriority: LocalBackendSpawnPriority): () => void {
|
||||
if (spawnPriority !== 'foreground') {
|
||||
return () => undefined
|
||||
}
|
||||
|
||||
const existing = backendPool.get(scopeKey)
|
||||
|
||||
if (existing) {
|
||||
promotePoolEntry(existing)
|
||||
} else {
|
||||
pendingForegroundSpawns.add(scopeKey)
|
||||
}
|
||||
|
||||
return () => void pendingForegroundSpawns.delete(scopeKey)
|
||||
}
|
||||
|
||||
function poolMaxBackends() {
|
||||
return poolLimits.maxBackends
|
||||
}
|
||||
@@ -10315,7 +10385,18 @@ function activeSshTerminalTarget(webContentsId?: number) {
|
||||
|
||||
const state = sshConnections.get(scope)
|
||||
|
||||
return state && state.ssh ? { ssh: state.ssh, scope } : 'pending'
|
||||
if (state && state.ssh) {
|
||||
return { ssh: state.ssh, scope }
|
||||
}
|
||||
|
||||
// The pool's single writer publishes under the per-profile bootstrap key
|
||||
// while stamping the entry with its registry connection id (#97345), so a
|
||||
// composite-key miss must still resolve the live tunnel by that identity
|
||||
// instead of reporting 'pending' forever.
|
||||
const pooledScope = registrySshPoolScopeByConnectionId(sshConnections, windowRoute.connectionId)
|
||||
const pooledState = pooledScope === null ? null : sshConnections.get(pooledScope)
|
||||
|
||||
return pooledState && pooledState.ssh ? { ssh: pooledState.ssh, scope: pooledScope } : 'pending'
|
||||
}
|
||||
|
||||
const profile = windowRoute?.profile ?? primaryProfileKey()
|
||||
@@ -10335,9 +10416,7 @@ function activeSshTerminalTarget(webContentsId?: number) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scope = route.connectionId
|
||||
? backendScopeKey(route.connectionId, profile)
|
||||
: sshScopeKey(route.source === 'profile' ? profile : null)
|
||||
const scope = v1SshTerminalPoolKey(route, profile)
|
||||
|
||||
const state = sshConnections.get(scope)
|
||||
|
||||
@@ -11369,8 +11448,9 @@ function profileRouteOptions(profile, request?) {
|
||||
// Resolve a backend connection for the given profile, per the routing table in
|
||||
// resolveProfileBackendRoute(). An empty / unknown profile resolves to the
|
||||
// primary, so legacy callers are unchanged.
|
||||
async function ensureBackend(profile) {
|
||||
async function ensureBackend(profile, opts: { spawnPriority?: LocalBackendSpawnPriority } = {}) {
|
||||
const key = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey()
|
||||
const spawnPriority = spawnPriorityFrom(opts.spawnPriority)
|
||||
|
||||
profileDeletionGate.assertCanStart(key)
|
||||
|
||||
@@ -11402,6 +11482,11 @@ async function ensureBackend(profile) {
|
||||
|
||||
if (existing) {
|
||||
existing.lastActiveAt = Date.now()
|
||||
|
||||
if (spawnPriority === 'foreground') {
|
||||
promotePoolEntry(existing)
|
||||
}
|
||||
|
||||
const connection = await existing.connectionPromise
|
||||
setWslBridgeProfileState(key, connection.mode !== 'remote')
|
||||
|
||||
@@ -11419,16 +11504,15 @@ async function ensureBackend(profile) {
|
||||
remoteBaseUrl: null,
|
||||
releaseLocalBackendSlot: null,
|
||||
localBackendSlotKey: null,
|
||||
localBackendSpawnRequest: null
|
||||
localBackendSpawnRequest: null,
|
||||
spawnPriority
|
||||
}
|
||||
|
||||
entry.connectionPromise = spawnPoolBackend(key, entry).catch(async error => {
|
||||
// Land the failure in desktop.log: without this a spawn that dies before
|
||||
// its child exists (guard rejection, runtime resolution) leaves no trace
|
||||
// beyond renderer-side rejections users never see in a bundle.
|
||||
rememberLog(
|
||||
`Hermes backend for profile "${key}" failed to start: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
logPoolSpawnFailure(`"${key}"`, error)
|
||||
|
||||
await teardownFailedLocalBackend(key, entry)
|
||||
throw error
|
||||
@@ -11449,7 +11533,13 @@ async function ensureBackend(profile) {
|
||||
// a genuinely-local child when the v1 mode says remote; non-local connections
|
||||
// pool under the composite key from backendScopeKey() and reuse the same pool
|
||||
// entry lifecycle (LRU, idle reaper, touch) as per-profile local backends.
|
||||
async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrelation = '') {
|
||||
async function ensureRegistryBackend(
|
||||
connectionId,
|
||||
profile,
|
||||
managedUpdateCorrelation = '',
|
||||
opts: { spawnPriority?: LocalBackendSpawnPriority } = {}
|
||||
) {
|
||||
const spawnPriority = spawnPriorityFrom(opts.spawnPriority)
|
||||
const registry = readDesktopConnectionsRegistry()
|
||||
const id = String(connectionId || '').trim() || registry.primary
|
||||
const source = registry.connections.find(c => c.id === id)
|
||||
@@ -11506,7 +11596,7 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela
|
||||
const primary = await reuseMatchingPrimarySshBackend({
|
||||
connectionId: id,
|
||||
effectiveFingerprint: resolveRegistryEffectiveFingerprint,
|
||||
ensurePrimary: () => ensureBackend(profile),
|
||||
ensurePrimary: () => ensureBackend(profile, { spawnPriority }),
|
||||
profile,
|
||||
registry,
|
||||
source
|
||||
@@ -11557,7 +11647,7 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela
|
||||
})
|
||||
|
||||
if (localRoute.delegate) {
|
||||
return ensureBackend(profile)
|
||||
return ensureBackend(profile, { spawnPriority })
|
||||
}
|
||||
|
||||
const stoppingLocal = poolStopper.inFlight(localRoute.poolKey)
|
||||
@@ -11571,6 +11661,10 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela
|
||||
if (existingLocal) {
|
||||
existingLocal.lastActiveAt = Date.now()
|
||||
|
||||
if (spawnPriority === 'foreground') {
|
||||
promotePoolEntry(existingLocal)
|
||||
}
|
||||
|
||||
return existingLocal.connectionPromise
|
||||
}
|
||||
|
||||
@@ -11585,7 +11679,8 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela
|
||||
remoteBaseUrl: null,
|
||||
releaseLocalBackendSlot: null,
|
||||
localBackendSlotKey: null,
|
||||
localBackendSpawnRequest: null
|
||||
localBackendSpawnRequest: null,
|
||||
spawnPriority
|
||||
}
|
||||
|
||||
localEntry.connectionPromise = spawnPoolBackend(profileKey, localEntry, {
|
||||
@@ -11594,9 +11689,7 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela
|
||||
}).catch(async error => {
|
||||
// Same trace rule as the v1 pool path: a forced-local child whose spawn
|
||||
// rejects before the child exists must still land in desktop.log.
|
||||
rememberLog(
|
||||
`Hermes backend for profile "${profileKey}" (forced-local) failed to start: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
logPoolSpawnFailure(`"${profileKey}" (forced-local)`, error)
|
||||
|
||||
await teardownFailedLocalBackend(localRoute.poolKey, localEntry)
|
||||
throw error
|
||||
@@ -12434,11 +12527,23 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po
|
||||
// pool-idle window (10 min) would hold the pool key hostage and every
|
||||
// later click on the profile would join that stale wait. Failing here
|
||||
// surfaces the "all N slots busy" reason instead of a generic boot timeout.
|
||||
const spawnRequest = localBackendSpawnCoordinator.request(poolKey, { timeoutMs: POOL_SLOT_WAIT_MS })
|
||||
// The caller stamped entry.spawnPriority from its own request; a foreground
|
||||
// dial that joined the claim before this entry existed left a mark instead.
|
||||
if (takeForegroundSpawn(poolKey, profile)) {
|
||||
entry.spawnPriority = 'foreground'
|
||||
}
|
||||
|
||||
const spawnPriority: LocalBackendSpawnPriority = spawnPriorityFrom(entry.spawnPriority)
|
||||
|
||||
const spawnRequest = localBackendSpawnCoordinator.request(poolKey, {
|
||||
timeoutMs: POOL_SLOT_WAIT_MS,
|
||||
priority: spawnPriority
|
||||
})
|
||||
|
||||
entry.localBackendSlotKey = poolKey
|
||||
entry.localBackendSpawnRequest = spawnRequest
|
||||
|
||||
if (localBackendSpawnCoordinator.activeCount >= poolMaxBackends()) {
|
||||
if (spawnRequest.queued) {
|
||||
rememberLog(
|
||||
`Profile backend "${profile}" waiting for a free local slot (${localBackendSpawnCoordinator.activeCount}/${poolMaxBackends()} busy, ${localBackendSpawnCoordinator.queuedCount} queued)`
|
||||
)
|
||||
@@ -14698,13 +14803,27 @@ function createWindow() {
|
||||
})
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:connection', async (_event, profile) => {
|
||||
ipcMain.handle('hermes:connection', async (_event, profile, extra) => {
|
||||
// Coalesce concurrent renderer dials for one profile scope (#90812): the
|
||||
// renderer-side reconnect lock is per-window, so two windows waking at once
|
||||
// both land here. The claim key mirrors ensureBackend()'s own profile
|
||||
// normalization so every spelling of the primary coalesces onto one dial.
|
||||
const profileKey = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey()
|
||||
const connection = await backendDialClaims.run(backendScopeKey(null, profileKey), () => ensureBackend(profile))
|
||||
// A user click may join an in-flight hydration claim; the foreground intent
|
||||
// is applied to that claim so its slot wait can take the reserved slot.
|
||||
const spawnPriority = spawnPriorityFrom(extra?.priority)
|
||||
|
||||
const scopeKey = backendScopeKey(null, profileKey)
|
||||
const clearSpawnPriority = applySpawnPriority(scopeKey, spawnPriority)
|
||||
|
||||
let connection
|
||||
|
||||
try {
|
||||
connection = await backendDialClaims.run(scopeKey, () => ensureBackend(profile, { spawnPriority }))
|
||||
} finally {
|
||||
clearSpawnPriority()
|
||||
}
|
||||
|
||||
const connectionId = resolvedConnectionId(readDesktopConnectionsRegistry(), connection)
|
||||
|
||||
return connectionId ? { ...connection, connectionId } : connection
|
||||
@@ -14715,13 +14834,24 @@ ipcMain.handle('hermes:connection', async (_event, profile) => {
|
||||
// forces a genuinely-local child when the v1 global mode is remote (the
|
||||
// registry 'local' entry always means this machine).
|
||||
ipcMain.handle('hermes:connection:for', async (_event, payload) => {
|
||||
const { connectionId, profile } = payload && typeof payload === 'object' ? (payload as any) : ({} as any)
|
||||
const { connectionId, profile, priority } = payload && typeof payload === 'object' ? (payload as any) : ({} as any)
|
||||
const registry = readDesktopConnectionsRegistry()
|
||||
const id = String(connectionId || '').trim() || registry.primary
|
||||
const spawnPriority = spawnPriorityFrom(priority)
|
||||
|
||||
// Same single-owner claim as 'hermes:connection', keyed by the composite
|
||||
// (connectionId, profile) scope (#90812): concurrent registry dials for one
|
||||
// scope share the first spawn instead of bootstrapping duplicate remotes.
|
||||
const connection = await backendDialClaims.run(backendScopeKey(id, profile), () => ensureRegistryBackend(id, profile))
|
||||
const scopeKey = backendScopeKey(id, profile)
|
||||
const clearSpawnPriority = applySpawnPriority(scopeKey, spawnPriority)
|
||||
|
||||
let connection
|
||||
|
||||
try {
|
||||
connection = await backendDialClaims.run(scopeKey, () => ensureRegistryBackend(id, profile, '', { spawnPriority }))
|
||||
} finally {
|
||||
clearSpawnPriority()
|
||||
}
|
||||
|
||||
return { ...connection, connectionId: id, registryScoped: true }
|
||||
})
|
||||
|
||||
@@ -6,7 +6,11 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { LocalBackendSpawnCoordinator, releaseLocalBackendSlotAfterExit } from './pool-spawn-coordinator'
|
||||
import {
|
||||
LocalBackendSlotWaitTimeoutError,
|
||||
LocalBackendSpawnCoordinator,
|
||||
releaseLocalBackendSlotAfterExit
|
||||
} from './pool-spawn-coordinator'
|
||||
|
||||
const deferred = () => {
|
||||
let resolve!: () => void
|
||||
@@ -306,6 +310,197 @@ test('setLimit rejects a non-positive or fractional cap', () => {
|
||||
assert.equal(coordinator.limit, 2)
|
||||
})
|
||||
|
||||
test('cap 3: two background leases leave a reserved slot for foreground', async () => {
|
||||
const coordinator = new LocalBackendSpawnCoordinator(3)
|
||||
const bg1 = await coordinator.request('bg-1', { priority: 'background' }).acquired
|
||||
const bg2 = await coordinator.request('bg-2', { priority: 'background' }).acquired
|
||||
assert.equal(coordinator.activeCount, 2)
|
||||
assert.equal(coordinator.queuedCount, 0)
|
||||
|
||||
let fgGranted = false
|
||||
|
||||
const fgPromise = coordinator.request('fg', { priority: 'foreground' }).acquired.then(release => {
|
||||
fgGranted = true
|
||||
|
||||
return release
|
||||
})
|
||||
|
||||
await flush()
|
||||
assert.equal(fgGranted, true)
|
||||
assert.equal(coordinator.activeCount, 3)
|
||||
|
||||
const releaseFg = await fgPromise
|
||||
bg1()
|
||||
bg2()
|
||||
releaseFg()
|
||||
assert.equal(coordinator.activeCount, 0)
|
||||
})
|
||||
|
||||
test('untagged acquire still fills the cap (foreground default)', async () => {
|
||||
const coordinator = new LocalBackendSpawnCoordinator(3)
|
||||
const releases = await Promise.all(['a', 'b', 'c'].map(key => coordinator.acquire(key)))
|
||||
assert.equal(coordinator.activeCount, 3)
|
||||
assert.equal(coordinator.queuedCount, 0)
|
||||
|
||||
for (const release of releases) {
|
||||
release()
|
||||
}
|
||||
|
||||
assert.equal(coordinator.activeCount, 0)
|
||||
})
|
||||
|
||||
test('foreground is granted the reserved slot ahead of a background hydration queue', async () => {
|
||||
const coordinator = new LocalBackendSpawnCoordinator(3)
|
||||
|
||||
const bgRunning = await Promise.all(
|
||||
['bg-run-1', 'bg-run-2'].map(key => coordinator.request(key, { priority: 'background' }).acquired)
|
||||
)
|
||||
|
||||
const queued = Array.from({ length: 20 }, (_, index) =>
|
||||
coordinator.request(`bg-wait-${index}`, { priority: 'background', timeoutMs: 5_000 })
|
||||
)
|
||||
|
||||
await flush()
|
||||
assert.equal(coordinator.activeCount, 2)
|
||||
assert.equal(coordinator.queuedCount, 20)
|
||||
|
||||
const started = Date.now()
|
||||
const releaseFg = await coordinator.request('user-click', { priority: 'foreground', timeoutMs: 100 }).acquired
|
||||
assert.ok(Date.now() - started < 80, 'foreground must not wait behind the background queue')
|
||||
assert.equal(coordinator.activeCount, 3)
|
||||
|
||||
for (const request of queued) {
|
||||
request.cancel()
|
||||
}
|
||||
|
||||
releaseFg()
|
||||
|
||||
for (const release of bgRunning) {
|
||||
release()
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
queued.map(request =>
|
||||
request.acquired.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
)
|
||||
)
|
||||
assert.equal(coordinator.activeCount, 0)
|
||||
assert.equal(coordinator.queuedCount, 0)
|
||||
})
|
||||
|
||||
test('drain prefers a foreground waiter over an earlier background waiter', async () => {
|
||||
const coordinator = new LocalBackendSpawnCoordinator(1)
|
||||
const releaseHolder = await coordinator.acquire('holder')
|
||||
const background = coordinator.request('background', { priority: 'background' })
|
||||
const foreground = coordinator.request('foreground', { priority: 'foreground' })
|
||||
await flush()
|
||||
assert.equal(coordinator.queuedCount, 2)
|
||||
|
||||
let backgroundEntered = false
|
||||
let foregroundEntered = false
|
||||
|
||||
const backgroundGrant = background.acquired.then(release => {
|
||||
backgroundEntered = true
|
||||
|
||||
return release
|
||||
})
|
||||
|
||||
const foregroundGrant = foreground.acquired.then(release => {
|
||||
foregroundEntered = true
|
||||
|
||||
return release
|
||||
})
|
||||
|
||||
releaseHolder()
|
||||
await flush()
|
||||
assert.equal(foregroundEntered, true)
|
||||
assert.equal(backgroundEntered, false)
|
||||
assert.equal(coordinator.activeCount, 1)
|
||||
|
||||
const releaseForeground = await foregroundGrant
|
||||
releaseForeground()
|
||||
const releaseBackground = await backgroundGrant
|
||||
assert.equal(backgroundEntered, true)
|
||||
releaseBackground()
|
||||
assert.equal(coordinator.activeCount, 0)
|
||||
})
|
||||
|
||||
test('background slot-wait timeout is distinguishable; foreground keeps a user-facing message', async () => {
|
||||
const coordinator = new LocalBackendSpawnCoordinator(1)
|
||||
const releaseFirst = await coordinator.acquire('first')
|
||||
|
||||
const background = coordinator.request('bg', { priority: 'background', timeoutMs: 10 })
|
||||
await assert.rejects(background.acquired, error => {
|
||||
assert.ok(error instanceof LocalBackendSlotWaitTimeoutError)
|
||||
assert.equal(error.name, 'LocalBackendSlotWaitTimeoutError')
|
||||
assert.equal(error.priority, 'background')
|
||||
assert.equal(error.silent, true)
|
||||
assert.match(error.message, /timed out while waiting for a free slot/)
|
||||
assert.match(error.message, /\(background\)/)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const foreground = coordinator.request('fg', { priority: 'foreground', timeoutMs: 10 })
|
||||
await assert.rejects(foreground.acquired, error => {
|
||||
assert.ok(error instanceof Error)
|
||||
assert.match(error.message, /timed out while waiting for a free slot/)
|
||||
assert.doesNotMatch(error.message, /\(background\)/)
|
||||
assert.notEqual(error.name, 'LocalBackendSlotWaitTimeoutError')
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
releaseFirst()
|
||||
assert.equal(coordinator.activeCount, 0)
|
||||
})
|
||||
|
||||
test('request() reports whether the caller actually waited behind the queue', async () => {
|
||||
const coordinator = new LocalBackendSpawnCoordinator(3)
|
||||
const bg1 = coordinator.request('bg-1', { priority: 'background' })
|
||||
const bg2 = coordinator.request('bg-2', { priority: 'background' })
|
||||
const bgWait = coordinator.request('bg-3', { priority: 'background' })
|
||||
assert.equal(bg1.queued, false)
|
||||
assert.equal(bg2.queued, false)
|
||||
assert.equal(bgWait.queued, true)
|
||||
|
||||
// The reserved slot is free: a foreground request is granted immediately
|
||||
// even though a background waiter is queued.
|
||||
const fg = coordinator.request('fg', { priority: 'foreground' })
|
||||
assert.equal(fg.queued, false)
|
||||
assert.equal(coordinator.activeCount, 3)
|
||||
|
||||
bgWait.cancel()
|
||||
await bgWait.acquired.catch(() => undefined)
|
||||
;(await fg.acquired)()
|
||||
;(await bg1.acquired)()
|
||||
;(await bg2.acquired)()
|
||||
assert.equal(coordinator.activeCount, 0)
|
||||
})
|
||||
|
||||
test('promoting a queued background waiter lets it take the reserved foreground slot', async () => {
|
||||
const coordinator = new LocalBackendSpawnCoordinator(3)
|
||||
const bg1 = await coordinator.request('bg-1', { priority: 'background' }).acquired
|
||||
const bg2 = await coordinator.request('bg-2', { priority: 'background' }).acquired
|
||||
const queued = coordinator.request('same-bot', { priority: 'background' })
|
||||
await flush()
|
||||
assert.equal(coordinator.activeCount, 2)
|
||||
assert.equal(coordinator.queuedCount, 1)
|
||||
|
||||
assert.equal(queued.promote('foreground'), true)
|
||||
const releasePromoted = await queued.acquired
|
||||
assert.equal(coordinator.activeCount, 3)
|
||||
assert.equal(coordinator.queuedCount, 0)
|
||||
|
||||
releasePromoted()
|
||||
bg1()
|
||||
bg2()
|
||||
assert.equal(coordinator.activeCount, 0)
|
||||
})
|
||||
|
||||
// ── main.ts wiring ──────────────────────────────────────────────────────────
|
||||
// The coordinator is only as good as the timeout main.ts hands it. A queued
|
||||
// ticket that outlives the renderer's backend-boot budget holds the pool key
|
||||
@@ -330,7 +525,10 @@ test('setLimit rejects a non-positive or fractional cap', () => {
|
||||
assert.ok(Number.isFinite(slotWait) && slotWait > 0, 'POOL_SLOT_WAIT_MS must be a literal in main.ts')
|
||||
assert.ok(Number.isFinite(bootBudget), 'BACKEND_BOOT_WAIT_TIMEOUT_MS must be a literal')
|
||||
assert.ok(slotWait < bootBudget, `slot wait ${slotWait}ms must be below the boot budget ${bootBudget}ms`)
|
||||
assert.match(mainSource, /localBackendSpawnCoordinator\.request\(poolKey, \{ timeoutMs: POOL_SLOT_WAIT_MS \}\)/)
|
||||
assert.match(
|
||||
mainSource,
|
||||
/localBackendSpawnCoordinator\.request\(poolKey, \{\s*timeoutMs: POOL_SLOT_WAIT_MS,\s*priority: spawnPriority\s*\}\)/
|
||||
)
|
||||
assert.doesNotMatch(mainSource, /request\(poolKey, \{ timeoutMs: POOL_IDLE_MS \}\)/)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,17 +1,47 @@
|
||||
export type ReleaseLocalBackendSlot = () => void
|
||||
|
||||
export type LocalBackendSpawnPriority = 'foreground' | 'background'
|
||||
|
||||
export type LocalBackendSpawnRequest = {
|
||||
acquired: Promise<ReleaseLocalBackendSlot>
|
||||
cancel: () => boolean
|
||||
promote: (priority: LocalBackendSpawnPriority) => boolean
|
||||
/** False when the slot was granted without waiting behind the queue. */
|
||||
queued: boolean
|
||||
}
|
||||
|
||||
type Waiter = {
|
||||
key: string
|
||||
priority: LocalBackendSpawnPriority
|
||||
resolve: (release: ReleaseLocalBackendSlot) => void
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
const SLOT_WAIT_TIMEOUT_MESSAGE = (key: string) =>
|
||||
`Local backend start for "${key}" timed out while waiting for a free slot.`
|
||||
|
||||
/**
|
||||
* Slot-wait timeout. Background hydrations set `silent` so call sites can fail
|
||||
* quiet instead of toasting a user-visible backend-start failure.
|
||||
*/
|
||||
export class LocalBackendSlotWaitTimeoutError extends Error {
|
||||
readonly priority: LocalBackendSpawnPriority
|
||||
readonly silent: boolean
|
||||
|
||||
constructor(key: string, priority: LocalBackendSpawnPriority) {
|
||||
const suffix = priority === 'background' ? ' (background)' : ''
|
||||
super(`${SLOT_WAIT_TIMEOUT_MESSAGE(key)}${suffix}`)
|
||||
this.name = 'LocalBackendSlotWaitTimeoutError'
|
||||
this.priority = priority
|
||||
this.silent = priority === 'background'
|
||||
}
|
||||
}
|
||||
|
||||
export function isBackgroundSlotWaitTimeout(error: unknown): boolean {
|
||||
return error instanceof LocalBackendSlotWaitTimeoutError && error.silent
|
||||
}
|
||||
|
||||
export async function releaseLocalBackendSlotAfterExit(
|
||||
release: ReleaseLocalBackendSlot,
|
||||
waitForExit: () => Promise<void>
|
||||
@@ -25,10 +55,15 @@ export async function releaseLocalBackendSlotAfterExit(
|
||||
*
|
||||
* A lease is acquired immediately before local start work and is held until
|
||||
* the child exits or the start fails. Remote descriptors never call request().
|
||||
*
|
||||
* When the cap is at least 2, one slot is reserved for foreground (user-open)
|
||||
* requests so background roster hydration cannot occupy the whole pool.
|
||||
* Untagged acquire() is foreground, so existing cap tests still fill `limit`.
|
||||
*/
|
||||
export class LocalBackendSpawnCoordinator {
|
||||
#limit: number
|
||||
#active = 0
|
||||
#activeForeground = 0
|
||||
#activeBackground = 0
|
||||
#queue: Waiter[] = []
|
||||
|
||||
constructor(limit: number) {
|
||||
@@ -40,7 +75,7 @@ export class LocalBackendSpawnCoordinator {
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.#active
|
||||
return this.#activeForeground + this.#activeBackground
|
||||
}
|
||||
|
||||
get limit(): number {
|
||||
@@ -66,39 +101,47 @@ export class LocalBackendSpawnCoordinator {
|
||||
return this.#queue.length
|
||||
}
|
||||
|
||||
request(key: string, options: { timeoutMs?: number } = {}): LocalBackendSpawnRequest {
|
||||
request(
|
||||
key: string,
|
||||
options: { timeoutMs?: number; priority?: LocalBackendSpawnPriority } = {}
|
||||
): LocalBackendSpawnRequest {
|
||||
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1)) {
|
||||
throw new RangeError('Local backend spawn timeout must be a positive number.')
|
||||
}
|
||||
|
||||
if (this.#active < this.#limit) {
|
||||
const priority: LocalBackendSpawnPriority = options.priority === 'background' ? 'background' : 'foreground'
|
||||
|
||||
if (this.#queue.length === 0 && this.#canGrant(priority)) {
|
||||
return {
|
||||
acquired: Promise.resolve(this.#grant()),
|
||||
cancel: () => false
|
||||
acquired: Promise.resolve(this.#grant(priority)),
|
||||
cancel: () => false,
|
||||
promote: () => false,
|
||||
queued: false
|
||||
}
|
||||
}
|
||||
|
||||
let waiter!: Waiter
|
||||
|
||||
const acquired = new Promise<ReleaseLocalBackendSlot>((resolve, reject) => {
|
||||
waiter = { key, resolve, reject, timer: null }
|
||||
waiter = { key, priority, resolve, reject, timer: null }
|
||||
this.#queue.push(waiter)
|
||||
|
||||
if (options.timeoutMs !== undefined) {
|
||||
waiter.timer = setTimeout(() => {
|
||||
this.#rejectWaiter(
|
||||
waiter,
|
||||
new Error(`Local backend start for "${key}" timed out while waiting for a free slot.`)
|
||||
)
|
||||
this.#rejectWaiter(waiter, this.#timeoutError(waiter))
|
||||
}, options.timeoutMs)
|
||||
waiter.timer.unref?.()
|
||||
}
|
||||
})
|
||||
|
||||
this.#drain()
|
||||
|
||||
return {
|
||||
acquired,
|
||||
cancel: () =>
|
||||
this.#rejectWaiter(waiter, new Error(`Local backend start for "${key}" was cancelled while queued.`))
|
||||
this.#rejectWaiter(waiter, new Error(`Local backend start for "${key}" was cancelled while queued.`)),
|
||||
promote: (nextPriority: LocalBackendSpawnPriority) => this.#promoteWaiter(waiter, nextPriority),
|
||||
queued: this.#queue.includes(waiter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +149,45 @@ export class LocalBackendSpawnCoordinator {
|
||||
return this.request(key).acquired
|
||||
}
|
||||
|
||||
#timeoutError(waiter: Waiter): Error {
|
||||
if (waiter.priority === 'background') {
|
||||
return new LocalBackendSlotWaitTimeoutError(waiter.key, 'background')
|
||||
}
|
||||
|
||||
return new Error(SLOT_WAIT_TIMEOUT_MESSAGE(waiter.key))
|
||||
}
|
||||
|
||||
#backgroundLimit(): number {
|
||||
return this.#limit >= 2 ? this.#limit - 1 : this.#limit
|
||||
}
|
||||
|
||||
#canGrant(priority: LocalBackendSpawnPriority): boolean {
|
||||
if (this.activeCount >= this.#limit) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (priority === 'background' && this.#activeBackground >= this.#backgroundLimit()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
#promoteWaiter(waiter: Waiter, priority: LocalBackendSpawnPriority): boolean {
|
||||
if (!this.#queue.includes(waiter)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (waiter.priority === priority) {
|
||||
return false
|
||||
}
|
||||
|
||||
waiter.priority = priority
|
||||
this.#drain()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
#rejectWaiter(waiter: Waiter, error: Error): boolean {
|
||||
const index = this.#queue.indexOf(waiter)
|
||||
|
||||
@@ -127,8 +209,13 @@ export class LocalBackendSpawnCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
#grant(): ReleaseLocalBackendSlot {
|
||||
this.#active += 1
|
||||
#grant(priority: LocalBackendSpawnPriority): ReleaseLocalBackendSlot {
|
||||
if (priority === 'background') {
|
||||
this.#activeBackground += 1
|
||||
} else {
|
||||
this.#activeForeground += 1
|
||||
}
|
||||
|
||||
let released = false
|
||||
|
||||
return () => {
|
||||
@@ -137,17 +224,49 @@ export class LocalBackendSpawnCoordinator {
|
||||
}
|
||||
|
||||
released = true
|
||||
this.#active -= 1
|
||||
|
||||
if (priority === 'background') {
|
||||
this.#activeBackground -= 1
|
||||
} else {
|
||||
this.#activeForeground -= 1
|
||||
}
|
||||
|
||||
this.#drain()
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand free slots to queued waiters while under the (possibly lowered) cap. */
|
||||
#takeWaiter(priority: LocalBackendSpawnPriority): Waiter | undefined {
|
||||
const index = this.#queue.findIndex(waiter => waiter.priority === priority)
|
||||
|
||||
if (index === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return this.#queue.splice(index, 1)[0]
|
||||
}
|
||||
|
||||
/** Hand free slots to queued waiters. Foreground waiters always go first. */
|
||||
#drain(): void {
|
||||
while (this.#active < this.#limit && this.#queue.length > 0) {
|
||||
const next = this.#queue.shift()!
|
||||
while (this.#canGrant('foreground')) {
|
||||
const next = this.#takeWaiter('foreground')
|
||||
|
||||
if (!next) {
|
||||
break
|
||||
}
|
||||
|
||||
this.#clearTimer(next)
|
||||
next.resolve(this.#grant())
|
||||
next.resolve(this.#grant('foreground'))
|
||||
}
|
||||
|
||||
while (this.#canGrant('background')) {
|
||||
const next = this.#takeWaiter('background')
|
||||
|
||||
if (!next) {
|
||||
break
|
||||
}
|
||||
|
||||
this.#clearTimer(next)
|
||||
next.resolve(this.#grant('background'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
// Launch-flag fact: the app was started with --local, so the renderer may
|
||||
// show the local-models surfaces. Static for the window's lifetime.
|
||||
localModelsEnabled: launchFlags?.localModels === true,
|
||||
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
|
||||
getConnection: (profile, opts) => ipcRenderer.invoke('hermes:connection', profile, opts),
|
||||
// Registry-scoped backend resolution: { connectionId, profile } → descriptor.
|
||||
getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload),
|
||||
getProfileRoutes: profiles => ipcRenderer.invoke('hermes:plugin-profile-routes', profiles),
|
||||
|
||||
@@ -1849,3 +1849,66 @@ test('cleanupStale keeps the lockfile when even SIGKILL cannot confirm the pid d
|
||||
// The record must survive so the next connect's reap pass retries.
|
||||
assert.ok(!ssh.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)))
|
||||
})
|
||||
test.skipIf(process.platform === 'win32')(
|
||||
'buildSpawnCommand quotes expandRemotePath fragments exactly once (real sh parse)',
|
||||
async () => {
|
||||
// expandRemotePath() output is pre-quoted; a second shq() ships literal quote
|
||||
// characters to the remote python. Parse the composed command with a real sh,
|
||||
// as the remote login shell does, and require every path to come out clean.
|
||||
const cmd = buildSpawnCommand('/x/hermes', 'work', {
|
||||
hermesHome: '~/.hermes',
|
||||
logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE),
|
||||
ownershipId: OWNERSHIP_ID,
|
||||
reservationNonce: SPAWN_NONCE,
|
||||
spawnNonce: SPAWN_NONCE,
|
||||
tokenFilePath: spawnTokenPath(OWNERSHIP_ID, SPAWN_NONCE),
|
||||
lockMetadata: { ownershipId: OWNERSHIP_ID, spawnNonce: SPAWN_NONCE }
|
||||
})
|
||||
|
||||
// Capture the argv a remote shell would hand to python3, via a shim on PATH.
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'hermes-argv-shim-'))
|
||||
|
||||
try {
|
||||
const shimDir = path.join(root, 'shim')
|
||||
const fakeHome = path.join(root, 'home')
|
||||
await mkdir(shimDir)
|
||||
await mkdir(fakeHome)
|
||||
const argvFile = path.join(shimDir, 'argv')
|
||||
await writeFile(path.join(shimDir, 'python3'), `#!/bin/sh\nprintf '%s\\0' "$@" > '${argvFile}'\n`, {
|
||||
mode: 0o755
|
||||
})
|
||||
await exec(cmd, {
|
||||
env: { ...process.env, PATH: `${shimDir}:${process.env.PATH}`, HOME: fakeHome }
|
||||
})
|
||||
const argv = (await readFile(argvFile, 'utf8')).split('\0')
|
||||
|
||||
// argv: ['-c', <mutex script>, <mutex path>, <payload>]
|
||||
assert.equal(
|
||||
argv[2],
|
||||
`${fakeHome}/.hermes/.hermes-update-in-progress.mutex`,
|
||||
'mutex path must reach python fully expanded, with no quote characters'
|
||||
)
|
||||
|
||||
// The payload assigns reservation/lock/owner_file before its mkdir loop.
|
||||
// Evaluate only that prefix the way the remote sh does; never the loop itself.
|
||||
const payload = argv[3]
|
||||
const loopStart = payload.indexOf('i=0;')
|
||||
assert.ok(loopStart > 0, 'payload prefix sentinel missing')
|
||||
|
||||
const { stdout } = await exec(
|
||||
`${payload.slice(0, loopStart)} printf '%s\\n' "$reservation" "$lock" "$owner_file"`,
|
||||
{
|
||||
env: { ...process.env, HOME: fakeHome }
|
||||
}
|
||||
)
|
||||
|
||||
const [reservation, lock, ownerFile] = stdout.split('\n')
|
||||
const base = `${fakeHome}/.hermes/desktop-ssh/${OWNERSHIP_ID}`
|
||||
assert.equal(reservation, `${base}/.connect.lock`)
|
||||
assert.equal(lock, `${base}/backend.lock.json`)
|
||||
assert.equal(ownerFile, `${base}/.connect.lock/owner`)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -895,7 +895,9 @@ finally:
|
||||
// the marker check, spawns the backend, and publishes its initial lockfile.
|
||||
// Python keeps the descriptor close-on-exec by default and passes it explicitly
|
||||
// only to the intended outer shell; each detached child closes it before
|
||||
// execing Hermes.
|
||||
// execing Hermes. mutexPath is expandRemotePath() output — a complete shell
|
||||
// word ("$HOME"'/…' or '/abs/…') embedded raw so $HOME expands remotely; a
|
||||
// second shq() would hand python the quote characters as part of the path.
|
||||
function withRemoteUpdateMutex(command, mutexPath) {
|
||||
const script = `
|
||||
import fcntl,os,subprocess,sys
|
||||
@@ -913,7 +915,7 @@ finally:
|
||||
sys.exit(result.returncode if result is not None else 1)
|
||||
`.trim()
|
||||
|
||||
return `python3 -c ${shq(script)} ${shq(mutexPath)} ${shq(command)}`
|
||||
return `python3 -c ${shq(script)} ${mutexPath} ${shq(command)}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizeWindowConnectionRoute,
|
||||
registrySshPoolScopeByConnectionId,
|
||||
registrySshScopeForWindowRoute,
|
||||
WindowConnectionRouteRegistry
|
||||
} from './window-connection-route'
|
||||
@@ -120,3 +121,20 @@ test('uses the canonical default profile scope when a registry SSH route has no
|
||||
'conn:source-b::default'
|
||||
)
|
||||
})
|
||||
|
||||
test('recovers the bootstrap pool key a registry SSH tunnel was published under', () => {
|
||||
const pool = new Map<string, any>([['', { registryConnectionId: 'source-b', ssh: { alive: true } }]])
|
||||
|
||||
assert.equal(registrySshPoolScopeByConnectionId(pool, 'source-b'), '')
|
||||
})
|
||||
|
||||
test('does not match another connection, an unlabelled entry, or a torn-down tunnel', () => {
|
||||
const pool = new Map<string, any>([
|
||||
['research', { registryConnectionId: 'source-a', ssh: { alive: true } }],
|
||||
['', { registryConnectionId: '', ssh: { alive: true } }],
|
||||
['worker', { registryConnectionId: 'source-b' }]
|
||||
])
|
||||
|
||||
assert.equal(registrySshPoolScopeByConnectionId(pool, 'source-b'), null)
|
||||
assert.equal(registrySshPoolScopeByConnectionId(pool, 'source-c'), null)
|
||||
})
|
||||
|
||||
@@ -40,6 +40,30 @@ export function registrySshScopeForWindowRoute(
|
||||
return backendScopeKey(route.connectionId, route.profile)
|
||||
}
|
||||
|
||||
export interface RegistrySshPoolEntry {
|
||||
registryConnectionId?: null | string
|
||||
ssh?: unknown
|
||||
}
|
||||
|
||||
// The sshConnections pool has a single writer that publishes every tunnel under
|
||||
// its per-profile bootstrap key while stamping the entry with the registry
|
||||
// connection that owns it. A registry-scoped lookup under the composite
|
||||
// backendScopeKey can therefore miss a live tunnel; this recovers the writer's
|
||||
// actual key from the stamped identity, the same match managedSshScopeRole
|
||||
// applies to pool entries.
|
||||
export function registrySshPoolScopeByConnectionId(
|
||||
entries: Iterable<readonly [string, RegistrySshPoolEntry | undefined]>,
|
||||
connectionId: string
|
||||
): null | string {
|
||||
for (const [scope, entry] of entries) {
|
||||
if (entry?.ssh && entry.registryConnectionId === connectionId) {
|
||||
return scope
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export class WindowConnectionRouteRegistry {
|
||||
private readonly routes = new Map<number, WindowConnectionRoute>()
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import { afterEach, test, vi } from 'vitest'
|
||||
|
||||
// Execute the shipped page, including its inline script, rather than matching
|
||||
// source strings or testing a second implementation of the progress client.
|
||||
const html = fs.readFileSync(new URL('../../../scripts/desktop-update/ui.html', import.meta.url), 'utf8')
|
||||
const windows = []
|
||||
|
||||
function openPage(fetch) {
|
||||
vi.useFakeTimers()
|
||||
const dom = new JSDOM(html, {
|
||||
url: 'http://127.0.0.1:12345/',
|
||||
runScripts: 'dangerously',
|
||||
beforeParse(window) {
|
||||
window.fetch = fetch
|
||||
window.AbortController = AbortController
|
||||
window.setTimeout = setTimeout
|
||||
window.clearTimeout = clearTimeout
|
||||
window.requestAnimationFrame = () => 1
|
||||
window.cancelAnimationFrame = () => {}
|
||||
}
|
||||
})
|
||||
windows.push(dom.window)
|
||||
return dom.window.document
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
windows.splice(0).forEach(window => window.close())
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test.each(['done', 'manual', 'error'])('renders %s before acknowledging terminal delivery', async status => {
|
||||
let document
|
||||
const requests = []
|
||||
const receipt = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const fetch = vi.fn(async (url, options) => {
|
||||
requests.push(url)
|
||||
if (url.startsWith('/ack/')) {
|
||||
assert.equal(options.method, 'POST')
|
||||
assert.equal(document.body.className, status === 'error' ? 'error' : 'done')
|
||||
assert.notEqual(document.getElementById('title').textContent, 'Updating Hermes')
|
||||
return { ok: true }
|
||||
}
|
||||
return { ok: true, json: async () => ({ status, receipt, message: 'The updater result' }) }
|
||||
})
|
||||
document = openPage(fetch)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
assert.equal(document.body.className, status === 'error' ? 'error' : 'done')
|
||||
assert.notEqual(document.getElementById('title').textContent, 'Updating Hermes')
|
||||
assert.deepEqual(requests, ['/progress', `/ack/${receipt}`])
|
||||
})
|
||||
|
||||
test.each(['disconnect', 'hung', 'hung-body', 'http', 'invalid'])('bounds %s progress failures without inventing an update outcome', async failure => {
|
||||
let attempts = 0
|
||||
const fetch = vi.fn((_url, options) => {
|
||||
attempts++
|
||||
if (attempts === 1) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ status: 'running', message: 'Installing dependencies' }) })
|
||||
}
|
||||
if (failure === 'hung' || failure === 'hung-body') {
|
||||
const pending = () => new Promise((_resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => reject(new Error('timeout')), { once: true })
|
||||
})
|
||||
return failure === 'hung' ? pending() : Promise.resolve({ ok: true, json: pending })
|
||||
}
|
||||
if (failure === 'http') return Promise.resolve({ ok: false })
|
||||
if (failure === 'invalid') return Promise.resolve({ ok: true, json: async () => ({}) })
|
||||
return Promise.reject(new Error('connection refused'))
|
||||
})
|
||||
const document = openPage(fetch)
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
assert.equal(document.body.className, 'disconnected')
|
||||
assert.equal(document.getElementById('title').textContent, 'Update status unavailable')
|
||||
assert.match(document.getElementById('line').textContent, /Check Hermes/)
|
||||
assert.ok(attempts <= 4, `unbounded retry loop: ${attempts}`)
|
||||
})
|
||||
|
||||
test.each(['legacy', 'transient', 'ack-failure'])('preserves terminal truth with %s servers', async mode => {
|
||||
let attempts = 0
|
||||
const fetch = vi.fn(async url => {
|
||||
if (url.startsWith('/ack/')) throw new Error('server already stopped')
|
||||
if (++attempts === 1 && mode === 'transient') throw new Error('temporary disconnect')
|
||||
return { ok: true, json: async () => ({ status: 'done', ...(mode === 'ack-failure' ? { receipt: 'test-receipt' } : {}) }) }
|
||||
})
|
||||
const document = openPage(fetch)
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
assert.equal(document.body.className, 'done')
|
||||
assert.equal(document.getElementById('title').textContent, 'Update complete')
|
||||
})
|
||||
|
||||
test('continues displaying a healthy long update while progress remains reachable', async () => {
|
||||
const fetch = vi.fn(async () => ({ ok: true, json: async () => ({ status: 'running', message: 'Building Desktop' }) }))
|
||||
const document = openPage(fetch)
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
assert.equal(document.body.className, '')
|
||||
assert.equal(document.getElementById('title').textContent, 'Updating Hermes')
|
||||
assert.equal(document.getElementById('line').textContent, 'Building Desktop')
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
|
||||
import { adoptSpokenReplySession, markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
|
||||
import { CONVERSATION_LEASE, READ_ALOUD_LEASE, syncTtsLease } from '@/lib/tts-lease'
|
||||
import { clearWakeIndicator, syncWakeIndicatorWithVoice } from '@/lib/wake-indicator'
|
||||
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
|
||||
@@ -65,8 +65,15 @@ export function useComposerVoice({
|
||||
const { $messages } = useComposerScope()
|
||||
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
|
||||
const ownsWakeIndicatorRef = useRef(false)
|
||||
const previousSessionIdRef = useRef(sessionId)
|
||||
const voiceStartRequest = useStore($voiceConversationStartRequest)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- session-id adopt token, not an atom mirror
|
||||
useEffect(() => {
|
||||
adoptSpokenReplySession(previousSessionIdRef.current, sessionId)
|
||||
previousSessionIdRef.current = sessionId
|
||||
}, [sessionId])
|
||||
|
||||
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
|
||||
focusInput,
|
||||
maxRecordingSeconds,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $composerActionsBySession } from '@/store/composer-actions'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { $sessionControlBySession, type SessionControlEntry } from '@/store/session-control'
|
||||
import { $todosBySession } from '@/store/todos'
|
||||
|
||||
import { useSessionStatusPresence } from './use-status-presence'
|
||||
|
||||
const SID = 'presence-session-1'
|
||||
|
||||
const mockEntry = (overrides?: Partial<SessionControlEntry>): SessionControlEntry => ({
|
||||
capability: 'supported',
|
||||
error: null,
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: {
|
||||
goal: null,
|
||||
heartbeat: null,
|
||||
loop: null,
|
||||
revision: 'rev-1',
|
||||
updated_at: 1000
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('useSessionStatusPresence', () => {
|
||||
beforeEach(() => {
|
||||
$todosBySession.set({})
|
||||
$composerActionsBySession.set({})
|
||||
$previewStatusBySession.set({})
|
||||
$sessionControlBySession.set({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$todosBySession.set({})
|
||||
$composerActionsBySession.set({})
|
||||
$previewStatusBySession.set({})
|
||||
$sessionControlBySession.set({})
|
||||
})
|
||||
|
||||
it('returns false when session is null or empty', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(null))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
const { result: emptyResult } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(emptyResult.current).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when legacy status items exist', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
$todosBySession.set({
|
||||
[SID]: [{ content: 'task 1', id: '1', status: 'in_progress' }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when structured goal exists in session control', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: {
|
||||
goal: {
|
||||
contract: {
|
||||
boundaries: '',
|
||||
constraints: '',
|
||||
outcome: 'test outcome',
|
||||
stop_when: '',
|
||||
verification: ''
|
||||
},
|
||||
gates: [],
|
||||
max_turns: 10,
|
||||
status: 'active',
|
||||
subgoals: [],
|
||||
title: 'Structured Goal',
|
||||
turns_used: 1
|
||||
},
|
||||
heartbeat: null,
|
||||
loop: null,
|
||||
revision: 'rev-2',
|
||||
updated_at: 2000
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when structured loop exists in session control', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: {
|
||||
goal: null,
|
||||
heartbeat: null,
|
||||
loop: {
|
||||
awaiting_response: false,
|
||||
created_at: 1000,
|
||||
current_delay: 60,
|
||||
deferred_by_goal: false,
|
||||
interval_seconds: 60,
|
||||
last_fired_at: 1000,
|
||||
max_ticks: 10,
|
||||
mode: 'interval',
|
||||
next_due_at: 2000,
|
||||
prompt: 'Run loop',
|
||||
status: 'active',
|
||||
ticks_fired: 0,
|
||||
times: 5,
|
||||
until: ''
|
||||
},
|
||||
revision: 'rev-3',
|
||||
updated_at: 2000
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when structured heartbeat exists in session control', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: {
|
||||
goal: null,
|
||||
heartbeat: {
|
||||
created_at: 1000,
|
||||
fire_count: 1,
|
||||
interval_seconds: 300,
|
||||
last_fired_at: 1000,
|
||||
prompt: 'Heartbeat check',
|
||||
status: 'active'
|
||||
},
|
||||
loop: null,
|
||||
revision: 'rev-4',
|
||||
updated_at: 2000
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when session control entry has empty snapshot (null goal, loop, heartbeat)', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: {
|
||||
goal: null,
|
||||
heartbeat: null,
|
||||
loop: null,
|
||||
revision: 'rev-empty',
|
||||
updated_at: 2000
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when session control entry has only an error', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
error: 'Gateway connection failed',
|
||||
snapshot: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,9 @@ import { useSyncExternalStore } from 'react'
|
||||
import { $composerActionsBySession } from '@/store/composer-actions'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { $sessionControlBySession } from '@/store/session-control'
|
||||
|
||||
/** Structural view of the three per-session feeds — they hold different item
|
||||
/** Structural view of the per-session feeds — they hold different item
|
||||
* types, and all this hook needs from each is "does this key have rows". */
|
||||
interface PresenceFeed {
|
||||
get(): Record<string, undefined | unknown[]>
|
||||
@@ -14,7 +15,7 @@ interface PresenceFeed {
|
||||
const FEEDS: PresenceFeed[] = [$statusItemsBySession, $composerActionsBySession, $previewStatusBySession]
|
||||
|
||||
const subscribe = (onChange: () => void) => {
|
||||
const offs = FEEDS.map(feed => feed.listen(onChange))
|
||||
const offs = [...FEEDS.map(feed => feed.listen(onChange)), $sessionControlBySession.listen(onChange)]
|
||||
|
||||
return () => {
|
||||
for (const off of offs) {
|
||||
@@ -24,8 +25,9 @@ const subscribe = (onChange: () => void) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a session has any status items, micro actions, or previews, as a
|
||||
* coarse *edge*: the boolean only flips when the stack appears/disappears.
|
||||
* Whether a session has any status items, micro actions, previews, or
|
||||
* structured session controls (goal, loop, heartbeat), as a coarse *edge*:
|
||||
* the boolean only flips when the stack appears/disappears.
|
||||
* ChatBar uses it to toggle a styling data-attr — subscribing to the whole
|
||||
* `$statusItemsBySession` (a `computed` that rebuilds the entire map) /
|
||||
* `$previewStatusBySession` maps re-rendered the ~1.4k ChatBar on every
|
||||
@@ -39,6 +41,16 @@ export function useSessionStatusPresence(sessionId: string | null): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
return FEEDS.some(feed => (feed.get()[sessionId]?.length ?? 0) > 0)
|
||||
if (FEEDS.some(feed => (feed.get()[sessionId]?.length ?? 0) > 0)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const control = $sessionControlBySession.get()[sessionId]
|
||||
|
||||
return Boolean(
|
||||
control?.error ||
|
||||
(control?.snapshot &&
|
||||
(control.snapshot.goal !== null || control.snapshot.loop !== null || control.snapshot.heartbeat !== null))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1195,6 +1195,7 @@ export function ChatBar({
|
||||
grows upward over the thread and the dock's own measurement covers
|
||||
it. Collapses to nothing when every status is empty. */}
|
||||
<ComposerStatusStack
|
||||
onSubmit={onSubmit}
|
||||
queue={
|
||||
activeQueueSessionKey && queuedPrompts.length > 0 ? (
|
||||
<QueuePanel
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router'
|
||||
|
||||
import { blurComposerInput } from '@/app/chat/composer/focus'
|
||||
import { AGENTS_ROUTE } from '@/app/routes'
|
||||
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { BillingBanner } from '@/components/billing-banner'
|
||||
import { composerDockCard } from '@/components/chat/composer-dock'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
@@ -24,12 +25,14 @@ import {
|
||||
type StatusGroup,
|
||||
stopBackgroundProcess
|
||||
} from '@/store/composer-status'
|
||||
import { refreshSessionGoal } from '@/store/goals'
|
||||
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
|
||||
import { $sessionControlBySession, refreshSessionControl } from '@/store/session-control'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { PreviewStatusRow } from './preview-row'
|
||||
import { SessionControlSections } from './session-control'
|
||||
import { useSessionValue } from './session-control-utils'
|
||||
import { StatusItemRow } from './status-row'
|
||||
|
||||
// Slow safety-net poll for silent exits (processes without notify_on_complete
|
||||
@@ -74,6 +77,7 @@ const hasRunningTodo = (group: StatusGroup) =>
|
||||
group.type === 'todo' && group.items.some(item => item.todoStatus === 'in_progress' && item.state === 'running')
|
||||
|
||||
interface ComposerStatusStackProps {
|
||||
onSubmit?: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
|
||||
/** The queue, built by the composer (it owns the queue's callbacks). Rendered
|
||||
* as the last group so it stays fused to the composer like before. */
|
||||
queue: ReactNode
|
||||
@@ -85,7 +89,7 @@ interface ComposerStatusStackProps {
|
||||
* every session-scoped status — subagents, background tasks, queue — grouped by
|
||||
* type and separated by light dividers. Collapses to nothing when empty.
|
||||
*/
|
||||
export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) {
|
||||
export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStatusStackProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
// Subscribe to THIS session's slice only. Both maps churn on other
|
||||
@@ -96,10 +100,22 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
// items actually changed.
|
||||
const items = useSessionSlice($statusItemsBySession, sessionId)
|
||||
const previews = useSessionSlice($previewStatusBySession, sessionId)
|
||||
const controlEntry = useSessionValue($sessionControlBySession, sessionId)
|
||||
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const billing = useStore($billingBlock)
|
||||
|
||||
const groups = useMemo(() => groupStatusItems(items), [items])
|
||||
const isStructuredSupported = controlEntry?.capability === 'supported'
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const raw = groupStatusItems(items)
|
||||
|
||||
if (isStructuredSupported) {
|
||||
return raw.filter(g => g.type !== 'goal')
|
||||
}
|
||||
|
||||
return raw
|
||||
}, [items, isStructuredSupported])
|
||||
|
||||
// Seed from the registry on session open; event-driven refreshes (terminal /
|
||||
// process tool completions) live in use-message-stream. This must NOT reset
|
||||
@@ -112,7 +128,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
void refreshSessionGoal(sessionId)
|
||||
void refreshSessionControl(sessionId)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
@@ -160,6 +176,21 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
|
||||
}
|
||||
|
||||
const hasControlContent = Boolean(
|
||||
controlEntry &&
|
||||
(controlEntry.error ||
|
||||
controlEntry.snapshot?.goal ||
|
||||
controlEntry.snapshot?.loop ||
|
||||
controlEntry.snapshot?.heartbeat)
|
||||
)
|
||||
|
||||
if (sessionId && controlEntry && hasControlContent) {
|
||||
sections.push({
|
||||
key: 'session-control',
|
||||
node: <SessionControlSections entry={controlEntry} onSubmit={onSubmit} sessionId={sessionId} />
|
||||
})
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
sections.push({
|
||||
key: group.type,
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
|
||||
import { queueKickoffIfSessionBusy } from '@/app/session/hooks/use-prompt-actions/queue-if-busy'
|
||||
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
runSessionControlAction,
|
||||
type SessionControlAction,
|
||||
type SessionControlActionArgs,
|
||||
type SessionControlGoal
|
||||
} from '@/store/session-control'
|
||||
|
||||
import type { ConfirmState } from './session-control-utils'
|
||||
|
||||
interface GoalSectionProps {
|
||||
goal: SessionControlGoal
|
||||
sessionId: string
|
||||
pendingAction: SessionControlAction | null
|
||||
onSubmit?: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
|
||||
onFeedback: (error: string | null, success: string | null) => void
|
||||
}
|
||||
|
||||
export const SessionControlGoalSection = memo(function SessionControlGoalSection({
|
||||
goal,
|
||||
sessionId,
|
||||
pendingAction,
|
||||
onSubmit,
|
||||
onFeedback
|
||||
}: GoalSectionProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack
|
||||
const ctrl = s.control
|
||||
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const [addCriterionOpen, setAddCriterionOpen] = useState(false)
|
||||
const [addCriterionError, setAddCriterionError] = useState<string | null>(null)
|
||||
const [newCriterionText, setNewCriterionText] = useState('')
|
||||
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const isBusy = Boolean(pendingAction)
|
||||
|
||||
const handleAction = useCallback(
|
||||
async (
|
||||
action: SessionControlAction,
|
||||
args?: SessionControlActionArgs,
|
||||
onFailure?: (message: string) => void
|
||||
): Promise<boolean> => {
|
||||
onFeedback(null, null)
|
||||
|
||||
try {
|
||||
const dispatch = await runSessionControlAction(sessionId, action, args)
|
||||
|
||||
if (dispatch.type === 'send') {
|
||||
if (!dispatch.message || !onSubmit) {
|
||||
onFeedback(ctrl.continuationFailed, null)
|
||||
onFailure?.(ctrl.continuationFailed)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// The backend has already resumed the goal; if a turn is running the
|
||||
// kickoff must queue (same as a typed `/goal resume`, slash.ts), not
|
||||
// read as a failure.
|
||||
const queued = queueKickoffIfSessionBusy({
|
||||
displayText: dispatch.display ?? undefined,
|
||||
sessionId,
|
||||
text: dispatch.message
|
||||
})
|
||||
|
||||
if (queued !== 'idle') {
|
||||
const copy = queued === 'queued' ? ctrl.continuationQueued : ctrl.continuationBusy
|
||||
onFeedback(queued === 'queued' ? null : copy, queued === 'queued' ? copy : null)
|
||||
|
||||
return queued === 'queued'
|
||||
}
|
||||
|
||||
const submitted = await onSubmit(dispatch.message, {
|
||||
displayKind: 'hidden',
|
||||
sessionId
|
||||
})
|
||||
|
||||
if (!submitted) {
|
||||
onFeedback(ctrl.continuationFailed, null)
|
||||
onFailure?.(ctrl.continuationFailed)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
onFeedback(null, ctrl.actionSucceeded)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const failure = ctrl.actionFailed(msg)
|
||||
onFeedback(failure, null)
|
||||
onFailure?.(failure)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[sessionId, onSubmit, onFeedback, ctrl]
|
||||
)
|
||||
|
||||
const copyCriterionText = useCallback(
|
||||
async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
onFeedback(null, ctrl.copySuccess)
|
||||
} catch {
|
||||
onFeedback(ctrl.copyFailure, null)
|
||||
}
|
||||
},
|
||||
[onFeedback, ctrl]
|
||||
)
|
||||
|
||||
const visibleState: 'waiting' | 'active' | 'paused' | 'done' = goal.wait_barrier
|
||||
? 'waiting'
|
||||
: goal.status === 'paused'
|
||||
? 'paused'
|
||||
: goal.status === 'done'
|
||||
? 'done'
|
||||
: 'active'
|
||||
|
||||
const iconClass =
|
||||
goal.last_verdict === 'blocked'
|
||||
? 'text-red-500'
|
||||
: visibleState === 'done'
|
||||
? 'text-muted-foreground/70'
|
||||
: visibleState === 'active'
|
||||
? 'text-emerald-500'
|
||||
: 'text-amber-500'
|
||||
|
||||
const stateLabel =
|
||||
goal.last_verdict === 'blocked'
|
||||
? s.goalBlocked
|
||||
: visibleState === 'waiting'
|
||||
? s.goalWaiting
|
||||
: visibleState === 'paused'
|
||||
? s.goalPaused
|
||||
: visibleState === 'done'
|
||||
? s.goalDone
|
||||
: s.goalActive
|
||||
|
||||
const headerLabel =
|
||||
visibleState === 'done'
|
||||
? `${stateLabel} · ${ctrl.goalDoneTurns(goal.turns_used)}`
|
||||
: goal.max_turns > 0
|
||||
? `${stateLabel} · ${ctrl.goalActiveTurns(goal.turns_used, goal.max_turns)}`
|
||||
: goal.turns_used > 0
|
||||
? `${stateLabel} · ${ctrl.goalTurn(goal.turns_used)}`
|
||||
: stateLabel
|
||||
|
||||
const confirmClearGoal = () => {
|
||||
setConfirmState({
|
||||
title: ctrl.clearGoalConfirmTitle,
|
||||
description: ctrl.clearGoalConfirmBody,
|
||||
destructive: true,
|
||||
confirmLabel: ctrl.clearGoal,
|
||||
onConfirm: async () => {
|
||||
await handleAction('goal.clear')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const confirmRemoveCriterion = (index: number) => {
|
||||
setConfirmState({
|
||||
title: ctrl.removeCriterionConfirmTitle(index),
|
||||
description: ctrl.removeCriterionConfirmBody(index),
|
||||
destructive: true,
|
||||
confirmLabel: ctrl.removeCriterion(index),
|
||||
onConfirm: async () => {
|
||||
await handleAction('subgoal.remove', { index })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const confirmClearCriteria = () => {
|
||||
setConfirmState({
|
||||
title: ctrl.clearCriteriaConfirmTitle,
|
||||
description: ctrl.clearCriteriaConfirmBody,
|
||||
destructive: true,
|
||||
confirmLabel: ctrl.clearCriteria,
|
||||
onConfirm: async () => {
|
||||
await handleAction('subgoal.clear')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openAddCriterion = () => {
|
||||
setAddCriterionError(null)
|
||||
setAddCriterionOpen(true)
|
||||
}
|
||||
|
||||
const renderMenuItems = (isContext = false) => {
|
||||
const Item = isContext ? ContextMenuItem : DropdownMenuItem
|
||||
const Sep = isContext ? ContextMenuSeparator : DropdownMenuSeparator
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasDetails && (
|
||||
<Item disabled={isBusy} onSelect={() => setDetailsOpen(true)}>
|
||||
<Codicon name="eye" size="0.8rem" />
|
||||
<span>{ctrl.viewDetails}</span>
|
||||
</Item>
|
||||
)}
|
||||
{visibleState !== 'done' && (
|
||||
<Item disabled={isBusy} onSelect={openAddCriterion}>
|
||||
<Codicon name="add" size="0.8rem" />
|
||||
<span>{ctrl.addCriterion}</span>
|
||||
</Item>
|
||||
)}
|
||||
{(hasDetails || visibleState !== 'done') && <Sep />}
|
||||
{visibleState === 'active' && (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('goal.pause')}>
|
||||
<Codicon name="debug-pause" size="0.8rem" />
|
||||
<span>{ctrl.pauseGoal}</span>
|
||||
</Item>
|
||||
)}
|
||||
{visibleState === 'paused' && (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('goal.resume')}>
|
||||
<Codicon name="play" size="0.8rem" />
|
||||
<span>{ctrl.resumeGoal}</span>
|
||||
</Item>
|
||||
)}
|
||||
{visibleState === 'waiting' && (
|
||||
<>
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('goal.unwait')}>
|
||||
<Codicon name="play" size="0.8rem" />
|
||||
<span>{ctrl.resumeNow}</span>
|
||||
</Item>
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('goal.pause')}>
|
||||
<Codicon name="debug-pause" size="0.8rem" />
|
||||
<span>{ctrl.pauseGoal}</span>
|
||||
</Item>
|
||||
</>
|
||||
)}
|
||||
<Item disabled={isBusy} onSelect={confirmClearGoal} variant="destructive">
|
||||
<Codicon name="trash" size="0.8rem" />
|
||||
<span>{ctrl.clearGoal}</span>
|
||||
</Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const hasDetails = Boolean(
|
||||
goal.contract.outcome ||
|
||||
goal.contract.verification ||
|
||||
goal.contract.constraints ||
|
||||
goal.contract.boundaries ||
|
||||
goal.contract.stop_when ||
|
||||
goal.wait_barrier ||
|
||||
goal.gates.length > 0
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div data-slot="session-control-goal">
|
||||
<StatusSection
|
||||
accessory={
|
||||
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<Tip label={ctrl.goalActions}>
|
||||
<span className="inline-flex">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-haspopup="menu"
|
||||
aria-label={ctrl.goalActions}
|
||||
className="size-6 rounded-md text-muted-foreground/70 hover:text-foreground/90"
|
||||
disabled={isBusy}
|
||||
onClick={event => {
|
||||
// Radix opens pointer interactions from pointerdown. Keyboard,
|
||||
// assistive-tech, and programmatic clicks have no pointer sequence.
|
||||
if (event.detail === 0) {
|
||||
setMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'F10' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.8rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</span>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{renderMenuItems(false)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
defaultCollapsed={false}
|
||||
icon={<Codicon className={iconClass} name="target" size="0.8rem" />}
|
||||
label={headerLabel}
|
||||
>
|
||||
<div className="space-y-1.5 px-1 py-1">
|
||||
{/* Full goal title */}
|
||||
<div className="text-xs font-normal leading-relaxed text-foreground/92 break-words">{goal.title}</div>
|
||||
|
||||
{/* Optional reasons */}
|
||||
{!detailsOpen && goal.wait_barrier && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">
|
||||
{goal.wait_barrier.reason
|
||||
? `${ctrl.waitBarrierTitle}: ${goal.wait_barrier.reason}`
|
||||
: ctrl.waitBarrierTitle}
|
||||
</div>
|
||||
)}
|
||||
{!goal.wait_barrier && goal.paused_reason && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">{goal.paused_reason}</div>
|
||||
)}
|
||||
{!goal.wait_barrier && !goal.paused_reason && goal.last_reason && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">{goal.last_reason}</div>
|
||||
)}
|
||||
|
||||
{/* View details button */}
|
||||
{hasDetails && (
|
||||
<div>
|
||||
<Button
|
||||
className="text-[0.7rem] text-muted-foreground/75 hover:text-foreground/90"
|
||||
onClick={() => setDetailsOpen(true)}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{ctrl.viewDetails}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Criteria subsection */}
|
||||
<div className="mt-2 border-t border-(--ui-stroke-tertiary)/40 pt-1.5">
|
||||
<div className="flex items-center justify-between pb-1 text-[0.68rem] font-medium text-muted-foreground/75">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span aria-hidden="true" className={`inline-flex ${iconClass}`} data-slot="criteria-state-marker">
|
||||
<Codicon name="target" size="0.68rem" />
|
||||
</span>
|
||||
<span>{ctrl.criteriaHeader(goal.subgoals.length)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
className="text-[0.68rem] text-muted-foreground/75 hover:text-foreground/90"
|
||||
disabled={isBusy}
|
||||
onClick={openAddCriterion}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{ctrl.addCriterion}
|
||||
</Button>
|
||||
{goal.subgoals.length > 0 && (
|
||||
<Button
|
||||
className="text-[0.68rem] text-muted-foreground/60 hover:text-destructive"
|
||||
disabled={isBusy}
|
||||
onClick={confirmClearCriteria}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{ctrl.clearCriteria}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{goal.subgoals.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{goal.subgoals.map((subgoal, idx) => {
|
||||
const index = idx + 1
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/criterion flex items-start justify-between gap-1.5 rounded px-1 py-0.5 text-xs text-foreground/85 hover:bg-(--ui-control-active-background)/40"
|
||||
key={`${index}-${subgoal}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-1.5 leading-relaxed">
|
||||
<span className="shrink-0 text-muted-foreground/60 tabular-nums">{index}.</span>
|
||||
<span className="break-words">{subgoal}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5 opacity-80 group-hover/criterion:opacity-100">
|
||||
<Tip label={ctrl.copyCriterion(index)}>
|
||||
<Button
|
||||
aria-label={ctrl.copyCriterion(index)}
|
||||
className="size-6 rounded text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={() => void copyCriterionText(subgoal)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="copy" size="0.7rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={ctrl.removeCriterion(index)}>
|
||||
<Button
|
||||
aria-label={ctrl.removeCriterion(index)}
|
||||
className="size-6 rounded text-muted-foreground/60 hover:text-destructive"
|
||||
disabled={isBusy}
|
||||
onClick={() => confirmRemoveCriterion(index)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.7rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</StatusSection>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-44">{renderMenuItems(true)}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{/* Add Criterion Dialog */}
|
||||
<Dialog
|
||||
onOpenChange={open => {
|
||||
if (!open) {
|
||||
setAddCriterionOpen(false)
|
||||
setAddCriterionError(null)
|
||||
}
|
||||
}}
|
||||
open={addCriterionOpen}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<form
|
||||
onSubmit={async e => {
|
||||
e.preventDefault()
|
||||
const text = newCriterionText.trim()
|
||||
|
||||
if (!text || isBusy) {
|
||||
return
|
||||
}
|
||||
|
||||
setAddCriterionError(null)
|
||||
const ok = await handleAction('subgoal.add', { text }, setAddCriterionError)
|
||||
|
||||
if (ok) {
|
||||
setNewCriterionText('')
|
||||
setAddCriterionOpen(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{ctrl.addCriterionDialogTitle}</DialogTitle>
|
||||
<DialogDescription>{ctrl.addCriterionPlaceholder}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<textarea
|
||||
aria-label={ctrl.criterionLabel}
|
||||
autoFocus
|
||||
className="w-full rounded-md border border-(--ui-stroke-secondary) bg-transparent p-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
disabled={isBusy}
|
||||
onChange={e => setNewCriterionText(e.target.value)}
|
||||
placeholder={ctrl.addCriterionPlaceholder}
|
||||
rows={3}
|
||||
value={newCriterionText}
|
||||
/>
|
||||
</div>
|
||||
{addCriterionError && (
|
||||
<div className="text-xs text-destructive" role="alert">
|
||||
{addCriterionError}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button disabled={isBusy} onClick={() => setAddCriterionOpen(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={isBusy || !newCriterionText.trim()} type="submit">
|
||||
{ctrl.add}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Goal Details Dialog */}
|
||||
<Dialog onOpenChange={open => !open && setDetailsOpen(false)} open={detailsOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{ctrl.goalDetailsTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2 text-xs">
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.objectiveLabel}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground leading-relaxed break-words">{goal.title}</div>
|
||||
</div>
|
||||
|
||||
{/* Contract fields */}
|
||||
{goal.contract.outcome && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.contractOutcome}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.outcome}</div>
|
||||
</div>
|
||||
)}
|
||||
{goal.contract.verification && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.contractVerification}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.verification}</div>
|
||||
</div>
|
||||
)}
|
||||
{goal.contract.constraints && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.contractConstraints}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.constraints}</div>
|
||||
</div>
|
||||
)}
|
||||
{goal.contract.boundaries && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.contractBoundaries}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.boundaries}</div>
|
||||
</div>
|
||||
)}
|
||||
{goal.contract.stop_when && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.contractStopWhen}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.stop_when}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wait Barrier */}
|
||||
{goal.wait_barrier && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.waitBarrierTitle}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground/90 leading-relaxed">
|
||||
{goal.wait_barrier.type === 'until' &&
|
||||
ctrl.waitUntil(new Date(goal.wait_barrier.until_at * 1000).toLocaleTimeString())}
|
||||
{goal.wait_barrier.type === 'session' && ctrl.waitSession(goal.wait_barrier.target)}
|
||||
{goal.wait_barrier.type === 'pid' && ctrl.waitPid(goal.wait_barrier.target)}
|
||||
{goal.wait_barrier.reason && (
|
||||
<div className="italic text-muted-foreground/80 mt-0.5">{goal.wait_barrier.reason}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality Gates */}
|
||||
{goal.gates.length > 0 && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.qualityGatesTitle}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{goal.gates.map((gate, i) => (
|
||||
<div
|
||||
className="rounded border border-(--ui-stroke-tertiary)/60 bg-(--ui-control-active-background)/20 p-2"
|
||||
key={`${i}-${gate.command}`}
|
||||
>
|
||||
<div className="font-mono text-[0.7rem] text-foreground/95">{gate.command}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[0.68rem] text-muted-foreground/80">
|
||||
<span>{ctrl.gateAttempts(gate.attempts, gate.max_retries)}</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span>{ctrl.gateTimeout(gate.timeout_seconds)}</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span>{ctrl.gateLastExit(gate.last_exit_code)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setDetailsOpen(false)} type="button">
|
||||
{t.common.close}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmState && (
|
||||
<ConfirmDialog
|
||||
cancelLabel={t.common.cancel}
|
||||
confirmLabel={confirmState.confirmLabel}
|
||||
description={confirmState.description}
|
||||
destructive={confirmState.destructive}
|
||||
onClose={() => setConfirmState(null)}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
open={Boolean(confirmState)}
|
||||
title={confirmState.title}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
runSessionControlAction,
|
||||
type SessionControlAction,
|
||||
type SessionControlActionArgs,
|
||||
type SessionControlHeartbeat
|
||||
} from '@/store/session-control'
|
||||
|
||||
import { type ConfirmState, formatHeartbeatCountdown, formatHeartbeatInterval } from './session-control-utils'
|
||||
|
||||
interface HeartbeatSectionProps {
|
||||
heartbeat: SessionControlHeartbeat
|
||||
sessionId: string
|
||||
pendingAction: SessionControlAction | null
|
||||
onFeedback: (error: string | null, success: string | null) => void
|
||||
}
|
||||
|
||||
function useHeartbeatClock(active: boolean): number {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
const tick = () => setNow(Date.now())
|
||||
const interval = window.setInterval(tick, 1_000)
|
||||
|
||||
return () => window.clearInterval(interval)
|
||||
}, [active])
|
||||
|
||||
return now
|
||||
}
|
||||
|
||||
export const SessionControlHeartbeatSection = memo(function SessionControlHeartbeatSection({
|
||||
heartbeat,
|
||||
sessionId,
|
||||
pendingAction,
|
||||
onFeedback
|
||||
}: HeartbeatSectionProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack
|
||||
const ctrl = s.control
|
||||
|
||||
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const isBusy = Boolean(pendingAction)
|
||||
|
||||
const handleAction = useCallback(
|
||||
async (action: SessionControlAction, args?: SessionControlActionArgs): Promise<boolean> => {
|
||||
onFeedback(null, null)
|
||||
|
||||
try {
|
||||
await runSessionControlAction(sessionId, action, args)
|
||||
onFeedback(null, ctrl.actionSucceeded)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
onFeedback(ctrl.actionFailed(msg), null)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[sessionId, onFeedback, ctrl]
|
||||
)
|
||||
|
||||
const stateLabel = heartbeat.status === 'paused' ? ctrl.heartbeatPaused : ctrl.heartbeatActive
|
||||
const now = useHeartbeatClock(heartbeat.status === 'active')
|
||||
const intervalLabel = formatHeartbeatInterval(heartbeat.interval_seconds, t)
|
||||
const nextDueTimestamp = (heartbeat.last_fired_at || heartbeat.created_at) + heartbeat.interval_seconds
|
||||
const nextDueMs = nextDueTimestamp > 1e11 ? nextDueTimestamp : nextDueTimestamp * 1_000
|
||||
const isDue = heartbeat.status === 'active' && nextDueMs <= now
|
||||
|
||||
const nextRunLabel =
|
||||
heartbeat.status === 'active'
|
||||
? ` · ${isDue ? ctrl.heartbeatDueWaitingForIdle : ctrl.heartbeatNext(formatHeartbeatCountdown(nextDueTimestamp, now))}`
|
||||
: ''
|
||||
|
||||
const iconClass =
|
||||
heartbeat.status === 'paused' ? 'text-red-500' : isDue ? 'text-amber-500' : 'text-emerald-500'
|
||||
|
||||
const headerLabel = `${stateLabel} · ${intervalLabel}${nextRunLabel}`
|
||||
|
||||
const confirmClearHeartbeat = () => {
|
||||
setConfirmState({
|
||||
title: ctrl.clearHeartbeatConfirmTitle,
|
||||
description: ctrl.clearHeartbeatConfirmBody,
|
||||
destructive: true,
|
||||
confirmLabel: ctrl.clearHeartbeat,
|
||||
onConfirm: async () => {
|
||||
await handleAction('heartbeat.clear')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderMenuItems = (isContext = false) => {
|
||||
const Item = isContext ? ContextMenuItem : DropdownMenuItem
|
||||
const Sep = isContext ? ContextMenuSeparator : DropdownMenuSeparator
|
||||
|
||||
return (
|
||||
<>
|
||||
{heartbeat.status === 'active' && (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('heartbeat.pause')}>
|
||||
<Codicon name="debug-pause" size="0.8rem" />
|
||||
<span>{ctrl.pauseHeartbeat}</span>
|
||||
</Item>
|
||||
)}
|
||||
{heartbeat.status === 'paused' && (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('heartbeat.resume')}>
|
||||
<Codicon name="play" size="0.8rem" />
|
||||
<span>{ctrl.resumeHeartbeat}</span>
|
||||
</Item>
|
||||
)}
|
||||
<Sep />
|
||||
<Item disabled={isBusy} onSelect={confirmClearHeartbeat} variant="destructive">
|
||||
<Codicon name="trash" size="0.8rem" />
|
||||
<span>{ctrl.clearHeartbeat}</span>
|
||||
</Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div data-slot="session-control-heartbeat">
|
||||
<StatusSection
|
||||
accessory={
|
||||
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<Tip label={ctrl.heartbeatActions}>
|
||||
<span className="inline-flex">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-haspopup="menu"
|
||||
aria-label={ctrl.heartbeatActions}
|
||||
className="size-6 rounded-md text-muted-foreground/70 hover:text-foreground/90"
|
||||
disabled={isBusy}
|
||||
onClick={event => {
|
||||
// Radix opens pointer interactions from pointerdown. Keyboard,
|
||||
// assistive-tech, and programmatic clicks have no pointer sequence.
|
||||
if (event.detail === 0) {
|
||||
setMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'F10' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.8rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</span>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{renderMenuItems(false)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
defaultCollapsed={true}
|
||||
icon={<Codicon className={iconClass} name="pulse" size="0.8rem" />}
|
||||
label={headerLabel}
|
||||
>
|
||||
<div className="space-y-1 px-1 py-1 text-xs">
|
||||
<div className="text-foreground/92 leading-relaxed break-words">{heartbeat.prompt}</div>
|
||||
<div className="text-[0.7rem] text-muted-foreground/80">
|
||||
<span>{ctrl.heartbeatFiredCount(heartbeat.fire_count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</StatusSection>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-44">{renderMenuItems(true)}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{confirmState && (
|
||||
<ConfirmDialog
|
||||
cancelLabel={t.common.cancel}
|
||||
confirmLabel={confirmState.confirmLabel}
|
||||
description={confirmState.description}
|
||||
destructive={confirmState.destructive}
|
||||
onClose={() => setConfirmState(null)}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
open={Boolean(confirmState)}
|
||||
title={confirmState.title}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
runSessionControlAction,
|
||||
type SessionControlAction,
|
||||
type SessionControlActionArgs,
|
||||
type SessionControlLoop
|
||||
} from '@/store/session-control'
|
||||
|
||||
import { type ConfirmState, formatApproximateTime, formatInterval } from './session-control-utils'
|
||||
|
||||
interface LoopSectionProps {
|
||||
loop: SessionControlLoop
|
||||
sessionId: string
|
||||
pendingAction: SessionControlAction | null
|
||||
onFeedback: (error: string | null, success: string | null) => void
|
||||
}
|
||||
|
||||
export const SessionControlLoopSection = memo(function SessionControlLoopSection({
|
||||
loop,
|
||||
sessionId,
|
||||
pendingAction,
|
||||
onFeedback
|
||||
}: LoopSectionProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack
|
||||
const ctrl = s.control
|
||||
|
||||
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const isBusy = Boolean(pendingAction)
|
||||
|
||||
const iconClass =
|
||||
loop.status === 'done'
|
||||
? 'text-muted-foreground/70'
|
||||
: loop.status === 'paused' || loop.deferred_by_goal
|
||||
? 'text-amber-500'
|
||||
: 'text-emerald-500'
|
||||
|
||||
const handleAction = useCallback(
|
||||
async (action: SessionControlAction, args?: SessionControlActionArgs): Promise<boolean> => {
|
||||
onFeedback(null, null)
|
||||
|
||||
try {
|
||||
await runSessionControlAction(sessionId, action, args)
|
||||
onFeedback(null, ctrl.actionSucceeded)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
onFeedback(ctrl.actionFailed(msg), null)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[sessionId, onFeedback, ctrl]
|
||||
)
|
||||
|
||||
const stateLabel = loop.deferred_by_goal
|
||||
? ctrl.loopDeferred
|
||||
: loop.status === 'paused'
|
||||
? ctrl.loopPaused
|
||||
: loop.status === 'done'
|
||||
? ctrl.loopFinished
|
||||
: loop.awaiting_response
|
||||
? `${ctrl.loopActive} · ${ctrl.loopAwaitingResponse}`
|
||||
: ctrl.loopActive
|
||||
|
||||
const runCountLabel =
|
||||
loop.times > 0 ? ctrl.loopRunCount(loop.ticks_fired, loop.times) : ctrl.loopRuns(loop.ticks_fired)
|
||||
|
||||
const nextRunLabel =
|
||||
loop.status === 'active' && !loop.deferred_by_goal && loop.next_due_at
|
||||
? ` · ${ctrl.loopNext(formatApproximateTime(loop.next_due_at))}`
|
||||
: ''
|
||||
|
||||
const headerLabel = `${stateLabel} · ${runCountLabel}${nextRunLabel}`
|
||||
|
||||
const confirmStopLoop = () => {
|
||||
if (loop.status === 'done') {
|
||||
void handleAction('loop.stop')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setConfirmState({
|
||||
title: ctrl.stopLoopConfirmTitle,
|
||||
description: ctrl.stopLoopConfirmBody,
|
||||
destructive: true,
|
||||
confirmLabel: ctrl.stopLoop,
|
||||
onConfirm: async () => {
|
||||
await handleAction('loop.stop')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderMenuItems = (isContext = false) => {
|
||||
const Item = isContext ? ContextMenuItem : DropdownMenuItem
|
||||
const Sep = isContext ? ContextMenuSeparator : DropdownMenuSeparator
|
||||
|
||||
return (
|
||||
<>
|
||||
{loop.status === 'active' && (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('loop.pause')}>
|
||||
<Codicon name="debug-pause" size="0.8rem" />
|
||||
<span>{ctrl.pauseLoop}</span>
|
||||
</Item>
|
||||
)}
|
||||
{loop.status === 'paused' && (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('loop.resume')}>
|
||||
<Codicon name="play" size="0.8rem" />
|
||||
<span>{ctrl.resumeLoop}</span>
|
||||
</Item>
|
||||
)}
|
||||
{loop.status !== 'done' && <Sep />}
|
||||
{loop.status === 'done' ? (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('loop.stop')}>
|
||||
<Codicon name="close" size="0.8rem" />
|
||||
<span>{ctrl.dismissLoop}</span>
|
||||
</Item>
|
||||
) : (
|
||||
<Item disabled={isBusy} onSelect={confirmStopLoop} variant="destructive">
|
||||
<Codicon name="stop-circle" size="0.8rem" />
|
||||
<span>{ctrl.stopLoop}</span>
|
||||
</Item>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div data-slot="session-control-loop">
|
||||
<StatusSection
|
||||
accessory={
|
||||
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<Tip label={ctrl.loopActions}>
|
||||
<span className="inline-flex">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-haspopup="menu"
|
||||
aria-label={ctrl.loopActions}
|
||||
className="size-6 rounded-md text-muted-foreground/70 hover:text-foreground/90"
|
||||
disabled={isBusy}
|
||||
onClick={event => {
|
||||
// Radix opens pointer interactions from pointerdown. Keyboard,
|
||||
// assistive-tech, and programmatic clicks have no pointer sequence.
|
||||
if (event.detail === 0) {
|
||||
setMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'F10' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.8rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</span>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{renderMenuItems(false)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
defaultCollapsed={true}
|
||||
icon={<Codicon className={iconClass} name="sync" size="0.8rem" />}
|
||||
label={headerLabel}
|
||||
>
|
||||
<div className="space-y-1 px-1 py-1 text-xs">
|
||||
<div className="text-foreground/92 leading-relaxed break-words">{loop.prompt}</div>
|
||||
<div className="text-[0.7rem] text-muted-foreground/80">
|
||||
<span>
|
||||
{ctrl.loopCadenceLabel}:{' '}
|
||||
{loop.mode === 'self_paced' ? ctrl.loopSelfPaced : formatInterval(loop.interval_seconds, t)}
|
||||
</span>
|
||||
</div>
|
||||
{loop.until && (
|
||||
<div className="text-[0.7rem] text-muted-foreground/80">
|
||||
<span>
|
||||
{ctrl.loopUntilLabel}: {loop.until}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{loop.deferred_by_goal && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">{ctrl.loopDeferredNotice}</div>
|
||||
)}
|
||||
{loop.awaiting_response && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">{ctrl.loopAwaitingResponse}</div>
|
||||
)}
|
||||
{loop.paused_reason && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">{loop.paused_reason}</div>
|
||||
)}
|
||||
{loop.last_stop_reason && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">{loop.last_stop_reason}</div>
|
||||
)}
|
||||
</div>
|
||||
</StatusSection>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-40">{renderMenuItems(true)}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{confirmState && (
|
||||
<ConfirmDialog
|
||||
cancelLabel={t.common.cancel}
|
||||
confirmLabel={confirmState.confirmLabel}
|
||||
description={confirmState.description}
|
||||
destructive={confirmState.destructive}
|
||||
onClose={() => setConfirmState(null)}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
open={Boolean(confirmState)}
|
||||
title={confirmState.title}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
|
||||
import type { Translations } from '@/i18n'
|
||||
|
||||
interface SessionSliceStore<T> {
|
||||
get(): Record<string, T | undefined>
|
||||
listen(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** Subscribe to one stable per-session store entry without reacting to other sessions' writes. */
|
||||
export function useSessionValue<T>(store: SessionSliceStore<T>, sessionId: string | null): T | undefined {
|
||||
const subscribe = useCallback((onChange: () => void) => store.listen(onChange), [store])
|
||||
|
||||
return useSyncExternalStore(subscribe, () => (sessionId ? store.get()[sessionId] : undefined))
|
||||
}
|
||||
|
||||
export interface ConfirmState {
|
||||
title: string
|
||||
description: string
|
||||
destructive?: boolean
|
||||
confirmLabel?: string
|
||||
onConfirm: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function formatApproximateTime(targetTimestamp: number): string {
|
||||
if (!targetTimestamp) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const targetMs = targetTimestamp > 1e11 ? targetTimestamp : targetTimestamp * 1000
|
||||
const diffMs = targetMs - Date.now()
|
||||
|
||||
if (diffMs <= 0) {
|
||||
return '~0s'
|
||||
}
|
||||
|
||||
const seconds = Math.round(diffMs / 1000)
|
||||
|
||||
if (seconds < 60) {
|
||||
return `~${seconds}s`
|
||||
}
|
||||
|
||||
const minutes = Math.round(seconds / 60)
|
||||
|
||||
if (minutes < 60) {
|
||||
return `~${minutes}m`
|
||||
}
|
||||
|
||||
const hours = Math.round(minutes / 60)
|
||||
|
||||
if (hours < 24) {
|
||||
return `~${hours}h`
|
||||
}
|
||||
|
||||
const days = Math.round(hours / 24)
|
||||
|
||||
return `~${days}d`
|
||||
}
|
||||
|
||||
/** Formats a countdown precisely without exposing the user's local timezone. */
|
||||
export function formatHeartbeatCountdown(targetTimestamp: number, now = Date.now()): string {
|
||||
const targetMs = targetTimestamp > 1e11 ? targetTimestamp : targetTimestamp * 1000
|
||||
const remainingSeconds = Math.max(0, Math.ceil((targetMs - now) / 1000))
|
||||
const days = Math.floor(remainingSeconds / 86_400)
|
||||
const hours = Math.floor((remainingSeconds % 86_400) / 3_600)
|
||||
const minutes = Math.floor((remainingSeconds % 3_600) / 60)
|
||||
const seconds = remainingSeconds % 60
|
||||
const padded = (value: number) => String(value).padStart(2, '0')
|
||||
const clock = `${padded(hours)}:${padded(minutes)}:${padded(seconds)}`
|
||||
|
||||
return days > 0 ? `${days}d ${clock}` : clock
|
||||
}
|
||||
|
||||
export function formatInterval(seconds: number, t: Translations): string {
|
||||
if (seconds % 3600 === 0) {
|
||||
return t.statusStack.control.loopEveryHours(seconds / 3600)
|
||||
}
|
||||
|
||||
if (seconds % 60 === 0) {
|
||||
return t.statusStack.control.loopEveryMinutes(seconds / 60)
|
||||
}
|
||||
|
||||
return t.statusStack.control.loopEverySeconds(seconds)
|
||||
}
|
||||
|
||||
export function formatHeartbeatInterval(seconds: number, t: Translations): string {
|
||||
if (seconds % 3600 === 0) {
|
||||
return t.statusStack.control.heartbeatEveryHours(seconds / 3600)
|
||||
}
|
||||
|
||||
if (seconds % 60 === 0) {
|
||||
return t.statusStack.control.heartbeatEveryMinutes(seconds / 60)
|
||||
}
|
||||
|
||||
return t.statusStack.control.heartbeatEverySeconds(seconds)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as GoalsModule from '@/store/goals'
|
||||
import type * as SessionControlModule from '@/store/session-control'
|
||||
|
||||
const { mockRefreshSessionControl, mockRunSessionControlAction, mockRefreshSessionGoal } = vi.hoisted(() => ({
|
||||
mockRefreshSessionControl: vi.fn(),
|
||||
mockRunSessionControlAction: vi.fn(),
|
||||
mockRefreshSessionGoal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/session-control', async importOriginal => {
|
||||
const actual = await importOriginal<typeof SessionControlModule>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
refreshSessionControl: mockRefreshSessionControl,
|
||||
runSessionControlAction: mockRunSessionControlAction
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store/goals', async importOriginal => {
|
||||
const actual = await importOriginal<typeof GoalsModule>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
refreshSessionGoal: mockRefreshSessionGoal
|
||||
}
|
||||
})
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import { clearQueuedPrompts, getQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $goalsBySession } from '@/store/goals'
|
||||
import {
|
||||
$sessionControlBySession,
|
||||
type SessionControlEntry,
|
||||
type SessionControlGoal,
|
||||
type SessionControlHeartbeat,
|
||||
type SessionControlLoop,
|
||||
type SessionControlSnapshot
|
||||
} from '@/store/session-control'
|
||||
import { $sessionStates } from '@/store/session-states'
|
||||
import { $todosBySession } from '@/store/todos'
|
||||
|
||||
import { ComposerStatusStack } from './index'
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
|
||||
const SID = 'sess-ctrl-1'
|
||||
|
||||
const sampleGoal = (overrides?: Partial<SessionControlGoal>): SessionControlGoal => ({
|
||||
contract: {
|
||||
boundaries: 'test boundaries',
|
||||
constraints: 'test constraints',
|
||||
outcome: 'test outcome',
|
||||
stop_when: 'test stop condition',
|
||||
verification: 'test verification'
|
||||
},
|
||||
gates: [
|
||||
{
|
||||
attempts: 1,
|
||||
command: 'npm test',
|
||||
last_exit_code: 0,
|
||||
max_retries: 3,
|
||||
timeout_seconds: 30
|
||||
}
|
||||
],
|
||||
max_turns: 20,
|
||||
status: 'active',
|
||||
subgoals: ['First criterion', 'Second criterion'],
|
||||
title: 'Execute complete work order',
|
||||
turns_used: 3,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const sampleLoop = (overrides?: Partial<SessionControlLoop>): SessionControlLoop => ({
|
||||
awaiting_response: false,
|
||||
created_at: 1700000000,
|
||||
current_delay: 120,
|
||||
deferred_by_goal: false,
|
||||
interval_seconds: 120,
|
||||
last_fired_at: 1700000000,
|
||||
max_ticks: 10,
|
||||
mode: 'interval',
|
||||
next_due_at: 1700000120,
|
||||
prompt: 'Check pending reviews',
|
||||
status: 'active',
|
||||
ticks_fired: 3,
|
||||
times: 10,
|
||||
until: '',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const sampleHeartbeat = (overrides?: Partial<SessionControlHeartbeat>): SessionControlHeartbeat => ({
|
||||
created_at: 1700000000,
|
||||
fire_count: 4,
|
||||
interval_seconds: 1800,
|
||||
last_fired_at: 1700000000,
|
||||
prompt: 'System health check',
|
||||
status: 'active',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const sampleSnapshot = (overrides?: Partial<SessionControlSnapshot>): SessionControlSnapshot => ({
|
||||
goal: sampleGoal(),
|
||||
heartbeat: null,
|
||||
loop: null,
|
||||
revision: 'rev-goal-1',
|
||||
updated_at: 1700000000,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const mockEntry = (overrides?: Partial<SessionControlEntry>): SessionControlEntry => ({
|
||||
capability: 'supported',
|
||||
error: null,
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: sampleSnapshot(),
|
||||
...overrides
|
||||
})
|
||||
|
||||
function renderStack(sessionId: null | string = SID, props: Record<string, unknown> = {}) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<ComposerStatusStack queue={null} sessionId={sessionId} {...props} />
|
||||
</I18nProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ComposerStatusStack session-control UI', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
$goalsBySession.set({})
|
||||
$sessionControlBySession.set({})
|
||||
$todosBySession.set({})
|
||||
mockRefreshSessionControl.mockResolvedValue(undefined)
|
||||
mockRefreshSessionGoal.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$goalsBySession.set({})
|
||||
$sessionControlBySession.set({})
|
||||
$todosBySession.set({})
|
||||
$sessionStates.set({})
|
||||
clearQueuedPrompts('stored-1')
|
||||
})
|
||||
|
||||
// 1. initial structured hydration on mount; old-gateway/legacy goal remains until supported
|
||||
// 1. initial structured hydration on mount; old-gateway/legacy goal remains until supported
|
||||
it('calls refreshSessionControl on mount and keeps legacy goal while capability is unknown/unsupported', () => {
|
||||
$goalsBySession.set({
|
||||
[SID]: { status: 'active', title: 'Legacy Goal Title', updatedAt: Date.now() }
|
||||
})
|
||||
$sessionControlBySession.set({
|
||||
[SID]: { capability: 'unknown', error: null, loading: false, pendingAction: null, snapshot: null }
|
||||
})
|
||||
|
||||
renderStack()
|
||||
|
||||
expect(mockRefreshSessionControl).toHaveBeenCalledWith(SID)
|
||||
expect(mockRefreshSessionGoal).not.toHaveBeenCalled()
|
||||
// Legacy goal is rendered while capability is unknown
|
||||
expect(screen.getByText('Legacy Goal Title')).toBeTruthy()
|
||||
})
|
||||
|
||||
// 2. supported structured goal replaces, not duplicates, legacy goal
|
||||
it('replaces legacy goal when structured session control is supported without duplication', () => {
|
||||
$goalsBySession.set({
|
||||
[SID]: { status: 'active', title: 'Legacy Goal Title', updatedAt: Date.now() }
|
||||
})
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
capability: 'supported',
|
||||
snapshot: sampleSnapshot({ goal: sampleGoal({ title: 'Structured Goal Title' }) })
|
||||
})
|
||||
})
|
||||
|
||||
renderStack()
|
||||
|
||||
expect(screen.getByText('Structured Goal Title')).toBeTruthy()
|
||||
expect(screen.queryByText('Legacy Goal Title')).toBeNull()
|
||||
})
|
||||
|
||||
// 11. loop pause/resume/stop calls exact action
|
||||
it('calls loop.pause when pause loop is clicked', async () => {
|
||||
mockRunSessionControlAction.mockResolvedValue({
|
||||
type: 'exec',
|
||||
output: 'Paused loop',
|
||||
notice: null,
|
||||
message: null,
|
||||
display: null
|
||||
})
|
||||
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: sampleSnapshot({
|
||||
goal: null,
|
||||
loop: sampleLoop({ status: 'active' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
renderStack()
|
||||
|
||||
const loopMenuTrigger = screen.getByRole('button', { name: /loop actions/i })
|
||||
fireEvent.click(loopMenuTrigger)
|
||||
|
||||
const pauseItem = await screen.findByRole('menuitem', { name: /pause loop/i })
|
||||
fireEvent.click(pauseItem)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRunSessionControlAction).toHaveBeenCalledWith(SID, 'loop.pause', undefined)
|
||||
})
|
||||
})
|
||||
|
||||
// 12. heartbeat interval/next/fire count and controls
|
||||
it("shows a precise live countdown and makes an overdue heartbeat's idle wait explicit", () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(1_700_000_030_000))
|
||||
|
||||
try {
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: sampleSnapshot({
|
||||
goal: null,
|
||||
heartbeat: sampleHeartbeat({
|
||||
created_at: 1_700_000_000,
|
||||
fire_count: 0,
|
||||
interval_seconds: 90,
|
||||
last_fired_at: 0,
|
||||
status: 'active'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
renderStack()
|
||||
|
||||
expect(screen.getByText(/Heartbeat active · every 90s · next 00:01:00/)).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60_000)
|
||||
})
|
||||
|
||||
expect(screen.getByText(/Heartbeat active · every 90s · due — waiting for idle/)).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// 13. action rejection produces alert/live feedback
|
||||
it('displays an alert role when action rejects', async () => {
|
||||
mockRunSessionControlAction.mockRejectedValue(new Error('Gateway rejected mutation'))
|
||||
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: sampleSnapshot({
|
||||
goal: sampleGoal({ status: 'active' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
renderStack()
|
||||
|
||||
const menuTrigger = screen.getByRole('button', { name: /goal actions/i })
|
||||
fireEvent.click(menuTrigger)
|
||||
|
||||
const pauseItem = await screen.findByRole('menuitem', { name: /pause goal/i })
|
||||
fireEvent.click(pauseItem)
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent).toContain('Gateway rejected mutation')
|
||||
})
|
||||
|
||||
// 14. send dispatch submits exact hidden continuation via ChatBar callback and never calls a text slash path
|
||||
it('submits hidden continuation on send dispatch through onSubmit callback', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(true)
|
||||
|
||||
mockRunSessionControlAction.mockResolvedValue({
|
||||
type: 'send',
|
||||
message: 'Continue toward goal: verify tests',
|
||||
output: null,
|
||||
notice: null,
|
||||
display: null
|
||||
})
|
||||
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: sampleSnapshot({
|
||||
goal: sampleGoal({ status: 'paused' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
renderStack(SID, { onSubmit })
|
||||
|
||||
const menuTrigger = screen.getByRole('button', { name: /goal actions/i })
|
||||
fireEvent.click(menuTrigger)
|
||||
|
||||
const resumeItem = await screen.findByRole('menuitem', { name: /resume goal/i })
|
||||
fireEvent.click(resumeItem)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith('Continue toward goal: verify tests', {
|
||||
displayKind: 'hidden',
|
||||
sessionId: SID
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 21. continuation failure when onSubmit returns false
|
||||
it('reports continuation failure when onSubmit returns false or is missing', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(false)
|
||||
|
||||
mockRunSessionControlAction.mockResolvedValue({
|
||||
display: null,
|
||||
message: 'Continue toward goal: run checks',
|
||||
notice: null,
|
||||
output: null,
|
||||
type: 'send'
|
||||
})
|
||||
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: sampleSnapshot({ goal: sampleGoal({ status: 'paused' }) })
|
||||
})
|
||||
})
|
||||
|
||||
renderStack(SID, { onSubmit })
|
||||
|
||||
const menuTrigger = screen.getByRole('button', { name: /goal actions/i })
|
||||
fireEvent.click(menuTrigger)
|
||||
|
||||
const resumeItem = await screen.findByRole('menuitem', { name: /resume goal/i })
|
||||
fireEvent.click(resumeItem)
|
||||
|
||||
await waitFor(() => {
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert.textContent).toContain('Failed to submit goal continuation')
|
||||
})
|
||||
})
|
||||
|
||||
it('queues the resume continuation instead of failing when the target session is busy', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(false)
|
||||
$sessionStates.set({ [SID]: { busy: true, storedSessionId: 'stored-1' } as never })
|
||||
|
||||
mockRunSessionControlAction.mockResolvedValue({
|
||||
display: '/goal resume',
|
||||
message: 'Continue toward goal: verify tests',
|
||||
notice: null,
|
||||
output: null,
|
||||
type: 'send'
|
||||
})
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({ snapshot: sampleSnapshot({ goal: sampleGoal({ status: 'paused' }) }) })
|
||||
})
|
||||
|
||||
renderStack(SID, { onSubmit })
|
||||
fireEvent.click(screen.getByRole('button', { name: /goal actions/i }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: /resume goal/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getQueuedPrompts('stored-1')).toMatchObject([
|
||||
{ displayText: '/goal resume', text: 'Continue toward goal: verify tests' }
|
||||
])
|
||||
})
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(screen.getByText(/continuation queued/i)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
|
||||
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { SessionControlEntry } from '@/store/session-control'
|
||||
|
||||
import { SessionControlGoalSection } from './session-control-goal'
|
||||
import { SessionControlHeartbeatSection } from './session-control-heartbeat'
|
||||
import { SessionControlLoopSection } from './session-control-loop'
|
||||
|
||||
export interface SessionControlSectionsProps {
|
||||
entry: SessionControlEntry
|
||||
onSubmit?: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export const SessionControlSections = memo(function SessionControlSections({
|
||||
entry,
|
||||
onSubmit,
|
||||
sessionId
|
||||
}: SessionControlSectionsProps) {
|
||||
const { t } = useI18n()
|
||||
const ctrl = t.statusStack.control
|
||||
|
||||
const [feedbackError, setFeedbackError] = useState<string | null>(null)
|
||||
const [feedbackSuccess, setFeedbackSuccess] = useState<string | null>(null)
|
||||
const [dismissedStoreError, setDismissedStoreError] = useState<string | null>(null)
|
||||
|
||||
const handleFeedback = useCallback((error: string | null, success: string | null) => {
|
||||
setFeedbackError(error)
|
||||
setFeedbackSuccess(success)
|
||||
}, [])
|
||||
|
||||
// Action failures arrive through onFeedback (which wins below); a store
|
||||
// error with no feedback is a read/hydration failure, not a failed action.
|
||||
const storeError = entry.error ? ctrl.controlUnavailable(entry.error) : null
|
||||
const displayError = feedbackError ?? (entry.error === dismissedStoreError ? null : storeError)
|
||||
|
||||
const handleDismissError = useCallback(() => {
|
||||
if (feedbackError) {
|
||||
setFeedbackError(null)
|
||||
} else {
|
||||
setDismissedStoreError(entry.error)
|
||||
}
|
||||
}, [entry.error, feedbackError])
|
||||
|
||||
const snapshot = entry.snapshot
|
||||
|
||||
if (!snapshot && !displayError) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{displayError && (
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 border-b border-destructive/20 bg-destructive/10 px-3 py-1.5 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
<Codicon className="shrink-0" name="error" size="0.85rem" />
|
||||
<span className="truncate">{displayError}</span>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={ctrl.dismissError}
|
||||
className="size-6 shrink-0 p-0 text-destructive hover:bg-destructive/20"
|
||||
onClick={handleDismissError}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{feedbackSuccess && (
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{feedbackSuccess}
|
||||
</div>
|
||||
)}
|
||||
{snapshot?.goal && (
|
||||
<SessionControlGoalSection
|
||||
goal={snapshot.goal}
|
||||
onFeedback={handleFeedback}
|
||||
onSubmit={onSubmit}
|
||||
pendingAction={entry.pendingAction}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)}
|
||||
{snapshot?.loop && (
|
||||
<SessionControlLoopSection
|
||||
loop={snapshot.loop}
|
||||
onFeedback={handleFeedback}
|
||||
pendingAction={entry.pendingAction}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)}
|
||||
{snapshot?.heartbeat && (
|
||||
<SessionControlHeartbeatSection
|
||||
heartbeat={snapshot.heartbeat}
|
||||
onFeedback={handleFeedback}
|
||||
pendingAction={entry.pendingAction}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -139,6 +139,7 @@ import {
|
||||
ARTIFACTS_ROUTE,
|
||||
CRON_ROUTE,
|
||||
MESSAGING_ROUTE,
|
||||
SESSION_IMPORT_ROUTE,
|
||||
SIDEBAR_NAV_AREA,
|
||||
type SidebarNavContribution,
|
||||
SKILLS_ROUTE
|
||||
@@ -225,6 +226,12 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
icon: props => <Codicon name="watch" {...props} />,
|
||||
route: CRON_ROUTE,
|
||||
keybindActionId: 'nav.cron'
|
||||
},
|
||||
{
|
||||
id: 'session-import',
|
||||
label: '',
|
||||
icon: props => <Codicon name="cloud-download" {...props} />,
|
||||
route: SESSION_IMPORT_ROUTE
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1497,6 +1504,7 @@ export function ChatSidebar({
|
||||
(item.id === 'messaging' && currentView === 'messaging') ||
|
||||
(item.id === 'artifacts' && currentView === 'artifacts') ||
|
||||
(item.id === 'cron' && currentView === 'cron') ||
|
||||
(item.id === 'session-import' && currentView === 'session-import') ||
|
||||
// Contributed rows light up at their own route.
|
||||
(Boolean(item.route) && pathname === item.route)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
HUD_SURFACE,
|
||||
HUD_TEXT
|
||||
} from '@/app/floating-hud'
|
||||
import { SESSION_IMPORT_ROUTE } from '@/app/routes'
|
||||
import { codiconIcon } from '@/components/ui/codicon'
|
||||
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { HighlightMatches } from '@/components/ui/highlight-matches'
|
||||
@@ -898,6 +899,13 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
|
||||
label: cc.sections.sessions,
|
||||
run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`)
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
id: 'session-import',
|
||||
keywords: ['import', 'claude', 'codex', 'conversation'],
|
||||
label: t.sessionImport.action,
|
||||
run: go(SESSION_IMPORT_ROUTE)
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
id: 'cc-system',
|
||||
|
||||
@@ -113,6 +113,7 @@ import {
|
||||
SETTINGS_ROUTE,
|
||||
syncWorkspaceRoute
|
||||
} from '../routes'
|
||||
import { SessionImportView } from '../session-import'
|
||||
import { SessionPickerOverlay } from '../session-picker-overlay'
|
||||
import { SessionSwitcher } from '../session-switcher'
|
||||
import { useBackgroundQueueDrain } from '../session/hooks/use-background-queue-drain'
|
||||
@@ -1246,6 +1247,18 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{currentView === 'session-import' && (
|
||||
<SessionImportView
|
||||
key={`${activeConnectionId}:${activeGatewayProfile}`}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onOpenSession={sessionId => {
|
||||
closeOverlayToPreviousRoute()
|
||||
openSession(sessionId, navigate, 'stack')
|
||||
}}
|
||||
owner={{ connectionId: activeConnectionId || 'local', profile: activeGatewayProfile }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{commandCenterOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CommandCenterView
|
||||
|
||||
@@ -10,6 +10,7 @@ export const SESSION_ROUTE_PREFIX = '/'
|
||||
export const NEW_CHAT_ROUTE = '/'
|
||||
export const SETTINGS_ROUTE = '/settings'
|
||||
export const COMMAND_CENTER_ROUTE = '/command-center'
|
||||
export const SESSION_IMPORT_ROUTE = '/session-import'
|
||||
export const SKILLS_ROUTE = '/skills'
|
||||
export const MESSAGING_ROUTE = '/messaging'
|
||||
export const WEBHOOKS_ROUTE = '/webhooks'
|
||||
@@ -20,6 +21,7 @@ export const AGENTS_ROUTE = '/agents'
|
||||
export const STARMAP_ROUTE = '/starmap'
|
||||
|
||||
export type AppView =
|
||||
| 'session-import'
|
||||
| 'agents'
|
||||
| 'artifacts'
|
||||
| 'chat'
|
||||
@@ -38,6 +40,7 @@ export type AppView =
|
||||
| 'webhooks'
|
||||
|
||||
export type AppRouteId =
|
||||
| 'session-import'
|
||||
| 'agents'
|
||||
| 'artifacts'
|
||||
| 'command-center'
|
||||
@@ -57,6 +60,7 @@ export interface AppRoute {
|
||||
}
|
||||
|
||||
export const APP_ROUTES = [
|
||||
{ id: 'session-import', path: SESSION_IMPORT_ROUTE, view: 'session-import' },
|
||||
{ id: 'new', path: NEW_CHAT_ROUTE, view: 'chat' },
|
||||
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
|
||||
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
|
||||
@@ -123,6 +127,7 @@ export interface SidebarNavContribution {
|
||||
// While one is open the app's titlebar control clusters must hide so they don't
|
||||
// bleed over the overlay (they sit at a higher z-index than the overlay card).
|
||||
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
|
||||
'session-import',
|
||||
'agents',
|
||||
'command-center',
|
||||
'cron',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { requestGatewayForAgent } from '@/store/gateway'
|
||||
import type { SessionOwnerRoute } from '@/store/session-request-router'
|
||||
|
||||
export interface ForeignSession {
|
||||
id: string
|
||||
source: 'claude' | 'codex'
|
||||
label: string
|
||||
title: string
|
||||
cwd: string | null
|
||||
mtime: number
|
||||
turn_count: number
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
export interface ForeignPage {
|
||||
sessions: ForeignSession[]
|
||||
next_offset: number | null
|
||||
host: string
|
||||
unreadable: number
|
||||
}
|
||||
|
||||
export interface ForeignPreview {
|
||||
truncated: boolean
|
||||
messages: { role: string; content: string }[]
|
||||
total: number
|
||||
already_imported: string | null
|
||||
cwd: string | null
|
||||
}
|
||||
|
||||
export interface ForeignImportResult {
|
||||
session_id: string
|
||||
already_imported: boolean
|
||||
}
|
||||
|
||||
export function foreignRequest<T>(
|
||||
owner: SessionOwnerRoute,
|
||||
method: 'list' | 'preview' | 'import',
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
return requestGatewayForAgent<T>(
|
||||
owner.connectionId,
|
||||
owner.profile,
|
||||
`session.foreign.${method}`,
|
||||
params,
|
||||
60_000,
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { setSessionOwnerHint } from '@/store/session'
|
||||
|
||||
import { type ForeignImportResult, foreignRequest } from './api'
|
||||
|
||||
import { SessionImportView } from './index'
|
||||
|
||||
vi.mock('./api', () => ({ foreignRequest: vi.fn() }))
|
||||
vi.mock('@/store/session', () => ({ setSessionOwnerHint: vi.fn() }))
|
||||
vi.mock('@/components/assistant-ui/markdown-text', () => ({
|
||||
MarkdownTextContent: ({ text }: { text: string }) => <p>{text}</p>
|
||||
}))
|
||||
vi.mock('../overlays/overlay-view', () => ({
|
||||
OverlayView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const owner = { connectionId: 'workstation', profile: 'research' }
|
||||
|
||||
const session = {
|
||||
id: 'foreign-one',
|
||||
source: 'claude',
|
||||
label: 'Claude Code',
|
||||
title: 'Repair imports',
|
||||
cwd: '/work/project',
|
||||
mtime: 1000,
|
||||
turn_count: 2,
|
||||
excerpt: 'Help with imports'
|
||||
}
|
||||
|
||||
function mount(onOpenSession = vi.fn()) {
|
||||
return {
|
||||
onOpenSession,
|
||||
...render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<SessionImportView onClose={vi.fn()} onOpenSession={onOpenSession} owner={owner} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it('browses without importing, then retries a failed import on the captured owner before opening', async () => {
|
||||
let attempts = 0
|
||||
vi.mocked(foreignRequest).mockImplementation(async (_owner, method) => {
|
||||
if (method === 'list') {
|
||||
return { sessions: [session], next_offset: null, host: 'studio', unreadable: 0 }
|
||||
}
|
||||
|
||||
if (method === 'preview') {
|
||||
return { messages: [{ role: 'user', content: 'Please repair this' }], total: 1, already_imported: null }
|
||||
}
|
||||
|
||||
if (++attempts === 1) {
|
||||
throw new Error('Connection interrupted')
|
||||
}
|
||||
|
||||
return { session_id: 'durable-one', already_imported: false }
|
||||
})
|
||||
const { onOpenSession } = mount()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Repair imports/ }))
|
||||
await screen.findByText('Please repair this')
|
||||
expect(attempts).toBe(0)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue in Hermes' }))
|
||||
await screen.findByRole('alert')
|
||||
expect(onOpenSession).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue in Hermes' }))
|
||||
await waitFor(() => expect(onOpenSession).toHaveBeenCalledWith('durable-one'))
|
||||
expect(setSessionOwnerHint).toHaveBeenCalledWith('durable-one', owner)
|
||||
expect(foreignRequest).toHaveBeenCalledWith(owner, 'import', { id: 'foreign-one' }, expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('does not navigate when an import finishes after the view has closed', async () => {
|
||||
let finish!: (result: ForeignImportResult) => void
|
||||
vi.mocked(foreignRequest).mockImplementation(async (_owner, method) => {
|
||||
if (method === 'list') {
|
||||
return { sessions: [session], next_offset: null, host: 'studio', unreadable: 0 }
|
||||
}
|
||||
|
||||
if (method === 'preview') {
|
||||
return { messages: [], total: 0, already_imported: 'existing' }
|
||||
}
|
||||
|
||||
return new Promise<ForeignImportResult>(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
})
|
||||
const { onOpenSession, unmount } = mount()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Repair imports/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Open in Hermes' }))
|
||||
await waitFor(() => expect(finish).toBeDefined())
|
||||
unmount()
|
||||
finish({ session_id: 'existing', already_imported: true })
|
||||
await waitFor(() => expect(setSessionOwnerHint).toHaveBeenCalledWith('existing', owner))
|
||||
expect(onOpenSession).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { ErrorState } from '@/components/ui/error-state'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setSessionOwnerHint } from '@/store/session'
|
||||
import type { SessionOwnerRoute } from '@/store/session-request-router'
|
||||
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { PanelEmpty } from '../overlays/panel'
|
||||
|
||||
import { type ForeignImportResult, type ForeignPage, type ForeignPreview, foreignRequest } from './api'
|
||||
|
||||
interface SessionImportViewProps {
|
||||
owner: SessionOwnerRoute
|
||||
onClose: () => void
|
||||
onOpenSession: (id: string) => void
|
||||
}
|
||||
|
||||
export function SessionImportView({ owner, onClose, onOpenSession }: SessionImportViewProps) {
|
||||
const { t, locale } = useI18n()
|
||||
const copy = t.sessionImport
|
||||
const queryClient = useQueryClient()
|
||||
const [source, setSource] = useState<'all' | 'claude' | 'codex'>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const lifetime = useRef<AbortController | null>(null)
|
||||
// eslint-disable-next-line no-restricted-syntax -- lifetime cancellation, not a mirrored reactive value
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
lifetime.current = controller
|
||||
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
const scope = [owner.connectionId, owner.profile]
|
||||
|
||||
const sessions = useInfiniteQuery({
|
||||
queryKey: ['foreign-sessions', ...scope, source],
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
foreignRequest<ForeignPage>(
|
||||
owner,
|
||||
'list',
|
||||
{
|
||||
source: source === 'all' ? null : source,
|
||||
offset: pageParam
|
||||
},
|
||||
signal
|
||||
),
|
||||
getNextPageParam: page => page.next_offset,
|
||||
retry: false
|
||||
})
|
||||
|
||||
const rows = [
|
||||
...new Map((sessions.data?.pages.flatMap(page => page.sessions) ?? []).map(row => [row.id, row])).values()
|
||||
]
|
||||
|
||||
const visible = rows.filter(row =>
|
||||
`${row.title} ${row.cwd ?? ''} ${row.excerpt}`.toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
)
|
||||
|
||||
const current = rows.find(row => row.id === selected)
|
||||
|
||||
const preview = useQuery({
|
||||
queryKey: ['foreign-preview', ...scope, selected],
|
||||
queryFn: ({ signal }) => foreignRequest<ForeignPreview>(owner, 'preview', { id: selected }, signal),
|
||||
enabled: Boolean(current),
|
||||
retry: false
|
||||
})
|
||||
|
||||
const host = sessions.data?.pages[0]?.host
|
||||
const unreadable = sessions.data?.pages.reduce((sum, page) => sum + page.unreadable, 0) ?? 0
|
||||
|
||||
async function continueSession() {
|
||||
if (!current || pending) {
|
||||
return
|
||||
}
|
||||
|
||||
const signal = lifetime.current?.signal
|
||||
setPending(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await foreignRequest<ForeignImportResult>(owner, 'import', { id: current.id }, signal)
|
||||
setSessionOwnerHint(result.session_id, owner)
|
||||
void queryClient.invalidateQueries({ queryKey: ['foreign-preview', ...scope] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['sessions'] })
|
||||
|
||||
if (!signal?.aborted) {
|
||||
onOpenSession(result.session_id)
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!signal?.aborted) {
|
||||
setError(cause instanceof Error ? cause.message : copy.importError)
|
||||
}
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayView
|
||||
onClose={onClose}
|
||||
rootClassName="bg-(--ui-bg-elevated)"
|
||||
titlebarActions={
|
||||
<Button
|
||||
aria-label={t.common.refresh}
|
||||
disabled={sessions.isFetching}
|
||||
onClick={() => {
|
||||
void sessions.refetch()
|
||||
|
||||
if (current) {
|
||||
void preview.refetch()
|
||||
}
|
||||
}}
|
||||
size="icon-titlebar"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<section aria-label={copy.title} className="session-import flex h-full min-h-0 flex-col">
|
||||
<header className="px-8 pb-6 pt-12">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-(--ui-text-primary)">{copy.title}</h1>
|
||||
<p className="mt-2 text-sm text-(--ui-text-secondary)">{copy.subtitle}</p>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-(--ui-text-secondary)">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Codicon name="device-desktop" />
|
||||
{copy.readingFrom} {host ?? copy.connectedComputer}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Codicon name="account" />
|
||||
{copy.destination} {owner.targetProfile ?? owner.profile}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(17rem,0.85fr)_minmax(0,1.6fr)] border-t border-(--ui-stroke-tertiary) max-[760px]:grid-cols-1">
|
||||
<aside
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col bg-(--ui-sidebar-surface-background)',
|
||||
selected && 'max-[760px]:hidden'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-5 px-6 pb-4 pt-6">
|
||||
<SegmentedControl
|
||||
onChange={value => {
|
||||
setSource(value)
|
||||
setSelected(null)
|
||||
setError('')
|
||||
}}
|
||||
options={[
|
||||
{ id: 'all', label: copy.all },
|
||||
{ id: 'claude', label: 'Claude Code' },
|
||||
{ id: 'codex', label: 'Codex' }
|
||||
]}
|
||||
value={source}
|
||||
/>
|
||||
{rows.length > 0 && (
|
||||
<SearchField
|
||||
aria-label={copy.search}
|
||||
containerClassName="opacity-100"
|
||||
onChange={setSearch}
|
||||
placeholder={copy.search}
|
||||
value={search}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-5">
|
||||
{sessions.isPending && <Loader className="mx-auto my-12" label={copy.scanning} />}
|
||||
{sessions.isError && (
|
||||
<ErrorState className="p-5" description={copy.scanHelp} title={copy.scanError}>
|
||||
<Button onClick={() => void sessions.refetch()} variant="secondary">
|
||||
{t.common.retry}
|
||||
</Button>
|
||||
</ErrorState>
|
||||
)}
|
||||
{!sessions.isPending && !sessions.isError && visible.length === 0 && (
|
||||
<div className="p-5">
|
||||
<EmptyState
|
||||
description={search ? copy.searchHelp : copy.emptyHelp}
|
||||
title={search ? copy.noMatches : copy.empty}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visible.map((row, index) => {
|
||||
const date = new Date(row.mtime * 1000).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
|
||||
const previous = visible[index - 1]
|
||||
|
||||
const newDate =
|
||||
!previous ||
|
||||
new Date(previous.mtime * 1000).toDateString() !== new Date(row.mtime * 1000).toDateString()
|
||||
|
||||
return (
|
||||
<div key={row.id}>
|
||||
{newDate && <p className="px-3 pb-2 pt-5 text-xs font-medium text-(--ui-text-secondary)">{date}</p>}
|
||||
<button
|
||||
aria-pressed={selected === row.id}
|
||||
className={cn(
|
||||
'w-full cursor-pointer rounded-md px-3 py-4 text-start transition-colors hover:bg-(--chrome-action-hover) focus-visible:outline-2 focus-visible:outline-(--ui-accent)',
|
||||
selected === row.id && 'bg-(--chrome-action-hover)'
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelected(row.id)
|
||||
setError('')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="mb-2 flex items-center gap-2 text-xs text-(--ui-text-secondary)">
|
||||
<Codicon name="comment-discussion" />
|
||||
{row.label}
|
||||
<span className="ms-auto tabular-nums">
|
||||
{row.turn_count} {copy.messages}
|
||||
</span>
|
||||
</span>
|
||||
<span className="line-clamp-2 text-sm font-medium leading-5 text-(--ui-text-primary)">
|
||||
{row.title}
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-xs leading-5 text-(--ui-text-secondary)">
|
||||
{row.cwd || row.excerpt}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{unreadable > 0 && <p className="px-3 py-4 text-xs text-(--ui-text-secondary)">{copy.skipped}</p>}
|
||||
{sessions.hasNextPage && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
disabled={sessions.isFetchingNextPage}
|
||||
onClick={() => void sessions.fetchNextPage()}
|
||||
variant="text"
|
||||
>
|
||||
{copy.more}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<main className={cn('flex min-h-0 min-w-0 flex-col', !selected && 'max-[760px]:hidden')}>
|
||||
{!current ? (
|
||||
<PanelEmpty description={copy.chooseHelp} icon="comment-discussion" title={copy.choose} />
|
||||
) : (
|
||||
<>
|
||||
<div className="px-8 pb-5 pt-6">
|
||||
<div className="mb-4 hidden max-[760px]:block">
|
||||
<Button onClick={() => setSelected(null)} variant="text">
|
||||
{t.common.back}
|
||||
</Button>
|
||||
</div>
|
||||
<h2 className="text-xl font-medium leading-7 tracking-tight">{current.title}</h2>
|
||||
<p className="mt-2 text-xs text-(--ui-text-secondary)">
|
||||
{current.label} · {current.turn_count} {copy.messages}
|
||||
</p>
|
||||
</div>
|
||||
<div aria-live="polite" className="min-h-0 flex-1 overflow-y-auto px-8 pb-8" key={current.id}>
|
||||
{preview.isPending && <Loader className="mx-auto my-12" label={copy.previewLoading} />}
|
||||
{preview.isError && (
|
||||
<ErrorState description={copy.previewHelp} title={copy.previewError}>
|
||||
<Button onClick={() => void preview.refetch()} variant="secondary">
|
||||
{t.common.retry}
|
||||
</Button>
|
||||
</ErrorState>
|
||||
)}
|
||||
{preview.data && (
|
||||
<div className="max-w-prose space-y-7">
|
||||
{preview.data.truncated && (
|
||||
<p className="text-xs text-(--ui-text-secondary)">{copy.previewLimit}</p>
|
||||
)}
|
||||
{preview.data.messages.map((message, index) => (
|
||||
<article className="min-w-0" key={index}>
|
||||
<p className="mb-2 text-xs font-semibold text-(--ui-text-secondary)">
|
||||
{message.role === 'user' ? copy.you : current.label}
|
||||
</p>
|
||||
<div className="text-sm leading-6 wrap-anywhere">
|
||||
<MarkdownTextContent isRunning={false} previewOnly text={message.content} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="flex flex-wrap items-center justify-between gap-4 border-t border-(--ui-stroke-tertiary) px-8 py-5">
|
||||
<p className="max-w-xs text-xs leading-5 text-(--ui-text-secondary)">
|
||||
{preview.data?.already_imported ? copy.snapshot : copy.copyNotice}
|
||||
</p>
|
||||
<Button disabled={pending || !preview.data || preview.isError} onClick={() => void continueSession()}>
|
||||
{pending ? copy.importing : preview.data?.already_imported ? copy.open : copy.continue}
|
||||
<Codicon name="arrow-right" />
|
||||
</Button>
|
||||
{error && (
|
||||
<div className="w-full text-sm text-destructive" role="alert">
|
||||
{copy.importError} {error}
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { handleDesktopBridgeEvent } from './desktop-bridge'
|
||||
import { handleInputRequestEvent } from './input-requests'
|
||||
import { handleLifecycleEvent } from './lifecycle'
|
||||
import { handleMessageStreamEvent } from './message-stream'
|
||||
import { handleControlEvent } from './session-control'
|
||||
import { handleSessionInfoEvent } from './session-info'
|
||||
import { handleStatusEvent } from './status'
|
||||
import { handleToolEvent } from './tools'
|
||||
@@ -84,6 +85,7 @@ const PROVIDER_WAIT_SUPERSEDING_EVENT_TYPES = new Set([
|
||||
const HANDLERS: GatewayEventHandler[] = [
|
||||
handleLifecycleEvent,
|
||||
handleSessionInfoEvent,
|
||||
handleControlEvent,
|
||||
handleMessageStreamEvent,
|
||||
handleToolEvent,
|
||||
handleInputRequestEvent,
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { refreshSupportedSessionControlAfterTurn } = vi.hoisted(() => ({
|
||||
refreshSupportedSessionControlAfterTurn: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/session-control', () => ({ refreshSupportedSessionControlAfterTurn }))
|
||||
|
||||
import { handleMessageStreamEvent } from './message-stream'
|
||||
import type { GatewayEventContext } from './types'
|
||||
|
||||
function context(type: string): GatewayEventContext {
|
||||
return {
|
||||
deps: {
|
||||
activeGatewayProfile: 'default',
|
||||
activeSessionIdRef: { current: 's1' },
|
||||
appendAssistantDelta: vi.fn(),
|
||||
appendReasoningDelta: vi.fn(),
|
||||
compactedTurnRef: { current: new Set() },
|
||||
completeAssistantMessage: vi.fn(),
|
||||
failAssistantMessage: vi.fn(),
|
||||
finalizeInterimAssistantMessage: vi.fn(),
|
||||
flushQueuedDeltas: vi.fn(),
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
lastCwdInfoSessionRef: { current: null },
|
||||
nativeSubagentSessionsRef: { current: new Set() },
|
||||
queryClient: {} as GatewayEventContext['deps']['queryClient'],
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
scheduleSessionsRefresh: vi.fn(),
|
||||
sessionInterrupted: vi.fn(() => false),
|
||||
sessionStateByRuntimeIdRef: { current: new Map() },
|
||||
updateSessionState: vi.fn(),
|
||||
upsertToolCall: vi.fn()
|
||||
},
|
||||
event: { type },
|
||||
explicitSid: 's1',
|
||||
fromActiveSource: () => true,
|
||||
isActiveEvent: false,
|
||||
occurredAt: 1_700_000_100,
|
||||
payload: { text: 'completed' },
|
||||
scheduleConfigRefresh: vi.fn(),
|
||||
sessionId: 's1'
|
||||
}
|
||||
}
|
||||
|
||||
describe('handleMessageStreamEvent session-control integration', () => {
|
||||
it('refreshes only after message.complete, through the store seam', () => {
|
||||
expect(handleMessageStreamEvent(context('message.delta'))).toBe(true)
|
||||
expect(refreshSupportedSessionControlAfterTurn).not.toHaveBeenCalled()
|
||||
|
||||
expect(handleMessageStreamEvent(context('message.complete'))).toBe(true)
|
||||
expect(refreshSupportedSessionControlAfterTurn).toHaveBeenCalledTimes(1)
|
||||
expect(refreshSupportedSessionControlAfterTurn).toHaveBeenCalledWith('s1')
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
||||
import { clearAllPrompts } from '@/store/prompts'
|
||||
import { providerWaitText, setSessionProviderWait } from '@/store/provider-wait'
|
||||
import { setCurrentUsage, setTurnStartedAt } from '@/store/session'
|
||||
import { refreshSupportedSessionControlAfterTurn } from '@/store/session-control'
|
||||
import { pruneFinishedSessionSubagents } from '@/store/subagents'
|
||||
import { clearActiveSessionTodos } from '@/store/todos'
|
||||
|
||||
@@ -388,6 +389,10 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh only the structured-control sessions already proven capable.
|
||||
// Initial hydration owns the unknown capability probe.
|
||||
void refreshSupportedSessionControlAfterTurn(sessionId)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $sessionControlBySession, clearAllSessionControl, type SessionControlSnapshot } from '@/store/session-control'
|
||||
|
||||
import { handleControlEvent } from './session-control'
|
||||
import type { GatewayEventContext } from './types'
|
||||
|
||||
const SNAPSHOT: SessionControlSnapshot = {
|
||||
goal: null,
|
||||
heartbeat: null,
|
||||
loop: null,
|
||||
revision: 'event-revision',
|
||||
updated_at: 1_700_000_100
|
||||
}
|
||||
|
||||
function context(overrides: Partial<GatewayEventContext> = {}): GatewayEventContext {
|
||||
return {
|
||||
deps: {} as GatewayEventContext['deps'],
|
||||
event: { payload: { control: SNAPSHOT }, session_id: 'event-session', type: 'session.control.update' },
|
||||
explicitSid: 'event-session',
|
||||
fromActiveSource: () => true,
|
||||
isActiveEvent: true,
|
||||
occurredAt: 1_700_000_100,
|
||||
payload: { control: SNAPSHOT } as GatewayEventContext['payload'],
|
||||
scheduleConfigRefresh: vi.fn(),
|
||||
sessionId: 'routed-session',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('handleControlEvent', () => {
|
||||
afterEach(() => {
|
||||
clearAllSessionControl()
|
||||
})
|
||||
|
||||
it('does not claim non-control events', () => {
|
||||
expect(handleControlEvent(context({ event: { type: 'message.complete' } }))).toBe(false)
|
||||
})
|
||||
|
||||
it('applies a valid event to the routed session and proves it supported', () => {
|
||||
expect(handleControlEvent(context())).toBe(true)
|
||||
|
||||
expect($sessionControlBySession.get()).toMatchObject({
|
||||
'routed-session': { capability: 'supported', snapshot: { revision: 'event-revision' } }
|
||||
})
|
||||
expect($sessionControlBySession.get()['event-session']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('claims malformed control events without replacing the last good state', () => {
|
||||
handleControlEvent(context())
|
||||
const first = $sessionControlBySession.get()['routed-session']
|
||||
|
||||
expect(
|
||||
handleControlEvent(
|
||||
context({
|
||||
payload: { control: { ...SNAPSHOT, updated_at: Number.POSITIVE_INFINITY } } as GatewayEventContext['payload']
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
expect($sessionControlBySession.get()['routed-session']).toBe(first)
|
||||
})
|
||||
|
||||
it('claims an unscoped control event without creating state', () => {
|
||||
expect(handleControlEvent(context({ sessionId: null }))).toBe(true)
|
||||
expect($sessionControlBySession.get()).toEqual({})
|
||||
})
|
||||
})
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { applySessionControlUpdate } from '@/store/session-control'
|
||||
|
||||
import type { GatewayEventContext } from './types'
|
||||
|
||||
export function handleControlEvent(ctx: GatewayEventContext): boolean {
|
||||
const { event, payload, sessionId } = ctx
|
||||
|
||||
if (event.type !== 'session.control.update') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return true
|
||||
}
|
||||
|
||||
const control = payload && typeof payload === 'object' ? (payload as { control?: unknown }).control : undefined
|
||||
applySessionControlUpdate(sessionId, control)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { enqueueQueuedPrompt } from '@/store/composer-queue'
|
||||
import { $sessions, resolveComposerSessionKey } from '@/store/session'
|
||||
import { $sessionStates } from '@/store/session-states'
|
||||
|
||||
import { isTargetSessionBusy } from './utils'
|
||||
|
||||
export interface QueueIfBusyInput {
|
||||
/** Runtime session the command was resolved against. */
|
||||
sessionId: string
|
||||
/** Stored session the composer queue is keyed by; defaults to the runtime id
|
||||
* (or its published storedSessionId when known). */
|
||||
storedSessionId?: string | null
|
||||
/** Foreground busy flag — only consulted when there is no session id. */
|
||||
foregroundBusy?: boolean
|
||||
text: string
|
||||
displayText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* When the target session is mid-turn, park a backend-produced kickoff on the
|
||||
* composer queue instead of dropping it. The backend has ALREADY executed the
|
||||
* command (a `/goal` is set, a goal is resumed) and `text` is the prompt that
|
||||
* tells the agent about it — losing it leaves the goal silently unheard
|
||||
* (#63352). Returns `'idle'` when the caller should submit normally, `'queued'`
|
||||
* when the prompt now waits on the queue, and `'busy'` when the session is
|
||||
* busy but nothing could be queued.
|
||||
*/
|
||||
export function queueKickoffIfSessionBusy({
|
||||
displayText,
|
||||
foregroundBusy = false,
|
||||
sessionId,
|
||||
storedSessionId,
|
||||
text
|
||||
}: QueueIfBusyInput): 'busy' | 'idle' | 'queued' {
|
||||
const states = $sessionStates.get()
|
||||
|
||||
if (!isTargetSessionBusy(states, sessionId, foregroundBusy)) {
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
const stored = storedSessionId ?? states[sessionId]?.storedSessionId ?? null
|
||||
const queueKey = resolveComposerSessionKey(stored, $sessions.get()) || stored || sessionId
|
||||
|
||||
return enqueueQueuedPrompt(queueKey, { attachments: [], displayText, text }) ? 'queued' : 'busy'
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { isMissingRpcMethod } from '@/lib/gateway-rpc'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { openCommandPalettePage } from '@/store/command-palette'
|
||||
import { setComposerDraft } from '@/store/composer'
|
||||
import { enqueueQueuedPrompt } from '@/store/composer-queue'
|
||||
import { applyGoalStatusText } from '@/store/goals'
|
||||
import { dismissNotification, notify, notifyError } from '@/store/notifications'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
@@ -34,7 +33,6 @@ import {
|
||||
$connection,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
resolveComposerSessionKey,
|
||||
setActiveSessionId,
|
||||
setCurrentUsage,
|
||||
setModelPickerOpen,
|
||||
@@ -61,11 +59,11 @@ import type {
|
||||
SlashExecResponse
|
||||
} from '../../../types'
|
||||
|
||||
import { queueKickoffIfSessionBusy } from './queue-if-busy'
|
||||
import { resolveTargetSessionId } from './resolve-target-session'
|
||||
import {
|
||||
type GatewayRequest,
|
||||
isSessionIdCandidate,
|
||||
isTargetSessionBusy,
|
||||
renderCommandsCatalog,
|
||||
renderRpcResult,
|
||||
slashStatusText,
|
||||
@@ -345,25 +343,25 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
// view's — see isTargetSessionBusy. `busyRef` mirrors whatever chat
|
||||
// is on screen, while this command runs against the session
|
||||
// resolveTargetSessionId picked, routinely a different one.
|
||||
if (isTargetSessionBusy($sessionStates.get(), sessionId, busyRef.current)) {
|
||||
// The backend already executed the command — for `/goal <text>`
|
||||
// the goal is set and `message` is its kickoff prompt. Dropping
|
||||
// it here loses the kickoff silently (the goal exists but the
|
||||
// agent never hears about it, #63352). Queue it on the composer
|
||||
// queue instead: it fires when the running turn settles, and the
|
||||
// queue panel above the composer shows it in the meantime.
|
||||
//
|
||||
// Park it on the same stored session the output writer is bound to
|
||||
// rather than re-reading the globals here — a session switch between
|
||||
// dispatch and this branch would otherwise queue the kickoff on
|
||||
// whichever chat is now in front.
|
||||
const queueKey = resolveComposerSessionKey(storedSessionId, $sessions.get()) || storedSessionId || sessionId
|
||||
//
|
||||
// Parked on the same stored session the output writer is bound to
|
||||
// rather than re-reading the globals — a session switch between
|
||||
// dispatch and this branch would otherwise queue the kickoff on
|
||||
// whichever chat is now in front (#63352).
|
||||
const queued = queueKickoffIfSessionBusy({
|
||||
displayText,
|
||||
foregroundBusy: busyRef.current,
|
||||
sessionId,
|
||||
storedSessionId,
|
||||
text: message
|
||||
})
|
||||
|
||||
if (enqueueQueuedPrompt(queueKey, { attachments: [], text: message, displayText })) {
|
||||
renderSlashOutput('session busy — message queued to send when the current turn finishes')
|
||||
} else {
|
||||
renderSlashOutput('session busy — /interrupt the current turn before sending this command')
|
||||
}
|
||||
if (queued !== 'idle') {
|
||||
renderSlashOutput(
|
||||
queued === 'queued'
|
||||
? 'session busy — message queued to send when the current turn finishes'
|
||||
: 'session busy — /interrupt the current turn before sending this command'
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
setWorkspaceCwdOwner,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { clearSessionControl } from '@/store/session-control'
|
||||
import { isSessionOwnerResolutionError } from '@/store/session-owner-resolution'
|
||||
import {
|
||||
beginSessionMutation,
|
||||
@@ -1001,9 +1002,11 @@ export function useSessionActions({
|
||||
// dial the owning backend without moving $activeGatewayProfile.
|
||||
if ($showAllProfiles.get()) {
|
||||
if (resolvedConnectionId) {
|
||||
await openGatewayForAgent(resolvedConnectionId, ownerRoute?.profile || sessionProfile || 'default')
|
||||
await openGatewayForAgent(resolvedConnectionId, ownerRoute?.profile || sessionProfile || 'default', {
|
||||
spawnPriority: 'foreground'
|
||||
})
|
||||
} else if (sessionProfile) {
|
||||
await openGatewayForProfile(normalizeProfileKey(sessionProfile))
|
||||
await openGatewayForProfile(normalizeProfileKey(sessionProfile), { spawnPriority: 'foreground' })
|
||||
}
|
||||
} else if (resolvedConnectionId) {
|
||||
await ensureGatewayAgent(resolvedConnectionId, ownerRoute?.profile || sessionProfile || 'default')
|
||||
@@ -2478,6 +2481,7 @@ export function useSessionActions({
|
||||
|
||||
if (closingRuntimeId) {
|
||||
clearQueuedPrompts(closingRuntimeId)
|
||||
clearSessionControl(closingRuntimeId)
|
||||
}
|
||||
|
||||
// A tiled copy of this session must not outlive it: collapse the pane
|
||||
|
||||
@@ -57,11 +57,12 @@ describe('statusbar item visibility', () => {
|
||||
item('gateway-health', 'Gateway')
|
||||
])
|
||||
|
||||
for (const label of ['Cron', 'Webhooks', 'Agents', 'Terminal', 'Approvals']) {
|
||||
for (const label of ['Cron', 'Webhooks', 'Agents', 'Terminal']) {
|
||||
expect(screen.queryByText(label)).toBeNull()
|
||||
}
|
||||
|
||||
expect(screen.getByText('Gateway')).toBeTruthy()
|
||||
expect(screen.getByText('Approvals')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows an item once the user enables it from the bar context menu', async () => {
|
||||
|
||||
@@ -162,7 +162,8 @@ export type CommandDispatchResponse =
|
||||
| SendCommandDispatchResponse
|
||||
| PrefillCommandDispatchResponse
|
||||
|
||||
export type SidebarNavId = 'artifacts' | 'command-center' | 'cron' | 'messaging' | 'new-session' | 'settings' | 'skills'
|
||||
export type SidebarNavId =
|
||||
'artifacts' | 'command-center' | 'cron' | 'messaging' | 'new-session' | 'session-import' | 'settings' | 'skills'
|
||||
|
||||
export interface SidebarNavItem {
|
||||
/** Built-in view id, or a contributed row's namespaced contribution id. */
|
||||
|
||||
@@ -449,6 +449,8 @@ interface MarkdownTextSurfaceProps {
|
||||
/** Disable artifact-card promotion for fenced blocks (reasoning text — a
|
||||
* model's scratchpad draft must not register artifact versions). */
|
||||
disableArtifacts?: boolean
|
||||
/** Foreign history must not load images or mount live transcript directives. */
|
||||
previewOnly?: boolean
|
||||
}
|
||||
|
||||
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
|
||||
@@ -537,7 +539,8 @@ function MarkdownTextSurface({
|
||||
containerClassName,
|
||||
containerProps,
|
||||
defer,
|
||||
disableArtifacts
|
||||
disableArtifacts,
|
||||
previewOnly
|
||||
}: MarkdownTextSurfaceProps) {
|
||||
const { status, text } = useMessagePartText()
|
||||
const isStreaming = status.type === 'running'
|
||||
@@ -564,8 +567,9 @@ function MarkdownTextSurface({
|
||||
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
|
||||
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
|
||||
),
|
||||
p: (props: ComponentProps<'p'>) => <MarkdownParagraph {...props} streaming={isStreaming} />,
|
||||
a: MarkdownLink,
|
||||
p: (props: ComponentProps<'p'>) =>
|
||||
previewOnly ? <p {...props} /> : <MarkdownParagraph {...props} streaming={isStreaming} />,
|
||||
a: previewOnly ? ({ children }: ComponentProps<'a'>) => <span>{children}</span> : MarkdownLink,
|
||||
// Inline code must not vote when an ancestor resolves `dir="auto"`
|
||||
// (HTML's algorithm skips descendants that carry their own dir),
|
||||
// mirroring the CSS isolate that already keeps it out of the
|
||||
@@ -624,13 +628,13 @@ function MarkdownTextSurface({
|
||||
td: ({ className, ...props }: ComponentProps<'td'>) => (
|
||||
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
|
||||
),
|
||||
img: MarkdownImage,
|
||||
img: previewOnly ? ({ alt }: ComponentProps<'img'>) => <span>{alt}</span> : MarkdownImage,
|
||||
// ```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)
|
||||
const artifact = disableArtifacts || previewOnly ? null : detectArtifact(props.language, props.code)
|
||||
|
||||
if (artifact) {
|
||||
return <ArtifactCard code={props.code} detection={artifact} streaming={isStreaming} />
|
||||
@@ -646,7 +650,7 @@ function MarkdownTextSurface({
|
||||
)
|
||||
}
|
||||
}) as StreamdownTextComponents,
|
||||
[disableArtifacts, isStreaming]
|
||||
[disableArtifacts, isStreaming, previewOnly]
|
||||
)
|
||||
|
||||
if (text.length > MAX_MARKDOWN_CHARS) {
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { $paneStates } from '@/store/panes'
|
||||
|
||||
import { group, split, type SplitNode } from '../model'
|
||||
import { $hiddenTreePanes, $layoutTree, markCollapsePane, setTreeGroupMinimized } from '../store'
|
||||
|
||||
import { TreeSplit } from './tree-split'
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const disposers: (() => void)[] = []
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.stubGlobal('CSS', { ...globalThis.CSS, escape: (value: string) => value })
|
||||
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||
vi.stubGlobal('cancelAnimationFrame', () => undefined)
|
||||
Element.prototype.hasPointerCapture ??= () => false
|
||||
Element.prototype.setPointerCapture ??= () => undefined
|
||||
Element.prototype.releasePointerCapture ??= () => undefined
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
$hiddenTreePanes.set(new Set())
|
||||
$paneStates.set({})
|
||||
|
||||
disposers.push(
|
||||
registry.register({ area: 'panes', data: { placement: 'main' }, id: 'chat', render: () => null, title: 'Chat' }),
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { placement: 'main', width: '100px' },
|
||||
id: 'cron',
|
||||
render: () => null,
|
||||
title: 'Cron'
|
||||
}),
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { placement: 'main' },
|
||||
id: 'browser',
|
||||
render: () => null,
|
||||
title: 'Browser'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$layoutTree.set(null)
|
||||
$paneStates.set({})
|
||||
disposers.splice(0).forEach(dispose => dispose())
|
||||
})
|
||||
|
||||
function rect(width: number): DOMRect {
|
||||
return {
|
||||
bottom: 600,
|
||||
height: 600,
|
||||
left: 0,
|
||||
right: width,
|
||||
toJSON: () => ({}),
|
||||
top: 0,
|
||||
width,
|
||||
x: 0,
|
||||
y: 0
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
function setWidth(element: HTMLElement, width: number) {
|
||||
Object.defineProperty(element, 'getBoundingClientRect', { configurable: true, value: () => rect(width) })
|
||||
}
|
||||
|
||||
function setHeight(element: HTMLElement, height: number) {
|
||||
Object.defineProperty(element, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({ ...rect(1000), bottom: height, height })
|
||||
})
|
||||
}
|
||||
|
||||
function row(): SplitNode {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
if (!tree || tree.type !== 'split') {
|
||||
throw new Error('expected root row split')
|
||||
}
|
||||
|
||||
return tree
|
||||
}
|
||||
|
||||
describe('TreeSplit cascading expansion', () => {
|
||||
it('grows Browser through Cron into Chat after Cron reaches its minimum', () => {
|
||||
const tree = split(
|
||||
'row',
|
||||
[
|
||||
group(['chat'], { id: 'chat-zone' }),
|
||||
group(['cron'], { id: 'cron-zone' }),
|
||||
group(['browser'], { id: 'browser-zone' })
|
||||
],
|
||||
[5, 1, 2],
|
||||
'root-row'
|
||||
)
|
||||
|
||||
$layoutTree.set(tree)
|
||||
|
||||
render(<TreeSplit node={tree} root rootRow />)
|
||||
|
||||
const container = document.querySelector<HTMLElement>('[data-tree-split="root-row"]')!
|
||||
const [chat, cron, browser] = [...container.children] as HTMLElement[]
|
||||
setWidth(container, 800)
|
||||
setWidth(chat, 500)
|
||||
setWidth(cron, 100)
|
||||
setWidth(browser, 200)
|
||||
setWidth(document.querySelector<HTMLElement>('[data-tree-group="cron-zone"]')!, 100)
|
||||
|
||||
const browserSash = document.querySelectorAll('[role="separator"]')[1]!
|
||||
fireEvent.pointerDown(browserSash, { button: 0, clientX: 600, pointerId: 1, pointerType: 'mouse' })
|
||||
fireEvent.pointerMove(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
|
||||
fireEvent.pointerUp(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
|
||||
|
||||
// Browser's 300px requested growth first takes Cron from 100px to its
|
||||
// 80px floor, then takes the remaining 280px from Chat. The browser gets
|
||||
// every released pixel instead of stopping at Cron's local floor.
|
||||
expect($paneStates.get().cron?.widthOverride).toBe(80)
|
||||
expect(row().weights).toEqual([2.2, 1, 5])
|
||||
})
|
||||
it('commits a regular cascade when an unrelated tool rail is already minimized', () => {
|
||||
markCollapsePane('terminal')
|
||||
disposers.push(
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { maxWidth: '600px', minWidth: '160px', placement: 'right', width: '200px' },
|
||||
id: 'browser',
|
||||
render: () => null,
|
||||
title: 'Browser'
|
||||
}),
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { placement: 'bottom' },
|
||||
id: 'terminal',
|
||||
render: () => null,
|
||||
title: 'Terminal'
|
||||
})
|
||||
)
|
||||
|
||||
const tree = split(
|
||||
'row',
|
||||
[
|
||||
group(['chat'], { id: 'chat-zone' }),
|
||||
group(['cron'], { id: 'cron-zone' }),
|
||||
group(['browser'], { id: 'browser-zone' }),
|
||||
group(['terminal'], { id: 'terminal-zone' })
|
||||
],
|
||||
[5, 1, 2, 0.28],
|
||||
'root-row'
|
||||
)
|
||||
|
||||
$layoutTree.set(tree)
|
||||
$paneStates.set({ browser: { open: true, widthOverride: 200 } })
|
||||
setTreeGroupMinimized('terminal-zone', true)
|
||||
|
||||
render(<TreeSplit node={row()} root rootRow />)
|
||||
|
||||
const container = document.querySelector<HTMLElement>('[data-tree-split="root-row"]')!
|
||||
const [chat, cron, browser, terminal] = [...container.children] as HTMLElement[]
|
||||
setWidth(container, 828)
|
||||
setWidth(chat, 500)
|
||||
setWidth(cron, 100)
|
||||
setWidth(browser, 200)
|
||||
setWidth(terminal, 28)
|
||||
setWidth(document.querySelector<HTMLElement>('[data-tree-group="cron-zone"]')!, 100)
|
||||
setWidth(document.querySelector<HTMLElement>('[data-tree-group="browser-zone"]')!, 200)
|
||||
setWidth(document.querySelector<HTMLElement>('[data-tree-group="terminal-zone"]')!, 28)
|
||||
|
||||
const browserSash = document.querySelectorAll('[role="separator"]')[1]!
|
||||
fireEvent.pointerDown(browserSash, { button: 0, clientX: 600, pointerId: 1, pointerType: 'mouse' })
|
||||
fireEvent.pointerMove(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
|
||||
fireEvent.pointerUp(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
|
||||
|
||||
expect($paneStates.get().cron?.widthOverride).toBe(80)
|
||||
expect($paneStates.get().browser?.widthOverride).toBe(500)
|
||||
expect(row().weights[0]).toBeCloseTo(2.2)
|
||||
expect(row().children[3]).toMatchObject({ id: 'terminal-zone', minimized: true })
|
||||
})
|
||||
it('folding a tool zone at its floor leaves no drag preview pinned on the flex sibling', () => {
|
||||
markCollapsePane('terminal')
|
||||
disposers.push(
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { height: '200px', placement: 'bottom' },
|
||||
id: 'terminal',
|
||||
render: () => null,
|
||||
title: 'Terminal'
|
||||
})
|
||||
)
|
||||
|
||||
const tree = split(
|
||||
'column',
|
||||
[group(['chat'], { id: 'chat-zone' }), group(['terminal'], { id: 'terminal-zone' })],
|
||||
[1, 1],
|
||||
'root-column'
|
||||
)
|
||||
|
||||
$layoutTree.set(tree)
|
||||
|
||||
render(<TreeSplit node={tree} root />)
|
||||
|
||||
const container = document.querySelector<HTMLElement>('[data-tree-split="root-column"]')!
|
||||
const [chat, terminal] = [...container.children] as HTMLElement[]
|
||||
setHeight(container, 800)
|
||||
setHeight(chat, 600)
|
||||
setHeight(terminal, 200)
|
||||
setHeight(document.querySelector<HTMLElement>('[data-tree-group="terminal-zone"]')!, 200)
|
||||
|
||||
const chatFlex = chat.style.flex
|
||||
const terminalSash = document.querySelectorAll('[role="separator"]')[0]!
|
||||
fireEvent.pointerDown(terminalSash, { button: 0, clientY: 600, pointerId: 1, pointerType: 'mouse' })
|
||||
fireEvent.pointerMove(window, { clientY: 790, pointerId: 1, pointerType: 'mouse' })
|
||||
fireEvent.pointerUp(window, { clientY: 790, pointerId: 1, pointerType: 'mouse' })
|
||||
|
||||
// The zone folds to its rail (no sliver persisted) and the chat wrapper —
|
||||
// which the commit does not re-render — is back on React's own flex, not
|
||||
// the `0 1 <px>` pin the gesture previewed.
|
||||
expect(row().children[1]).toMatchObject({ id: 'terminal-zone', minimized: true })
|
||||
expect($paneStates.get().terminal?.heightOverride).toBeUndefined()
|
||||
expect(chat.style.flex).toBe(chatFlex)
|
||||
})
|
||||
})
|
||||
@@ -73,6 +73,7 @@ function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['override
|
||||
|
||||
const snapshot = useCallback(() => {
|
||||
const all = $paneStates.get()
|
||||
|
||||
const sig = paneIds.map(id => `${id}:${all[id]?.widthOverride ?? ''}:${all[id]?.heightOverride ?? ''}`).join('|')
|
||||
|
||||
if (cache.current.sig !== sig) {
|
||||
@@ -245,15 +246,165 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
|
||||
return
|
||||
}
|
||||
|
||||
const a = sideFor(node.children[aIndex], kidA, 'end')
|
||||
const b = sideFor(node.children[bIndex], kidB, 'start')
|
||||
const a0px = a.fixed ? a.size : sizeOf(kidA)
|
||||
const b0px = b.fixed ? b.size : sizeOf(kidB)
|
||||
const lo = Math.max(a.min - a0px, b0px - b.max)
|
||||
const hi = Math.min(a.max - a0px, b0px - b.min)
|
||||
const tracks = node.children.map((child, index) => {
|
||||
const element = container.children[index] as HTMLElement | undefined
|
||||
|
||||
if (!element) {
|
||||
return null
|
||||
}
|
||||
|
||||
const side = sideFor(child, element, index <= aIndex ? 'end' : 'start')
|
||||
|
||||
return {
|
||||
...side,
|
||||
element,
|
||||
index,
|
||||
initial: side.fixed ? side.size : sizeOf(element),
|
||||
// A minimized rail is its 28px strip: it neither donates nor takes,
|
||||
// and its remembered weight must survive the gesture so restoring
|
||||
// it brings back the size it had before it was folded.
|
||||
minimized: child.type === 'group' && Boolean(child.minimized),
|
||||
visible: !isCollapsed(child)
|
||||
}
|
||||
})
|
||||
|
||||
if (tracks.some(track => !track)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sashTracks = tracks as Array<NonNullable<(typeof tracks)[number]>>
|
||||
const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride
|
||||
|
||||
// The seam is ONE boundary of a whole run: the side the pointer moves
|
||||
// toward gives up space, its seam partner first. When the partner hits
|
||||
// its floor the drag keeps going and takes from the partner's next
|
||||
// visible sibling, and so on — a row behaves like one workspace instead
|
||||
// of a set of unrelated boxes. Plans are absolute (from the sizes at
|
||||
// pointerdown), so every frame is a pure function of the pointer.
|
||||
const planFor = (requestedShift: number, allowCascade: boolean) => {
|
||||
const toward = requestedShift >= 0 ? 1 : -1
|
||||
const targetIndex = toward > 0 ? aIndex : bIndex
|
||||
// The partner is the nearest VISIBLE sibling (`aIndex`/`bIndex`), not
|
||||
// `target ± 1` — a hidden zone parked between them (the closed preview
|
||||
// pane) is display:none and must neither donate nor block.
|
||||
const partnerIndex = toward > 0 ? bIndex : aIndex
|
||||
const target = sashTracks[targetIndex]
|
||||
const next = sashTracks.map(track => track.initial)
|
||||
let remaining = Math.min(Math.abs(requestedShift), Math.max(0, target.max - target.initial))
|
||||
let transferred = 0
|
||||
let cascaded = false
|
||||
// A tool rail is locally resizable at its own seam, but must not make
|
||||
// every unrelated seam in the row local-only. It also remains the
|
||||
// terminal donor when reached only after another regular track.
|
||||
const cascade = allowCascade && !target.collapseId
|
||||
|
||||
for (let donorIndex = partnerIndex; donorIndex >= 0 && donorIndex < sashTracks.length; donorIndex += toward) {
|
||||
const donor = sashTracks[donorIndex]
|
||||
|
||||
if (!donor.visible) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (donor.collapseId && donorIndex !== partnerIndex) {
|
||||
break
|
||||
}
|
||||
|
||||
const take = Math.min(remaining, Math.max(0, donor.initial - donor.min))
|
||||
next[donorIndex] -= take
|
||||
transferred += take
|
||||
remaining -= take
|
||||
|
||||
if (take > 0 && donorIndex !== partnerIndex) {
|
||||
cascaded = true
|
||||
}
|
||||
|
||||
if (remaining === 0 || !cascade) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
next[targetIndex] += transferred
|
||||
|
||||
return { cascaded, moved: Math.sign(requestedShift) * transferred, sizes: next }
|
||||
}
|
||||
|
||||
// One store commit on release: fixed zones get their px override
|
||||
// (sidebar semantics), the flex run gets weights. Clamped px → weights
|
||||
// so persisted weights match what's on screen.
|
||||
const commitPlan = (plan: ReturnType<typeof planFor>) => {
|
||||
const weights = [...node.weights]
|
||||
let weightsChanged = false
|
||||
|
||||
sashTracks.forEach((track, index) => {
|
||||
if (!track.visible || track.minimized) {
|
||||
return
|
||||
}
|
||||
|
||||
const px = Math.round(plan.sizes[index])
|
||||
|
||||
if (track.fixed) {
|
||||
if (px !== Math.round(track.initial)) {
|
||||
track.paneIds.forEach(id => setOverride(id, px))
|
||||
}
|
||||
} else {
|
||||
weights[index] = Math.max(0.01, plan.sizes[index] / pxPerWeight)
|
||||
weightsChanged = true
|
||||
}
|
||||
})
|
||||
|
||||
if (weightsChanged) {
|
||||
setTreeSplitWeights(node.id, weights)
|
||||
}
|
||||
}
|
||||
|
||||
const styleSnapshots = sashTracks.map(track => track.element.getAttribute('style'))
|
||||
|
||||
const restoreStyles = () => {
|
||||
sashTracks.forEach((track, index) => {
|
||||
const style = styleSnapshots[index]
|
||||
|
||||
if (style === null) {
|
||||
track.element.removeAttribute('style')
|
||||
} else {
|
||||
track.element.setAttribute('style', style)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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 run 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 track 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.
|
||||
// - every visible flex track is pinned to `0 1 <px>`. The plan keeps
|
||||
// the run's total px constant, so no leftover gap can open.
|
||||
// - hidden (display:none) and minimized tracks are left untouched.
|
||||
// - cleanup: release restores the captured style attributes, then a
|
||||
// real drag commits the store once and React re-renders from it.
|
||||
const previewPlan = (plan: ReturnType<typeof planFor>) => {
|
||||
sashTracks.forEach((track, index) => {
|
||||
if (!track.visible || track.minimized) {
|
||||
return
|
||||
}
|
||||
|
||||
const px = plan.sizes[index]
|
||||
|
||||
if (track.fixed) {
|
||||
track.element.style.flexBasis = `${px}px`
|
||||
} else {
|
||||
track.element.style.flex = `0 1 ${px}px`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
handle.setPointerCapture?.(pointerId)
|
||||
} catch {
|
||||
@@ -272,70 +423,26 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
|
||||
|
||||
// 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)))
|
||||
}
|
||||
|
||||
if (b.fixed) {
|
||||
b.paneIds.forEach(id => setOverride(id, Math.round(b0px - shiftPx)))
|
||||
}
|
||||
|
||||
if (!a.fixed && !b.fixed) {
|
||||
const weights = [...node.weights]
|
||||
// Clamped px → weights so persisted weights match what's on screen.
|
||||
weights[aIndex] = (a0px + shiftPx) / pxPerWeight
|
||||
weights[bIndex] = (b0px - shiftPx) / pxPerWeight
|
||||
setTreeSplitWeights(node.id, weights)
|
||||
}
|
||||
}
|
||||
|
||||
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 resize = rafCoalesce(previewPlan)
|
||||
// A return drag stays local only after this gesture has actually crossed
|
||||
// a second donor. A one-pixel opposite wobble must not poison the real
|
||||
// forward movement that follows.
|
||||
let cascadeDirection: -1 | 1 | null = null
|
||||
let lastPlan: null | ReturnType<typeof planFor> = null
|
||||
let done = false
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
lastShift = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))
|
||||
resize.push(lastShift)
|
||||
const shift = (horizontal ? ev.clientX : ev.clientY) - start
|
||||
const direction = Math.sign(shift) as -1 | 0 | 1
|
||||
|
||||
const allowCascade = cascadeDirection === null || direction === cascadeDirection
|
||||
lastPlan = planFor(shift, allowCascade)
|
||||
|
||||
if (cascadeDirection === null && direction !== 0 && lastPlan.cascaded) {
|
||||
cascadeDirection = direction
|
||||
}
|
||||
|
||||
resize.push(lastPlan)
|
||||
}
|
||||
|
||||
// Ends through several racing paths (pointerup, pointercancel, window
|
||||
@@ -349,35 +456,28 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
|
||||
done = true
|
||||
resize.finish()
|
||||
|
||||
if (lastShift !== null) {
|
||||
// Put every wrapper's inline style back exactly as React last wrote
|
||||
// it BEFORE the store commit. React only rewrites a wrapper whose
|
||||
// style prop changed; a preview pinned on a track the commit leaves
|
||||
// alone (the flex run beside a zone that folded to its rail) would
|
||||
// otherwise survive as a stale `flex: 0 1 <px>` and stop it growing.
|
||||
// A no-movement click has no commit, so this is also its whole cleanup.
|
||||
restoreStyles()
|
||||
|
||||
if (lastPlan && lastPlan.moved !== 0) {
|
||||
// Dragged a tool panel down to its collapsed header? Fold the zone
|
||||
// to its rail instead of persisting a sliver — and DON'T write the
|
||||
// sliver size, so restoring brings back the size it had before.
|
||||
const collapsedSide =
|
||||
(a.collapseId && a0px + lastShift <= a.floor && a.collapseId) ||
|
||||
(b.collapseId && b0px - lastShift <= b.floor && b.collapseId) ||
|
||||
null
|
||||
// Only a track THIS gesture took to its floor counts: an unrelated
|
||||
// rail already resting there must not cancel the commit.
|
||||
const collapsedSide = sashTracks.find(
|
||||
(track, index) => track.collapseId && track.initial > track.floor && lastPlan!.sizes[index] <= track.floor
|
||||
)?.collapseId
|
||||
|
||||
if (collapsedSide) {
|
||||
setTreeGroupMinimized(collapsedSide, true)
|
||||
} else {
|
||||
// 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)
|
||||
commitPlan(lastPlan)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+5
-1
@@ -20,12 +20,16 @@ declare global {
|
||||
// Resolve a backend connection. Omit `profile` (or pass the primary) for
|
||||
// the window's backend; pass a named profile to lazily spawn/reuse that
|
||||
// profile's backend from the pool.
|
||||
getConnection: (profile?: string | null) => Promise<HermesConnection>
|
||||
getConnection: (
|
||||
profile?: string | null,
|
||||
opts?: { priority?: 'foreground' | 'background' }
|
||||
) => Promise<HermesConnection>
|
||||
// Registry-scoped backend resolution: dial (connectionId, profile). An
|
||||
// empty/local connectionId delegates to the legacy getConnection path.
|
||||
getConnectionFor?: (payload: {
|
||||
connectionId?: null | string
|
||||
profile?: null | string
|
||||
priority?: 'foreground' | 'background'
|
||||
}) => Promise<HermesConnection>
|
||||
// Registry-scoped fresh WS URL (same result contract as getGatewayWsUrl).
|
||||
getGatewayWsUrlFor?: (payload: {
|
||||
|
||||
+128
-1
@@ -1,6 +1,39 @@
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
export const ar = defineLocale({
|
||||
sessionImport: {
|
||||
title: 'المتابعة من تطبيق آخر',
|
||||
subtitle: 'انقل محادثة إلى Hermes وتابع من حيث توقفت.',
|
||||
action: 'استيراد جلسة',
|
||||
readingFrom: 'القراءة من',
|
||||
connectedComputer: 'الكمبيوتر المتصل',
|
||||
destination: 'الاستيراد إلى',
|
||||
all: 'الكل',
|
||||
search: 'البحث في الجلسات المحملة',
|
||||
scanning: 'جارٍ البحث عن المحادثات',
|
||||
scanError: 'تعذر العثور على الجلسات',
|
||||
scanHelp: 'تحقق من اتصال الخادم ثم أعد المحاولة. قد تحتاج الخوادم القديمة إلى تحديث.',
|
||||
empty: 'لا توجد محادثات',
|
||||
emptyHelp: 'ستظهر هنا جلسات Claude Code وCodex الموجودة على هذا الخادم.',
|
||||
noMatches: 'لا توجد محادثات مطابقة',
|
||||
searchHelp: 'جرّب عنوانًا أو مجلدًا آخر، أو حمّل المزيد من الجلسات.',
|
||||
skipped: 'تم تجاوز بعض السجلات الفارغة أو غير المقروءة أو الكبيرة جدًا.',
|
||||
more: 'تحميل المزيد من الجلسات',
|
||||
messages: 'رسائل',
|
||||
choose: 'محادثة تستحق المتابعة',
|
||||
chooseHelp: 'اختر جلسة لقراءة سجلها قبل نقلها إلى Hermes.',
|
||||
previewLoading: 'جارٍ فتح المعاينة',
|
||||
previewError: 'المعاينة غير متاحة',
|
||||
previewHelp: 'ربما تم نقل الملف الأصلي أو تغييره. حدّث القائمة وحاول مرة أخرى.',
|
||||
previewLimit: 'تم اختصار المعاينة لتسهيل القراءة. يتم استيراد المحادثة كاملة.',
|
||||
you: 'أنت',
|
||||
snapshot: 'هذه المحادثة موجودة بالفعل في Hermes. افتح نسختك الحالية للمتابعة.',
|
||||
copyNotice: 'ينسخ نص المحادثة دون تغيير الملفات الأصلية. لا يشمل مخرجات الأدوات أو الاستدلال.',
|
||||
importing: 'جارٍ الاستيراد…',
|
||||
open: 'فتح في Hermes',
|
||||
continue: 'المتابعة في Hermes',
|
||||
importError: 'تعذر استيراد هذه المحادثة.'
|
||||
},
|
||||
sendDiagnostics: {
|
||||
title: 'إرسال التشخيصات إلى Nous',
|
||||
privacyNotice:
|
||||
@@ -1698,7 +1731,8 @@ export const ar = defineLocale({
|
||||
chat: 'المحادثة',
|
||||
settings: 'الإعدادات',
|
||||
cron: 'المهام المجدولة',
|
||||
agents: 'الوكلاء'
|
||||
agents: 'الوكلاء',
|
||||
'session-import': 'استيراد جلسة'
|
||||
},
|
||||
searchAria: 'البحث في الجلسات',
|
||||
searchPlaceholder: 'البحث في الجلسات...',
|
||||
@@ -1951,12 +1985,105 @@ export const ar = defineLocale({
|
||||
statusStack: {
|
||||
agents: 'الوكلاء',
|
||||
background: count => `${count} في الخلفية`,
|
||||
goalActive: 'الهدف نشط',
|
||||
goalBlocked: 'الهدف متعذر',
|
||||
goalDone: 'اكتمل الهدف',
|
||||
goalPaused: 'الهدف متوقف مؤقتًا',
|
||||
goalWaiting: 'الهدف قيد الانتظار',
|
||||
subagents: count => `${count} ${count === 1 ? 'وكيل فرعي' : 'وكيل فرعي'}`,
|
||||
todos: (done, total) => `المهام ${done}/${total}`,
|
||||
running: 'قيد التشغيل',
|
||||
stop: 'إيقاف',
|
||||
dismiss: 'تجاهل',
|
||||
exit: code => `خروج ${code}`,
|
||||
control: {
|
||||
goalActiveTurns: (turn, maxTurns) => `الجولة ${turn}/${maxTurns}`,
|
||||
goalDoneTurns: turns => `${turns} جولات`,
|
||||
goalTurn: turn => `الجولة ${turn}`,
|
||||
goalActions: 'إجراءات الهدف',
|
||||
viewDetails: 'عرض التفاصيل',
|
||||
addCriterion: 'إضافة معيار',
|
||||
addCriterionDialogTitle: 'إضافة معيار',
|
||||
addCriterionPlaceholder: 'أدخل نص المعيار...',
|
||||
criterionLabel: 'المعيار',
|
||||
pauseGoal: 'إيقاف الهدف مؤقتاً',
|
||||
resumeGoal: 'استئناف الهدف',
|
||||
resumeNow: 'استئناف الآن',
|
||||
clearGoal: 'مسح الهدف',
|
||||
clearGoalConfirmTitle: 'مسح الهدف؟',
|
||||
clearGoalConfirmBody: 'هل أنت متأكد من مسح الهدف النشط؟ لا يمكن التراجع عن هذا الإجراء.',
|
||||
copyCriterion: index => `نسخ المعيار ${index}`,
|
||||
removeCriterion: index => `إزالة المعيار ${index}`,
|
||||
removeCriterionConfirmTitle: index => `إزالة المعيار ${index}؟`,
|
||||
removeCriterionConfirmBody: index => `هل أنت متأكد من إزالة المعيار ${index}؟`,
|
||||
clearCriteria: 'مسح جميع المعايير',
|
||||
clearCriteriaConfirmTitle: 'مسح جميع المعايير؟',
|
||||
clearCriteriaConfirmBody: 'هل أنت متأكد من رغبتك في إزالة جميع المعايير من هذا الهدف؟',
|
||||
criteriaHeader: count => `المعايير · ${count}`,
|
||||
noCriteria: 'لا توجد معايير',
|
||||
goalDetailsTitle: 'تفاصيل الهدف',
|
||||
objectiveLabel: 'الهدف',
|
||||
contractOutcome: 'النتيجة',
|
||||
contractVerification: 'التحقق',
|
||||
contractConstraints: 'القيود',
|
||||
contractBoundaries: 'الحدود',
|
||||
contractStopWhen: 'التوقف عند',
|
||||
waitBarrierTitle: 'شرط الانتظار',
|
||||
waitUntil: target => `في الانتظار حتى ${target}`,
|
||||
waitSession: target => `في انتظار الجلسة ${target}`,
|
||||
waitPid: pid => `في انتظار العملية ${pid}`,
|
||||
qualityGatesTitle: 'بوابات الجودة',
|
||||
gateCommand: 'الأمر',
|
||||
gateAttempts: (attempts, max) => `${attempts}/${max} محاولات`,
|
||||
gateTimeout: seconds => `مهلة ${seconds} ثانية`,
|
||||
gateLastExit: code => (code === null ? 'قيد الانتظار' : `رمز الخروج: ${code}`),
|
||||
loopActive: 'التكرار نشط',
|
||||
loopPaused: 'التكرار متوقف مؤقتاً',
|
||||
loopDeferred: 'التكرار مؤجل',
|
||||
loopFinished: 'التكرار منتهي',
|
||||
loopRuns: runs => `${runs} مرات تشغيل`,
|
||||
loopRunCount: (current, total) => `تشغيل ${current}/${total}`,
|
||||
loopNext: time => `التالي ${time}`,
|
||||
loopEverySeconds: seconds => `كل ${seconds} ث`,
|
||||
loopEveryMinutes: minutes => `كل ${minutes} د`,
|
||||
loopEveryHours: hours => `كل ${hours} س`,
|
||||
loopSelfPaced: 'ذاتي الإيقاع',
|
||||
loopActions: 'إجراءات التكرار',
|
||||
pauseLoop: 'إيقاف التكرار مؤقتاً',
|
||||
resumeLoop: 'استئناف التكرار',
|
||||
stopLoop: 'إيقاف التكرار',
|
||||
stopLoopConfirmTitle: 'إيقاف التكرار؟',
|
||||
stopLoopConfirmBody: 'هل أنت متأكد من رغبتك في إيقاف هذا التكرار؟',
|
||||
dismissLoop: 'تجاهل التكرار',
|
||||
loopPromptLabel: 'الموجه',
|
||||
loopCadenceLabel: 'الإيقاع',
|
||||
loopUntilLabel: 'شرط الانتهاء',
|
||||
loopDeferredNotice: 'الهدف النشط يتحكم بالجلسة حالياً.',
|
||||
loopAwaitingResponse: 'في انتظار الاستجابة',
|
||||
heartbeatActive: 'النبض نشط',
|
||||
heartbeatPaused: 'النبض متوقف مؤقتاً',
|
||||
heartbeatEveryMinutes: minutes => `كل ${minutes} د`,
|
||||
heartbeatEveryHours: hours => `كل ${hours} س`,
|
||||
heartbeatEverySeconds: seconds => `كل ${seconds} ث`,
|
||||
heartbeatNext: time => `التالي ${time}`,
|
||||
heartbeatActions: 'إجراءات النبض',
|
||||
pauseHeartbeat: 'إيقاف النبض مؤقتاً',
|
||||
resumeHeartbeat: 'استئناف النبض',
|
||||
clearHeartbeat: 'مسح النبض',
|
||||
clearHeartbeatConfirmTitle: 'مسح النبض؟',
|
||||
clearHeartbeatConfirmBody: 'هل أنت متأكد من رغبتك في مسح هذا النبض؟',
|
||||
heartbeatFiredCount: count => `انطلق ${count} مرة`,
|
||||
actionFailed: msg => `فشل الإجراء: ${msg}`,
|
||||
actionSucceeded: 'تم الإجراء بنجاح',
|
||||
copySuccess: 'تم نسخ المعيار إلى الحافظة',
|
||||
copyFailure: 'فشل نسخ المعيار إلى الحافظة',
|
||||
continuationFailed: 'فشل إرسال متابعة الهدف',
|
||||
continuationQueued: 'تم استئناف الهدف — المتابعة في قائمة الانتظار حتى انتهاء الدور الحالي',
|
||||
continuationBusy: 'تم استئناف الهدف — الجلسة مشغولة، نفّذ /interrupt للدور الحالي للمتابعة',
|
||||
controlUnavailable: msg => `عناصر تحكم الجلسة غير متاحة: ${msg}`,
|
||||
dismissError: 'تجاهل الخطأ',
|
||||
add: 'إضافة'
|
||||
},
|
||||
coding: {
|
||||
title: 'شجرة العمل',
|
||||
noBranch: 'لا يوجد فرع',
|
||||
|
||||
+126
-1
@@ -3,6 +3,40 @@ import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
|
||||
import type { Translations } from './types'
|
||||
|
||||
export const en: Translations = {
|
||||
sessionImport: {
|
||||
title: 'Continue from another app',
|
||||
subtitle: 'Bring a conversation into Hermes and pick up where you left off.',
|
||||
action: 'Import session',
|
||||
readingFrom: 'Reading from',
|
||||
connectedComputer: 'the connected computer',
|
||||
destination: 'Import into',
|
||||
all: 'All',
|
||||
search: 'Search loaded sessions',
|
||||
scanning: 'Finding conversations',
|
||||
scanError: 'Could not find sessions',
|
||||
scanHelp: 'Check your backend connection, then try again. Older backends may need an update.',
|
||||
empty: 'No conversations found',
|
||||
emptyHelp: 'Claude Code and Codex sessions on this backend will appear here.',
|
||||
noMatches: 'No matching conversations',
|
||||
searchHelp: 'Try another title or folder, or load more sessions.',
|
||||
skipped: 'Some logs were empty, unreadable, or too large to preview.',
|
||||
more: 'Load more sessions',
|
||||
messages: 'messages',
|
||||
choose: 'A conversation worth continuing',
|
||||
chooseHelp: 'Choose a session to read its history before bringing it into Hermes.',
|
||||
previewLoading: 'Opening preview',
|
||||
previewError: 'Preview unavailable',
|
||||
previewHelp: 'The source may have moved or changed. Refresh the list and try again.',
|
||||
previewLimit: 'Preview shortened for readability. The complete conversation is imported.',
|
||||
you: 'You',
|
||||
snapshot: 'This conversation is already in Hermes. Open your existing copy to continue.',
|
||||
copyNotice:
|
||||
'Copies conversation text. Source files stay unchanged. Tool output and reasoning are not carried over.',
|
||||
importing: 'Importing…',
|
||||
open: 'Open in Hermes',
|
||||
continue: 'Continue in Hermes',
|
||||
importError: 'Could not import this conversation.'
|
||||
},
|
||||
common: {
|
||||
apply: 'Apply',
|
||||
back: 'Back',
|
||||
@@ -2323,7 +2357,8 @@ export const en: Translations = {
|
||||
skills: 'Capabilities',
|
||||
messaging: 'Messaging',
|
||||
artifacts: 'Artifacts',
|
||||
cron: 'Scheduled jobs'
|
||||
cron: 'Scheduled jobs',
|
||||
'session-import': 'Import session'
|
||||
},
|
||||
searchAria: 'Search sessions',
|
||||
searchPlaceholder: 'Search sessions…',
|
||||
@@ -2665,6 +2700,7 @@ export const en: Translations = {
|
||||
agents: 'Agents',
|
||||
background: count => `${count} Background`,
|
||||
goalActive: 'Goal active',
|
||||
goalBlocked: 'Goal blocked',
|
||||
goalDone: 'Goal done',
|
||||
goalPaused: 'Goal paused',
|
||||
goalWaiting: 'Goal waiting',
|
||||
@@ -2674,6 +2710,95 @@ export const en: Translations = {
|
||||
stop: 'Stop',
|
||||
dismiss: 'Dismiss',
|
||||
exit: code => `exit ${code}`,
|
||||
control: {
|
||||
goalActiveTurns: (turn, maxTurns) => `Turn ${turn}/${maxTurns}`,
|
||||
goalDoneTurns: turns => `${turns} turn${turns === 1 ? '' : 's'}`,
|
||||
goalTurn: turn => `Turn ${turn}`,
|
||||
goalActions: 'Goal actions',
|
||||
viewDetails: 'View details',
|
||||
addCriterion: 'Add criterion',
|
||||
addCriterionDialogTitle: 'Add criterion',
|
||||
addCriterionPlaceholder: 'Enter criterion text...',
|
||||
criterionLabel: 'Criterion',
|
||||
pauseGoal: 'Pause goal',
|
||||
resumeGoal: 'Resume goal',
|
||||
resumeNow: 'Resume now',
|
||||
clearGoal: 'Clear goal',
|
||||
clearGoalConfirmTitle: 'Clear goal?',
|
||||
clearGoalConfirmBody: 'Are you sure you want to clear the active goal? This cannot be undone.',
|
||||
copyCriterion: index => `Copy criterion ${index}`,
|
||||
removeCriterion: index => `Remove criterion ${index}`,
|
||||
removeCriterionConfirmTitle: index => `Remove criterion ${index}?`,
|
||||
removeCriterionConfirmBody: index => `Are you sure you want to remove criterion ${index}?`,
|
||||
clearCriteria: 'Clear all criteria',
|
||||
clearCriteriaConfirmTitle: 'Clear all criteria?',
|
||||
clearCriteriaConfirmBody: 'Are you sure you want to remove all criteria from this goal?',
|
||||
criteriaHeader: count => `Criteria · ${count}`,
|
||||
noCriteria: 'No criteria',
|
||||
goalDetailsTitle: 'Goal details',
|
||||
objectiveLabel: 'Objective',
|
||||
contractOutcome: 'Outcome',
|
||||
contractVerification: 'Verification',
|
||||
contractConstraints: 'Constraints',
|
||||
contractBoundaries: 'Boundaries',
|
||||
contractStopWhen: 'Stop when',
|
||||
waitBarrierTitle: 'Wait condition',
|
||||
waitUntil: target => `Waiting until ${target}`,
|
||||
waitSession: target => `Waiting on session ${target}`,
|
||||
waitPid: pid => `Waiting on process ${pid}`,
|
||||
qualityGatesTitle: 'Quality gates',
|
||||
gateCommand: 'Command',
|
||||
gateAttempts: (attempts, max) => `${attempts}/${max} attempts`,
|
||||
gateTimeout: seconds => `${seconds}s timeout`,
|
||||
gateLastExit: code => (code === null ? 'Pending' : `Exit code: ${code}`),
|
||||
loopActive: 'Loop active',
|
||||
loopPaused: 'Loop paused',
|
||||
loopDeferred: 'Loop deferred',
|
||||
loopFinished: 'Loop finished',
|
||||
loopRuns: runs => `${runs} run${runs === 1 ? '' : 's'}`,
|
||||
loopRunCount: (current, total) => `Run ${current}/${total}`,
|
||||
loopNext: time => `next ${time}`,
|
||||
loopEverySeconds: seconds => `every ${seconds}s`,
|
||||
loopEveryMinutes: minutes => `every ${minutes}m`,
|
||||
loopEveryHours: hours => `every ${hours}h`,
|
||||
loopSelfPaced: 'self-paced',
|
||||
loopActions: 'Loop actions',
|
||||
pauseLoop: 'Pause loop',
|
||||
resumeLoop: 'Resume loop',
|
||||
stopLoop: 'Stop loop',
|
||||
stopLoopConfirmTitle: 'Stop loop?',
|
||||
stopLoopConfirmBody: 'Are you sure you want to stop this loop?',
|
||||
dismissLoop: 'Dismiss loop',
|
||||
loopPromptLabel: 'Prompt',
|
||||
loopCadenceLabel: 'Cadence',
|
||||
loopUntilLabel: 'Until condition',
|
||||
loopDeferredNotice: 'An active goal currently controls the session.',
|
||||
loopAwaitingResponse: 'Awaiting response',
|
||||
heartbeatActive: 'Heartbeat active',
|
||||
heartbeatPaused: 'Heartbeat paused',
|
||||
heartbeatEveryMinutes: minutes => `every ${minutes}m`,
|
||||
heartbeatEveryHours: hours => `every ${hours}h`,
|
||||
heartbeatEverySeconds: seconds => `every ${seconds}s`,
|
||||
heartbeatNext: time => `next ${time}`,
|
||||
heartbeatDueWaitingForIdle: 'due — waiting for idle',
|
||||
heartbeatActions: 'Heartbeat actions',
|
||||
pauseHeartbeat: 'Pause heartbeat',
|
||||
resumeHeartbeat: 'Resume heartbeat',
|
||||
clearHeartbeat: 'Clear heartbeat',
|
||||
clearHeartbeatConfirmTitle: 'Clear heartbeat?',
|
||||
clearHeartbeatConfirmBody: 'Are you sure you want to clear this heartbeat?',
|
||||
heartbeatFiredCount: count => `Fired ${count} time${count === 1 ? '' : 's'}`,
|
||||
actionFailed: msg => `Action failed: ${msg}`,
|
||||
actionSucceeded: 'Action succeeded',
|
||||
copySuccess: 'Criterion copied to clipboard',
|
||||
copyFailure: 'Failed to copy criterion to clipboard',
|
||||
continuationFailed: 'Failed to submit goal continuation',
|
||||
continuationQueued: 'Goal resumed — continuation queued until the current turn finishes',
|
||||
continuationBusy: 'Goal resumed — session busy, /interrupt the current turn to continue',
|
||||
controlUnavailable: msg => `Session controls unavailable: ${msg}`,
|
||||
dismissError: 'Dismiss error',
|
||||
add: 'Add'
|
||||
},
|
||||
coding: {
|
||||
title: 'Working tree',
|
||||
noBranch: 'No branch',
|
||||
|
||||
+125
-1
@@ -3,6 +3,39 @@ import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
export const ja = defineLocale({
|
||||
sessionImport: {
|
||||
title: '別のアプリから続ける',
|
||||
subtitle: '会話をHermesに取り込み、続きを始めましょう。',
|
||||
action: 'セッションを取り込む',
|
||||
readingFrom: '読み込み元',
|
||||
connectedComputer: '接続先のコンピューター',
|
||||
destination: '取り込み先',
|
||||
all: 'すべて',
|
||||
search: '読み込み済みのセッションを検索',
|
||||
scanning: '会話を検索中',
|
||||
scanError: 'セッションを取得できません',
|
||||
scanHelp: 'バックエンドの接続を確認して再試行してください。古いバックエンドは更新が必要な場合があります。',
|
||||
empty: '会話が見つかりません',
|
||||
emptyHelp: 'このバックエンドのClaude CodeとCodexのセッションがここに表示されます。',
|
||||
noMatches: '一致する会話がありません',
|
||||
searchHelp: '別のタイトルやフォルダーを検索するか、セッションを追加で読み込んでください。',
|
||||
skipped: '空、読み込み不可、または大きすぎるログをスキップしました。',
|
||||
more: 'さらに読み込む',
|
||||
messages: 'メッセージ',
|
||||
choose: '会話の続きを始めましょう',
|
||||
chooseHelp: 'セッションを選び、取り込む前に履歴を確認できます。',
|
||||
previewLoading: 'プレビューを開いています',
|
||||
previewError: 'プレビューできません',
|
||||
previewHelp: '元のファイルが移動または変更された可能性があります。一覧を更新してください。',
|
||||
previewLimit: '読みやすいようにプレビューを省略しています。取り込み時は会話全体をコピーします。',
|
||||
you: 'あなた',
|
||||
snapshot: 'この会話は取り込み済みです。既存のコピーを開いて続けられます。',
|
||||
copyNotice: '会話のテキストをコピーします。元のファイルは変更されません。ツール出力と推論は含まれません。',
|
||||
importing: '取り込み中…',
|
||||
open: 'Hermesで開く',
|
||||
continue: 'Hermesで続ける',
|
||||
importError: '会話を取り込めませんでした。'
|
||||
},
|
||||
common: {
|
||||
apply: '適用',
|
||||
back: '戻る',
|
||||
@@ -1998,7 +2031,8 @@ export const ja = defineLocale({
|
||||
skills: 'スキルとツール',
|
||||
messaging: 'メッセージング',
|
||||
artifacts: 'アーティファクト',
|
||||
cron: 'スケジュール済みジョブ'
|
||||
cron: 'スケジュール済みジョブ',
|
||||
'session-import': 'セッションを取り込む'
|
||||
},
|
||||
searchAria: 'セッションを検索',
|
||||
searchPlaceholder: 'セッションを検索…',
|
||||
@@ -2291,6 +2325,7 @@ export const ja = defineLocale({
|
||||
agents: 'エージェント',
|
||||
background: count => `バックグラウンド ${count} 件`,
|
||||
goalActive: '目標進行中',
|
||||
goalBlocked: '目標がブロックされています',
|
||||
goalDone: '目標達成',
|
||||
goalPaused: '目標一時停止中',
|
||||
goalWaiting: '目標待機中',
|
||||
@@ -2300,6 +2335,95 @@ export const ja = defineLocale({
|
||||
stop: '停止',
|
||||
dismiss: '閉じる',
|
||||
exit: code => `終了コード ${code}`,
|
||||
control: {
|
||||
goalActiveTurns: (turn, maxTurns) => `ターン ${turn}/${maxTurns}`,
|
||||
goalDoneTurns: turns => `${turns} ターン`,
|
||||
goalTurn: turn => `ターン ${turn}`,
|
||||
goalActions: 'ゴールのアクション',
|
||||
viewDetails: '詳細を表示',
|
||||
addCriterion: '基準を追加',
|
||||
addCriterionDialogTitle: '基準の追加',
|
||||
addCriterionPlaceholder: '基準を入力...',
|
||||
criterionLabel: '基準',
|
||||
pauseGoal: 'ゴールを一時停止',
|
||||
resumeGoal: 'ゴールを再開',
|
||||
resumeNow: '今すぐ再開',
|
||||
clearGoal: 'ゴールをクリア',
|
||||
clearGoalConfirmTitle: 'ゴールをクリアしますか?',
|
||||
clearGoalConfirmBody: 'アクティブなゴールをクリアしてもよろしいですか?この操作は取り消せません。',
|
||||
copyCriterion: index => `基準 ${index} をコピー`,
|
||||
removeCriterion: index => `基準 ${index} を削除`,
|
||||
removeCriterionConfirmTitle: index => `基準 ${index} を削除しますか?`,
|
||||
removeCriterionConfirmBody: index => `基準 ${index} を削除してもよろしいですか?`,
|
||||
clearCriteria: 'すべての基準をクリア',
|
||||
clearCriteriaConfirmTitle: 'すべての基準をクリアしますか?',
|
||||
clearCriteriaConfirmBody: 'このゴールからすべての基準を削除してもよろしいですか?',
|
||||
criteriaHeader: count => `基準 · ${count}`,
|
||||
noCriteria: '基準なし',
|
||||
goalDetailsTitle: 'ゴールの詳細',
|
||||
objectiveLabel: '目標',
|
||||
contractOutcome: '成果',
|
||||
contractVerification: '検証',
|
||||
contractConstraints: '制約',
|
||||
contractBoundaries: '境界',
|
||||
contractStopWhen: '終了条件',
|
||||
waitBarrierTitle: '待機条件',
|
||||
waitUntil: target => `${target} まで待機`,
|
||||
waitSession: target => `セッション ${target} を待機中`,
|
||||
waitPid: pid => `プロセス ${pid} を待機中`,
|
||||
qualityGatesTitle: '品質ゲート',
|
||||
gateCommand: 'コマンド',
|
||||
gateAttempts: (attempts, max) => `試行 ${attempts}/${max}`,
|
||||
gateTimeout: seconds => `タイムアウト ${seconds}秒`,
|
||||
gateLastExit: code => (code === null ? '保留中' : `終了コード: ${code}`),
|
||||
loopActive: 'ループ実行中',
|
||||
loopPaused: 'ループ一時停止',
|
||||
loopDeferred: 'ループ保留中',
|
||||
loopFinished: 'ループ終了',
|
||||
loopRuns: runs => `${runs} 回実行`,
|
||||
loopRunCount: (current, total) => `実行 ${current}/${total}`,
|
||||
loopNext: time => `次回 ${time}`,
|
||||
loopEverySeconds: seconds => `${seconds}秒ごと`,
|
||||
loopEveryMinutes: minutes => `${minutes}分ごと`,
|
||||
loopEveryHours: hours => `${hours}時間ごと`,
|
||||
loopSelfPaced: 'セルフペース',
|
||||
loopActions: 'ループのアクション',
|
||||
pauseLoop: 'ループを一時停止',
|
||||
resumeLoop: 'ループを再開',
|
||||
stopLoop: 'ループを停止',
|
||||
stopLoopConfirmTitle: 'ループを停止しますか?',
|
||||
stopLoopConfirmBody: 'このループを停止してもよろしいですか?',
|
||||
dismissLoop: 'ループを閉じる',
|
||||
loopPromptLabel: 'プロンプト',
|
||||
loopCadenceLabel: 'ペース',
|
||||
loopUntilLabel: '終了条件',
|
||||
loopDeferredNotice: '現在アクティブなゴールがセッションを制御しています。',
|
||||
loopAwaitingResponse: '応答待ち',
|
||||
heartbeatActive: 'ハートビート実行中',
|
||||
heartbeatPaused: 'ハートビート一時停止',
|
||||
heartbeatEveryMinutes: minutes => `${minutes}分ごと`,
|
||||
heartbeatEveryHours: hours => `${hours}時間ごと`,
|
||||
heartbeatEverySeconds: seconds => `${seconds}秒ごと`,
|
||||
heartbeatNext: time => `次回 ${time}`,
|
||||
heartbeatDueWaitingForIdle: '期限到来 — アイドル待ち',
|
||||
heartbeatActions: 'ハートビートのアクション',
|
||||
pauseHeartbeat: 'ハートビートを一時停止',
|
||||
resumeHeartbeat: 'ハートビートを再開',
|
||||
clearHeartbeat: 'ハートビートをクリア',
|
||||
clearHeartbeatConfirmTitle: 'ハートビートをクリアしますか?',
|
||||
clearHeartbeatConfirmBody: 'このハートビートをクリアしてもよろしいですか?',
|
||||
heartbeatFiredCount: count => `${count} 回発火`,
|
||||
actionFailed: msg => `アクションに失敗しました: ${msg}`,
|
||||
actionSucceeded: 'アクションが完了しました',
|
||||
copySuccess: '基準をクリップボードにコピーしました',
|
||||
copyFailure: '基準のコピーに失敗しました',
|
||||
continuationFailed: 'ゴールの継続送信に失敗しました',
|
||||
continuationQueued: 'ゴールを再開しました — 継続は現在のターン終了後に送信されます',
|
||||
continuationBusy: 'ゴールを再開しました — セッションが実行中です。続行するには /interrupt してください',
|
||||
controlUnavailable: msg => `セッション制御を利用できません: ${msg}`,
|
||||
dismissError: 'エラーを閉じる',
|
||||
add: '追加'
|
||||
},
|
||||
coding: {
|
||||
title: 'ワークツリー',
|
||||
noBranch: 'ブランチなし',
|
||||
|
||||
+126
-1
@@ -24,6 +24,40 @@ const RU_NOUN = (count: number | string, one: string, few: string, many: string)
|
||||
}
|
||||
|
||||
export const ru = defineLocale({
|
||||
sessionImport: {
|
||||
title: 'Продолжить из другого приложения',
|
||||
subtitle: 'Перенесите разговор в Hermes и продолжите с того места, где остановились.',
|
||||
action: 'Импортировать сессию',
|
||||
readingFrom: 'Читаем с',
|
||||
connectedComputer: 'подключённого компьютера',
|
||||
destination: 'Импорт в',
|
||||
all: 'Все',
|
||||
search: 'Поиск по загруженным сессиям',
|
||||
scanning: 'Поиск разговоров',
|
||||
scanError: 'Не удалось найти сессии',
|
||||
scanHelp: 'Проверьте подключение к серверу и повторите попытку. Старому серверу может требоваться обновление.',
|
||||
empty: 'Разговоров пока нет',
|
||||
emptyHelp: 'Здесь появятся сессии Claude Code и Codex с этого сервера.',
|
||||
noMatches: 'Совпадений нет',
|
||||
searchHelp: 'Попробуйте другой заголовок или папку либо загрузите ещё сессии.',
|
||||
skipped: 'Некоторые журналы пусты, недоступны или слишком велики для просмотра.',
|
||||
more: 'Загрузить ещё сессии',
|
||||
messages: 'сообщений',
|
||||
choose: 'Разговор, который стоит продолжить',
|
||||
chooseHelp: 'Выберите сессию, чтобы прочитать историю перед импортом в Hermes.',
|
||||
previewLoading: 'Открываем просмотр',
|
||||
previewError: 'Просмотр недоступен',
|
||||
previewHelp: 'Исходный файл мог переместиться или измениться. Обновите список и повторите попытку.',
|
||||
previewLimit: 'Просмотр сокращён для удобства чтения. Импортируется весь разговор.',
|
||||
you: 'Вы',
|
||||
snapshot: 'Этот разговор уже есть в Hermes. Откройте существующую копию, чтобы продолжить.',
|
||||
copyNotice:
|
||||
'Копируется текст разговора. Исходные файлы не меняются. Вывод инструментов и рассуждения не переносятся.',
|
||||
importing: 'Импорт…',
|
||||
open: 'Открыть в Hermes',
|
||||
continue: 'Продолжить в Hermes',
|
||||
importError: 'Не удалось импортировать разговор.'
|
||||
},
|
||||
common: {
|
||||
apply: 'Применить',
|
||||
back: 'Назад',
|
||||
@@ -2359,7 +2393,8 @@ export const ru = defineLocale({
|
||||
skills: 'Возможности',
|
||||
messaging: 'Сообщения',
|
||||
artifacts: 'Артефакты',
|
||||
cron: 'Запланированные задачи'
|
||||
cron: 'Запланированные задачи',
|
||||
'session-import': 'Импортировать сессию'
|
||||
},
|
||||
searchAria: 'Поиск сеансов',
|
||||
searchPlaceholder: 'Поиск сеансов…',
|
||||
@@ -2701,6 +2736,7 @@ export const ru = defineLocale({
|
||||
agents: 'Агенты',
|
||||
background: count => `${count} ${RU_NOUN(count, 'фоновая задача', 'фоновые задачи', 'фоновых задач')}`,
|
||||
goalActive: 'Цель активна',
|
||||
goalBlocked: 'Цель заблокирована',
|
||||
goalDone: 'Цель выполнена',
|
||||
goalPaused: 'Цель на паузе',
|
||||
goalWaiting: 'Цель ожидает',
|
||||
@@ -2710,6 +2746,95 @@ export const ru = defineLocale({
|
||||
stop: 'Стоп',
|
||||
dismiss: 'Скрыть',
|
||||
exit: code => `exit ${code}`,
|
||||
control: {
|
||||
goalActiveTurns: (turn, maxTurns) => `Ход ${turn}/${maxTurns}`,
|
||||
goalDoneTurns: turns => `${turns} ходов`,
|
||||
goalTurn: turn => `Ход ${turn}`,
|
||||
goalActions: 'Действия с целью',
|
||||
viewDetails: 'Подробнее',
|
||||
addCriterion: 'Добавить критерий',
|
||||
addCriterionDialogTitle: 'Добавить критерий',
|
||||
addCriterionPlaceholder: 'Введите текст критерия...',
|
||||
criterionLabel: 'Критерий',
|
||||
pauseGoal: 'Приостановить цель',
|
||||
resumeGoal: 'Возобновить цель',
|
||||
resumeNow: 'Возобновить сейчас',
|
||||
clearGoal: 'Очистить цель',
|
||||
clearGoalConfirmTitle: 'Очистить цель?',
|
||||
clearGoalConfirmBody: 'Вы уверены, что хотите очистить активную цель? Это действие необратимо.',
|
||||
copyCriterion: index => `Скопировать критерий ${index}`,
|
||||
removeCriterion: index => `Удалить критерий ${index}`,
|
||||
removeCriterionConfirmTitle: index => `Удалить критерий ${index}?`,
|
||||
removeCriterionConfirmBody: index => `Вы уверены, что хотите удалить критерий ${index}?`,
|
||||
clearCriteria: 'Очистить все критерии',
|
||||
clearCriteriaConfirmTitle: 'Очистить все критерии?',
|
||||
clearCriteriaConfirmBody: 'Вы уверены, что хотите удалить все критерии этой цели?',
|
||||
criteriaHeader: count => `Критерии · ${count}`,
|
||||
noCriteria: 'Нет критериев',
|
||||
goalDetailsTitle: 'Детали цели',
|
||||
objectiveLabel: 'Задача',
|
||||
contractOutcome: 'Результат',
|
||||
contractVerification: 'Проверка',
|
||||
contractConstraints: 'Ограничения',
|
||||
contractBoundaries: 'Границы',
|
||||
contractStopWhen: 'Условие остановки',
|
||||
waitBarrierTitle: 'Условие ожидания',
|
||||
waitUntil: target => `Ожидание до ${target}`,
|
||||
waitSession: target => `Ожидание сессии ${target}`,
|
||||
waitPid: pid => `Ожидание процесса ${pid}`,
|
||||
qualityGatesTitle: 'Контроли качества',
|
||||
gateCommand: 'Команда',
|
||||
gateAttempts: (attempts, max) => `${attempts}/${max} попыток`,
|
||||
gateTimeout: seconds => `таймаут ${seconds}с`,
|
||||
gateLastExit: code => (code === null ? 'В ожидании' : `Код возврата: ${code}`),
|
||||
loopActive: 'Цикл активен',
|
||||
loopPaused: 'Цикл приостановлен',
|
||||
loopDeferred: 'Цикл отложен',
|
||||
loopFinished: 'Цикл завершен',
|
||||
loopRuns: runs => `${runs} запусков`,
|
||||
loopRunCount: (current, total) => `Запуск ${current}/${total}`,
|
||||
loopNext: time => `следующий ${time}`,
|
||||
loopEverySeconds: seconds => `каждые ${seconds}с`,
|
||||
loopEveryMinutes: minutes => `каждые ${minutes}м`,
|
||||
loopEveryHours: hours => `каждые ${hours}ч`,
|
||||
loopSelfPaced: 'автономный',
|
||||
loopActions: 'Действия цикла',
|
||||
pauseLoop: 'Приостановить цикл',
|
||||
resumeLoop: 'Возобновить цикл',
|
||||
stopLoop: 'Остановить цикл',
|
||||
stopLoopConfirmTitle: 'Остановить цикл?',
|
||||
stopLoopConfirmBody: 'Вы уверены, что хотите остановить этот цикл?',
|
||||
dismissLoop: 'Закрыть цикл',
|
||||
loopPromptLabel: 'Промпт',
|
||||
loopCadenceLabel: 'Интервал',
|
||||
loopUntilLabel: 'Условие окончания',
|
||||
loopDeferredNotice: 'Активная цель в данный момент управляет сессией.',
|
||||
loopAwaitingResponse: 'Ожидание ответа',
|
||||
heartbeatActive: 'Пульс активен',
|
||||
heartbeatPaused: 'Пульс приостановлен',
|
||||
heartbeatEveryMinutes: minutes => `каждые ${minutes}м`,
|
||||
heartbeatEveryHours: hours => `каждые ${hours}ч`,
|
||||
heartbeatEverySeconds: seconds => `каждые ${seconds}с`,
|
||||
heartbeatNext: time => `следующий ${time}`,
|
||||
heartbeatDueWaitingForIdle: 'пора — ожидание простоя',
|
||||
heartbeatActions: 'Действия пульса',
|
||||
pauseHeartbeat: 'Приостановить пульс',
|
||||
resumeHeartbeat: 'Возобновить пульс',
|
||||
clearHeartbeat: 'Очистить пульс',
|
||||
clearHeartbeatConfirmTitle: 'Очистить пульс?',
|
||||
clearHeartbeatConfirmBody: 'Вы уверены, что хотите очистить этот пульс?',
|
||||
heartbeatFiredCount: count => `Сработал ${count} раз`,
|
||||
actionFailed: msg => `Ошибка действия: ${msg}`,
|
||||
actionSucceeded: 'Действие выполнено успешно',
|
||||
copySuccess: 'Критерий скопирован в буфер обмена',
|
||||
copyFailure: 'Не удалось скопировать критерий в буфер обмена',
|
||||
continuationFailed: 'Не удалось отправить продолжение цели',
|
||||
continuationQueued: 'Цель возобновлена — продолжение поставлено в очередь до конца текущего хода',
|
||||
continuationBusy: 'Цель возобновлена — сессия занята, выполните /interrupt, чтобы продолжить',
|
||||
controlUnavailable: msg => `Управление сессией недоступно: ${msg}`,
|
||||
dismissError: 'Скрыть ошибку',
|
||||
add: 'Добавить'
|
||||
},
|
||||
coding: {
|
||||
title: 'Рабочее дерево',
|
||||
noBranch: 'Без ветки',
|
||||
|
||||
@@ -51,6 +51,39 @@ interface AuxTaskCopy {
|
||||
}
|
||||
|
||||
export interface Translations {
|
||||
sessionImport: {
|
||||
title: string
|
||||
subtitle: string
|
||||
action: string
|
||||
readingFrom: string
|
||||
connectedComputer: string
|
||||
destination: string
|
||||
all: string
|
||||
search: string
|
||||
scanning: string
|
||||
scanError: string
|
||||
scanHelp: string
|
||||
empty: string
|
||||
emptyHelp: string
|
||||
noMatches: string
|
||||
searchHelp: string
|
||||
skipped: string
|
||||
more: string
|
||||
messages: string
|
||||
choose: string
|
||||
chooseHelp: string
|
||||
previewLoading: string
|
||||
previewError: string
|
||||
previewHelp: string
|
||||
previewLimit: string
|
||||
you: string
|
||||
snapshot: string
|
||||
copyNotice: string
|
||||
importing: string
|
||||
open: string
|
||||
continue: string
|
||||
importError: string
|
||||
}
|
||||
common: {
|
||||
apply: string
|
||||
back: string
|
||||
@@ -2261,6 +2294,7 @@ export interface Translations {
|
||||
agents: string
|
||||
background: (count: number) => string
|
||||
goalActive: string
|
||||
goalBlocked: string
|
||||
goalDone: string
|
||||
goalPaused: string
|
||||
goalWaiting: string
|
||||
@@ -2270,6 +2304,95 @@ export interface Translations {
|
||||
stop: string
|
||||
dismiss: string
|
||||
exit: (code: number) => string
|
||||
control: {
|
||||
goalActiveTurns: (turn: number, maxTurns: number) => string
|
||||
goalDoneTurns: (turns: number) => string
|
||||
goalTurn: (turn: number) => string
|
||||
goalActions: string
|
||||
viewDetails: string
|
||||
addCriterion: string
|
||||
addCriterionDialogTitle: string
|
||||
addCriterionPlaceholder: string
|
||||
criterionLabel: string
|
||||
pauseGoal: string
|
||||
resumeGoal: string
|
||||
resumeNow: string
|
||||
clearGoal: string
|
||||
clearGoalConfirmTitle: string
|
||||
clearGoalConfirmBody: string
|
||||
copyCriterion: (index: number) => string
|
||||
removeCriterion: (index: number) => string
|
||||
removeCriterionConfirmTitle: (index: number) => string
|
||||
removeCriterionConfirmBody: (index: number) => string
|
||||
clearCriteria: string
|
||||
clearCriteriaConfirmTitle: string
|
||||
clearCriteriaConfirmBody: string
|
||||
criteriaHeader: (count: number) => string
|
||||
noCriteria: string
|
||||
goalDetailsTitle: string
|
||||
objectiveLabel: string
|
||||
contractOutcome: string
|
||||
contractVerification: string
|
||||
contractConstraints: string
|
||||
contractBoundaries: string
|
||||
contractStopWhen: string
|
||||
waitBarrierTitle: string
|
||||
waitUntil: (target: string) => string
|
||||
waitSession: (target: string) => string
|
||||
waitPid: (pid: number) => string
|
||||
qualityGatesTitle: string
|
||||
gateCommand: string
|
||||
gateAttempts: (attempts: number, max: number) => string
|
||||
gateTimeout: (seconds: number) => string
|
||||
gateLastExit: (code: number | null) => string
|
||||
loopActive: string
|
||||
loopPaused: string
|
||||
loopDeferred: string
|
||||
loopFinished: string
|
||||
loopRuns: (runs: number) => string
|
||||
loopRunCount: (current: number, total: number) => string
|
||||
loopNext: (time: string) => string
|
||||
loopEverySeconds: (seconds: number) => string
|
||||
loopEveryMinutes: (minutes: number) => string
|
||||
loopEveryHours: (hours: number) => string
|
||||
loopSelfPaced: string
|
||||
loopActions: string
|
||||
pauseLoop: string
|
||||
resumeLoop: string
|
||||
stopLoop: string
|
||||
stopLoopConfirmTitle: string
|
||||
stopLoopConfirmBody: string
|
||||
dismissLoop: string
|
||||
loopPromptLabel: string
|
||||
loopCadenceLabel: string
|
||||
loopUntilLabel: string
|
||||
loopDeferredNotice: string
|
||||
loopAwaitingResponse: string
|
||||
heartbeatActive: string
|
||||
heartbeatPaused: string
|
||||
heartbeatEveryMinutes: (minutes: number) => string
|
||||
heartbeatEveryHours: (hours: number) => string
|
||||
heartbeatEverySeconds: (seconds: number) => string
|
||||
heartbeatNext: (time: string) => string
|
||||
heartbeatDueWaitingForIdle: string
|
||||
heartbeatActions: string
|
||||
pauseHeartbeat: string
|
||||
resumeHeartbeat: string
|
||||
clearHeartbeat: string
|
||||
clearHeartbeatConfirmTitle: string
|
||||
clearHeartbeatConfirmBody: string
|
||||
heartbeatFiredCount: (count: number) => string
|
||||
actionFailed: (msg: string) => string
|
||||
actionSucceeded: string
|
||||
copySuccess: string
|
||||
copyFailure: string
|
||||
continuationFailed: string
|
||||
continuationQueued: string
|
||||
continuationBusy: string
|
||||
controlUnavailable: (msg: string) => string
|
||||
dismissError: string
|
||||
add: string
|
||||
}
|
||||
coding: {
|
||||
title: string
|
||||
noBranch: string
|
||||
|
||||
@@ -3,6 +3,39 @@ import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
export const zhHant = defineLocale({
|
||||
sessionImport: {
|
||||
title: '從其他應用程式繼續',
|
||||
subtitle: '將對話匯入 Hermes,接著上次的進度繼續。',
|
||||
action: '匯入工作階段',
|
||||
readingFrom: '讀取自',
|
||||
connectedComputer: '已連線的電腦',
|
||||
destination: '匯入至',
|
||||
all: '全部',
|
||||
search: '搜尋已載入的工作階段',
|
||||
scanning: '正在尋找對話',
|
||||
scanError: '無法尋找工作階段',
|
||||
scanHelp: '請檢查後端連線並重試。舊版後端可能需要更新。',
|
||||
empty: '找不到對話',
|
||||
emptyHelp: '此後端上的 Claude Code 和 Codex 工作階段將顯示在這裡。',
|
||||
noMatches: '沒有符合的對話',
|
||||
searchHelp: '嘗試其他標題或資料夾,或載入更多工作階段。',
|
||||
skipped: '部分記錄為空白、無法讀取或過大,已略過。',
|
||||
more: '載入更多工作階段',
|
||||
messages: '則訊息',
|
||||
choose: '繼續一段對話',
|
||||
chooseHelp: '選擇工作階段,在匯入 Hermes 前查看歷程記錄。',
|
||||
previewLoading: '正在開啟預覽',
|
||||
previewError: '無法預覽',
|
||||
previewHelp: '來源檔案可能已移動或變更。請重新整理清單後重試。',
|
||||
previewLimit: '預覽已縮短,方便閱讀。匯入時會複製完整對話。',
|
||||
you: '你',
|
||||
snapshot: '此對話已匯入 Hermes。開啟現有副本即可繼續。',
|
||||
copyNotice: '複製對話文字,不變更來源檔案。不包含工具輸出和推理內容。',
|
||||
importing: '正在匯入…',
|
||||
open: '在 Hermes 中開啟',
|
||||
continue: '在 Hermes 中繼續',
|
||||
importError: '無法匯入此對話。'
|
||||
},
|
||||
common: {
|
||||
apply: '套用',
|
||||
back: '返回',
|
||||
@@ -1923,7 +1956,8 @@ export const zhHant = defineLocale({
|
||||
skills: '技能與工具',
|
||||
messaging: '訊息平台',
|
||||
artifacts: '成品',
|
||||
cron: '排程工作'
|
||||
cron: '排程工作',
|
||||
'session-import': '匯入工作階段'
|
||||
},
|
||||
searchAria: '搜尋工作階段',
|
||||
searchPlaceholder: '搜尋工作階段…',
|
||||
@@ -2211,6 +2245,7 @@ export const zhHant = defineLocale({
|
||||
agents: '代理',
|
||||
background: count => `${count} 個背景任務`,
|
||||
goalActive: '目標進行中',
|
||||
goalBlocked: '目標受阻',
|
||||
goalDone: '目標已完成',
|
||||
goalPaused: '目標已暫停',
|
||||
goalWaiting: '目標等待中',
|
||||
@@ -2220,6 +2255,95 @@ export const zhHant = defineLocale({
|
||||
stop: '停止',
|
||||
dismiss: '關閉',
|
||||
exit: code => `結束碼 ${code}`,
|
||||
control: {
|
||||
goalActiveTurns: (turn, maxTurns) => `第 ${turn}/${maxTurns} 輪`,
|
||||
goalDoneTurns: turns => `共 ${turns} 輪`,
|
||||
goalTurn: turn => `第 ${turn} 輪`,
|
||||
goalActions: '目標操作',
|
||||
viewDetails: '檢視詳細資訊',
|
||||
addCriterion: '新增條件',
|
||||
addCriterionDialogTitle: '新增條件',
|
||||
addCriterionPlaceholder: '輸入條件內容...',
|
||||
criterionLabel: '條件',
|
||||
pauseGoal: '暫停目標',
|
||||
resumeGoal: '繼續目標',
|
||||
resumeNow: '立即繼續',
|
||||
clearGoal: '清除目標',
|
||||
clearGoalConfirmTitle: '清除目標?',
|
||||
clearGoalConfirmBody: '確定要清除目前活躍的目標嗎?此動作無法復原。',
|
||||
copyCriterion: index => `複製條件 ${index}`,
|
||||
removeCriterion: index => `移除條件 ${index}`,
|
||||
removeCriterionConfirmTitle: index => `移除條件 ${index}?`,
|
||||
removeCriterionConfirmBody: index => `確定要移除條件 ${index} 嗎?`,
|
||||
clearCriteria: '清除全部條件',
|
||||
clearCriteriaConfirmTitle: '清除全部條件?',
|
||||
clearCriteriaConfirmBody: '確定要移除此目標下的所有條件嗎?',
|
||||
criteriaHeader: count => `條件 · ${count}`,
|
||||
noCriteria: '無條件',
|
||||
goalDetailsTitle: '目標詳細資訊',
|
||||
objectiveLabel: '目標內容',
|
||||
contractOutcome: '預期成果',
|
||||
contractVerification: '驗證方式',
|
||||
contractConstraints: '限制條件',
|
||||
contractBoundaries: '邊界',
|
||||
contractStopWhen: '停止條件',
|
||||
waitBarrierTitle: '等待條件',
|
||||
waitUntil: target => `等待至 ${target}`,
|
||||
waitSession: target => `等待工作階段 ${target}`,
|
||||
waitPid: pid => `等待處理程序 ${pid}`,
|
||||
qualityGatesTitle: '品質關卡',
|
||||
gateCommand: '命令',
|
||||
gateAttempts: (attempts, max) => `嘗試 ${attempts}/${max}`,
|
||||
gateTimeout: seconds => `逾時 ${seconds} 秒`,
|
||||
gateLastExit: code => (code === null ? '擱置中' : `結束代碼: ${code}`),
|
||||
loopActive: '循環進行中',
|
||||
loopPaused: '循環已暫停',
|
||||
loopDeferred: '循環已延後',
|
||||
loopFinished: '循環已完成',
|
||||
loopRuns: runs => `執行 ${runs} 次`,
|
||||
loopRunCount: (current, total) => `執行 ${current}/${total}`,
|
||||
loopNext: time => `下次 ${time}`,
|
||||
loopEverySeconds: seconds => `每 ${seconds} 秒`,
|
||||
loopEveryMinutes: minutes => `每 ${minutes} 分鐘`,
|
||||
loopEveryHours: hours => `每 ${hours} 小時`,
|
||||
loopSelfPaced: '自訂步調',
|
||||
loopActions: '循環操作',
|
||||
pauseLoop: '暫停循環',
|
||||
resumeLoop: '繼續循環',
|
||||
stopLoop: '停止循環',
|
||||
stopLoopConfirmTitle: '停止循環?',
|
||||
stopLoopConfirmBody: '確定要停止此循環嗎?',
|
||||
dismissLoop: '關閉循環',
|
||||
loopPromptLabel: '提示詞',
|
||||
loopCadenceLabel: '頻率',
|
||||
loopUntilLabel: '截止條件',
|
||||
loopDeferredNotice: '目前由進行中的目標控制工作階段。',
|
||||
loopAwaitingResponse: '等待回應中',
|
||||
heartbeatActive: '活動訊號進行中',
|
||||
heartbeatPaused: '活動訊號已暫停',
|
||||
heartbeatEveryMinutes: minutes => `每 ${minutes} 分鐘`,
|
||||
heartbeatEveryHours: hours => `每 ${hours} 小時`,
|
||||
heartbeatEverySeconds: seconds => `每 ${seconds} 秒`,
|
||||
heartbeatNext: time => `下次 ${time}`,
|
||||
heartbeatDueWaitingForIdle: '已到期 — 等待會話閒置',
|
||||
heartbeatActions: '活動訊號操作',
|
||||
pauseHeartbeat: '暫停活動訊號',
|
||||
resumeHeartbeat: '繼續活動訊號',
|
||||
clearHeartbeat: '清除活動訊號',
|
||||
clearHeartbeatConfirmTitle: '清除活動訊號?',
|
||||
clearHeartbeatConfirmBody: '確定要清除此活動訊號嗎?',
|
||||
heartbeatFiredCount: count => `已觸發 ${count} 次`,
|
||||
actionFailed: msg => `操作失敗: ${msg}`,
|
||||
actionSucceeded: '操作成功',
|
||||
copySuccess: '已將條件複製到剪貼板',
|
||||
copyFailure: '複製條件到剪貼板失敗',
|
||||
continuationFailed: '提交目標延續內容失敗',
|
||||
continuationQueued: '目標已恢復 — 延續內容已排隊,將在目前回合結束後送出',
|
||||
continuationBusy: '目標已恢復 — 會話忙碌中,請 /interrupt 目前回合以繼續',
|
||||
controlUnavailable: msg => `會話控制無法使用: ${msg}`,
|
||||
dismissError: '關閉錯誤',
|
||||
add: '新增'
|
||||
},
|
||||
coding: {
|
||||
title: '工作區',
|
||||
noBranch: '無分支',
|
||||
|
||||
+125
-1
@@ -3,6 +3,39 @@ import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import type { Translations } from './types'
|
||||
|
||||
export const zh: Translations = {
|
||||
sessionImport: {
|
||||
title: '从其他应用继续',
|
||||
subtitle: '将对话导入 Hermes,接着上次的进度继续。',
|
||||
action: '导入会话',
|
||||
readingFrom: '读取自',
|
||||
connectedComputer: '已连接的计算机',
|
||||
destination: '导入到',
|
||||
all: '全部',
|
||||
search: '搜索已加载的会话',
|
||||
scanning: '正在查找对话',
|
||||
scanError: '无法查找会话',
|
||||
scanHelp: '请检查后端连接并重试。旧版后端可能需要更新。',
|
||||
empty: '未找到对话',
|
||||
emptyHelp: '此后端上的 Claude Code 和 Codex 会话将显示在这里。',
|
||||
noMatches: '没有匹配的对话',
|
||||
searchHelp: '尝试其他标题或文件夹,或加载更多会话。',
|
||||
skipped: '部分日志为空、无法读取或过大,已跳过。',
|
||||
more: '加载更多会话',
|
||||
messages: '条消息',
|
||||
choose: '继续一段对话',
|
||||
chooseHelp: '选择会话,在导入 Hermes 前查看历史记录。',
|
||||
previewLoading: '正在打开预览',
|
||||
previewError: '预览不可用',
|
||||
previewHelp: '源文件可能已移动或更改。请刷新列表后重试。',
|
||||
previewLimit: '预览已缩短,便于阅读。导入时会复制完整对话。',
|
||||
you: '你',
|
||||
snapshot: '此对话已导入 Hermes。打开现有副本即可继续。',
|
||||
copyNotice: '复制对话文本,不更改源文件。不包含工具输出和推理内容。',
|
||||
importing: '正在导入…',
|
||||
open: '在 Hermes 中打开',
|
||||
continue: '在 Hermes 中继续',
|
||||
importError: '无法导入此对话。'
|
||||
},
|
||||
common: {
|
||||
apply: '应用',
|
||||
back: '返回',
|
||||
@@ -2490,7 +2523,8 @@ export const zh: Translations = {
|
||||
skills: '技能与工具',
|
||||
messaging: '消息平台',
|
||||
artifacts: '产物',
|
||||
cron: '定时任务'
|
||||
cron: '定时任务',
|
||||
'session-import': '导入会话'
|
||||
},
|
||||
searchAria: '搜索会话',
|
||||
searchPlaceholder: '搜索会话…',
|
||||
@@ -2830,6 +2864,7 @@ export const zh: Translations = {
|
||||
agents: '代理',
|
||||
background: count => `${count} 个后台任务`,
|
||||
goalActive: '目标进行中',
|
||||
goalBlocked: '目标受阻',
|
||||
goalDone: '目标已完成',
|
||||
goalPaused: '目标已暂停',
|
||||
goalWaiting: '目标等待中',
|
||||
@@ -2839,6 +2874,95 @@ export const zh: Translations = {
|
||||
stop: '停止',
|
||||
dismiss: '关闭',
|
||||
exit: code => `退出码 ${code}`,
|
||||
control: {
|
||||
goalActiveTurns: (turn, maxTurns) => `第 ${turn}/${maxTurns} 轮`,
|
||||
goalDoneTurns: turns => `共 ${turns} 轮`,
|
||||
goalTurn: turn => `第 ${turn} 轮`,
|
||||
goalActions: '目标操作',
|
||||
viewDetails: '查看详情',
|
||||
addCriterion: '添加条件',
|
||||
addCriterionDialogTitle: '添加条件',
|
||||
addCriterionPlaceholder: '输入条件内容...',
|
||||
criterionLabel: '条件',
|
||||
pauseGoal: '暂停目标',
|
||||
resumeGoal: '恢复目标',
|
||||
resumeNow: '立即恢复',
|
||||
clearGoal: '清除目标',
|
||||
clearGoalConfirmTitle: '清除目标?',
|
||||
clearGoalConfirmBody: '确定要清除当前活跃的目标吗?此操作无法撤销。',
|
||||
copyCriterion: index => `复制条件 ${index}`,
|
||||
removeCriterion: index => `移除条件 ${index}`,
|
||||
removeCriterionConfirmTitle: index => `移除条件 ${index}?`,
|
||||
removeCriterionConfirmBody: index => `确定要移除条件 ${index} 吗?`,
|
||||
clearCriteria: '清除全部条件',
|
||||
clearCriteriaConfirmTitle: '清除全部条件?',
|
||||
clearCriteriaConfirmBody: '确定要移除此目标下的所有条件吗?',
|
||||
criteriaHeader: count => `标准 · ${count}`,
|
||||
noCriteria: '无条件',
|
||||
goalDetailsTitle: '目标详情',
|
||||
objectiveLabel: '目标内容',
|
||||
contractOutcome: '预期结果',
|
||||
contractVerification: '验证方式',
|
||||
contractConstraints: '约束条件',
|
||||
contractBoundaries: '边界条件',
|
||||
contractStopWhen: '停止条件',
|
||||
waitBarrierTitle: '等待状态',
|
||||
waitUntil: target => `等待至 ${target}`,
|
||||
waitSession: target => `等待会话 ${target}`,
|
||||
waitPid: pid => `等待进程 ${pid}`,
|
||||
qualityGatesTitle: '质量关卡',
|
||||
gateCommand: '命令',
|
||||
gateAttempts: (attempts, max) => `尝试 ${attempts}/${max}`,
|
||||
gateTimeout: seconds => `超时 ${seconds} 秒`,
|
||||
gateLastExit: code => (code === null ? '等待中' : `退出代码: ${code}`),
|
||||
loopActive: '循环活跃',
|
||||
loopPaused: '循环暂停',
|
||||
loopDeferred: '循环已推迟',
|
||||
loopFinished: '循环已完成',
|
||||
loopRuns: runs => `运行 ${runs} 次`,
|
||||
loopRunCount: (current, total) => `运行 ${current}/${total}`,
|
||||
loopNext: time => `下次 ${time}`,
|
||||
loopEverySeconds: seconds => `每 ${seconds} 秒`,
|
||||
loopEveryMinutes: minutes => `每 ${minutes} 分钟`,
|
||||
loopEveryHours: hours => `每 ${hours} 小时`,
|
||||
loopSelfPaced: '自节奏',
|
||||
loopActions: '循环操作',
|
||||
pauseLoop: '暂停循环',
|
||||
resumeLoop: '恢复循环',
|
||||
stopLoop: '停止循环',
|
||||
stopLoopConfirmTitle: '停止循环?',
|
||||
stopLoopConfirmBody: '确定要停止此循环吗?',
|
||||
dismissLoop: '关闭循环',
|
||||
loopPromptLabel: '提示词',
|
||||
loopCadenceLabel: '节奏',
|
||||
loopUntilLabel: '终止条件',
|
||||
loopDeferredNotice: '活跃目标当前正在控制会话。',
|
||||
loopAwaitingResponse: '等待响应中',
|
||||
heartbeatActive: '心跳活跃',
|
||||
heartbeatPaused: '心跳暂停',
|
||||
heartbeatEveryMinutes: minutes => `每 ${minutes} 分钟`,
|
||||
heartbeatEveryHours: hours => `每 ${hours} 小时`,
|
||||
heartbeatEverySeconds: seconds => `每 ${seconds} 秒`,
|
||||
heartbeatNext: time => `下次 ${time}`,
|
||||
heartbeatDueWaitingForIdle: '已到期,等待会话空闲',
|
||||
heartbeatActions: '心跳操作',
|
||||
pauseHeartbeat: '暂停心跳',
|
||||
resumeHeartbeat: '恢复心跳',
|
||||
clearHeartbeat: '清除心跳',
|
||||
clearHeartbeatConfirmTitle: '清除心跳?',
|
||||
clearHeartbeatConfirmBody: '确定要清除该心跳吗?',
|
||||
heartbeatFiredCount: count => `已触发 ${count} 次`,
|
||||
actionFailed: msg => `操作失败: ${msg}`,
|
||||
actionSucceeded: '操作成功',
|
||||
copySuccess: '已将条件复制到剪贴板',
|
||||
copyFailure: '复制条件到剪贴板失败',
|
||||
continuationFailed: '提交目标后续内容失败',
|
||||
continuationQueued: '目标已恢复 — 后续内容已排队,将在当前回合结束后发送',
|
||||
continuationBusy: '目标已恢复 — 会话正忙,请 /interrupt 当前回合以继续',
|
||||
controlUnavailable: msg => `会话控制不可用: ${msg}`,
|
||||
dismissError: '关闭错误',
|
||||
add: '添加'
|
||||
},
|
||||
coding: {
|
||||
title: '工作区',
|
||||
noBranch: '无分支',
|
||||
|
||||
@@ -1342,6 +1342,30 @@ describe('collectUnspokenTurnSpeech', () => {
|
||||
expect(collectUnspokenTurnSpeech([assistant('a1', 'Done.')], 'a1')).toBeNull()
|
||||
expect(collectUnspokenTurnSpeech([user('u1', 'hello'), assistant('a1', '')], null)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not replay earlier turns when the spoken id is missing or stale', () => {
|
||||
const messages = [
|
||||
user('u1', 'old question'),
|
||||
assistant('a1', 'Previous output from last turn.'),
|
||||
user('u2', 'new question'),
|
||||
assistant('a2', 'Live reply only.')
|
||||
]
|
||||
|
||||
expect(collectUnspokenTurnSpeech(messages, null)?.text).toBe('Live reply only.')
|
||||
expect(collectUnspokenTurnSpeech(messages, 'vanished-stream-id')?.text).toBe('Live reply only.')
|
||||
expect(collectUnspokenTurnSpeech(messages, 'a1')?.text).toBe('Live reply only.')
|
||||
})
|
||||
|
||||
it('bounds to a hidden user turn too (widget intents render no bubble)', () => {
|
||||
const messages = [
|
||||
user('u1', 'old question'),
|
||||
assistant('a1', 'Previous output from last turn.'),
|
||||
{ ...user('u2', 'widget intent'), hidden: true },
|
||||
assistant('a2', 'Live reply only.')
|
||||
]
|
||||
|
||||
expect(collectUnspokenTurnSpeech(messages, null)?.text).toBe('Live reply only.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripPendingClarifyProjectionForCache', () => {
|
||||
|
||||
@@ -68,12 +68,28 @@ export interface UnspokenTurnSpeech {
|
||||
* selecting only one bubble silently drops everything after it. The blank-line
|
||||
* join is a sentence boundary for the server's cutter, so a sealed bubble's
|
||||
* tail is flushed as soon as the next bubble starts.
|
||||
*
|
||||
* If `lastSpokenId` is missing or stale (session id assigned mid-turn,
|
||||
* live-tail rewrite missed), do **not** fall back to index -1 — that replays
|
||||
* every earlier assistant turn as one speech string. Bound to the current
|
||||
* turn (assistant bubbles after the last user message) instead. Hidden user
|
||||
* rows count: a widget intent (`display_kind: hidden`) is a real turn for the
|
||||
* agent even though no bubble renders. A slice with no user row (mid-turn
|
||||
* interims only) still collects those assistants.
|
||||
*/
|
||||
export function collectUnspokenTurnSpeech(
|
||||
messages: ChatMessage[],
|
||||
lastSpokenId: string | null
|
||||
): UnspokenTurnSpeech | null {
|
||||
const spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1
|
||||
let spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1
|
||||
|
||||
if (spokenIndex < 0) {
|
||||
const lastUser = messages.findLastIndex(m => m.role === 'user')
|
||||
|
||||
if (lastUser >= 0) {
|
||||
spokenIndex = lastUser
|
||||
}
|
||||
}
|
||||
|
||||
let id: string | null = null
|
||||
let pending = false
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
absorbSpokenReplyRewrite,
|
||||
adoptSpokenReplySession,
|
||||
assistantReplyOrdinal,
|
||||
clearSpokenRepliesForTests,
|
||||
isLiveTailReplyId,
|
||||
@@ -90,3 +91,30 @@ describe('resolveSpokenReply', () => {
|
||||
expect(spoken?.ordinal).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('adoptSpokenReplySession', () => {
|
||||
it('moves the null-session anchor onto the created session id', () => {
|
||||
markAssistantIdSpoken(null, [assistant('a1')], 'a1')
|
||||
adoptSpokenReplySession(null, 'session-created')
|
||||
|
||||
expect(spokenReplyOf('session-created')?.id).toBe('a1')
|
||||
// Moved, not copied: the next new chat (null session again) starts clean.
|
||||
expect(spokenReplyOf(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not overwrite an anchor the created session already has', () => {
|
||||
markAssistantIdSpoken(null, [assistant('a1')], 'a1')
|
||||
markAssistantIdSpoken('session-created', [assistant('a1'), assistant('a2')], 'a2')
|
||||
adoptSpokenReplySession(null, 'session-created')
|
||||
|
||||
expect(spokenReplyOf('session-created')?.id).toBe('a2')
|
||||
})
|
||||
|
||||
it('does not leak a spoken anchor from one real session into another', () => {
|
||||
markAssistantIdSpoken('session-a', [assistant('a1')], 'a1')
|
||||
adoptSpokenReplySession('session-a', 'session-b')
|
||||
|
||||
expect(spokenReplyOf('session-b')).toBeNull()
|
||||
expect(spokenReplyOf('session-a')?.id).toBe('a1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,6 +111,38 @@ export function markAssistantIdSpoken(
|
||||
markSpokenReply(sessionId, { id, ordinal })
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry the spoken anchor when a chat gets a real session id (null → created)
|
||||
* mid voice-conversation. Do not copy across two real sessions — that would
|
||||
* leak "already spoken" into a different transcript. The null-session entry is
|
||||
* moved, not copied: left behind, it would mark the NEXT new chat's first reply
|
||||
* as already spoken.
|
||||
*/
|
||||
export function adoptSpokenReplySession(
|
||||
fromSessionId: string | null | undefined,
|
||||
toSessionId: string | null | undefined
|
||||
): void {
|
||||
const fromKey = sessionKey(fromSessionId)
|
||||
const toKey = sessionKey(toSessionId)
|
||||
|
||||
if (fromKey !== NO_SESSION || toKey === NO_SESSION) {
|
||||
return
|
||||
}
|
||||
|
||||
const from = lastSpokenBySession.get(fromKey)
|
||||
|
||||
if (!from) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dropped even when not adopted below: the anchor belongs to this chat.
|
||||
lastSpokenBySession.delete(fromKey)
|
||||
|
||||
if (!lastSpokenBySession.has(toKey)) {
|
||||
lastSpokenBySession.set(toKey, from)
|
||||
}
|
||||
}
|
||||
|
||||
/** Current spoken anchor, migrated in place when the live row was rewritten. */
|
||||
export function resolveSpokenReply(
|
||||
sessionId: string | null | undefined,
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { RosterRow } from './types'
|
||||
|
||||
interface MentionCompletionItem {
|
||||
display: string
|
||||
insert: string
|
||||
@@ -58,7 +60,7 @@ const ROSTER = {
|
||||
}
|
||||
|
||||
const { cache, hostMock, live } = vi.hoisted(() => {
|
||||
const live = { focused: 'default', profile: 'default' }
|
||||
const live = { focused: 'default', profile: 'default', stored: null as string | null }
|
||||
|
||||
return {
|
||||
cache: new Map<string, { key: unknown[]; value: unknown }>(),
|
||||
@@ -69,7 +71,7 @@ const { cache, hostMock, live } = vi.hoisted(() => {
|
||||
state: {
|
||||
connectionId: { get: () => 'local', listen: () => () => undefined },
|
||||
focusedSessionProfile: { get: () => live.focused, listen: () => () => undefined },
|
||||
focusedStoredSessionId: { get: () => null, listen: () => () => undefined },
|
||||
focusedStoredSessionId: { get: () => live.stored, listen: () => () => undefined },
|
||||
gateway: { get: () => null, listen: () => () => undefined },
|
||||
profile: { get: () => live.profile, listen: () => () => undefined }
|
||||
}
|
||||
@@ -173,6 +175,54 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
live.focused = 'default'
|
||||
live.profile = 'default'
|
||||
live.stored = null
|
||||
})
|
||||
|
||||
describe('Bot Chat reset guard', () => {
|
||||
it.each([
|
||||
{ connectionId: 'local', sourceScoped: true },
|
||||
{ connectionId: 'remote', remoteSource: true, sourceScoped: true },
|
||||
{}
|
||||
])('compacts only the selected bot canonical chat: %j', async source => {
|
||||
const { handler } = await contributions()
|
||||
const { $lastRoster } = await import('./data')
|
||||
const { saveSelectedRosterBot } = await import('./bot-state')
|
||||
|
||||
const selected: RosterRow = {
|
||||
...source,
|
||||
name: 'writer',
|
||||
canonical_session: { id: 'bot-root', resolved_id: 'bot-tip' }
|
||||
}
|
||||
|
||||
// An identically named bot on another connection must not win the lookup.
|
||||
$lastRoster.set([
|
||||
{ name: 'writer', connectionId: 'other', sourceScoped: true, canonical_session: { id: 'other-chat' } },
|
||||
selected
|
||||
])
|
||||
saveSelectedRosterBot(selected)
|
||||
|
||||
for (const stored of ['bot-root', 'bot-tip']) {
|
||||
live.stored = stored
|
||||
|
||||
for (const text of ['/new', '/reset']) {
|
||||
hostMock.notify.mockClear()
|
||||
expect(await handler({ text })).toEqual({ text: '/compact' })
|
||||
expect(hostMock.notify).toHaveBeenCalledOnce()
|
||||
expect(hostMock.notify).toHaveBeenCalledWith(expect.objectContaining({ title: 'This chat never resets' }))
|
||||
}
|
||||
}
|
||||
|
||||
for (const stored of ['scratch-chat', 'other-chat', null]) {
|
||||
live.stored = stored
|
||||
|
||||
for (const text of ['/new', '/reset']) {
|
||||
hostMock.notify.mockClear()
|
||||
const draft = { text }
|
||||
expect(await handler(draft)).toBe(draft)
|
||||
expect(hostMock.notify).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('@-mention completions', () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
$lastRoster,
|
||||
botHandle,
|
||||
botMentionTag,
|
||||
botSelectionKey,
|
||||
cachedUnionRoster,
|
||||
isActiveRosterBot,
|
||||
migrateBotMeta,
|
||||
@@ -665,7 +666,7 @@ export default {
|
||||
// server-side by name), matching either the durable row id or
|
||||
// the compression-lineage tip currently on screen.
|
||||
const roster = $lastRoster.get()
|
||||
const row = Array.isArray(roster) ? roster.find(bot => bot?.name === activeBot) : null
|
||||
const row = Array.isArray(roster) ? roster.find(bot => botSelectionKey(bot) === activeBot) : null
|
||||
|
||||
// The STORED id, which is the id space canonical_session is keyed
|
||||
// in. `host.state.activeSessionId` is the runtime id and could
|
||||
|
||||
@@ -911,11 +911,14 @@ export const host = {
|
||||
// not the registry-secondary path openGatewayForAgent takes for a 'local'
|
||||
// connection id. Behavior for a plain local open is unchanged.
|
||||
const dial = explicitRoute
|
||||
? () => openGatewayForAgent(explicitRoute.connectionId, explicitRoute.profile)
|
||||
? () =>
|
||||
openGatewayForAgent(explicitRoute.connectionId, explicitRoute.profile, {
|
||||
spawnPriority: 'foreground'
|
||||
})
|
||||
: plan.switchWorkspace
|
||||
? () => ensureGatewayProfile(plan.switchWorkspace as string)
|
||||
: plan.dialWithoutSwitching
|
||||
? () => openGatewayForProfile(plan.dialWithoutSwitching as string)
|
||||
? () => openGatewayForProfile(plan.dialWithoutSwitching as string, { spawnPriority: 'foreground' })
|
||||
: null
|
||||
|
||||
if (dial) {
|
||||
|
||||
@@ -607,7 +607,11 @@ describe('profile-aware plugin session opens', () => {
|
||||
|
||||
await host.openSession('remote-chat', { route })
|
||||
|
||||
expect(openGatewayForAgent).toHaveBeenCalledWith('source-a', 'default')
|
||||
expect(openGatewayForAgent).toHaveBeenCalledWith(
|
||||
'source-a',
|
||||
'default',
|
||||
expect.objectContaining({ spawnPriority: 'foreground' })
|
||||
)
|
||||
expect(ensureGatewayProfile).not.toHaveBeenCalled()
|
||||
expect(setShowAllProfiles).toHaveBeenCalledWith(true)
|
||||
expect($activeGatewayProfile.get()).toBe('remote-worker')
|
||||
@@ -1158,7 +1162,10 @@ describe('profile-aware plugin session opens', () => {
|
||||
})
|
||||
|
||||
expect(ensureGatewayProfile).not.toHaveBeenCalled()
|
||||
expect(openGatewayForProfile).toHaveBeenCalledWith('worker')
|
||||
expect(openGatewayForProfile).toHaveBeenCalledWith(
|
||||
'worker',
|
||||
expect.objectContaining({ spawnPriority: 'foreground' })
|
||||
)
|
||||
expect(setShowAllProfiles).toHaveBeenCalledWith(true)
|
||||
expect($activeGatewayProfile.get()).toBe('default')
|
||||
})
|
||||
@@ -1169,7 +1176,10 @@ describe('profile-aware plugin session opens', () => {
|
||||
await host.openSession('bot-chat', { profile: 'worker' })
|
||||
|
||||
expect(ensureGatewayProfile).not.toHaveBeenCalled()
|
||||
expect(openGatewayForProfile).toHaveBeenCalledWith('worker')
|
||||
expect(openGatewayForProfile).toHaveBeenCalledWith(
|
||||
'worker',
|
||||
expect.objectContaining({ spawnPriority: 'foreground' })
|
||||
)
|
||||
expect(setShowAllProfiles).toHaveBeenCalledWith(true)
|
||||
expect($activeGatewayProfile.get()).toBe('default')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// #102281: a user-initiated open must reach Electron main as a FOREGROUND dial
|
||||
// on its FIRST IPC, not only on the secondary's connect. Every open first
|
||||
// probes the route (sharedPrimaryRoute / isAttachedSharedRemote) with
|
||||
// getConnection / getConnectionFor; if that probe is untagged, main starts the
|
||||
// spawn as a background slot wait and the click waits out the probe's 20s
|
||||
// timeout before anything promotes it.
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
setApiRequestConnection: vi.fn(),
|
||||
HermesGateway: class {
|
||||
connectionState = 'closed'
|
||||
connect = async (): Promise<void> => {
|
||||
this.connectionState = 'open'
|
||||
}
|
||||
close = (): void => {
|
||||
this.connectionState = 'closed'
|
||||
}
|
||||
onEvent = vi.fn(() => () => {})
|
||||
onState = vi.fn(() => () => {})
|
||||
}
|
||||
}))
|
||||
vi.mock('@/store/session', () => ({ setConnection: vi.fn(), setGatewayState: vi.fn() }))
|
||||
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
|
||||
|
||||
const {
|
||||
closeSecondaryGateways,
|
||||
configureGatewayRegistry,
|
||||
ensureGatewayForAgent,
|
||||
ensureGatewayForProfile,
|
||||
openGatewayForAgent,
|
||||
openGatewayForProfile,
|
||||
setPrimaryGateway
|
||||
} = await import('./gateway')
|
||||
|
||||
const conn = {
|
||||
authMode: 'token',
|
||||
baseUrl: 'https://homelab.invalid',
|
||||
mode: 'remote',
|
||||
profile: 'research',
|
||||
token: 'fake-test-token',
|
||||
wsUrl: 'wss://homelab.invalid/api/ws?token=fake-test-token'
|
||||
}
|
||||
|
||||
function installDesktop(): { getConnection: ReturnType<typeof vi.fn>; getConnectionFor: ReturnType<typeof vi.fn> } {
|
||||
const stub = {
|
||||
getConnection: vi.fn(async () => conn),
|
||||
getConnectionFor: vi.fn(async () => conn)
|
||||
}
|
||||
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = stub
|
||||
|
||||
return stub
|
||||
}
|
||||
|
||||
function priorities(mock: ReturnType<typeof vi.fn>, pick: (args: unknown[]) => unknown): unknown[] {
|
||||
return mock.mock.calls.map(args => pick(args))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
configureGatewayRegistry({ onEvent: vi.fn() })
|
||||
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeSecondaryGateways()
|
||||
vi.clearAllMocks()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
describe('user opens dial main as foreground from the first IPC (#102281)', () => {
|
||||
it('ensureGatewayForProfile tags the route probe AND the connect dial', async () => {
|
||||
const desktop = installDesktop()
|
||||
|
||||
await ensureGatewayForProfile('research')
|
||||
|
||||
const seen = priorities(desktop.getConnection, args => (args[1] as { priority?: string } | undefined)?.priority)
|
||||
expect(seen.length).toBeGreaterThanOrEqual(2)
|
||||
expect(seen.every(priority => priority === 'foreground')).toBe(true)
|
||||
})
|
||||
|
||||
it('openGatewayForProfile without a priority never tags a dial as foreground', async () => {
|
||||
const desktop = installDesktop()
|
||||
|
||||
await openGatewayForProfile('research')
|
||||
|
||||
const seen = priorities(desktop.getConnection, args => (args[1] as { priority?: string } | undefined)?.priority)
|
||||
expect(seen.length).toBeGreaterThanOrEqual(1)
|
||||
expect(seen.every(priority => priority === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('openGatewayForAgent forwards spawnPriority to every registry dial', async () => {
|
||||
const desktop = installDesktop()
|
||||
|
||||
await openGatewayForAgent('homelab', 'research', { spawnPriority: 'foreground' })
|
||||
|
||||
const seen = priorities(desktop.getConnectionFor, args => (args[0] as { priority?: string }).priority)
|
||||
expect(seen.length).toBeGreaterThanOrEqual(1)
|
||||
expect(seen.every(priority => priority === 'foreground')).toBe(true)
|
||||
})
|
||||
|
||||
it('ensureGatewayForAgent is always a foreground open', async () => {
|
||||
const desktop = installDesktop()
|
||||
|
||||
await ensureGatewayForAgent('homelab', 'research')
|
||||
|
||||
const seen = priorities(desktop.getConnectionFor, args => (args[0] as { priority?: string }).priority)
|
||||
expect(seen.length).toBeGreaterThanOrEqual(1)
|
||||
expect(seen.every(priority => priority === 'foreground')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
setSessions,
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import { clearAllSessionControl } from '@/store/session-control'
|
||||
import { resetSessionPinMirror } from '@/store/session-pin-sync'
|
||||
import { clearAllSessionStates } from '@/store/session-states'
|
||||
import { clearTranscriptTails } from '@/store/transcript-tail-cache'
|
||||
@@ -206,6 +207,9 @@ export function wipeSessionListsForGatewaySwitch(): void {
|
||||
// that are still unread once the next gateway's lists load — so a profile
|
||||
// round-trip doesn't swallow green dots.
|
||||
clearAllSessionStates()
|
||||
// Structured goal/loop/heartbeat entries are keyed by runtime id, which the
|
||||
// next backend re-mints, so a full wipe is exact (and stale-response-safe).
|
||||
clearAllSessionControl()
|
||||
resetLiveRuntimeTracking()
|
||||
resetLiveSync()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
|
||||
@@ -20,6 +20,26 @@ import { setConnection, setGatewayState } from '@/store/session'
|
||||
|
||||
const normKey = (profile: string | null | undefined): string => (profile ?? '').trim() || 'default'
|
||||
|
||||
// Spawn-slot priority handed to Electron main with every backend dial. A
|
||||
// user-initiated open is 'foreground' and may take the pool's reserved slot;
|
||||
// roster hydration, hover prewarm and untagged dials are 'background' — main's
|
||||
// default, so background dials keep the pre-priority IPC payload shape.
|
||||
type SpawnPriority = 'foreground' | 'background'
|
||||
|
||||
function dialPriority(spawnPriority: SpawnPriority): { priority: 'foreground' } | Record<never, never> {
|
||||
return spawnPriority === 'foreground' ? { priority: 'foreground' } : {}
|
||||
}
|
||||
|
||||
function dialProfile(
|
||||
desktop: NonNullable<typeof window.hermesDesktop>,
|
||||
profile: string,
|
||||
spawnPriority: SpawnPriority
|
||||
): Promise<HermesConnection> {
|
||||
return spawnPriority === 'foreground'
|
||||
? desktop.getConnection(profile, { priority: 'foreground' })
|
||||
: desktop.getConnection(profile)
|
||||
}
|
||||
|
||||
// Read connection state through a call so TS control-flow analysis doesn't
|
||||
// narrow the getter to a constant across guards (it genuinely changes).
|
||||
const isOpen = (gateway: HermesGateway | null): boolean => gateway?.connectionState === 'open'
|
||||
@@ -302,7 +322,11 @@ function isPrimaryRegistryRoute(connectionId: null | string, profile: string): b
|
||||
* dials a second WebSocket at the same Tailscale URL, which accept/closes in
|
||||
* ~30ms (`messages=1`) and never runs `session.create` (#96493). Isolated
|
||||
* SSH/pooled backends (`sharedRemote: false`) still get their own secondary. */
|
||||
async function isAttachedSharedRemote(connectionId: null | string, profile: string): Promise<boolean> {
|
||||
async function isAttachedSharedRemote(
|
||||
connectionId: null | string,
|
||||
profile: string,
|
||||
spawnPriority: SpawnPriority = 'background'
|
||||
): Promise<boolean> {
|
||||
const id = String(connectionId ?? '').trim()
|
||||
const key = normKey(profile)
|
||||
|
||||
@@ -322,7 +346,7 @@ async function isAttachedSharedRemote(connectionId: null | string, profile: stri
|
||||
|
||||
try {
|
||||
const conn = await withTimeout(
|
||||
desktop.getConnectionFor({ connectionId: id, profile: key }),
|
||||
desktop.getConnectionFor({ connectionId: id, profile: key, ...dialPriority(spawnPriority) }),
|
||||
RECONNECT_ATTEMPT_TIMEOUT_MS,
|
||||
`Timed out resolving shared-remote route for "${key}"`
|
||||
)
|
||||
@@ -489,7 +513,7 @@ function clearTimer(entry: Secondary): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function openSecondary(entry: Secondary): Promise<void> {
|
||||
async function openSecondary(entry: Secondary, spawnPriority: SpawnPriority = 'background'): Promise<void> {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop) {
|
||||
@@ -497,6 +521,20 @@ async function openSecondary(entry: Secondary): Promise<void> {
|
||||
}
|
||||
|
||||
if (entry.connectPromise) {
|
||||
if (spawnPriority === 'foreground') {
|
||||
// Hydration may already own this dial as a background slot wait. Kick a
|
||||
// foreground IPC so main can promote it onto the reserved slot.
|
||||
void (
|
||||
entry.connectionId && desktop.getConnectionFor
|
||||
? desktop.getConnectionFor({
|
||||
connectionId: entry.connectionId,
|
||||
profile: entry.profile,
|
||||
priority: 'foreground'
|
||||
})
|
||||
: desktop.getConnection(entry.profile, { priority: 'foreground' })
|
||||
).catch(() => undefined)
|
||||
}
|
||||
|
||||
await entry.connectPromise
|
||||
|
||||
return
|
||||
@@ -543,12 +581,16 @@ async function openSecondary(entry: Secondary): Promise<void> {
|
||||
const conn =
|
||||
entry.connectionId && desktop.getConnectionFor
|
||||
? await withTimeout(
|
||||
desktop.getConnectionFor({ connectionId: entry.connectionId, profile: entry.profile }),
|
||||
desktop.getConnectionFor({
|
||||
connectionId: entry.connectionId,
|
||||
profile: entry.profile,
|
||||
...dialPriority(spawnPriority)
|
||||
}),
|
||||
RECONNECT_ATTEMPT_TIMEOUT_MS,
|
||||
`Timed out connecting to profile "${entry.profile}"`
|
||||
)
|
||||
: await withTimeout(
|
||||
desktop.getConnection(entry.profile),
|
||||
dialProfile(desktop, entry.profile, spawnPriority),
|
||||
RECONNECT_ATTEMPT_TIMEOUT_MS,
|
||||
`Timed out connecting to profile "${entry.profile}"`
|
||||
)
|
||||
@@ -752,7 +794,7 @@ function createSecondary(profile: string, connectionId: null | string = null): S
|
||||
// the second dial fails (tunnel/token are per-backend) and the closed socket
|
||||
// poisons the active gateway with "not connected" even though the primary is
|
||||
// open right next to it.
|
||||
async function sharedPrimaryRoute(profile: string): Promise<boolean> {
|
||||
async function sharedPrimaryRoute(profile: string, spawnPriority: SpawnPriority = 'background'): Promise<boolean> {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop) {
|
||||
@@ -764,8 +806,11 @@ async function sharedPrimaryRoute(profile: string): Promise<boolean> {
|
||||
// like any other failure, not hang the route decision forever, since
|
||||
// every caller (gatewayForProfile → requestGatewayForProfile/Agent) awaits
|
||||
// this before it can fall back to dialing a secondary.
|
||||
// This is the FIRST dial main sees for a user open, so it must already
|
||||
// carry the foreground priority — otherwise the spawn it starts queues as
|
||||
// background and the click waits out this probe before being promoted.
|
||||
const conn = await withTimeout(
|
||||
desktop.getConnection(profile),
|
||||
dialProfile(desktop, profile, spawnPriority),
|
||||
RECONNECT_ATTEMPT_TIMEOUT_MS,
|
||||
`Timed out resolving the shared-primary route for profile "${profile}"`
|
||||
)
|
||||
@@ -781,7 +826,8 @@ async function sharedPrimaryRoute(profile: string): Promise<boolean> {
|
||||
// request-scope flag; dedicated local/remote profiles use their pooled socket.
|
||||
async function gatewayForProfile(
|
||||
profile: string,
|
||||
leaseRequest = false
|
||||
leaseRequest = false,
|
||||
spawnPriority: SpawnPriority = 'background'
|
||||
): Promise<{ gateway: HermesGateway | null; key: string; release: () => void; scopeProfile: boolean }> {
|
||||
const key = normKey(profile)
|
||||
const noRelease = () => undefined
|
||||
@@ -790,7 +836,7 @@ async function gatewayForProfile(
|
||||
return { gateway: g.primaryGateway, key, release: noRelease, scopeProfile: false }
|
||||
}
|
||||
|
||||
if (await sharedPrimaryRoute(key)) {
|
||||
if (await sharedPrimaryRoute(key, spawnPriority)) {
|
||||
return { gateway: g.primaryGateway, key, release: noRelease, scopeProfile: true }
|
||||
}
|
||||
|
||||
@@ -840,7 +886,7 @@ async function gatewayForProfile(
|
||||
|
||||
try {
|
||||
if (!isOpen(entry.gateway)) {
|
||||
await openSecondary(entry)
|
||||
await openSecondary(entry, spawnPriority)
|
||||
}
|
||||
} catch (error) {
|
||||
release()
|
||||
@@ -1300,8 +1346,11 @@ function releaseTerminalTurnLease(scope: string, event: GatewayEvent): void {
|
||||
// it. No scheduleReconnect on failure: a hover is speculative, so a dead
|
||||
// backend must not start a background retry loop — the real switch owns retry
|
||||
// and error UX. An already-open (or primary) profile is a no-op.
|
||||
export async function openGatewayForProfile(profile: string): Promise<void> {
|
||||
await gatewayForProfile(profile)
|
||||
export async function openGatewayForProfile(
|
||||
profile: string,
|
||||
{ spawnPriority = 'background' }: { spawnPriority?: SpawnPriority } = {}
|
||||
): Promise<void> {
|
||||
await gatewayForProfile(profile, false, spawnPriority)
|
||||
}
|
||||
|
||||
// ── Connection-scoped agents (multi-source roster) ─────────────────────────
|
||||
@@ -1321,15 +1370,18 @@ export async function openGatewayForProfile(profile: string): Promise<void> {
|
||||
export async function openGatewayForAgent(
|
||||
connectionId: null | string,
|
||||
profile: string,
|
||||
{ activationLease = false }: { activationLease?: boolean } = {}
|
||||
{
|
||||
activationLease = false,
|
||||
spawnPriority = 'background'
|
||||
}: { activationLease?: boolean; spawnPriority?: SpawnPriority } = {}
|
||||
): Promise<void> {
|
||||
const scope = registryBackendScopeKey(connectionId, profile)
|
||||
|
||||
if (scope === normKey(profile) || isPrimaryRegistryRoute(connectionId, profile)) {
|
||||
return openGatewayForProfile(profile)
|
||||
return openGatewayForProfile(profile, { spawnPriority })
|
||||
}
|
||||
|
||||
if (await isAttachedSharedRemote(connectionId, profile)) {
|
||||
if (await isAttachedSharedRemote(connectionId, profile, spawnPriority)) {
|
||||
if (!isOpen(g.primaryGateway)) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
@@ -1356,7 +1408,7 @@ export async function openGatewayForAgent(
|
||||
}
|
||||
|
||||
try {
|
||||
await openSecondary(entry)
|
||||
await openSecondary(entry, spawnPriority)
|
||||
} catch (error) {
|
||||
if (activationLease) {
|
||||
entry.activationLeaseUntil = 0
|
||||
@@ -1383,7 +1435,7 @@ export async function ensureGatewayForAgent(
|
||||
return !signal?.aborted
|
||||
}
|
||||
|
||||
if (await isAttachedSharedRemote(connectionId, profile)) {
|
||||
if (await isAttachedSharedRemote(connectionId, profile, 'foreground')) {
|
||||
return Boolean(isOpen(g.primaryGateway) && !signal?.aborted)
|
||||
}
|
||||
|
||||
@@ -1412,7 +1464,7 @@ export async function ensureGatewayForAgent(
|
||||
entry.reconnectAttempt = 0
|
||||
|
||||
try {
|
||||
await openSecondary(entry)
|
||||
await openSecondary(entry, 'foreground')
|
||||
} catch {
|
||||
scheduleReconnect(entry)
|
||||
}
|
||||
@@ -1465,7 +1517,7 @@ export async function ensureGatewayForProfile(profile: string): Promise<void> {
|
||||
// primary instead of dialing a doomed duplicate socket at the same
|
||||
// descriptor — $activeGatewayProfile still moves to `key`, so request
|
||||
// scoping and profile-aware surfaces behave identically.
|
||||
if (await sharedPrimaryRoute(key)) {
|
||||
if (await sharedPrimaryRoute(key, 'foreground')) {
|
||||
applyActive(g.primaryProfile, activationEpoch)
|
||||
|
||||
return
|
||||
@@ -1489,7 +1541,7 @@ export async function ensureGatewayForProfile(profile: string): Promise<void> {
|
||||
entry.reconnectAttempt = 0
|
||||
|
||||
try {
|
||||
await openSecondary(entry)
|
||||
await openSecondary(entry, 'foreground')
|
||||
} catch (error) {
|
||||
// #81094: a failed secondary dial must NOT fall through to setActive()
|
||||
// with a closed socket — that silently routes the user's messages to the
|
||||
|
||||
@@ -603,7 +603,10 @@ export async function openGatewayAgent(connectionId: string, profile: string): P
|
||||
return
|
||||
}
|
||||
|
||||
await openGatewayForAgent(connection, normalizeProfileKey(profile), { activationLease: true })
|
||||
await openGatewayForAgent(connection, normalizeProfileKey(profile), {
|
||||
activationLease: true,
|
||||
spawnPriority: 'foreground'
|
||||
})
|
||||
}
|
||||
|
||||
// Activate a connection-scoped agent's gateway — the (connectionId, profile)
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import { JsonRpcGatewayError } from '@hermes/shared'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { refreshLegacyGoal } = vi.hoisted(() => ({ refreshLegacyGoal: vi.fn() }))
|
||||
|
||||
vi.mock('./goals', async importOriginal => ({
|
||||
...(await importOriginal()),
|
||||
refreshSessionGoal: refreshLegacyGoal
|
||||
}))
|
||||
|
||||
import { $gateway } from './gateway'
|
||||
import { resetBackgroundPollingGuard } from './runtime-gone'
|
||||
import {
|
||||
$sessionControlBySession,
|
||||
applySessionControlSnapshot,
|
||||
applySessionControlUpdate,
|
||||
clearAllSessionControl,
|
||||
clearSessionControl,
|
||||
parseSessionControlSnapshot,
|
||||
refreshSessionControl,
|
||||
refreshSupportedSessionControlAfterTurn,
|
||||
runSessionControlAction,
|
||||
type SessionControlSnapshot
|
||||
} from './session-control'
|
||||
|
||||
const FULL_SNAPSHOT: SessionControlSnapshot = {
|
||||
goal: {
|
||||
contract: {
|
||||
boundaries: 'desktop store only',
|
||||
constraints: 'do not lose state',
|
||||
outcome: 'session control is hydrated',
|
||||
stop_when: 'a human decision is required',
|
||||
verification: 'focused tests pass'
|
||||
},
|
||||
created_at: 1_700_000_000,
|
||||
gates: [{ attempts: 0, command: 'npm test', last_exit_code: null, max_retries: 2, timeout_seconds: 60 }],
|
||||
max_turns: 20,
|
||||
status: 'active',
|
||||
subgoals: ['write tests', 'repair state'],
|
||||
title: 'Repair session control',
|
||||
turns_used: 3,
|
||||
updated_at: 1_700_000_100,
|
||||
wait_barrier: { reason: 'waiting for deploy', type: 'until', until_at: 1_700_000_200 }
|
||||
},
|
||||
heartbeat: {
|
||||
created_at: 1_700_000_000,
|
||||
fire_count: 5,
|
||||
interval_seconds: 600,
|
||||
last_fired_at: 1_700_000_100,
|
||||
prompt: 'check health',
|
||||
status: 'active'
|
||||
},
|
||||
loop: {
|
||||
awaiting_response: false,
|
||||
created_at: 1_700_000_000,
|
||||
current_delay: 300,
|
||||
deferred_by_goal: false,
|
||||
interval_seconds: 300,
|
||||
last_fired_at: 1_700_000_100,
|
||||
max_ticks: 10,
|
||||
mode: 'interval',
|
||||
next_due_at: 1_700_000_400,
|
||||
prompt: 'check build status',
|
||||
status: 'active',
|
||||
ticks_fired: 3,
|
||||
times: 3,
|
||||
until: ''
|
||||
},
|
||||
revision: 'revision-1',
|
||||
updated_at: 1_700_000_100
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
function useGateway(request: (method: string, params: Record<string, unknown>) => Promise<unknown>): void {
|
||||
$gateway.set({ request } as never)
|
||||
}
|
||||
|
||||
describe('session-control store', () => {
|
||||
beforeEach(() => {
|
||||
refreshLegacyGoal.mockReset()
|
||||
clearAllSessionControl()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$gateway.set(null as never)
|
||||
resetBackgroundPollingGuard()
|
||||
clearAllSessionControl()
|
||||
})
|
||||
|
||||
it('parses the exact persisted goal, loop, heartbeat, and wait-barrier shapes into fresh data', () => {
|
||||
const parsed = parseSessionControlSnapshot(FULL_SNAPSHOT)
|
||||
|
||||
expect(parsed).toEqual(FULL_SNAPSHOT)
|
||||
expect(parsed).not.toBe(FULL_SNAPSHOT)
|
||||
expect(parsed!.goal).not.toBe(FULL_SNAPSHOT.goal)
|
||||
expect(parsed!.goal!.contract).not.toBe(FULL_SNAPSHOT.goal!.contract)
|
||||
expect(parsed!.loop!.mode).toBe('interval')
|
||||
expect(parsed!.goal!.wait_barrier).toEqual({ reason: 'waiting for deploy', type: 'until', until_at: 1_700_000_200 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unknown goal status', { ...FULL_SNAPSHOT, goal: { ...FULL_SNAPSHOT.goal!, status: 'waiting' } }],
|
||||
['non-finite top-level timestamp', { ...FULL_SNAPSHOT, updated_at: Number.NaN }],
|
||||
['malformed goal contract', { ...FULL_SNAPSHOT, goal: { ...FULL_SNAPSHOT.goal!, contract: { outcome: 3 } } }],
|
||||
[
|
||||
'gate output that is not in the allowlisted summary',
|
||||
{
|
||||
...FULL_SNAPSHOT,
|
||||
goal: { ...FULL_SNAPSHOT.goal!, gates: [{ ...FULL_SNAPSHOT.goal!.gates[0], last_output_tail: 'leak' }] }
|
||||
}
|
||||
],
|
||||
[
|
||||
'wait target that does not match its discriminator',
|
||||
{ ...FULL_SNAPSHOT, goal: { ...FULL_SNAPSHOT.goal!, wait_barrier: { reason: 'pid', target: '7', type: 'pid' } } }
|
||||
],
|
||||
['unknown loop mode', { ...FULL_SNAPSHOT, loop: { ...FULL_SNAPSHOT.loop!, mode: 'fixed' } }],
|
||||
['malformed heartbeat', { ...FULL_SNAPSHOT, heartbeat: { ...FULL_SNAPSHOT.heartbeat!, fire_count: 'five' } }],
|
||||
['unknown top-level field', { ...FULL_SNAPSHOT, unsupported: true }]
|
||||
])('rejects %s without accepting a partial snapshot', (_name, value) => {
|
||||
expect(parseSessionControlSnapshot(value)).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back once on the unsupported transition and suppresses future compatibility retries', async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw new JsonRpcGatewayError('method not found', { code: -32601 })
|
||||
})
|
||||
|
||||
useGateway(request)
|
||||
|
||||
await refreshSessionControl('s1')
|
||||
await refreshSessionControl('s1')
|
||||
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
capability: 'unsupported',
|
||||
loading: false,
|
||||
snapshot: null
|
||||
})
|
||||
expect(refreshLegacyGoal).toHaveBeenCalledTimes(1)
|
||||
expect(refreshLegacyGoal).toHaveBeenCalledWith('s1')
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retains the last good snapshot and reports a bounded ordinary read error', async () => {
|
||||
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
|
||||
const message = 'x'.repeat(500)
|
||||
useGateway(
|
||||
vi.fn(async () => {
|
||||
throw new Error(message)
|
||||
})
|
||||
)
|
||||
|
||||
await expect(refreshSessionControl('s1', { background: true })).resolves.toBeDefined()
|
||||
|
||||
const entry = $sessionControlBySession.get().s1!
|
||||
expect(entry.snapshot!.revision).toBe(FULL_SNAPSHOT.revision)
|
||||
expect(entry.capability).toBe('supported')
|
||||
expect(entry.error).toHaveLength(240)
|
||||
expect(entry.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('downgrades a current action method-not-found once, preserves its snapshot, and still rejects', async () => {
|
||||
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
|
||||
useGateway(
|
||||
vi.fn(async () => {
|
||||
throw new JsonRpcGatewayError('method not found', { code: -32601 })
|
||||
})
|
||||
)
|
||||
|
||||
await expect(runSessionControlAction('s1', 'goal.pause')).rejects.toThrow('method not found')
|
||||
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
capability: 'unsupported',
|
||||
error: null,
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: { revision: FULL_SNAPSHOT.revision }
|
||||
})
|
||||
expect(refreshLegacyGoal).toHaveBeenCalledTimes(1)
|
||||
expect(refreshLegacyGoal).toHaveBeenCalledWith('s1')
|
||||
})
|
||||
|
||||
it('does not let a method-not-found action response downgrade a newer event snapshot', async () => {
|
||||
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
|
||||
const response = deferred<unknown>()
|
||||
useGateway(vi.fn(() => response.promise))
|
||||
|
||||
const action = runSessionControlAction('s1', 'goal.pause')
|
||||
applySessionControlUpdate('s1', { ...FULL_SNAPSHOT, revision: 'event-newer' })
|
||||
response.reject(new JsonRpcGatewayError('method not found', { code: -32601 }))
|
||||
|
||||
await expect(action).rejects.toThrow('method not found')
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
capability: 'supported',
|
||||
error: 'method not found',
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: { revision: 'event-newer' }
|
||||
})
|
||||
expect(refreshLegacyGoal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let a stale method-not-found response disturb a newer action', async () => {
|
||||
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
|
||||
const olderResponse = deferred<unknown>()
|
||||
const newerResponse = deferred<unknown>()
|
||||
useGateway(
|
||||
vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => olderResponse.promise)
|
||||
.mockImplementationOnce(() => newerResponse.promise)
|
||||
)
|
||||
|
||||
const olderAction = runSessionControlAction('s1', 'goal.pause')
|
||||
const newerAction = runSessionControlAction('s1', 'goal.resume')
|
||||
olderResponse.reject(new JsonRpcGatewayError('method not found', { code: -32601 }))
|
||||
|
||||
await expect(olderAction).rejects.toThrow('method not found')
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
capability: 'supported',
|
||||
error: null,
|
||||
loading: true,
|
||||
pendingAction: 'goal.resume',
|
||||
snapshot: { revision: FULL_SNAPSHOT.revision }
|
||||
})
|
||||
expect(refreshLegacyGoal).not.toHaveBeenCalled()
|
||||
|
||||
newerResponse.resolve({
|
||||
control: { ...FULL_SNAPSHOT, revision: 'newer-action' },
|
||||
dispatch: { display: null, message: null, notice: null, output: 'resumed', type: 'exec' }
|
||||
})
|
||||
await expect(newerAction).resolves.toMatchObject({ output: 'resumed' })
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: { revision: 'newer-action' }
|
||||
})
|
||||
})
|
||||
|
||||
it('submits only the requested action and exposes its pending state on that session', async () => {
|
||||
const response = deferred<unknown>()
|
||||
const request = vi.fn(() => response.promise)
|
||||
useGateway(request)
|
||||
|
||||
const action = runSessionControlAction('s1', 'subgoal.add', { text: 'verify hydration' })
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
capability: 'unknown',
|
||||
loading: true,
|
||||
pendingAction: 'subgoal.add'
|
||||
})
|
||||
expect($sessionControlBySession.get().s2).toBeUndefined()
|
||||
expect(request).toHaveBeenCalledWith('session.control', {
|
||||
action: 'subgoal.add',
|
||||
args: { text: 'verify hydration' },
|
||||
session_id: 's1'
|
||||
})
|
||||
|
||||
response.resolve({
|
||||
control: { ...FULL_SNAPSHOT, revision: 'action-revision' },
|
||||
dispatch: { display: null, message: null, notice: null, output: 'added', type: 'exec' }
|
||||
})
|
||||
|
||||
await expect(action).resolves.toEqual({ display: null, message: null, notice: null, output: 'added', type: 'exec' })
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
capability: 'supported',
|
||||
error: null,
|
||||
pendingAction: null
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a failed action truthfully while retaining the good snapshot and clearing its pending state', async () => {
|
||||
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
|
||||
useGateway(
|
||||
vi.fn(async () => {
|
||||
throw new Error('backend unavailable')
|
||||
})
|
||||
)
|
||||
|
||||
await expect(runSessionControlAction('s1', 'goal.pause')).rejects.toThrow('backend unavailable')
|
||||
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({ error: 'backend unavailable', pendingAction: null })
|
||||
expect($sessionControlBySession.get().s1!.snapshot!.revision).toBe(FULL_SNAPSHOT.revision)
|
||||
})
|
||||
|
||||
it('keeps a pending action current through an event and publishes its later failure', async () => {
|
||||
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
|
||||
const response = deferred<unknown>()
|
||||
useGateway(vi.fn(() => response.promise))
|
||||
|
||||
const action = runSessionControlAction('s1', 'goal.pause')
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({ loading: true, pendingAction: 'goal.pause' })
|
||||
|
||||
applySessionControlUpdate('s1', { ...FULL_SNAPSHOT, revision: 'event-newer' })
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
error: null,
|
||||
loading: true,
|
||||
pendingAction: 'goal.pause',
|
||||
snapshot: { revision: 'event-newer' }
|
||||
})
|
||||
|
||||
response.reject(new Error('backend unavailable'))
|
||||
await expect(action).rejects.toThrow('backend unavailable')
|
||||
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
error: 'backend unavailable',
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: { revision: 'event-newer' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let a late read overwrite a newer event', async () => {
|
||||
const slow = deferred<unknown>()
|
||||
useGateway(vi.fn(() => slow.promise))
|
||||
|
||||
const read = refreshSessionControl('s1')
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({ loading: true, pendingAction: null })
|
||||
|
||||
applySessionControlUpdate('s1', { ...FULL_SNAPSHOT, revision: 'event-newer' })
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: { revision: 'event-newer' }
|
||||
})
|
||||
|
||||
slow.resolve({ control: { ...FULL_SNAPSHOT, revision: 'read-stale' } })
|
||||
await read
|
||||
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: { revision: 'event-newer' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let a late read overwrite a newer action response', async () => {
|
||||
const slow = deferred<unknown>()
|
||||
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => slow.promise)
|
||||
.mockResolvedValueOnce({
|
||||
control: { ...FULL_SNAPSHOT, revision: 'action-newer' },
|
||||
dispatch: { display: null, message: null, notice: null, output: 'paused', type: 'exec' }
|
||||
})
|
||||
|
||||
useGateway(request)
|
||||
|
||||
const read = refreshSessionControl('s1')
|
||||
await runSessionControlAction('s1', 'goal.pause')
|
||||
slow.resolve({ control: { ...FULL_SNAPSHOT, revision: 'read-stale' } })
|
||||
await read
|
||||
|
||||
expect($sessionControlBySession.get().s1!.snapshot!.revision).toBe('action-newer')
|
||||
})
|
||||
|
||||
it('a gateway-switch wipe drops every entry and strands in-flight responses from the old backend', async () => {
|
||||
applySessionControlSnapshot('supported', FULL_SNAPSHOT)
|
||||
const slowRead = deferred<unknown>()
|
||||
const slowAction = deferred<unknown>()
|
||||
useGateway(vi.fn((method: string) => (method === 'session.control' ? slowAction.promise : slowRead.promise)))
|
||||
|
||||
const read = refreshSessionControl('probing')
|
||||
const action = runSessionControlAction('supported', 'goal.pause')
|
||||
|
||||
clearAllSessionControl()
|
||||
expect($sessionControlBySession.get()).toEqual({})
|
||||
|
||||
slowRead.resolve({ control: FULL_SNAPSHOT })
|
||||
slowAction.resolve({
|
||||
control: { ...FULL_SNAPSHOT, revision: 'stale-action' },
|
||||
dispatch: { display: null, message: null, notice: null, output: 'paused', type: 'exec' }
|
||||
})
|
||||
await read
|
||||
await action
|
||||
|
||||
expect($sessionControlBySession.get()).toEqual({})
|
||||
})
|
||||
|
||||
it('does not let a late read repopulate a cleared session', async () => {
|
||||
const slow = deferred<unknown>()
|
||||
useGateway(vi.fn(() => slow.promise))
|
||||
|
||||
const read = refreshSessionControl('s1')
|
||||
clearSessionControl('s1')
|
||||
slow.resolve({ control: FULL_SNAPSHOT })
|
||||
await read
|
||||
|
||||
expect($sessionControlBySession.get().s1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not let an older read overwrite a newer read', async () => {
|
||||
const slow = deferred<unknown>()
|
||||
const fast = deferred<unknown>()
|
||||
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => slow.promise)
|
||||
.mockImplementationOnce(() => fast.promise)
|
||||
|
||||
useGateway(request)
|
||||
|
||||
const older = refreshSessionControl('s1')
|
||||
const newer = refreshSessionControl('s1')
|
||||
fast.resolve({ control: { ...FULL_SNAPSHOT, revision: 'newer-read' } })
|
||||
await newer
|
||||
slow.resolve({ control: { ...FULL_SNAPSHOT, revision: 'older-read' } })
|
||||
await older
|
||||
|
||||
expect($sessionControlBySession.get().s1!.snapshot!.revision).toBe('newer-read')
|
||||
})
|
||||
|
||||
it('does not let a late post-turn refresh overwrite a newer event', async () => {
|
||||
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
|
||||
const slow = deferred<unknown>()
|
||||
useGateway(vi.fn(() => slow.promise))
|
||||
|
||||
const refresh = refreshSupportedSessionControlAfterTurn('s1')
|
||||
applySessionControlUpdate('s1', { ...FULL_SNAPSHOT, revision: 'event-newer' })
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: { revision: 'event-newer' }
|
||||
})
|
||||
|
||||
slow.resolve({ control: { ...FULL_SNAPSHOT, revision: 'post-turn-stale' } })
|
||||
await refresh
|
||||
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: { revision: 'event-newer' }
|
||||
})
|
||||
})
|
||||
|
||||
it('applies an authoritative action response after an intermediate event', async () => {
|
||||
const slow = deferred<unknown>()
|
||||
useGateway(vi.fn(() => slow.promise))
|
||||
|
||||
const action = runSessionControlAction('s1', 'goal.pause')
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({ loading: true, pendingAction: 'goal.pause' })
|
||||
|
||||
applySessionControlUpdate('s1', { ...FULL_SNAPSHOT, revision: 'event-newer' })
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
loading: true,
|
||||
pendingAction: 'goal.pause',
|
||||
snapshot: { revision: 'event-newer' }
|
||||
})
|
||||
|
||||
slow.resolve({
|
||||
control: { ...FULL_SNAPSHOT, revision: 'action-stale' },
|
||||
dispatch: { display: null, message: null, notice: null, output: 'paused', type: 'exec' }
|
||||
})
|
||||
|
||||
await expect(action).resolves.toMatchObject({ output: 'paused', type: 'exec' })
|
||||
expect($sessionControlBySession.get().s1).toMatchObject({
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: { revision: 'action-stale' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,872 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { $gateway } from './gateway'
|
||||
import { refreshSessionGoal } from './goals'
|
||||
import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone'
|
||||
import { ambientRequestFor } from './session-gone-latch'
|
||||
import { requestForOwnedSession } from './session-states'
|
||||
|
||||
export type SessionControlGoalStatus = 'active' | 'done' | 'paused'
|
||||
export type SessionControlLoopMode = 'interval' | 'self_paced'
|
||||
export type SessionControlLoopStatus = 'active' | 'done' | 'paused'
|
||||
export type SessionControlHeartbeatStatus = 'active' | 'paused'
|
||||
|
||||
export interface SessionControlGoalContract {
|
||||
boundaries: string
|
||||
constraints: string
|
||||
outcome: string
|
||||
stop_when: string
|
||||
verification: string
|
||||
}
|
||||
|
||||
export interface SessionControlGate {
|
||||
attempts: number
|
||||
command: string
|
||||
last_exit_code: number | null
|
||||
max_retries: number
|
||||
timeout_seconds: number
|
||||
}
|
||||
|
||||
export type SessionControlWaitBarrier =
|
||||
| { reason: string; type: 'until'; until_at: number }
|
||||
| { reason: string; target: string; type: 'session' }
|
||||
| { reason: string; target: number; type: 'pid' }
|
||||
|
||||
export interface SessionControlGoal {
|
||||
contract: SessionControlGoalContract
|
||||
gates: SessionControlGate[]
|
||||
last_reason?: string
|
||||
last_verdict?: 'blocked' | 'continue' | 'done' | 'skipped' | 'wait'
|
||||
max_turns: number
|
||||
paused_reason?: string
|
||||
status: SessionControlGoalStatus
|
||||
subgoals: string[]
|
||||
title: string
|
||||
turns_used: number
|
||||
updated_at?: number
|
||||
wait_barrier?: SessionControlWaitBarrier
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export interface SessionControlLoop {
|
||||
awaiting_response: boolean
|
||||
created_at: number
|
||||
current_delay: number
|
||||
deferred_by_goal: boolean
|
||||
interval_seconds: number
|
||||
last_fired_at: number
|
||||
last_stop_reason?: string
|
||||
max_ticks: number
|
||||
mode: SessionControlLoopMode
|
||||
next_due_at: number
|
||||
paused_reason?: string
|
||||
prompt: string
|
||||
status: SessionControlLoopStatus
|
||||
ticks_fired: number
|
||||
times: number
|
||||
until: string
|
||||
}
|
||||
|
||||
export interface SessionControlHeartbeat {
|
||||
created_at: number
|
||||
fire_count: number
|
||||
interval_seconds: number
|
||||
last_fired_at: number
|
||||
prompt: string
|
||||
status: SessionControlHeartbeatStatus
|
||||
}
|
||||
|
||||
export interface SessionControlSnapshot {
|
||||
goal: SessionControlGoal | null
|
||||
heartbeat: SessionControlHeartbeat | null
|
||||
loop: SessionControlLoop | null
|
||||
revision: string
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type SessionControlAction =
|
||||
| 'goal.clear'
|
||||
| 'goal.pause'
|
||||
| 'goal.resume'
|
||||
| 'goal.unwait'
|
||||
| 'heartbeat.clear'
|
||||
| 'heartbeat.pause'
|
||||
| 'heartbeat.resume'
|
||||
| 'loop.pause'
|
||||
| 'loop.resume'
|
||||
| 'loop.stop'
|
||||
| 'subgoal.add'
|
||||
| 'subgoal.clear'
|
||||
| 'subgoal.remove'
|
||||
|
||||
export type SessionControlActionArgs = { index: number } | { text: string }
|
||||
|
||||
export interface SessionControlDispatch {
|
||||
display: string | null
|
||||
message: string | null
|
||||
notice: string | null
|
||||
output: string | null
|
||||
type: 'exec' | 'send'
|
||||
}
|
||||
|
||||
export interface SessionControlEntry {
|
||||
capability: 'unknown' | 'supported' | 'unsupported'
|
||||
error: string | null
|
||||
loading: boolean
|
||||
pendingAction: SessionControlAction | null
|
||||
snapshot: SessionControlSnapshot | null
|
||||
}
|
||||
|
||||
interface RefreshOptions {
|
||||
background?: boolean
|
||||
}
|
||||
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
|
||||
const GOAL_STATUSES = new Set<SessionControlGoalStatus>(['active', 'done', 'paused'])
|
||||
|
||||
const GOAL_VERDICTS = new Set<NonNullable<SessionControlGoal['last_verdict']>>([
|
||||
'blocked',
|
||||
'continue',
|
||||
'done',
|
||||
'skipped',
|
||||
'wait'
|
||||
])
|
||||
|
||||
const LOOP_MODES = new Set<SessionControlLoopMode>(['interval', 'self_paced'])
|
||||
const LOOP_STATUSES = new Set<SessionControlLoopStatus>(['active', 'done', 'paused'])
|
||||
const HEARTBEAT_STATUSES = new Set<SessionControlHeartbeatStatus>(['active', 'paused'])
|
||||
const DISPATCH_TYPES = new Set<SessionControlDispatch['type']>(['exec', 'send'])
|
||||
const ERROR_LIMIT = 240
|
||||
|
||||
const versions = new Map<string, number>()
|
||||
const eventVersions = new Map<string, number>()
|
||||
|
||||
export const $sessionControlBySession = atom<Record<string, SessionControlEntry>>({})
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function hasOwn(value: UnknownRecord, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, key)
|
||||
}
|
||||
|
||||
function hasExactFields(value: UnknownRecord, required: string[], optional: string[] = []): boolean {
|
||||
const allowed = new Set([...required, ...optional])
|
||||
|
||||
return required.every(key => hasOwn(value, key)) && Object.keys(value).every(key => allowed.has(key))
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function isInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value)
|
||||
}
|
||||
|
||||
function hasOptionalStrings(value: UnknownRecord, keys: string[]): boolean {
|
||||
return keys.every(key => !hasOwn(value, key) || typeof value[key] === 'string')
|
||||
}
|
||||
|
||||
function parseGoalContract(value: unknown): SessionControlGoalContract | null {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasExactFields(value, ['outcome', 'verification', 'constraints', 'boundaries', 'stop_when'])
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.outcome !== 'string' ||
|
||||
typeof value.verification !== 'string' ||
|
||||
typeof value.constraints !== 'string' ||
|
||||
typeof value.boundaries !== 'string' ||
|
||||
typeof value.stop_when !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
boundaries: value.boundaries,
|
||||
constraints: value.constraints,
|
||||
outcome: value.outcome,
|
||||
stop_when: value.stop_when,
|
||||
verification: value.verification
|
||||
}
|
||||
}
|
||||
|
||||
function parseGate(value: unknown): SessionControlGate | null {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasExactFields(value, ['command', 'timeout_seconds', 'max_retries', 'attempts', 'last_exit_code'])
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.command !== 'string' ||
|
||||
!isInteger(value.timeout_seconds) ||
|
||||
!isInteger(value.max_retries) ||
|
||||
!isInteger(value.attempts) ||
|
||||
(value.last_exit_code !== null && !isInteger(value.last_exit_code))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
attempts: value.attempts,
|
||||
command: value.command,
|
||||
last_exit_code: value.last_exit_code,
|
||||
max_retries: value.max_retries,
|
||||
timeout_seconds: value.timeout_seconds
|
||||
}
|
||||
}
|
||||
|
||||
function parseWaitBarrier(value: unknown): SessionControlWaitBarrier | null {
|
||||
if (!isRecord(value) || typeof value.type !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (value.type === 'until') {
|
||||
if (
|
||||
!hasExactFields(value, ['type', 'until_at', 'reason']) ||
|
||||
!isFiniteNumber(value.until_at) ||
|
||||
typeof value.reason !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { reason: value.reason, type: 'until', until_at: value.until_at }
|
||||
}
|
||||
|
||||
if (value.type === 'session') {
|
||||
if (
|
||||
!hasExactFields(value, ['type', 'target', 'reason']) ||
|
||||
typeof value.target !== 'string' ||
|
||||
typeof value.reason !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { reason: value.reason, target: value.target, type: 'session' }
|
||||
}
|
||||
|
||||
if (value.type === 'pid') {
|
||||
if (
|
||||
!hasExactFields(value, ['type', 'target', 'reason']) ||
|
||||
!isInteger(value.target) ||
|
||||
typeof value.reason !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { reason: value.reason, target: value.target, type: 'pid' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseGoal(value: unknown): SessionControlGoal | null {
|
||||
const required = ['title', 'status', 'turns_used', 'max_turns', 'contract', 'subgoals', 'gates']
|
||||
const optional = ['created_at', 'updated_at', 'paused_reason', 'last_verdict', 'last_reason', 'wait_barrier']
|
||||
|
||||
if (!isRecord(value) || !hasExactFields(value, required, optional)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.title !== 'string' ||
|
||||
typeof value.status !== 'string' ||
|
||||
!GOAL_STATUSES.has(value.status as SessionControlGoalStatus) ||
|
||||
!isInteger(value.turns_used) ||
|
||||
!isInteger(value.max_turns) ||
|
||||
!Array.isArray(value.subgoals) ||
|
||||
!value.subgoals.every(subgoal => typeof subgoal === 'string') ||
|
||||
!Array.isArray(value.gates) ||
|
||||
!hasOptionalStrings(value, ['paused_reason', 'last_reason']) ||
|
||||
(hasOwn(value, 'created_at') && !isFiniteNumber(value.created_at)) ||
|
||||
(hasOwn(value, 'updated_at') && !isFiniteNumber(value.updated_at)) ||
|
||||
(hasOwn(value, 'last_verdict') &&
|
||||
(typeof value.last_verdict !== 'string' ||
|
||||
!GOAL_VERDICTS.has(value.last_verdict as NonNullable<SessionControlGoal['last_verdict']>)))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const contract = parseGoalContract(value.contract)
|
||||
const parsedGates = value.gates.map(parseGate)
|
||||
const waitBarrier = hasOwn(value, 'wait_barrier') ? parseWaitBarrier(value.wait_barrier) : undefined
|
||||
|
||||
if (!contract || parsedGates.some(gate => gate === null) || (hasOwn(value, 'wait_barrier') && !waitBarrier)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const gates = parsedGates.filter((gate): gate is SessionControlGate => gate !== null)
|
||||
|
||||
const goal: SessionControlGoal = {
|
||||
contract,
|
||||
gates,
|
||||
max_turns: value.max_turns,
|
||||
status: value.status as SessionControlGoalStatus,
|
||||
subgoals: [...value.subgoals],
|
||||
title: value.title,
|
||||
turns_used: value.turns_used
|
||||
}
|
||||
|
||||
if (hasOwn(value, 'created_at')) {
|
||||
goal.created_at = value.created_at as number
|
||||
}
|
||||
|
||||
if (hasOwn(value, 'updated_at')) {
|
||||
goal.updated_at = value.updated_at as number
|
||||
}
|
||||
|
||||
if (hasOwn(value, 'paused_reason')) {
|
||||
goal.paused_reason = value.paused_reason as string
|
||||
}
|
||||
|
||||
if (hasOwn(value, 'last_reason')) {
|
||||
goal.last_reason = value.last_reason as string
|
||||
}
|
||||
|
||||
if (hasOwn(value, 'last_verdict')) {
|
||||
goal.last_verdict = value.last_verdict as SessionControlGoal['last_verdict']
|
||||
}
|
||||
|
||||
if (waitBarrier) {
|
||||
goal.wait_barrier = waitBarrier
|
||||
}
|
||||
|
||||
return goal
|
||||
}
|
||||
|
||||
function parseLoop(value: unknown): SessionControlLoop | null {
|
||||
const required = [
|
||||
'prompt',
|
||||
'status',
|
||||
'mode',
|
||||
'interval_seconds',
|
||||
'current_delay',
|
||||
'times',
|
||||
'until',
|
||||
'max_ticks',
|
||||
'ticks_fired',
|
||||
'created_at',
|
||||
'last_fired_at',
|
||||
'next_due_at',
|
||||
'awaiting_response',
|
||||
'deferred_by_goal'
|
||||
]
|
||||
|
||||
if (!isRecord(value) || !hasExactFields(value, required, ['paused_reason', 'last_stop_reason'])) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.prompt !== 'string' ||
|
||||
typeof value.status !== 'string' ||
|
||||
!LOOP_STATUSES.has(value.status as SessionControlLoopStatus) ||
|
||||
typeof value.mode !== 'string' ||
|
||||
!LOOP_MODES.has(value.mode as SessionControlLoopMode) ||
|
||||
!isFiniteNumber(value.interval_seconds) ||
|
||||
!isFiniteNumber(value.current_delay) ||
|
||||
!isInteger(value.times) ||
|
||||
typeof value.until !== 'string' ||
|
||||
!isInteger(value.max_ticks) ||
|
||||
!isInteger(value.ticks_fired) ||
|
||||
!isFiniteNumber(value.created_at) ||
|
||||
!isFiniteNumber(value.last_fired_at) ||
|
||||
!isFiniteNumber(value.next_due_at) ||
|
||||
typeof value.awaiting_response !== 'boolean' ||
|
||||
typeof value.deferred_by_goal !== 'boolean' ||
|
||||
!hasOptionalStrings(value, ['paused_reason', 'last_stop_reason'])
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const loop: SessionControlLoop = {
|
||||
awaiting_response: value.awaiting_response,
|
||||
created_at: value.created_at,
|
||||
current_delay: value.current_delay,
|
||||
deferred_by_goal: value.deferred_by_goal,
|
||||
interval_seconds: value.interval_seconds,
|
||||
last_fired_at: value.last_fired_at,
|
||||
max_ticks: value.max_ticks,
|
||||
mode: value.mode as SessionControlLoopMode,
|
||||
next_due_at: value.next_due_at,
|
||||
prompt: value.prompt,
|
||||
status: value.status as SessionControlLoopStatus,
|
||||
ticks_fired: value.ticks_fired,
|
||||
times: value.times,
|
||||
until: value.until
|
||||
}
|
||||
|
||||
if (hasOwn(value, 'paused_reason')) {
|
||||
loop.paused_reason = value.paused_reason as string
|
||||
}
|
||||
|
||||
if (hasOwn(value, 'last_stop_reason')) {
|
||||
loop.last_stop_reason = value.last_stop_reason as string
|
||||
}
|
||||
|
||||
return loop
|
||||
}
|
||||
|
||||
function parseHeartbeat(value: unknown): SessionControlHeartbeat | null {
|
||||
const required = ['prompt', 'status', 'interval_seconds', 'created_at', 'last_fired_at', 'fire_count']
|
||||
|
||||
if (!isRecord(value) || !hasExactFields(value, required)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.prompt !== 'string' ||
|
||||
typeof value.status !== 'string' ||
|
||||
!HEARTBEAT_STATUSES.has(value.status as SessionControlHeartbeatStatus) ||
|
||||
!isInteger(value.interval_seconds) ||
|
||||
!isFiniteNumber(value.created_at) ||
|
||||
!isFiniteNumber(value.last_fired_at) ||
|
||||
!isInteger(value.fire_count)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
created_at: value.created_at,
|
||||
fire_count: value.fire_count,
|
||||
interval_seconds: value.interval_seconds,
|
||||
last_fired_at: value.last_fired_at,
|
||||
prompt: value.prompt,
|
||||
status: value.status as SessionControlHeartbeatStatus
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses the stable allowlisted backend shape into fresh renderer-owned data. */
|
||||
export function parseSessionControlSnapshot(value: unknown): SessionControlSnapshot | null {
|
||||
if (!isRecord(value) || !hasExactFields(value, ['goal', 'loop', 'heartbeat', 'revision', 'updated_at'])) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof value.revision !== 'string' || !isFiniteNumber(value.updated_at)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const goal = value.goal === null ? null : parseGoal(value.goal)
|
||||
const loop = value.loop === null ? null : parseLoop(value.loop)
|
||||
const heartbeat = value.heartbeat === null ? null : parseHeartbeat(value.heartbeat)
|
||||
|
||||
if ((value.goal !== null && !goal) || (value.loop !== null && !loop) || (value.heartbeat !== null && !heartbeat)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { goal, heartbeat, loop, revision: value.revision, updated_at: value.updated_at }
|
||||
}
|
||||
|
||||
function parseSessionControlDispatch(value: unknown): SessionControlDispatch | null {
|
||||
if (!isRecord(value) || !hasExactFields(value, ['type', 'output', 'notice', 'message', 'display'])) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.type !== 'string' ||
|
||||
!DISPATCH_TYPES.has(value.type as SessionControlDispatch['type']) ||
|
||||
![value.output, value.notice, value.message, value.display].every(
|
||||
field => field === null || typeof field === 'string'
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
display: value.display as string | null,
|
||||
message: value.message as string | null,
|
||||
notice: value.notice as string | null,
|
||||
output: value.output as string | null,
|
||||
type: value.type as SessionControlDispatch['type']
|
||||
}
|
||||
}
|
||||
|
||||
function emptyEntry(): SessionControlEntry {
|
||||
return { capability: 'unknown', error: null, loading: false, pendingAction: null, snapshot: null }
|
||||
}
|
||||
|
||||
function currentVersion(sessionId: string): number {
|
||||
return versions.get(sessionId) ?? 0
|
||||
}
|
||||
|
||||
function advanceVersion(sessionId: string): number {
|
||||
const next = currentVersion(sessionId) + 1
|
||||
versions.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function currentEventVersion(sessionId: string): number {
|
||||
return eventVersions.get(sessionId) ?? 0
|
||||
}
|
||||
|
||||
function advanceEventVersion(sessionId: string): number {
|
||||
const next = currentEventVersion(sessionId) + 1
|
||||
eventVersions.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function isCurrent(sessionId: string, token: number): boolean {
|
||||
return currentVersion(sessionId) === token
|
||||
}
|
||||
|
||||
function sameEntry(first: SessionControlEntry, second: SessionControlEntry): boolean {
|
||||
return (
|
||||
first.capability === second.capability &&
|
||||
first.error === second.error &&
|
||||
first.loading === second.loading &&
|
||||
first.pendingAction === second.pendingAction &&
|
||||
first.snapshot === second.snapshot
|
||||
)
|
||||
}
|
||||
|
||||
function publishEntry(sessionId: string, next: SessionControlEntry): SessionControlEntry {
|
||||
const entries = $sessionControlBySession.get()
|
||||
const current = entries[sessionId]
|
||||
|
||||
if (current && sameEntry(current, next)) {
|
||||
return current
|
||||
}
|
||||
|
||||
$sessionControlBySession.set({ ...entries, [sessionId]: next })
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function applyParsedSnapshot(sessionId: string, snapshot: SessionControlSnapshot): SessionControlEntry {
|
||||
advanceVersion(sessionId)
|
||||
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
|
||||
const nextSnapshot = current.snapshot?.revision === snapshot.revision ? current.snapshot : snapshot
|
||||
|
||||
return publishEntry(sessionId, {
|
||||
capability: 'supported',
|
||||
error: null,
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: nextSnapshot
|
||||
})
|
||||
}
|
||||
|
||||
/** Applies a valid read/action snapshot and marks its session as supported. */
|
||||
export function applySessionControlSnapshot(sessionId: string, rawSnapshot: unknown): SessionControlEntry | undefined {
|
||||
if (!sessionId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const snapshot = parseSessionControlSnapshot(rawSnapshot)
|
||||
|
||||
return snapshot ? applyParsedSnapshot(sessionId, snapshot) : undefined
|
||||
}
|
||||
|
||||
/** Applies an event update; invalid updates are deliberately a claimed no-op. */
|
||||
export function applySessionControlUpdate(sessionId: string, rawSnapshot: unknown): SessionControlEntry | undefined {
|
||||
if (!sessionId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const snapshot = parseSessionControlSnapshot(rawSnapshot)
|
||||
|
||||
if (!snapshot) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
|
||||
const actionIsPending = current.pendingAction !== null
|
||||
|
||||
advanceEventVersion(sessionId)
|
||||
|
||||
if (!actionIsPending) {
|
||||
advanceVersion(sessionId)
|
||||
}
|
||||
|
||||
const nextSnapshot = current.snapshot?.revision === snapshot.revision ? current.snapshot : snapshot
|
||||
|
||||
return publishEntry(sessionId, {
|
||||
...current,
|
||||
capability: 'supported',
|
||||
error: null,
|
||||
loading: actionIsPending ? current.loading : false,
|
||||
pendingAction: actionIsPending ? current.pendingAction : null,
|
||||
snapshot: nextSnapshot
|
||||
})
|
||||
}
|
||||
|
||||
/** Drops one runtime session's entry when the session record is closed/deleted. */
|
||||
export function clearSessionControl(sessionId: string): void {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
advanceVersion(sessionId)
|
||||
const entries = $sessionControlBySession.get()
|
||||
|
||||
if (!(sessionId in entries)) {
|
||||
return
|
||||
}
|
||||
|
||||
const { [sessionId]: _removed, ...remaining } = entries
|
||||
$sessionControlBySession.set(remaining)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipes every entry — the gateway-switch seam. Entries are keyed by runtime
|
||||
* session id and a different backend mints new ids, so nothing here can be
|
||||
* reused; in-flight reads/actions from the old backend are invalidated by the
|
||||
* version bump so a late response cannot repopulate the map.
|
||||
*/
|
||||
export function clearAllSessionControl(): void {
|
||||
for (const sessionId of new Set([...versions.keys(), ...Object.keys($sessionControlBySession.get())])) {
|
||||
advanceVersion(sessionId)
|
||||
}
|
||||
|
||||
$sessionControlBySession.set({})
|
||||
versions.clear()
|
||||
eventVersions.clear()
|
||||
}
|
||||
|
||||
function beginRead(sessionId: string, background: boolean): number {
|
||||
const token = advanceVersion(sessionId)
|
||||
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
|
||||
|
||||
publishEntry(sessionId, {
|
||||
...current,
|
||||
error: null,
|
||||
loading: background ? current.loading : true
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
function beginAction(sessionId: string, action: SessionControlAction): number {
|
||||
const token = advanceVersion(sessionId)
|
||||
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
|
||||
|
||||
publishEntry(sessionId, {
|
||||
...current,
|
||||
error: null,
|
||||
loading: true,
|
||||
pendingAction: action
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
function boundedError(error: unknown): string {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: isRecord(error) && typeof error.message === 'string'
|
||||
? error.message
|
||||
: 'Session control request failed'
|
||||
|
||||
return message.trim().slice(0, ERROR_LIMIT) || 'Session control request failed'
|
||||
}
|
||||
|
||||
function publishFailure(sessionId: string, token: number, error: unknown, clearPendingAction: boolean): void {
|
||||
if (!isCurrent(sessionId, token)) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
|
||||
publishEntry(sessionId, {
|
||||
...current,
|
||||
error: boundedError(error),
|
||||
loading: false,
|
||||
pendingAction: clearPendingAction ? null : current.pendingAction
|
||||
})
|
||||
}
|
||||
|
||||
function finishGoneRequest(sessionId: string, token: number, clearPendingAction: boolean): void {
|
||||
if (!isCurrent(sessionId, token)) {
|
||||
return
|
||||
}
|
||||
|
||||
markSessionGone(sessionId)
|
||||
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
|
||||
publishEntry(sessionId, {
|
||||
...current,
|
||||
loading: false,
|
||||
pendingAction: clearPendingAction ? null : current.pendingAction
|
||||
})
|
||||
}
|
||||
|
||||
function markUnsupported(sessionId: string, token: number): boolean {
|
||||
if (!isCurrent(sessionId, token)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
|
||||
|
||||
if (current.capability === 'unsupported') {
|
||||
return false
|
||||
}
|
||||
|
||||
advanceVersion(sessionId)
|
||||
publishEntry(sessionId, {
|
||||
...current,
|
||||
capability: 'unsupported',
|
||||
error: null,
|
||||
loading: false,
|
||||
pendingAction: null
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function isMethodNotFound(error: unknown): boolean {
|
||||
if (isRecord(error) && error.code === -32601) {
|
||||
return true
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error ? error.message : isRecord(error) && typeof error.message === 'string' ? error.message : ''
|
||||
|
||||
return message.toLowerCase().includes('method not found') || message.toLowerCase().includes('method-not-found')
|
||||
}
|
||||
|
||||
/** Hydrates one session's structured controls; background refreshes never flash a loading state. */
|
||||
export async function refreshSessionControl(
|
||||
sessionId: string,
|
||||
options: RefreshOptions = {}
|
||||
): Promise<SessionControlEntry | undefined> {
|
||||
const existing = $sessionControlBySession.get()[sessionId]
|
||||
|
||||
if (!sessionId || existing?.capability === 'unsupported' || isSessionGone(sessionId)) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const gateway = $gateway.get()
|
||||
|
||||
if (!gateway) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const token = beginRead(sessionId, Boolean(options.background))
|
||||
|
||||
try {
|
||||
const response = await requestForOwnedSession<unknown>(
|
||||
sessionId,
|
||||
ambientRequestFor(gateway),
|
||||
'session.control.read',
|
||||
{ session_id: sessionId }
|
||||
)
|
||||
|
||||
if (!isCurrent(sessionId, token)) {
|
||||
return $sessionControlBySession.get()[sessionId]
|
||||
}
|
||||
|
||||
const snapshot = isRecord(response) ? parseSessionControlSnapshot(response.control) : null
|
||||
|
||||
if (!snapshot) {
|
||||
publishFailure(sessionId, token, new Error('Invalid session.control.read response'), false)
|
||||
|
||||
return $sessionControlBySession.get()[sessionId]
|
||||
}
|
||||
|
||||
return applyParsedSnapshot(sessionId, snapshot)
|
||||
} catch (error) {
|
||||
if (!isCurrent(sessionId, token)) {
|
||||
return $sessionControlBySession.get()[sessionId]
|
||||
}
|
||||
|
||||
if (isMethodNotFound(error)) {
|
||||
const transitioned = markUnsupported(sessionId, token)
|
||||
|
||||
if (transitioned) {
|
||||
await refreshSessionGoal(sessionId)
|
||||
}
|
||||
|
||||
return $sessionControlBySession.get()[sessionId]
|
||||
}
|
||||
|
||||
if (isSessionGoneForBackgroundPolling(error)) {
|
||||
finishGoneRequest(sessionId, token, false)
|
||||
|
||||
return $sessionControlBySession.get()[sessionId]
|
||||
}
|
||||
|
||||
publishFailure(sessionId, token, error, false)
|
||||
|
||||
return $sessionControlBySession.get()[sessionId]
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs an allowlisted backend control action; callers own any composer/UI dispatch. */
|
||||
export async function runSessionControlAction(
|
||||
sessionId: string,
|
||||
action: SessionControlAction,
|
||||
args?: SessionControlActionArgs
|
||||
): Promise<SessionControlDispatch> {
|
||||
if (!sessionId) {
|
||||
throw new Error('A session id is required for session control')
|
||||
}
|
||||
|
||||
if (isSessionGone(sessionId)) {
|
||||
throw new Error('Session not found')
|
||||
}
|
||||
|
||||
const gateway = $gateway.get()
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Session control gateway is unavailable')
|
||||
}
|
||||
|
||||
const eventVersion = currentEventVersion(sessionId)
|
||||
const token = beginAction(sessionId, action)
|
||||
|
||||
try {
|
||||
const response = await requestForOwnedSession<unknown>(sessionId, ambientRequestFor(gateway), 'session.control', {
|
||||
action,
|
||||
args: args ?? {},
|
||||
session_id: sessionId
|
||||
})
|
||||
|
||||
const snapshot = isRecord(response) ? parseSessionControlSnapshot(response.control) : null
|
||||
const dispatch = isRecord(response) ? parseSessionControlDispatch(response.dispatch) : null
|
||||
|
||||
if (!snapshot || !dispatch) {
|
||||
const error = new Error('Invalid session.control action response')
|
||||
publishFailure(sessionId, token, error, true)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (isCurrent(sessionId, token)) {
|
||||
applyParsedSnapshot(sessionId, snapshot)
|
||||
}
|
||||
|
||||
return dispatch
|
||||
} catch (error) {
|
||||
if (isMethodNotFound(error)) {
|
||||
const transitioned = eventVersion === currentEventVersion(sessionId) ? markUnsupported(sessionId, token) : false
|
||||
|
||||
if (transitioned) {
|
||||
await refreshSessionGoal(sessionId)
|
||||
} else {
|
||||
publishFailure(sessionId, token, error, true)
|
||||
}
|
||||
} else if (isSessionGoneForBackgroundPolling(error)) {
|
||||
finishGoneRequest(sessionId, token, true)
|
||||
} else {
|
||||
publishFailure(sessionId, token, error, true)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Refreshes only sessions already proven to support the structured-control RPC. */
|
||||
export async function refreshSupportedSessionControlAfterTurn(sessionId: string): Promise<void> {
|
||||
if (!sessionId || $sessionControlBySession.get()[sessionId]?.capability !== 'supported') {
|
||||
return
|
||||
}
|
||||
|
||||
await refreshSessionControl(sessionId, { background: true })
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const LEGACY_VISIBLE_KEY = 'hermes.desktop.statusbarVisible'
|
||||
|
||||
const loadStore = () => import('./statusbar-prefs')
|
||||
|
||||
describe('statusbar whole-bar visibility', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('shows the bar on a fresh install and for installs that hid it under the v1 key', async () => {
|
||||
window.localStorage.setItem(LEGACY_VISIBLE_KEY, 'false')
|
||||
|
||||
const { $statusbarVisible } = await loadStore()
|
||||
|
||||
expect($statusbarVisible.get()).toBe(true)
|
||||
})
|
||||
|
||||
it('still honours a hide made after the update', async () => {
|
||||
const first = await loadStore()
|
||||
|
||||
first.toggleStatusbarVisible()
|
||||
expect(first.$statusbarVisible.get()).toBe(false)
|
||||
|
||||
vi.resetModules()
|
||||
const reloaded = await loadStore()
|
||||
|
||||
expect(reloaded.$statusbarVisible.get()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusbar hidden items', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('surfaces the approval pill for installs that hid it under the v1 defaults, keeping their other choices', async () => {
|
||||
window.localStorage.setItem(
|
||||
'hermes.desktop.statusbarHidden',
|
||||
JSON.stringify(['approval-mode', 'cron', 'gateway-health'])
|
||||
)
|
||||
|
||||
const { $statusbarHiddenIds } = await loadStore()
|
||||
|
||||
expect($statusbarHiddenIds.get()).toEqual(['cron', 'gateway-health'])
|
||||
})
|
||||
|
||||
it('still honours hiding the approval pill after the update', async () => {
|
||||
const first = await loadStore()
|
||||
|
||||
first.setStatusbarItemVisible('approval-mode', false)
|
||||
|
||||
vi.resetModules()
|
||||
const reloaded = await loadStore()
|
||||
|
||||
expect(reloaded.$statusbarHiddenIds.get()).toContain('approval-mode')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Codecs, persistentAtom } from '@/lib/persisted'
|
||||
import { readKey } from '@/lib/storage'
|
||||
|
||||
const STATUSBAR_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden'
|
||||
const STATUSBAR_VISIBLE_STORAGE_KEY = 'hermes.desktop.statusbarVisible'
|
||||
// v1 (`hermes.desktop.statusbarHidden`) was seeded with the approval pill
|
||||
// hidden, so every existing store carries an `approval-mode` the user never
|
||||
// chose. v2 seeds from v1 minus that id: other customizations survive, the
|
||||
// pill appears once on update, and hiding it again persists here.
|
||||
const STATUSBAR_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden.v2'
|
||||
const LEGACY_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden'
|
||||
// v1 (`hermes.desktop.statusbarVisible`) shipped a stretch where the bar was
|
||||
// opt-in, so many stores hold a `false` the user never chose. v2 is read fresh
|
||||
// and the v1 key is deliberately NOT seeded from: every existing install comes
|
||||
// back to "on" once, and hiding it again persists here.
|
||||
const STATUSBAR_VISIBLE_STORAGE_KEY = 'hermes.desktop.statusbarVisible.v2'
|
||||
|
||||
// Whole-bar visibility, VS Code's `workbench.statusBar.visible`. On by default.
|
||||
// Hiding it unmounts the bar (its 15s status poll goes with it), so the way back
|
||||
@@ -14,14 +24,15 @@ export function toggleStatusbarVisible() {
|
||||
|
||||
// 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. The per-turn
|
||||
// route shortcuts (cron/webhooks/agents) and the terminal toggle are
|
||||
// navigation, not status, so they start out of the way. The approval pill
|
||||
// (the yolo zap) stays: whether dangerous commands run unasked is state the
|
||||
// user should see at a glance. The per-turn
|
||||
// session readouts (running/session timers, context meter, cache hit rate,
|
||||
// tokens/sec) 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',
|
||||
'cache-hit-rate',
|
||||
'context-usage',
|
||||
'cron',
|
||||
@@ -38,12 +49,27 @@ export const STATUSBAR_HIDDEN_BY_DEFAULT: readonly string[] = [
|
||||
// staying off. An empty array is a real value — the user turned everything on —
|
||||
// so this uses a sanitizing json codec rather than Codecs.stringArray, which
|
||||
// drops the key when empty and would resurrect the defaults on next launch.
|
||||
const sanitizeHiddenIds = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
|
||||
|
||||
function legacyHiddenSeed(): string[] {
|
||||
const raw = readKey(LEGACY_HIDDEN_STORAGE_KEY)
|
||||
|
||||
if (raw === null) {
|
||||
return [...STATUSBAR_HIDDEN_BY_DEFAULT]
|
||||
}
|
||||
|
||||
try {
|
||||
return sanitizeHiddenIds(JSON.parse(raw)).filter(id => id !== 'approval-mode')
|
||||
} catch {
|
||||
return [...STATUSBAR_HIDDEN_BY_DEFAULT]
|
||||
}
|
||||
}
|
||||
|
||||
export const $statusbarHiddenIds = persistentAtom<string[]>(
|
||||
STATUSBAR_HIDDEN_STORAGE_KEY,
|
||||
[...STATUSBAR_HIDDEN_BY_DEFAULT],
|
||||
Codecs.json<string[]>(value =>
|
||||
Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
|
||||
)
|
||||
legacyHiddenSeed(),
|
||||
Codecs.json<string[]>(sanitizeHiddenIds)
|
||||
)
|
||||
|
||||
export function setStatusbarItemVisible(id: string, visible: boolean) {
|
||||
|
||||
@@ -301,6 +301,14 @@ model:
|
||||
#
|
||||
# # Data policy: "allow" (default) or "deny" to exclude providers that may store data
|
||||
# # data_collection: "deny"
|
||||
#
|
||||
# # Per-model overrides: same keys, applied only when the agent is on that model
|
||||
# # (spelling-tolerant match; unset keys fall through to the flat values above).
|
||||
# # models:
|
||||
# # "openai/gpt-6-astra":
|
||||
# # only: ["openai"]
|
||||
# # "anthropic/claude-fable-5.1":
|
||||
# # only: ["anthropic"]
|
||||
|
||||
# =============================================================================
|
||||
# OpenRouter Response Caching (only applies when using OpenRouter)
|
||||
|
||||
@@ -293,6 +293,7 @@ _TERMINAL_ENV_MAPPINGS = {
|
||||
"ssh_host", "ssh_user", "ssh_port", "ssh_key", "container_cpu", "container_memory",
|
||||
"container_disk", "container_persistent", "docker_volumes", "docker_env", "docker_extra_args",
|
||||
"docker_shm_size", "docker_mount_cwd_to_workspace", "docker_network", "docker_run_as_host_user",
|
||||
"docker_snap_compat",
|
||||
"docker_persist_across_processes", "docker_shared_container_key", "docker_orphan_reaper",
|
||||
"sandbox_dir", "persistent_shell",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Halldrix
|
||||
# PR #102118 /heartbeat TUI driver
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user