mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Merge remote-tracking branch 'origin/main' into feat/relay-slack-blockkit-native-parity
# Conflicts: # gateway/relay/adapter.py
This commit is contained in:
commit
dd866eef34
837 changed files with 60951 additions and 6100 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -115,6 +115,11 @@ jobs:
|
|||
needs: detect
|
||||
uses: ./.github/workflows/uv-lockfile-check.yml
|
||||
|
||||
infographic-check:
|
||||
name: Check no committed infographics
|
||||
needs: detect
|
||||
uses: ./.github/workflows/infographic-check.yml
|
||||
|
||||
lockfile-diff:
|
||||
name: package-lock.json diff
|
||||
needs: detect
|
||||
|
|
|
|||
78
.github/workflows/infographic-check.yml
vendored
Normal file
78
.github/workflows/infographic-check.yml
vendored
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
name: Infographic Check
|
||||
|
||||
# Rejects PRs that commit PR-infographic images into the repo.
|
||||
#
|
||||
# PR infographics are rendered to an image-provider URL (fal.media) and
|
||||
# embedded in the PR *description*. The PR body is the archive; the binary
|
||||
# never belongs in git history.
|
||||
#
|
||||
# This has now leaked twice. PR #48261 removed the first batch, PR #54564
|
||||
# removed a second batch and added `infographic/` to `.gitignore` — but
|
||||
# `.gitignore` only stops *accidental* `git add`. It does nothing against
|
||||
# `git add -f`, and it does nothing for a path that does not literally match
|
||||
# the ignore pattern. Nine more PNGs (~14MB) were committed in the four
|
||||
# weeks AFTER that rule landed, plus PR #70552 caught an `infograficos/`
|
||||
# spelling that sidestepped the pattern entirely.
|
||||
#
|
||||
# A passive ignore rule cannot enforce a policy. This check can.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: "JSON array of review_status objects for the synthesizer."
|
||||
value: ${{ jobs.check-no-committed-infographics.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-no-committed-infographics:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
review_status: ${{ steps.infographic-check.outputs.review_status }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- id: infographic-check
|
||||
name: Reject committed PR-infographic images
|
||||
run: |
|
||||
# Match on the IMAGE, not on a directory name. Keying this to
|
||||
# `infographic/` is what let `infograficos/` through in #70552 —
|
||||
# any localized or typo'd directory would sidestep it again.
|
||||
# Instead: find tracked raster images whose path contains an
|
||||
# infographic-ish segment, in any spelling, at any depth.
|
||||
#
|
||||
# `docs/assets` and `website/` legitimately hold product imagery
|
||||
# and are excluded; those are referenced from shipped docs pages.
|
||||
OFFENDERS=$(git ls-files -z \
|
||||
| tr '\0' '\n' \
|
||||
| grep -iE '(^|/)(infograph|infograf)[^/]*/' \
|
||||
| grep -iE '\.(png|jpe?g|webp|gif)$' \
|
||||
|| true)
|
||||
|
||||
if [ -n "$OFFENDERS" ]; then
|
||||
COUNT=$(printf '%s\n' "$OFFENDERS" | wc -l | tr -d ' ')
|
||||
STATUS='[{"source":"committed infographics","results":[{"kind":"action_required","title":"PR infographic committed to the repo","summary":"Infographic images belong in the PR description, never in git.","detail":"","how_to_fix":"Untrack the image and reference the provider URL from the PR body instead:\n```\ngit rm --cached <path-to-image>\n```\nThen put it in the PR description:\n```\n## Infographic\n\n\n```\n"}]}]'
|
||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
echo ""
|
||||
echo "::error::${COUNT} PR-infographic image(s) are tracked in git."
|
||||
echo ""
|
||||
printf '%s\n' "$OFFENDERS" | sed 's/^/ /'
|
||||
echo ""
|
||||
echo "PR infographics are rendered to an image-provider URL and"
|
||||
echo "embedded in the PR DESCRIPTION. The PR body is the archive —"
|
||||
echo "the binary never enters git history."
|
||||
echo ""
|
||||
echo "This rule has been re-established twice already (#48261,"
|
||||
echo "#54564) and leaked both times, because .gitignore cannot stop"
|
||||
echo "'git add -f' or a differently-spelled directory (#70552)."
|
||||
echo ""
|
||||
echo "To fix:"
|
||||
echo " git rm --cached <path> # keeps your local copy"
|
||||
echo " # then embed the provider URL in the PR description"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::No committed PR-infographic images."
|
||||
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -152,6 +152,11 @@ docs/superpowers/*
|
|||
.update-incomplete
|
||||
.update-incomplete.lock
|
||||
|
||||
# Checkout fingerprint the __pycache__ tree was last validated against
|
||||
# (launch-time stale-bytecode sweep). Runtime state, never a code change.
|
||||
.bytecode-fingerprint
|
||||
.bytecode-fingerprint.tmp
|
||||
|
||||
# Installer-written method stamp in the managed checkout root (scripts/install.sh).
|
||||
# Runtime metadata only — never a code change. Ignore so `git status` stays clean
|
||||
# and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855).
|
||||
|
|
@ -175,5 +180,13 @@ apps/desktop/demo/
|
|||
# image-provider (fal.media) URL — they are NEVER committed to the repo. The
|
||||
# PR body is the archive. See the hermes-agent-dev skill's
|
||||
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
|
||||
#
|
||||
# Spelling variants are listed because a single `infographic/` pattern was
|
||||
# sidestepped by an `infograficos/` directory (#70552). .gitignore is only
|
||||
# the first line of defence and cannot stop `git add -f` at all — the
|
||||
# infographic-check CI job is what actually enforces this.
|
||||
infographic/
|
||||
infographics/
|
||||
infograficos/
|
||||
infografico/
|
||||
native/fts5_cjk/*.so
|
||||
|
|
|
|||
|
|
@ -325,7 +325,7 @@ class AIAgent:
|
|||
provider: str = None,
|
||||
api_mode: str = None, # "chat_completions" | "codex_responses" | ...
|
||||
model: str = "", # empty → resolved from config/provider later
|
||||
max_iterations: int = 90, # tool-calling iterations (shared with subagents)
|
||||
max_iterations: int = 500, # tool-calling iterations (shared with subagents)
|
||||
enabled_toolsets: list = None,
|
||||
disabled_toolsets: list = None,
|
||||
quiet_mode: bool = False,
|
||||
|
|
|
|||
59
Dockerfile
59
Dockerfile
|
|
@ -1,3 +1,45 @@
|
|||
# Debian 13 still ships SQLite 3.46.1, which contains the upstream WAL-reset
|
||||
# corruption bug. Build a pinned shared library for the runtime image instead
|
||||
# of relying on a distro backport that trixie does not currently provide.
|
||||
# See #70480 and https://sqlite.org/wal.html#walresetbug.
|
||||
FROM debian:13.4 AS sqlite_build
|
||||
ARG SQLITE_AUTOCONF_VERSION=3530400
|
||||
ARG SQLITE_SHA256=0e9483900e92cd5de8fd48d16bf9200145a61f7fd5be542a5ac81d8a9516eb9c
|
||||
RUN apt-get -o Acquire::Retries=3 update && \
|
||||
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
|
||||
build-essential ca-certificates curl && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
(curl -fsSL --retry 1 --retry-all-errors --connect-timeout 15 --max-time 60 \
|
||||
-o /tmp/sqlite.tar.gz \
|
||||
"https://sqlite.org/2026/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz" || \
|
||||
curl -fsSL --retry 3 --retry-all-errors --connect-timeout 15 --max-time 120 \
|
||||
-o /tmp/sqlite.tar.gz \
|
||||
"https://sources.buildroot.net/sqlite/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz") && \
|
||||
printf '%s %s\n' "${SQLITE_SHA256}" /tmp/sqlite.tar.gz > /tmp/sqlite.sha256 && \
|
||||
sha256sum -c /tmp/sqlite.sha256 && \
|
||||
tar -xzf /tmp/sqlite.tar.gz -C /tmp && \
|
||||
cd "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}" && \
|
||||
CFLAGS="-O2 \
|
||||
-DSQLITE_ENABLE_FTS3 \
|
||||
-DSQLITE_ENABLE_FTS3_PARENTHESIS \
|
||||
-DSQLITE_ENABLE_FTS4 \
|
||||
-DSQLITE_ENABLE_FTS5 \
|
||||
-DSQLITE_ENABLE_RTREE \
|
||||
-DSQLITE_ENABLE_GEOPOLY \
|
||||
-DSQLITE_ENABLE_COLUMN_METADATA \
|
||||
-DSQLITE_ENABLE_UNLOCK_NOTIFY \
|
||||
-DSQLITE_ENABLE_DBSTAT_VTAB \
|
||||
-DSQLITE_ENABLE_DBPAGE_VTAB \
|
||||
-DSQLITE_ENABLE_MATH_FUNCTIONS \
|
||||
-DSQLITE_ENABLE_PREUPDATE_HOOK \
|
||||
-DSQLITE_ENABLE_SESSION \
|
||||
-DSQLITE_SECURE_DELETE \
|
||||
-DSQLITE_THREADSAFE=1 \
|
||||
-DSQLITE_MAX_VARIABLE_NUMBER=250000" \
|
||||
./configure --prefix=/opt/sqlite-fixed --disable-static && \
|
||||
make -j"$(nproc)" && \
|
||||
make install
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
|
||||
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# which reached EOL in April 2026 — we copy node + npm + corepack from the
|
||||
|
|
@ -31,6 +73,23 @@ RUN apt-get -o Acquire::Retries=3 update && \
|
|||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Prefer the fixed SQLite over Debian's vulnerable libsqlite3.so.0. Keep the
|
||||
# public library name stable so both the system interpreter and the uv-created
|
||||
# venv resolve the replacement without changing Python import paths.
|
||||
COPY --from=sqlite_build /opt/sqlite-fixed/lib/libsqlite3.so.3.53.4 /usr/local/lib/
|
||||
RUN ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so.0 && \
|
||||
ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so && \
|
||||
printf '/usr/local/lib\n' > /etc/ld.so.conf.d/000-sqlite-fixed.conf && \
|
||||
ldconfig && \
|
||||
python3 -c "import sqlite3, sys; \
|
||||
v = sqlite3.sqlite_version_info; \
|
||||
sys.exit(f'linked SQLite {sqlite3.sqlite_version} still has the WAL-reset bug') if v < (3, 51, 3) else None; \
|
||||
db = sqlite3.connect(':memory:'); \
|
||||
db.execute(\"CREATE VIRTUAL TABLE docs USING fts5(content, tokenize='trigram')\"); \
|
||||
db.execute(\"INSERT INTO docs VALUES ('hermes')\"); \
|
||||
sys.exit('SQLite FTS5 trigram self-test failed') if db.execute(\"SELECT count(*) FROM docs WHERE docs MATCH 'erm'\").fetchone()[0] != 1 else None; \
|
||||
db.close()"
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
# s6-overlay provides supervision for the main hermes process, the dashboard,
|
||||
# and per-profile gateways. /init becomes PID 1 below — see ENTRYPOINT.
|
||||
|
|
|
|||
|
|
@ -455,7 +455,7 @@ def init_agent(
|
|||
command: str = None,
|
||||
args: list[str] | None = None,
|
||||
model: str = "",
|
||||
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
|
||||
max_iterations: int = 500, # Default tool-calling iterations (shared with subagents)
|
||||
tool_delay: float = 1.0,
|
||||
enabled_toolsets: List[str] = None,
|
||||
disabled_toolsets: List[str] = None,
|
||||
|
|
@ -529,7 +529,7 @@ def init_agent(
|
|||
requested_provider (str): Original provider identity before runtime canonicalization
|
||||
api_mode (str): API mode override: "chat_completions" or "codex_responses"
|
||||
model (str): Model name to use (default: "anthropic/claude-opus-4.6")
|
||||
max_iterations (int): Maximum number of tool calling iterations (default: 90)
|
||||
max_iterations (int): Maximum number of tool calling iterations (default: 500)
|
||||
tool_delay (float): Delay between tool calls in seconds (default: 1.0)
|
||||
enabled_toolsets (List[str]): Only enable tools from these toolsets (optional)
|
||||
disabled_toolsets (List[str]): Disable tools from these toolsets (optional)
|
||||
|
|
@ -645,6 +645,13 @@ def init_agent(
|
|||
# AWS Bedrock — auto-detect from provider name or base URL
|
||||
# (bedrock-runtime.<region>.amazonaws.com).
|
||||
agent.api_mode = "bedrock_converse"
|
||||
elif agent.provider in {"nous", "nous-portal", "nousresearch"}:
|
||||
# Portal is dual-wire: anthropic/* → Messages, everything else →
|
||||
# chat_completions. Callers that already pass api_mode win above;
|
||||
# this covers direct AIAgent construction without a resolved runtime.
|
||||
from hermes_cli.providers import nous_api_mode
|
||||
|
||||
agent.api_mode = nous_api_mode(agent.model)
|
||||
else:
|
||||
agent.api_mode = "chat_completions"
|
||||
|
||||
|
|
@ -951,12 +958,6 @@ def init_agent(
|
|||
agent._stream_writer_tls = threading.local()
|
||||
agent._stream_writer_dropped = 0
|
||||
|
||||
# Displayed reasoning text streamed during the current model response,
|
||||
# captured only when a surface consumed it via a reasoning callback. Used
|
||||
# by active-turn redirect to checkpoint what the user actually saw without
|
||||
# ever persisting hidden provider reasoning.
|
||||
agent._current_streamed_reasoning_text = ""
|
||||
|
||||
# Optional current-turn user-message override used when the API-facing
|
||||
# user message intentionally differs from the persisted transcript
|
||||
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
|
||||
|
|
@ -1155,6 +1156,10 @@ def init_agent(
|
|||
elif base_url_host_matches(effective_base, "chatgpt.com"):
|
||||
from agent.auxiliary_client import _codex_cloudflare_headers
|
||||
client_kwargs["default_headers"] = _codex_cloudflare_headers(api_key)
|
||||
elif base_url_host_matches(effective_base, "x.ai"):
|
||||
from tools.xai_http import hermes_xai_default_headers
|
||||
|
||||
client_kwargs["default_headers"] = hermes_xai_default_headers()
|
||||
elif "default_headers" not in client_kwargs:
|
||||
# Fall back to profile.default_headers for providers that
|
||||
# declare custom headers (e.g. Kimi User-Agent on non-kimi.com
|
||||
|
|
@ -1952,8 +1957,12 @@ def init_agent(
|
|||
# parent_session_id chain, no `name #N` renumber). See #38763 and
|
||||
# agent/conversation_compression.py. Consumed by compress_context(), not the
|
||||
# compressor, so it rides on the agent.
|
||||
# Default True must match DEFAULT_CONFIG["compression"]["in_place"]
|
||||
# (#38763). default=False here previously flipped agents into rotation
|
||||
# mode whenever the merged config omitted the key (partial configs,
|
||||
# load_config failure → {}), re-arming the pre-lease drift abort.
|
||||
compression_in_place = is_truthy_value(
|
||||
_compression_cfg.get("in_place"), default=False
|
||||
_compression_cfg.get("in_place"), default=True
|
||||
)
|
||||
codex_app_server_auto_compaction = str(
|
||||
_compression_cfg.get("codex_app_server_auto", "native") or "native"
|
||||
|
|
|
|||
|
|
@ -1221,7 +1221,15 @@ def try_recover_primary_transport(
|
|||
if agent._is_openrouter_url():
|
||||
return False
|
||||
provider_lower = (agent.provider or "").strip().lower()
|
||||
if provider_lower in {"nous", "nous-research"}:
|
||||
# Portal OpenAI-wire traffic still rides aggregator retry infra, so one
|
||||
# more rebuilt OpenAI client won't help. Portal Claude on the native
|
||||
# Messages route holds a local Anthropic SDK client whose connection
|
||||
# pool *does* need the rebuild every other anthropic_messages provider
|
||||
# already gets — don't blanket-skip the dual-wire path.
|
||||
if (
|
||||
provider_lower in {"nous", "nous-portal", "nousresearch"}
|
||||
and getattr(agent, "api_mode", None) != "anthropic_messages"
|
||||
):
|
||||
return False
|
||||
|
||||
try:
|
||||
|
|
@ -1895,7 +1903,15 @@ def anthropic_prompt_cache_policy(
|
|||
|
||||
if is_native_anthropic:
|
||||
return True, True
|
||||
if (is_openrouter or is_nous_portal) and (is_claude or is_kimi):
|
||||
# Envelope layout is an OpenAI-wire construct. Portal Claude on the native
|
||||
# Messages route must fall through to the third-party anthropic_messages
|
||||
# branch below, which emits inner-block cache_control breakpoints; the
|
||||
# envelope form would be dropped and serve 0% cache hits.
|
||||
if (
|
||||
(is_openrouter or is_nous_portal)
|
||||
and (is_claude or is_kimi)
|
||||
and not is_anthropic_wire
|
||||
):
|
||||
return True, False
|
||||
# Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout
|
||||
# cache_control path as Portal Claude. Portal proxies to OpenRouter
|
||||
|
|
@ -2059,8 +2075,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
|||
from hermes_cli.providers import determine_api_mode
|
||||
|
||||
# ── Determine api_mode if not provided ──
|
||||
# Pass model so dual-wire providers (Nous Portal anthropic/* → Messages)
|
||||
# resolve correctly; without it determine_api_mode falls back to the
|
||||
# openai_chat overlay default.
|
||||
if not api_mode:
|
||||
api_mode = determine_api_mode(new_provider, base_url)
|
||||
api_mode = determine_api_mode(new_provider, base_url, model=new_model)
|
||||
|
||||
# Defense-in-depth: ensure OpenCode base_url doesn't carry a trailing
|
||||
# /v1 into the anthropic_messages client, which would cause the SDK to
|
||||
|
|
@ -2614,6 +2633,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||
_clarify_tool(
|
||||
question=next_args.get("question", ""),
|
||||
choices=next_args.get("choices"),
|
||||
multi_select=next_args.get("multi_select", False),
|
||||
callback=agent.clarify_callback,
|
||||
),
|
||||
next_args,
|
||||
|
|
@ -2759,6 +2779,129 @@ def repair_tool_call(agent, tool_name: str) -> str | None:
|
|||
|
||||
|
||||
|
||||
# Placeholder substituted for an empty non-final message that would otherwise
|
||||
# make the provider reject the whole request. Kept identical to the stub-
|
||||
# creation placeholder in chat_completion_helpers so a healed transcript reads
|
||||
# consistently whether the empty turn was caught at write time or send time.
|
||||
_INTERRUPTED_PLACEHOLDER = "[response interrupted]"
|
||||
|
||||
|
||||
def _msg_has_payload(msg: Dict[str, Any]) -> bool:
|
||||
"""True if ``msg`` carries anything the API treats as non-empty content.
|
||||
|
||||
Covers string content, non-empty multimodal content lists, tool_calls,
|
||||
tool_call_id linkage (tool results), and reasoning payloads. Mirrors the
|
||||
emptiness checks used by ``AIAgent._is_thinking_only_assistant`` but is
|
||||
role-agnostic so it can vet user/assistant/tool turns uniformly.
|
||||
"""
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
if content.strip():
|
||||
return True
|
||||
elif isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
# any typed block (text/image/tool_use/document/...) counts,
|
||||
# as long as a text block is not itself blank
|
||||
if block.get("type") == "text":
|
||||
if isinstance(block.get("text"), str) and block["text"].strip():
|
||||
return True
|
||||
continue
|
||||
return True
|
||||
elif block:
|
||||
return True
|
||||
elif content not in (None, ""):
|
||||
return True
|
||||
# Structural payloads that make an "empty-content" message still valid.
|
||||
if msg.get("tool_calls"):
|
||||
return True
|
||||
if isinstance(msg.get("reasoning_content"), str) and msg["reasoning_content"].strip():
|
||||
return True
|
||||
if msg.get("reasoning") or msg.get("reasoning_details"):
|
||||
return True
|
||||
# Codex Responses item carriers: a commentary-phase assistant turn
|
||||
# persists with content:"" by DESIGN — its text lives in
|
||||
# ``codex_message_items`` (delivered via the interim callback) and the
|
||||
# structured items are replayed for prefix-cache hits. Same for
|
||||
# ``codex_reasoning_items``. These turns are never wire-empty on any
|
||||
# api_mode: the codex transport replays the items, and the
|
||||
# chat-completions transport strips the carriers only after this repair
|
||||
# pass has already run. Treat them as payload so the repair never
|
||||
# rewrites a designed-empty codex turn (July 2026: a write-time pad that
|
||||
# ignored this broke codex commentary replay in CI).
|
||||
if msg.get("codex_message_items") or msg.get("codex_reasoning_items"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def repair_empty_non_final_messages(
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Heal empty-content non-final messages before they reach the provider.
|
||||
|
||||
Root-cause context: a stream that dies with 0 recovered characters (peer
|
||||
reset, stall-kill) could persist an assistant turn with ``content=None``
|
||||
and no tool_calls. The Anthropic message schema — and the litellm/Bedrock
|
||||
proxies in front of it — reject ANY request whose transcript contains an
|
||||
empty non-final message:
|
||||
|
||||
"all messages must have non-empty content except for the optional
|
||||
final assistant message" (HTTP 400 INVALID_REQUEST_BODY)
|
||||
|
||||
Once such a message lands mid-transcript it poisons EVERY subsequent turn
|
||||
of that session until it scrolls out of context. The write-time guard in
|
||||
``chat_completion_helpers`` stops NEW stubs, but sessions already carrying
|
||||
one (persisted before the guard, or fed in from a host history) stay stuck
|
||||
and previously needed a manual DB edit + gateway restart to recover.
|
||||
|
||||
This pass is the self-healing counterpart: it runs unconditionally on the
|
||||
per-call ``api_messages`` copy, so a poisoned transcript repairs itself
|
||||
IN MEMORY on the very next send — no restart, no DB surgery. The final
|
||||
message is left untouched (an empty final assistant turn is legal). The
|
||||
stored conversation history is never mutated; only the wire copy is
|
||||
repaired, so the UI/session trace stays faithful.
|
||||
|
||||
Repair strategy is substitution, not deletion: dropping a mid-transcript
|
||||
turn can break role alternation and tool-call pairing, whereas an honest
|
||||
minimal placeholder keeps the sequence intact and reads correctly as an
|
||||
interrupted turn on replay.
|
||||
"""
|
||||
if not messages or len(messages) < 2:
|
||||
return messages
|
||||
|
||||
repaired: List[Dict[str, Any]] = []
|
||||
healed = 0
|
||||
last_idx = len(messages) - 1
|
||||
for idx, msg in enumerate(messages):
|
||||
if (
|
||||
idx != last_idx
|
||||
and isinstance(msg, dict)
|
||||
# tool results are validated by their own orphan/pairing pass; an
|
||||
# empty tool result is a separate (and rarer) concern.
|
||||
and msg.get("role") in ("assistant", "user")
|
||||
and not _msg_has_payload(msg)
|
||||
):
|
||||
# Shallow-copy so stored history / prompt caching stays byte-stable.
|
||||
fixed = dict(msg)
|
||||
fixed["content"] = _INTERRUPTED_PLACEHOLDER
|
||||
repaired.append(fixed)
|
||||
healed += 1
|
||||
else:
|
||||
repaired.append(msg)
|
||||
|
||||
if healed:
|
||||
_ra().logger.warning(
|
||||
"Pre-call sanitizer: healed %d empty non-final message(s) by "
|
||||
"substituting placeholder content — an empty-content turn was in "
|
||||
"the transcript and would 400 the request ('messages must have "
|
||||
"non-empty content' / INVALID_REQUEST_BODY). Self-recovering the "
|
||||
"poisoned transcript in memory; no restart needed.",
|
||||
healed,
|
||||
)
|
||||
return repaired
|
||||
return messages
|
||||
|
||||
|
||||
def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Fix orphaned tool_call / tool_result pairs before every LLM call.
|
||||
|
||||
|
|
@ -2779,6 +2922,15 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
|
|||
filtered.append(msg)
|
||||
messages = filtered
|
||||
|
||||
# --- Heal empty-content non-final messages (self-recovery) ---
|
||||
# A dead stream can leave an empty assistant stub (or an empty user turn)
|
||||
# mid-transcript; the provider then 400s EVERY subsequent request until it
|
||||
# scrolls out. Repair it here, on the per-call copy, so a poisoned session
|
||||
# recovers itself in memory on the next send — no restart, no DB edit.
|
||||
# Done first so a substituted turn participates normally in the tool-pair
|
||||
# and dedup passes below.
|
||||
messages = repair_empty_non_final_messages(messages)
|
||||
|
||||
# --- Drop empty / malformed tool_calls arrays on assistant messages ---
|
||||
# An assistant message carrying ``tool_calls: []`` (an empty array) — or a
|
||||
# non-list value under the key — is semantically identical to an assistant
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from urllib.parse import urlparse
|
|||
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from utils import base_url_host_matches, normalize_proxy_env_vars
|
||||
from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars
|
||||
|
||||
# NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls
|
||||
# ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.)
|
||||
|
|
@ -546,15 +546,49 @@ def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:
|
|||
return "/anthropic" in normalized.rstrip("/").lower()
|
||||
|
||||
|
||||
def _is_nous_portal_endpoint(base_url: str | None) -> bool:
|
||||
"""Return True for Nous Portal's Anthropic Messages route.
|
||||
|
||||
Portal serves its ``anthropic/*`` catalog natively at
|
||||
``https://inference-api.nousresearch.com/v1/messages``. Portal-specific
|
||||
behaviours key off this: Bearer JWT auth, verbatim catalog model ids,
|
||||
and native thinking-signature replay.
|
||||
|
||||
Trusted hosts only:
|
||||
|
||||
1. Prod hostname ``inference-api.nousresearch.com``
|
||||
2. The operator-set ``NOUS_INFERENCE_BASE_URL`` hostname (staging/preview)
|
||||
|
||||
Lookalikes such as ``inference-api.nousresearch.com.attacker.test`` are
|
||||
rejected (hostname match, not substring).
|
||||
"""
|
||||
if base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.auth import _nous_inference_env_override
|
||||
|
||||
override = _nous_inference_env_override()
|
||||
except Exception:
|
||||
return False
|
||||
if not override:
|
||||
return False
|
||||
# Exact host equality (not subdomain) so the env override can't broaden
|
||||
# into sibling hosts the operator did not set.
|
||||
override_host = base_url_hostname(override)
|
||||
return bool(override_host) and base_url_hostname(base_url or "") == override_host
|
||||
|
||||
|
||||
def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
"""Return True for Anthropic-compatible providers that require Bearer auth.
|
||||
|
||||
Some third-party /anthropic endpoints implement Anthropic's Messages API but
|
||||
require Authorization: Bearer instead of Anthropic's native x-api-key header.
|
||||
MiniMax's global and China Anthropic-compatible endpoints, Azure AI
|
||||
Foundry's Anthropic-style endpoint, and Palantir Foundry's LLM proxy
|
||||
follow this pattern.
|
||||
Foundry's Anthropic-style endpoint, Palantir Foundry's LLM proxy, and Nous
|
||||
Portal's Messages route follow this pattern.
|
||||
"""
|
||||
if _is_nous_portal_endpoint(base_url):
|
||||
return True
|
||||
normalized = _normalize_base_url_text(base_url)
|
||||
if not normalized:
|
||||
return False
|
||||
|
|
@ -721,7 +755,11 @@ def _build_anthropic_client_with_bearer_hook(
|
|||
if common_betas:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
||||
|
||||
return _anthropic_sdk.Anthropic(**kwargs)
|
||||
client = _anthropic_sdk.Anthropic(**kwargs)
|
||||
# Same env-inference trap as build_anthropic_client: auth_token-only
|
||||
# construction would otherwise also send ANTHROPIC_API_KEY as X-Api-Key.
|
||||
client.api_key = None
|
||||
return client
|
||||
|
||||
|
||||
def build_anthropic_client(
|
||||
|
|
@ -850,7 +888,16 @@ def build_anthropic_client(
|
|||
if common_betas:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
||||
|
||||
return _anthropic_sdk.Anthropic(**kwargs)
|
||||
client = _anthropic_sdk.Anthropic(**kwargs)
|
||||
# Bearer-only construction leaves ``api_key`` unset, so the SDK fills it
|
||||
# from ``ANTHROPIC_API_KEY`` (Hermes loads that into the process env from
|
||||
# ``~/.hermes/.env``). The result is dual auth —
|
||||
# ``X-Api-Key: sk-ant-…`` *and* ``Authorization: Bearer <portal-jwt>`` —
|
||||
# on every Portal / MiniMax / OAuth Messages request. Clear the env-filled
|
||||
# key whenever we intentionally authenticated via auth_token alone.
|
||||
if "auth_token" in kwargs and "api_key" not in kwargs:
|
||||
client.api_key = None
|
||||
return client
|
||||
|
||||
|
||||
def build_anthropic_bedrock_client(region: str):
|
||||
|
|
@ -2060,6 +2107,28 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
|||
_apply_assistant_cache_control_to_last_cacheable_block(
|
||||
replayed, m.get("cache_control")
|
||||
)
|
||||
# apply_anthropic_cache_control marks an assistant turn with
|
||||
# non-empty text by writing cache_control INTO ``content`` (see
|
||||
# _apply_cache_marker's list branch), not at the top level. This
|
||||
# branch rebuilds the message from ordered_blocks and never reads
|
||||
# ``content``, so that marker would be dropped -- and because
|
||||
# _can_carry_marker already counted this message as a carrier, the
|
||||
# breakpoint is burned rather than relocated. #56195 covered the
|
||||
# complementary shape (blank content -> top-level marker); this is
|
||||
# the interleaved thinking + preamble-text + tool_use shape.
|
||||
_inline_cc = None
|
||||
_msg_content = m.get("content")
|
||||
if isinstance(_msg_content, list):
|
||||
for _blk in _msg_content:
|
||||
if isinstance(_blk, dict) and isinstance(
|
||||
_blk.get("cache_control"), dict
|
||||
):
|
||||
_inline_cc = _blk["cache_control"]
|
||||
break
|
||||
if _inline_cc is not None:
|
||||
_apply_assistant_cache_control_to_last_cacheable_block(
|
||||
replayed, _inline_cc
|
||||
)
|
||||
return {"role": "assistant", "content": replayed}
|
||||
|
||||
blocks = _extract_preserved_thinking_blocks(m)
|
||||
|
|
@ -2394,10 +2463,22 @@ def _manage_thinking_signatures(
|
|||
replayed assistant tool-call messages. See hermes-agent#13848 (Kimi) and
|
||||
hermes-agent#16748 (DeepSeek).
|
||||
|
||||
Nous Portal's ``/v1/messages`` route is the exception among third-party
|
||||
hosts: it proxies Claude to Anthropic/Vertex/Bedrock and validates the
|
||||
same signed thinking blocks. Sticky ``session_id`` keeps a conversation
|
||||
on one upstream instance so those signatures stay warm — stripping them
|
||||
here would 400 the first tool-loop turn ("thinking must be passed back").
|
||||
Portal therefore takes the native Anthropic replay path below.
|
||||
|
||||
Mutates ``result`` in place.
|
||||
"""
|
||||
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
|
||||
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
|
||||
# Portal speaks Anthropic's thinking contract end-to-end; do not treat it
|
||||
# as a signature-blind proxy even though the host is not anthropic.com.
|
||||
_is_third_party = (
|
||||
_is_third_party_anthropic_endpoint(base_url)
|
||||
and not _is_nous_portal_endpoint(base_url)
|
||||
)
|
||||
|
||||
last_assistant_idx = None
|
||||
for i in range(len(result) - 1, -1, -1):
|
||||
|
|
@ -2654,7 +2735,12 @@ def build_anthropic_kwargs(
|
|||
)
|
||||
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
|
||||
|
||||
model = normalize_model_name(model, preserve_dots=preserve_dots)
|
||||
# Nous Portal routes on its own catalog ids (``anthropic/claude-opus-4.8``);
|
||||
# normalizing to the bare Anthropic slug would make the model unresolvable
|
||||
# there. Skipping the call preserves the prefix AND the dots, so
|
||||
# ``preserve_dots`` stays irrelevant for Portal.
|
||||
if not _is_nous_portal_endpoint(base_url):
|
||||
model = normalize_model_name(model, preserve_dots=preserve_dots)
|
||||
# effective_max_tokens = output cap for this call (≠ total context window)
|
||||
# Use the resolver helper so non-positive values (negative ints,
|
||||
# fractional floats, NaN, non-numeric) fail locally with a clear error
|
||||
|
|
@ -2894,6 +2980,7 @@ def create_anthropic_message(
|
|||
log_prefix: str = "",
|
||||
prefer_stream: bool = True,
|
||||
on_stream_event=None,
|
||||
on_response=None,
|
||||
) -> Any:
|
||||
"""Create an Anthropic message, aggregating via stream when available.
|
||||
|
||||
|
|
@ -2910,6 +2997,13 @@ def create_anthropic_message(
|
|||
progress hook so a slow-but-generating summary model isn't treated as
|
||||
hung. Only fires on the streaming path; the ``create()`` fallback has no
|
||||
events to report.
|
||||
|
||||
``on_response``: optional callable invoked once with the underlying httpx
|
||||
response before the message is aggregated (best-effort, exceptions
|
||||
swallowed). Response *headers* carry out-of-band provider state that the
|
||||
parsed ``Message`` drops — Nous Portal's ``x-nous-credits-*`` balance family
|
||||
in particular. Only fires on the streaming path, which is the one the main
|
||||
turn loop takes.
|
||||
"""
|
||||
sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix)
|
||||
|
||||
|
|
@ -2920,6 +3014,14 @@ def create_anthropic_message(
|
|||
stream_kwargs.pop("stream", None)
|
||||
try:
|
||||
with stream_fn(**stream_kwargs) as stream:
|
||||
if callable(on_response):
|
||||
try:
|
||||
on_response(getattr(stream, "response", None))
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"%son_response callback failed",
|
||||
log_prefix, exc_info=True,
|
||||
)
|
||||
if callable(on_stream_event):
|
||||
# Consume the event stream manually so each event can
|
||||
# tick the caller's progress callback; get_final_message
|
||||
|
|
|
|||
|
|
@ -1362,10 +1362,32 @@ class AsyncCodexAuxiliaryClient:
|
|||
class _AnthropicCompletionsAdapter:
|
||||
"""OpenAI-client-compatible adapter for Anthropic Messages API."""
|
||||
|
||||
def __init__(self, real_client: Any, model: str, is_oauth: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
real_client: Any,
|
||||
model: str,
|
||||
is_oauth: bool = False,
|
||||
base_url: str | None = None,
|
||||
):
|
||||
self._client = real_client
|
||||
self._model = model
|
||||
self._is_oauth = is_oauth
|
||||
# Prefer the caller-supplied URL (AnthropicAuxiliaryClient keeps the
|
||||
# pre-strip Portal ``.../v1`` form). Only fall back to the SDK
|
||||
# client's host for Nous Portal — a blanket fallback would flip
|
||||
# MiniMax/Zhipu/etc. aux adapters from "unknown host = native
|
||||
# Anthropic" to third-party (stripping thinking signatures).
|
||||
self._base_url = base_url or None
|
||||
if not self._base_url:
|
||||
candidate = str(getattr(real_client, "base_url", "") or "") or None
|
||||
if candidate:
|
||||
try:
|
||||
from agent.anthropic_adapter import _is_nous_portal_endpoint
|
||||
|
||||
if _is_nous_portal_endpoint(candidate):
|
||||
self._base_url = candidate
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create(self, **kwargs) -> Any:
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs, create_anthropic_message
|
||||
|
|
@ -1417,6 +1439,11 @@ class _AnthropicCompletionsAdapter:
|
|||
reasoning_config=_reasoning_cfg,
|
||||
tool_choice=normalized_tool_choice,
|
||||
is_oauth=self._is_oauth,
|
||||
# Portal routes on ``anthropic/<slug>`` catalog ids and replays
|
||||
# signed thinking like native Anthropic; both carve-outs key off
|
||||
# base_url. Omitting it normalizes the id to a bare Anthropic
|
||||
# slug and the Portal Messages route cannot resolve it.
|
||||
base_url=self._base_url,
|
||||
)
|
||||
# Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set
|
||||
# temperature for models that still accept it. build_anthropic_kwargs
|
||||
|
|
@ -1510,7 +1537,9 @@ class AnthropicAuxiliaryClient:
|
|||
|
||||
def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False):
|
||||
self._real_client = real_client
|
||||
adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth)
|
||||
adapter = _AnthropicCompletionsAdapter(
|
||||
real_client, model, is_oauth=is_oauth, base_url=base_url,
|
||||
)
|
||||
self.chat = _AnthropicChatShim(adapter)
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
|
@ -2765,7 +2794,13 @@ def _build_xai_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str
|
|||
return None, None
|
||||
api_key, base_url = resolved
|
||||
logger.debug("Auxiliary client: xAI OAuth (%s via Responses API)", model)
|
||||
real_client = _create_openai_client(api_key=api_key, base_url=base_url)
|
||||
from tools.xai_http import hermes_xai_default_headers
|
||||
|
||||
real_client = _create_openai_client(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
default_headers=hermes_xai_default_headers(),
|
||||
)
|
||||
return CodexAuxiliaryClient(real_client, model), model
|
||||
|
||||
|
||||
|
|
@ -4188,6 +4223,7 @@ def _try_main_agent_model_fallback(
|
|||
failed_provider: str,
|
||||
task: str = None,
|
||||
reason: str = "error",
|
||||
failed_model: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||
"""Last-resort fallback to the user's main agent provider + model.
|
||||
|
||||
|
|
@ -4196,8 +4232,23 @@ def _try_main_agent_model_fallback(
|
|||
layer: if nothing the user asked for can serve the request, try the
|
||||
main chat model before giving up.
|
||||
|
||||
Skips when the failed provider already IS the main provider (no point
|
||||
retrying the same backend that just failed).
|
||||
``failed_model`` narrows the same-provider skip to the exact
|
||||
(provider, model) pair that just failed, mirroring
|
||||
:func:`_try_configured_fallback_chain`. This matters for self-hosted /
|
||||
custom endpoints serving several models behind one provider label: the
|
||||
aux compression model timing out says nothing about the health of the
|
||||
main agent model deployed on the same URL (real incident: aux
|
||||
``glm-5.2`` hung and timed out while main ``macaron-v1-venti`` on the
|
||||
identical endpoint was serving 448K-token turns fine — the
|
||||
provider-label skip discarded the one fallback that would have worked).
|
||||
|
||||
- Model-specific runtime failures (timeout, connection, rate limit,
|
||||
model-incompatible, invalid response) pass ``failed_model``: skip the
|
||||
main model only when it IS the exact model that failed.
|
||||
- Provider-wide failures (auth 401, payment 402) and legacy callers
|
||||
leave ``failed_model`` as None, keeping the whole-provider skip —
|
||||
the shared credentials/account are broken, so the main model on the
|
||||
same provider cannot help either.
|
||||
|
||||
Returns:
|
||||
(client, model, provider_label) or (None, None, "") if no fallback.
|
||||
|
|
@ -4214,9 +4265,23 @@ def _try_main_agent_model_fallback(
|
|||
if not main_provider or not main_model or main_provider.lower() in {"auto", ""}:
|
||||
return None, None, ""
|
||||
|
||||
skip = (failed_provider or "").lower().strip()
|
||||
if main_provider.lower() == skip:
|
||||
# The thing that failed IS the main model — nothing to fall back to.
|
||||
# Identity + scope semantics owned by agent.backend_identity (#72468):
|
||||
# model-scoped failures skip only the exact deployment that failed;
|
||||
# provider-wide failures (no failed_model) skip the credential surface.
|
||||
from agent.backend_identity import (
|
||||
BackendIdentity,
|
||||
FailureScope,
|
||||
should_skip_candidate,
|
||||
)
|
||||
|
||||
skip_model = (failed_model or "").strip().lower() or None
|
||||
if should_skip_candidate(
|
||||
BackendIdentity.build(provider=main_provider, model=main_model),
|
||||
BackendIdentity.build(provider=failed_provider, model=skip_model),
|
||||
FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL,
|
||||
):
|
||||
# The thing that failed IS the main model (or the failure was
|
||||
# provider-wide) — nothing to fall back to.
|
||||
return None, None, ""
|
||||
if _is_provider_unhealthy(main_provider):
|
||||
_log_skip_unhealthy(main_provider, task)
|
||||
|
|
@ -4326,6 +4391,7 @@ def _try_configured_fallback_chain(
|
|||
task: str,
|
||||
failed_provider: str,
|
||||
reason: str = "error",
|
||||
failed_model: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||
"""Try user-configured fallback_chain for a specific auxiliary task.
|
||||
|
||||
|
|
@ -4333,6 +4399,25 @@ def _try_configured_fallback_chain(
|
|||
entry in order. Each entry must have at least ``provider``; ``model``,
|
||||
``base_url``, and ``api_key`` are optional.
|
||||
|
||||
``failed_model`` narrows the skip check to the exact (provider, model)
|
||||
pair that just failed, rather than the whole provider. Without it every
|
||||
entry sharing the failed provider is skipped (the original behaviour).
|
||||
Callers pass it only when a sibling model on the same provider could
|
||||
plausibly recover:
|
||||
|
||||
- Model-specific runtime failures (timeout, connection, rate limit,
|
||||
model-incompatible, invalid response) pass ``failed_model`` so a
|
||||
chain that intentionally lists several models under the same provider
|
||||
— e.g. two more NVIDIA NIM models after the primary NIM model times
|
||||
out — is not skipped wholesale. Only the exact model that failed is
|
||||
skipped; the siblings still run instead of jumping straight to the
|
||||
main-agent-model safety net.
|
||||
- Provider-wide failures (auth 401, payment 402) and "no client could
|
||||
be built" callers leave ``failed_model`` as None, keeping the whole
|
||||
provider skipped — the shared credentials/account behind every model
|
||||
on that provider are broken, so a sibling can't help and the
|
||||
main-agent-model safety net should be reached instead.
|
||||
|
||||
Returns:
|
||||
(client, model, provider_label) or (None, None, "") if no fallback.
|
||||
"""
|
||||
|
|
@ -4344,7 +4429,24 @@ def _try_configured_fallback_chain(
|
|||
if not chain or not isinstance(chain, list):
|
||||
return None, None, ""
|
||||
|
||||
skip = failed_provider.lower().strip()
|
||||
skip_model = (failed_model or "").strip().lower() or None
|
||||
# Identity + scope semantics owned by agent.backend_identity (#59561,
|
||||
# #72468): a failed_model means the failure was model-scoped (timeout /
|
||||
# connection / rate limit) — only the exact deployment is skipped; no
|
||||
# failed_model means provider-wide (auth/payment) — the whole credential
|
||||
# surface is skipped.
|
||||
from agent.backend_identity import (
|
||||
BackendIdentity,
|
||||
FailureScope,
|
||||
should_skip_candidate,
|
||||
)
|
||||
|
||||
failed_ident = BackendIdentity.build(
|
||||
provider=failed_provider, model=skip_model,
|
||||
)
|
||||
failure_scope = (
|
||||
FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL
|
||||
)
|
||||
tried = []
|
||||
min_ctx = _task_minimum_context_length(task)
|
||||
|
||||
|
|
@ -4352,9 +4454,20 @@ def _try_configured_fallback_chain(
|
|||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fb_provider = str(entry.get("provider", "")).strip()
|
||||
if not fb_provider or fb_provider.lower() == skip:
|
||||
if not fb_provider:
|
||||
continue
|
||||
fb_model = str(entry.get("model", "")).strip() or None
|
||||
fb_model_raw = str(entry.get("model", "")).strip()
|
||||
if should_skip_candidate(
|
||||
BackendIdentity.build(
|
||||
provider=fb_provider,
|
||||
model=fb_model_raw,
|
||||
base_url=str(entry.get("base_url") or ""),
|
||||
),
|
||||
failed_ident,
|
||||
failure_scope,
|
||||
):
|
||||
continue
|
||||
fb_model = fb_model_raw or None
|
||||
|
||||
label = f"fallback_chain[{i}]({fb_provider})"
|
||||
|
||||
|
|
@ -4785,6 +4898,10 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
|
|||
async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
|
||||
elif base_url_host_matches(sync_base_url, "integrate.api.nvidia.com"):
|
||||
async_kwargs["default_headers"] = build_nvidia_nim_headers(sync_base_url)
|
||||
elif base_url_host_matches(sync_base_url, "x.ai"):
|
||||
from tools.xai_http import hermes_xai_default_headers
|
||||
|
||||
async_kwargs["default_headers"] = hermes_xai_default_headers()
|
||||
else:
|
||||
# Fall back to profile.default_headers for providers that declare
|
||||
# client-level headers on their ProviderProfile (e.g. attribution
|
||||
|
|
@ -5021,10 +5138,11 @@ def resolve_provider_client(
|
|||
|
||||
# ── Nous Portal (OAuth) ──────────────────────────────────────────
|
||||
if provider == "nous":
|
||||
# Detect vision tasks: either explicit model override from
|
||||
# _PROVIDER_VISION_MODELS, or caller passed a known vision model.
|
||||
# Detect vision tasks: caller flag (strict vision backend), explicit
|
||||
# model override from _PROVIDER_VISION_MODELS, or a known vision id.
|
||||
_is_vision = (
|
||||
model in _PROVIDER_VISION_MODELS.values()
|
||||
is_vision
|
||||
or model in _PROVIDER_VISION_MODELS.values()
|
||||
or (model or "").strip().lower() == "mimo-v2-omni"
|
||||
)
|
||||
client, default = _try_nous(vision=_is_vision)
|
||||
|
|
@ -5033,6 +5151,17 @@ def resolve_provider_client(
|
|||
"but Nous Portal not configured (run: hermes auth)")
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or default, provider)
|
||||
# Dual-wire: anthropic/* → /v1/messages, everything else stays on
|
||||
# /chat/completions. Derive from the catalog id (not a stale
|
||||
# api_mode=chat_completions) so aux matches the main agent.
|
||||
from hermes_cli.providers import nous_api_mode
|
||||
|
||||
portal_mode = nous_api_mode(final_model)
|
||||
api_key_str = str(getattr(client, "api_key", "") or "")
|
||||
base_url_str = str(getattr(client, "base_url", "") or "")
|
||||
client = _maybe_wrap_anthropic(
|
||||
client, final_model, api_key_str, base_url_str, portal_mode,
|
||||
)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
|
|
@ -5396,6 +5525,10 @@ def resolve_provider_client(
|
|||
))
|
||||
elif base_url_host_matches(base_url, "integrate.api.nvidia.com"):
|
||||
headers.update(build_nvidia_nim_headers(base_url))
|
||||
elif base_url_host_matches(base_url, "x.ai"):
|
||||
from tools.xai_http import hermes_xai_default_headers
|
||||
|
||||
headers.update(hermes_xai_default_headers())
|
||||
else:
|
||||
# Fall back to profile.default_headers for providers that declare
|
||||
# client-level attribution headers on their profile (e.g. GMI
|
||||
|
|
@ -5689,7 +5822,10 @@ def _resolve_strict_vision_backend(
|
|||
if provider == "openrouter":
|
||||
return _try_openrouter(model=model)
|
||||
if provider == "nous":
|
||||
return _try_nous(vision=True)
|
||||
# Must go through resolve_provider_client so anthropic/* vision
|
||||
# recommendations wrap onto /v1/messages — _try_nous alone returns
|
||||
# a bare OpenAI client and the call 404s.
|
||||
return resolve_provider_client("nous", model, is_vision=True)
|
||||
if provider == "openai-codex":
|
||||
# Route through resolve_provider_client so the caller's explicit
|
||||
# model is used. There is no safe default Codex model (shifting
|
||||
|
|
@ -6950,8 +7086,14 @@ def _build_call_kwargs(
|
|||
_is_gemini_native = is_native_gemini_base_url(_effective_base)
|
||||
except Exception:
|
||||
pass
|
||||
_nous_on_messages = False
|
||||
if _provider_norm in {"nous", "nous-portal", "nousresearch"}:
|
||||
from hermes_cli.providers import nous_api_mode
|
||||
|
||||
_nous_on_messages = nous_api_mode(model) == "anthropic_messages"
|
||||
if (
|
||||
_is_anthropic_compat_endpoint(provider, _effective_base)
|
||||
or _nous_on_messages
|
||||
or _is_nvidia_nim
|
||||
or _is_moa
|
||||
or _is_gemini_native
|
||||
|
|
@ -7046,21 +7188,43 @@ def _build_call_kwargs(
|
|||
else:
|
||||
effort = reasoning_config.get("effort") or "medium"
|
||||
merged_extra["reasoning"] = {"enabled": True, "effort": effort}
|
||||
if provider == "nous" and "tags" not in merged_extra:
|
||||
merged_extra["tags"] = _nous_portal_tags()
|
||||
# Portal product tags + sticky session_id. The provider profile usually
|
||||
# supplies both; this fallback covers profile-load failures and alias
|
||||
# spellings the profile lookup might miss. session_id keeps aux
|
||||
# compression/title/vision calls on the same upstream instance as the
|
||||
# main turn (cache warmth) — tags alone are not enough on /v1/messages.
|
||||
_provider_for_portal = str(provider or "").strip().lower()
|
||||
if _provider_for_portal in {"nous", "nous-portal", "nousresearch"}:
|
||||
if "tags" not in merged_extra:
|
||||
merged_extra["tags"] = _nous_portal_tags()
|
||||
if "session_id" not in merged_extra:
|
||||
try:
|
||||
from agent.portal_tags import get_conversation_context
|
||||
|
||||
sticky_key = get_conversation_context()
|
||||
except Exception:
|
||||
sticky_key = None
|
||||
if sticky_key:
|
||||
merged_extra["session_id"] = sticky_key
|
||||
if merged_extra:
|
||||
kwargs["extra_body"] = merged_extra
|
||||
|
||||
# Native Anthropic Messages adapters do not consume ``extra_body``. Carry
|
||||
# the normalized Hermes reasoning config through a private kwarg so the
|
||||
# adapter can pass it into build_anthropic_kwargs(), where provider-aware
|
||||
# thinking/output_config projection lives. Do not expose this private kwarg
|
||||
# to ordinary OpenAI-compatible SDK clients, which would reject it.
|
||||
# Anthropic Messages adapters translate Hermes reasoning into native
|
||||
# ``thinking`` via a private kwarg (and strip OpenAI-shaped
|
||||
# ``extra_body.reasoning``). Do not expose this private kwarg to ordinary
|
||||
# OpenAI-compatible SDK clients, which would reject it. Portal Claude is
|
||||
# dual-wire — include it when the catalog id selects /v1/messages.
|
||||
if reasoning_config and isinstance(reasoning_config, dict):
|
||||
provider_norm = str(provider or "").strip().lower()
|
||||
effective_base = base_url or ""
|
||||
_nous_on_messages = False
|
||||
if provider_norm in {"nous", "nous-portal", "nousresearch"}:
|
||||
from hermes_cli.providers import nous_api_mode
|
||||
|
||||
_nous_on_messages = nous_api_mode(model) == "anthropic_messages"
|
||||
if (
|
||||
provider_norm == "anthropic"
|
||||
or _nous_on_messages
|
||||
or _endpoint_speaks_anthropic_messages(effective_base)
|
||||
or _is_anthropic_compat_endpoint(provider_norm, effective_base)
|
||||
):
|
||||
|
|
@ -8078,6 +8242,15 @@ def call_llm(
|
|||
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
|
||||
task or "call", reason, resolved_provider, first_err)
|
||||
|
||||
# Narrow the configured-chain skip to the exact model that
|
||||
# failed ONLY for model-specific failures. Auth (401) and
|
||||
# payment (402) errors are provider-wide — the credentials or
|
||||
# account behind every model on that provider are the same — so
|
||||
# a sibling model can't recover; keep skipping the whole
|
||||
# provider so the main-agent-model safety net is still reached.
|
||||
_chain_failed_model = (
|
||||
None if reason in ("auth error", "payment error") else final_model
|
||||
)
|
||||
# Fallback order (#26882, #26803):
|
||||
# 1. User-configured fallback_chain (per-task) if set
|
||||
# 2. For auto: top-level main fallback_providers/fallback_model
|
||||
|
|
@ -8086,7 +8259,8 @@ def call_llm(
|
|||
fb_client, fb_model, fb_label = (None, None, "")
|
||||
if is_auto:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
task, resolved_provider or "auto", reason=reason,
|
||||
failed_model=_chain_failed_model)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
|
|
@ -8095,10 +8269,12 @@ def call_llm(
|
|||
resolved_provider, task, reason=reason)
|
||||
else:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
task, resolved_provider or "auto", reason=reason,
|
||||
failed_model=_chain_failed_model)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_agent_model_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
resolved_provider, task, reason=reason,
|
||||
failed_model=_chain_failed_model)
|
||||
|
||||
if fb_client is not None:
|
||||
fb_resp = _call_fallback_candidate_sync(
|
||||
|
|
@ -8612,6 +8788,15 @@ async def async_call_llm(
|
|||
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
|
||||
task or "call", reason, resolved_provider, first_err)
|
||||
|
||||
# Narrow the configured-chain skip to the exact model that
|
||||
# failed ONLY for model-specific failures. Auth (401) and
|
||||
# payment (402) errors are provider-wide — the credentials or
|
||||
# account behind every model on that provider are the same — so
|
||||
# a sibling model can't recover; keep skipping the whole
|
||||
# provider so the main-agent-model safety net is still reached.
|
||||
_chain_failed_model = (
|
||||
None if reason in ("auth error", "payment error") else final_model
|
||||
)
|
||||
# Fallback order (#26882, #26803):
|
||||
# 1. User-configured fallback_chain (per-task) if set
|
||||
# 2. For auto: top-level main fallback_providers/fallback_model
|
||||
|
|
@ -8620,7 +8805,8 @@ async def async_call_llm(
|
|||
fb_client, fb_model, fb_label = (None, None, "")
|
||||
if is_auto:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
task, resolved_provider or "auto", reason=reason,
|
||||
failed_model=_chain_failed_model)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
|
|
@ -8629,10 +8815,12 @@ async def async_call_llm(
|
|||
resolved_provider, task, reason=reason)
|
||||
else:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
task, resolved_provider or "auto", reason=reason,
|
||||
failed_model=_chain_failed_model)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_agent_model_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
resolved_provider, task, reason=reason,
|
||||
failed_model=_chain_failed_model)
|
||||
|
||||
if fb_client is not None:
|
||||
# Convert sync fallback client to async
|
||||
|
|
|
|||
204
agent/backend_identity.py
Normal file
204
agent/backend_identity.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""Single owner for backend identity and failure-scoped skip decisions.
|
||||
|
||||
Every fallback / dedup / skip / quarantine decision in Hermes ultimately asks
|
||||
one question: **"is this candidate the same backend as the one that failed,
|
||||
along the axis that failure invalidated?"** Before this module, that
|
||||
question was re-implemented inline at six call sites across four subsystems,
|
||||
each comparing whatever string was locally convenient (provider label,
|
||||
provider+model, base_url+model, ...). Each incident fixed one site while the
|
||||
others kept the bug: #22548 (same-shim aliases), #70893 (xai-oauth vs xai —
|
||||
same host, distinct credential), #59561 (aux chain skipped sibling models),
|
||||
#72468 (aux main-model safety net, same bug three weeks later), #62984 /
|
||||
#54250 / #57584 (dedup ignoring base_url strands multi-endpoint pools).
|
||||
|
||||
The root insight: "provider" conflates three independent identity axes, and
|
||||
each failure class invalidates a different one:
|
||||
|
||||
* **credential surface** — auth 401 / payment 402 kill everything sharing the
|
||||
credential (every model, every host reached with that key/token).
|
||||
* **endpoint** — DNS failure / connection refused kill everything behind the
|
||||
URL, regardless of model or credential.
|
||||
* **model deployment** — timeout / overload / rate limit / model-incompatible
|
||||
kill ONE model's deployment. A sibling model behind the same URL is an
|
||||
independent deployment (real incident: aux ``glm-5.2`` hung and timed out
|
||||
while main ``macaron-v1-venti`` on the identical endpoint was serving
|
||||
448K-token turns).
|
||||
|
||||
Call sites should build :class:`BackendIdentity` values, classify the failure
|
||||
with :func:`classify_failure_scope`, and ask :func:`should_skip_candidate`.
|
||||
Do not re-implement any comparison inline — extend THIS module instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FailureScope(Enum):
|
||||
"""Which identity axis a failure invalidates."""
|
||||
|
||||
#: Timeout, overload/429, connection blip, model-incompatible, invalid
|
||||
#: response: evidence against ONE model deployment only.
|
||||
MODEL = "model"
|
||||
#: Auth 401 / payment 402: evidence against the shared credential —
|
||||
#: every model reached with it is equally dead.
|
||||
CREDENTIAL = "credential"
|
||||
#: DNS / connection-refused / unreachable host: evidence against the
|
||||
#: endpoint — every model behind the URL is equally dead.
|
||||
ENDPOINT = "endpoint"
|
||||
|
||||
|
||||
#: Reason strings already used by auxiliary_client's except-chain, mapped to
|
||||
#: scopes. Unknown reasons default to MODEL — the least-invalidating scope —
|
||||
#: so an unrecognized failure never over-skips viable candidates.
|
||||
_REASON_SCOPES = {
|
||||
"auth error": FailureScope.CREDENTIAL,
|
||||
"payment error": FailureScope.CREDENTIAL,
|
||||
"rate limit": FailureScope.MODEL,
|
||||
"model incompatible with route": FailureScope.MODEL,
|
||||
"invalid provider response": FailureScope.MODEL,
|
||||
"connection error": FailureScope.MODEL,
|
||||
"timeout": FailureScope.MODEL,
|
||||
}
|
||||
|
||||
|
||||
def classify_failure_scope(reason: Optional[str]) -> FailureScope:
|
||||
"""Map a human-readable failure reason to the identity axis it kills."""
|
||||
return _REASON_SCOPES.get((reason or "").strip().lower(), FailureScope.MODEL)
|
||||
|
||||
|
||||
def _norm_provider(value: Optional[str]) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _norm_model(value: Optional[str]) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _norm_base_url(value: Optional[str]) -> str:
|
||||
return (value or "").strip().rstrip("/").lower()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendIdentity:
|
||||
"""Normalized identity of one (provider, model, endpoint) deployment.
|
||||
|
||||
Empty fields mean "unknown" — comparisons treat an unknown axis as
|
||||
non-distinguishing (it can neither prove sameness nor difference on its
|
||||
own; the remaining axes decide).
|
||||
"""
|
||||
|
||||
provider: str = ""
|
||||
model: str = ""
|
||||
base_url: str = ""
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> "BackendIdentity":
|
||||
return cls(
|
||||
provider=_norm_provider(provider),
|
||||
model=_norm_model(model),
|
||||
base_url=_norm_base_url(base_url),
|
||||
)
|
||||
|
||||
|
||||
def _both_first_class(a: BackendIdentity, b: BackendIdentity) -> bool:
|
||||
"""True when both providers are distinct registered first-class providers.
|
||||
|
||||
Two different registry providers have distinct credential surfaces even
|
||||
when they share an inference host (xai-oauth vs xai, openai-codex vs
|
||||
openai-api) — #70893. Custom/shim aliases are NOT in the registry, so
|
||||
two aliases pointing at one URL still count as the same backend (#22548).
|
||||
"""
|
||||
if not a.provider or not b.provider or a.provider == b.provider:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
|
||||
return a.provider in PROVIDER_REGISTRY and b.provider in PROVIDER_REGISTRY
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def same_credential_surface(a: BackendIdentity, b: BackendIdentity) -> bool:
|
||||
"""Do two identities share the credential a 401/402 just invalidated?
|
||||
|
||||
Conservative on purpose: an unprovable axis must answer "different"
|
||||
(try the candidate — worst case one wasted RTT) rather than "same"
|
||||
(skip — worst case stranded failover). Two distinct custom labels at
|
||||
one URL may carry different per-entry api_keys, so a shared URL alone
|
||||
never proves a shared credential; it is only used as a weak signal
|
||||
when a provider label is missing entirely.
|
||||
"""
|
||||
if a.provider and b.provider:
|
||||
# Same label = same configured credential. Different labels =
|
||||
# different credential config (first-class registry providers
|
||||
# explicitly so — #70893; custom entries can each carry their own
|
||||
# api_key, so sameness is unprovable and we must not skip).
|
||||
return a.provider == b.provider
|
||||
# Provider unknown on a side: same explicit URL is the best signal left.
|
||||
return bool(a.base_url and a.base_url == b.base_url)
|
||||
|
||||
|
||||
def same_endpoint(a: BackendIdentity, b: BackendIdentity) -> bool:
|
||||
"""Do two identities sit behind the endpoint that just went unreachable?"""
|
||||
if a.base_url and b.base_url:
|
||||
return a.base_url == b.base_url
|
||||
# An unknown base_url inherits the provider default → same provider
|
||||
# label implies the same default endpoint.
|
||||
return bool(a.provider and a.provider == b.provider)
|
||||
|
||||
|
||||
def same_deployment(a: BackendIdentity, b: BackendIdentity) -> bool:
|
||||
"""Are these the exact same model deployment (the thing a timeout kills)?
|
||||
|
||||
Provider+model must match; the base_url axis distinguishes only when BOTH
|
||||
sides carry an explicit URL (#62984: same provider+model on two different
|
||||
explicit URLs is two deployments — a pool). A side with an unknown URL
|
||||
inherits the provider default and cannot prove difference.
|
||||
"""
|
||||
if not (a.provider and b.provider and a.provider == b.provider):
|
||||
# Same-host different-label shims: same URL + same model IS the same
|
||||
# deployment even when the alias labels differ (#22548) — unless both
|
||||
# labels are first-class registry providers (#70893).
|
||||
if (
|
||||
a.base_url
|
||||
and a.base_url == b.base_url
|
||||
and a.model
|
||||
and a.model == b.model
|
||||
and not _both_first_class(a, b)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
if not (a.model and b.model and a.model == b.model):
|
||||
return False
|
||||
if a.base_url and b.base_url and a.base_url != b.base_url:
|
||||
return False # distinct explicit endpoints — a pool, not a dup
|
||||
return True
|
||||
|
||||
|
||||
def should_skip_candidate(
|
||||
candidate: BackendIdentity,
|
||||
failed: BackendIdentity,
|
||||
scope: FailureScope = FailureScope.MODEL,
|
||||
) -> bool:
|
||||
"""THE skip predicate: would trying ``candidate`` just repeat the failure?
|
||||
|
||||
True when the candidate is the same backend as ``failed`` along the axis
|
||||
``scope`` says the failure invalidated. Every fallback/dedup/skip site
|
||||
must call this instead of comparing labels inline.
|
||||
"""
|
||||
if scope is FailureScope.CREDENTIAL:
|
||||
return same_credential_surface(candidate, failed)
|
||||
if scope is FailureScope.ENDPOINT:
|
||||
return same_endpoint(candidate, failed)
|
||||
return same_deployment(candidate, failed)
|
||||
|
|
@ -107,6 +107,22 @@ class CardInfo:
|
|||
return f"{self.masked} — {label}" if label else self.masked
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PaymentMethodInfo:
|
||||
"""The payment method on file. `kind` is "card", "link", or "unknown"
|
||||
— anything else is normalised to "unknown" at parse time, so consumers
|
||||
only ever see fields that belong to the kind they are looking at."""
|
||||
|
||||
kind: str
|
||||
brand: Optional[str] = None
|
||||
last4: Optional[str] = None
|
||||
wallet: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
resolved_via: Optional[str] = None
|
||||
#: What the server called it, when we did not recognise the kind.
|
||||
raw_kind: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonthlyCap:
|
||||
limit_usd: Optional[Decimal] = None
|
||||
|
|
@ -150,6 +166,7 @@ class BillingState:
|
|||
min_usd: Optional[Decimal] = None
|
||||
max_usd: Optional[Decimal] = None
|
||||
card: Optional[CardInfo] = None
|
||||
payment_method: Optional[PaymentMethodInfo] = None
|
||||
monthly_cap: Optional[MonthlyCap] = None
|
||||
auto_reload: Optional[AutoReload] = None
|
||||
portal_url: Optional[str] = None
|
||||
|
|
@ -201,6 +218,41 @@ def _parse_card(raw: Any) -> Optional[CardInfo]:
|
|||
return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via)
|
||||
|
||||
|
||||
def _parse_payment_method(raw: Any) -> Optional[PaymentMethodInfo]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
kind = raw.get("kind")
|
||||
if not isinstance(kind, str):
|
||||
return None
|
||||
|
||||
def _optional_string(key: str) -> Optional[str]:
|
||||
value = raw.get(key)
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
resolved_via = _optional_string("resolvedVia")
|
||||
brand = _optional_string("brand")
|
||||
last4 = _optional_string("last4")
|
||||
# Settle the kind here, the way _parse_card settles a card, so nothing
|
||||
# downstream has to re-check which fields this kind is allowed to have.
|
||||
if kind == "card" and brand and last4:
|
||||
return PaymentMethodInfo(
|
||||
kind="card",
|
||||
brand=brand,
|
||||
last4=last4,
|
||||
wallet=_optional_string("wallet"),
|
||||
resolved_via=resolved_via,
|
||||
)
|
||||
if kind == "link":
|
||||
return PaymentMethodInfo(
|
||||
kind="link",
|
||||
email=_optional_string("email"),
|
||||
resolved_via=resolved_via,
|
||||
)
|
||||
return PaymentMethodInfo(
|
||||
kind="unknown", raw_kind=kind, resolved_via=resolved_via
|
||||
)
|
||||
|
||||
|
||||
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
|
@ -274,6 +326,7 @@ def billing_state_from_payload(
|
|||
min_usd=parse_money(bounds.get("minUsd")),
|
||||
max_usd=parse_money(bounds.get("maxUsd")),
|
||||
card=_parse_card(payload.get("card")),
|
||||
payment_method=_parse_payment_method(payload.get("paymentMethod")),
|
||||
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
|
||||
auto_reload=_parse_auto_reload(payload.get("autoReload")),
|
||||
portal_url=portal_url,
|
||||
|
|
|
|||
|
|
@ -188,6 +188,31 @@ def _provider_preferences_for_agent(agent) -> Dict[str, Any]:
|
|||
return preferences
|
||||
|
||||
|
||||
def _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs: dict) -> dict:
|
||||
"""Merge Portal ``tags`` / ``session_id`` onto an Anthropic Messages kwargs dict.
|
||||
|
||||
The Nous provider profile is only consulted by the OpenAI-wire transport;
|
||||
anthropic_messages callers must merge it themselves. Passes ``session_id``
|
||||
only — not ``provider_preferences`` (those become a top-level ``provider``
|
||||
routing object on the OpenAI wire). Never blocks a turn on tagging.
|
||||
"""
|
||||
if getattr(agent, "provider", None) not in {"nous", "nous-portal", "nousresearch"}:
|
||||
return anthropic_kwargs
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
|
||||
nous_profile = get_provider_profile("nous")
|
||||
if nous_profile is not None:
|
||||
anthropic_kwargs.setdefault("extra_body", {}).update(
|
||||
nous_profile.build_extra_body(
|
||||
session_id=getattr(agent, "session_id", None)
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — never block a turn on tagging
|
||||
logger.debug("Nous Portal extra_body merge failed: %s", exc)
|
||||
return anthropic_kwargs
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, str(default)))
|
||||
|
|
@ -433,26 +458,56 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
|
|||
|
||||
|
||||
def should_use_direct_api_call(agent) -> bool:
|
||||
"""Whether a cron OpenAI-wire request should skip the interrupt worker.
|
||||
"""Whether an OpenAI-wire request should skip the interrupt worker.
|
||||
|
||||
Issue #62151 is specific to OpenRouter's chat-completions path inside the
|
||||
gateway cron thread stack. Keep native/Codex/Bedrock/MoA transports on their
|
||||
established workers: their cancellation and client ownership differ, and
|
||||
the report provides no evidence that those paths share the pre-HTTP wedge.
|
||||
Two nested-pool contexts wedge before the socket opens when the request
|
||||
is pushed onto yet another daemon worker thread:
|
||||
|
||||
- Gateway cron turns (#62151): gateway asyncio loop → cron thread →
|
||||
interrupt worker. Fixed by running inline.
|
||||
- Delegated children (#60203): gateway loop → async-delegation executor
|
||||
(module-lifetime daemon pool) → per-child timeout executor → interrupt
|
||||
worker. Same fingerprint after multi-day gateway uptime — children hang
|
||||
at their FIRST API call with zero stale-detector output (the worker
|
||||
never reaches dispatch), all providers, restart cures it. The cron fix
|
||||
originally excluded delegation "for lack of evidence"; #60203 is that
|
||||
evidence.
|
||||
|
||||
Running inline drops the deepest thread layer (whose only job is
|
||||
interactive-interrupt responsiveness). Interrupts still work: the inline
|
||||
path registers ``agent._active_request_abort``, which ``interrupt()``
|
||||
invokes cross-thread to shut the active sockets — the same mechanism the
|
||||
async-delegation stall monitor (#72227) relies on.
|
||||
|
||||
Keep native/Codex/Bedrock/MoA transports on their established workers:
|
||||
their cancellation and client ownership differ.
|
||||
"""
|
||||
return (
|
||||
getattr(agent, "platform", None) == "cron"
|
||||
and getattr(agent, "api_mode", None) == "chat_completions"
|
||||
and getattr(agent, "provider", None) != "moa"
|
||||
)
|
||||
if getattr(agent, "api_mode", None) != "chat_completions":
|
||||
return False
|
||||
if getattr(agent, "provider", None) == "moa":
|
||||
return False
|
||||
if getattr(agent, "platform", None) == "cron":
|
||||
return True
|
||||
# Delegated child (delegate_task sync or background) — detected via the
|
||||
# execution ContextVar set by _run_single_child, with the agent's own
|
||||
# platform stamp as a fallback for callers that bypass the runner.
|
||||
try:
|
||||
from agent.delegation_context import is_delegated_child_context
|
||||
|
||||
if is_delegated_child_context():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return getattr(agent, "platform", None) == "subagent"
|
||||
|
||||
|
||||
def direct_api_call(agent, api_kwargs: dict):
|
||||
"""Run a non-streaming LLM call inline on the conversation thread.
|
||||
|
||||
Used when ``should_use_direct_api_call`` is True. Skips the interrupt worker
|
||||
(whose only job is interactive-interrupt responsiveness, which this context
|
||||
does not have) so the nested-pool deadlock (#62151) cannot occur. Because the
|
||||
Used when ``should_use_direct_api_call`` is True (cron turns and
|
||||
delegated children). Skips the interrupt worker (whose only job is
|
||||
interactive-interrupt responsiveness, which these contexts do not have)
|
||||
so the nested-pool deadlock (#62151, #60203) cannot occur. Because the
|
||||
request runs in-flight normally, the per-request OpenAI client's own httpx
|
||||
timeout (provider ``request_timeout_seconds`` / ``HERMES_API_TIMEOUT``) bounds
|
||||
a genuinely hung provider — the same bound interactive calls already rely on.
|
||||
|
|
@ -463,7 +518,7 @@ def direct_api_call(agent, api_kwargs: dict):
|
|||
request_client_lock = threading.Lock()
|
||||
|
||||
def _abort_active_request(reason: str) -> None:
|
||||
"""Abort the inline request from cron's watchdog/interrupt thread."""
|
||||
"""Abort the inline request from a watchdog/interrupt thread."""
|
||||
with request_client_lock:
|
||||
request_client = request_client_holder["client"]
|
||||
if request_client is not None:
|
||||
|
|
@ -993,7 +1048,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
|||
ephemeral_out = getattr(agent, "_ephemeral_max_output_tokens", None)
|
||||
if ephemeral_out is not None:
|
||||
agent._ephemeral_max_output_tokens = None # consume immediately
|
||||
return _transport.build_kwargs(
|
||||
anthropic_kwargs = _transport.build_kwargs(
|
||||
model=agent.model,
|
||||
messages=anthropic_messages,
|
||||
tools=tools_for_api,
|
||||
|
|
@ -1006,6 +1061,12 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
|||
fast_mode=(agent.request_overrides or {}).get("speed") == "fast",
|
||||
drop_context_1m_beta=bool(getattr(agent, "_oauth_1m_beta_disabled", False)),
|
||||
)
|
||||
# Nous Portal reads ``tags`` and ``session_id`` as top-level body fields
|
||||
# on its Messages route the same way it does on /chat/completions, but
|
||||
# the profile hook that produces them is only consulted by the
|
||||
# OpenAI-wire transport. Merge them here so Messages traffic keeps
|
||||
# product attribution and sticky routing.
|
||||
return _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs)
|
||||
|
||||
# AWS Bedrock native Converse API — bypasses the OpenAI client entirely.
|
||||
# The adapter handles message/tool conversion and boto3 calls directly.
|
||||
|
|
@ -1312,6 +1373,17 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
|
|||
from agent.redact import redact_sensitive_text
|
||||
_san_content = redact_sensitive_text(_san_content)
|
||||
|
||||
# NOTE (empty-content class fix): textless assistant turns are NOT padded
|
||||
# here. The single owner for "never send a turn strict wire validation
|
||||
# rejects as empty" is ``repair_empty_non_final_messages`` in
|
||||
# agent_runtime_helpers, which runs inside ``sanitize_api_messages`` — the
|
||||
# unconditional pre-send chokepoint for both the main loop and the summary
|
||||
# path. Padding at write time was tried (a single-space pad, later a
|
||||
# placeholder) and rejected: it forked the concept across three sites,
|
||||
# broke codex commentary turns (content:'' is a designed state there), and
|
||||
# a DB-side pad can't survive ``_rows_to_conversation``'s whitespace strip
|
||||
# anyway. Repair belongs at the send boundary, once.
|
||||
|
||||
msg = {
|
||||
"role": "assistant",
|
||||
"content": _san_content,
|
||||
|
|
@ -1514,45 +1586,6 @@ def _fallback_entry_key(fb: dict) -> tuple[str, str, str]:
|
|||
)
|
||||
|
||||
|
||||
def _fallback_entry_is_same_backend_by_base_url(
|
||||
*,
|
||||
current_provider: str,
|
||||
fb_provider: str,
|
||||
current_base_url: str,
|
||||
fb_base_url: str,
|
||||
current_model: str,
|
||||
fb_model: str,
|
||||
) -> bool:
|
||||
"""True when base_url+model identity means the fallback is the same backend.
|
||||
|
||||
Issue #22548: two ``custom_providers`` aliases that point at the same shim
|
||||
URL with the same model must be skipped, or failover loops on the dead
|
||||
backend. First-class providers that share a host while using different
|
||||
auth (``xai-oauth`` vs ``xai``, ``openai-codex`` vs ``openai-api``) are
|
||||
distinct credential surfaces — skipping them strands configured failover
|
||||
when primary and fallback reuse the same model slug on that host.
|
||||
"""
|
||||
if not (
|
||||
fb_base_url
|
||||
and current_base_url
|
||||
and fb_base_url == current_base_url
|
||||
and fb_model == current_model
|
||||
):
|
||||
return False
|
||||
if fb_provider == current_provider:
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
|
||||
# Both sides are registered first-class providers → different auth
|
||||
# identities even when the inference host matches. Allow failover.
|
||||
if current_provider in PROVIDER_REGISTRY and fb_provider in PROVIDER_REGISTRY:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _fallback_entry_unavailable_without_network(agent, fb: dict) -> Optional[str]:
|
||||
"""Return a skip reason for fallback entries known to be unusable locally."""
|
||||
fb_provider = (fb.get("provider") or "").strip().lower()
|
||||
|
|
@ -1638,33 +1671,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
|||
)
|
||||
return agent._try_activate_fallback(reason)
|
||||
|
||||
# Skip entries that resolve to the current (provider, model) — falling
|
||||
# back to the same backend that just failed loops the failure. Compare
|
||||
# base_url too so two distinct custom_providers entries pointing at the
|
||||
# same shim/proxy URL also dedup. See issue #22548. Do NOT treat
|
||||
# first-class providers that share a host (xai-oauth vs xai) as the same
|
||||
# backend — they use different credentials.
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
current_model = (getattr(agent, "model", "") or "").strip()
|
||||
current_base_url = str(getattr(agent, "base_url", "") or "").rstrip("/").lower()
|
||||
fb_base_url_for_dedup = (fb.get("base_url") or "").strip().rstrip("/").lower()
|
||||
if fb_provider == current_provider and fb_model == current_model:
|
||||
# Skip entries that resolve to the same backend that just failed —
|
||||
# falling back to it loops the failure. Identity semantics (which axes
|
||||
# distinguish two backends, shim aliases, first-class credential
|
||||
# surfaces, multi-endpoint pools) are owned by agent.backend_identity —
|
||||
# see #22548, #70893, #62984. Do not re-implement comparisons here.
|
||||
from agent.backend_identity import BackendIdentity, should_skip_candidate
|
||||
|
||||
current_ident = BackendIdentity.build(
|
||||
provider=getattr(agent, "provider", ""),
|
||||
model=getattr(agent, "model", ""),
|
||||
base_url=str(getattr(agent, "base_url", "") or ""),
|
||||
)
|
||||
fb_ident = BackendIdentity.build(
|
||||
provider=fb_provider,
|
||||
model=fb_model,
|
||||
base_url=(fb.get("base_url") or ""),
|
||||
)
|
||||
if should_skip_candidate(fb_ident, current_ident):
|
||||
logger.warning(
|
||||
"Fallback skip: chain entry %s/%s matches current provider/model",
|
||||
fb_provider, fb_model,
|
||||
)
|
||||
return agent._try_activate_fallback(reason)
|
||||
if _fallback_entry_is_same_backend_by_base_url(
|
||||
current_provider=current_provider,
|
||||
fb_provider=fb_provider,
|
||||
current_base_url=current_base_url,
|
||||
fb_base_url=fb_base_url_for_dedup,
|
||||
current_model=current_model,
|
||||
fb_model=fb_model,
|
||||
):
|
||||
logger.warning(
|
||||
"Fallback skip: chain entry base_url %s matches current backend",
|
||||
fb_base_url_for_dedup,
|
||||
"Fallback skip: chain entry %s/%s resolves to the same backend "
|
||||
"as the current one (%s)",
|
||||
fb_provider, fb_model, current_ident.base_url or current_ident.provider,
|
||||
)
|
||||
return agent._try_activate_fallback(reason)
|
||||
|
||||
|
|
@ -1715,6 +1743,14 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
|||
_fb_is_azure = agent._is_azure_openai_url(fb_base_url)
|
||||
if fb_provider == "openai-codex":
|
||||
fb_api_mode = "codex_responses"
|
||||
elif fb_provider in {"nous", "nous-portal", "nousresearch"}:
|
||||
# Portal is dual-wire: anthropic/* must land on /v1/messages.
|
||||
# resolve_provider_client still returns an OpenAI client for
|
||||
# Nous; the anthropic_messages branch below rebuilds the native
|
||||
# client from that credential + base_url.
|
||||
from hermes_cli.providers import nous_api_mode
|
||||
|
||||
fb_api_mode = nous_api_mode(fb_model)
|
||||
elif (
|
||||
fb_provider == "anthropic"
|
||||
or fb_base_url.rstrip("/").lower().endswith("/anthropic")
|
||||
|
|
@ -2141,7 +2177,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
|||
_ant_kw = _tsum.build_kwargs(model=agent.model, messages=api_messages, tools=None,
|
||||
max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config,
|
||||
is_oauth=agent._is_anthropic_oauth,
|
||||
preserve_dots=agent._anthropic_preserve_dots())
|
||||
preserve_dots=agent._anthropic_preserve_dots(),
|
||||
base_url=getattr(agent, "_anthropic_base_url", None))
|
||||
_ant_kw = _merge_nous_portal_messages_extra_body(agent, _ant_kw)
|
||||
summary_response = agent._anthropic_messages_create(_ant_kw)
|
||||
_summary_result = _tsum.normalize_response(summary_response, strip_tool_prefix=agent._is_anthropic_oauth)
|
||||
final_response = (_summary_result.content or "").strip()
|
||||
|
|
@ -2171,7 +2209,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
|||
_ant_kw2 = _tretry.build_kwargs(model=agent.model, messages=api_messages, tools=None,
|
||||
is_oauth=agent._is_anthropic_oauth,
|
||||
max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config,
|
||||
preserve_dots=agent._anthropic_preserve_dots())
|
||||
preserve_dots=agent._anthropic_preserve_dots(),
|
||||
base_url=getattr(agent, "_anthropic_base_url", None))
|
||||
_ant_kw2 = _merge_nous_portal_messages_extra_body(agent, _ant_kw2)
|
||||
retry_response = agent._anthropic_messages_create(_ant_kw2)
|
||||
_retry_result = _tretry.normalize_response(retry_response, strip_tool_prefix=agent._is_anthropic_oauth)
|
||||
final_response = (_retry_result.content or "").strip()
|
||||
|
|
@ -3889,6 +3929,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
result["error"],
|
||||
)
|
||||
_stub_finish_reason = FINISH_REASON_LENGTH
|
||||
# NOTE (empty-content class fix): the stub is deliberately allowed
|
||||
# to carry empty content here. The conversation loop's truncation
|
||||
# path detects an EMPTY partial-stream stub (PARTIAL_STREAM_STUB_ID
|
||||
# + no content) and skips appending it to history entirely — only
|
||||
# the continuation nudge is sent. Substituting placeholder text at
|
||||
# this site was tried and reverted: it defeats that guard (the stub
|
||||
# no longer looks empty), gets appended to history, and the
|
||||
# placeholder leaks into the stitched final response via
|
||||
# truncated_response_parts. Transcripts that already carry a
|
||||
# persisted empty turn are healed at the send boundary by
|
||||
# ``repair_empty_non_final_messages`` (the single owner).
|
||||
_stub_msg = SimpleNamespace(
|
||||
role="assistant", content=_partial_text, tool_calls=None,
|
||||
reasoning_content=None,
|
||||
|
|
|
|||
|
|
@ -778,12 +778,27 @@ def run_codex_app_server_turn(
|
|||
# the already-flushed user turn). See gateway/run.py agent_persisted.
|
||||
if getattr(agent, "_session_db", None) is not None:
|
||||
try:
|
||||
agent._flush_messages_to_session_db(messages)
|
||||
_codex_flush_ok = agent._flush_messages_to_session_db(messages)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
_codex_flush_ok = False
|
||||
logger.warning(
|
||||
"codex app-server projected-message flush failed",
|
||||
exc_info=True,
|
||||
)
|
||||
if _codex_flush_ok is False:
|
||||
# Unlike the chat-completions loop (which fails closed BEFORE
|
||||
# projection — see conversation_loop session_persistence_failed),
|
||||
# codex output has already streamed to the user by the time this
|
||||
# flush runs, so there is nothing left to withhold. We cannot
|
||||
# flip agent_persisted=False either: the gateway fallback write
|
||||
# would re-INSERT the already-flushed user turn (#860/#42039).
|
||||
# Surface the durability gap loudly instead of a silent debug.
|
||||
logger.warning(
|
||||
"codex app-server turn was delivered but could NOT be "
|
||||
"persisted to the session DB (session=%s) — this turn "
|
||||
"will be missing after restart/resume",
|
||||
getattr(agent, "session_id", None),
|
||||
)
|
||||
|
||||
|
||||
# Counter ticks for the agent-improvement loop.
|
||||
|
|
|
|||
|
|
@ -154,3 +154,207 @@ def compute_session_context_breakdown(
|
|||
"estimated_total": estimated_total,
|
||||
"model": getattr(agent, "model", "") or "",
|
||||
}
|
||||
|
||||
|
||||
# ── /context rendering (CLI + gateway) ──────────────────────────────────────
|
||||
#
|
||||
# Pure text renderers over the payload above. The CLI shows a glyph block-grid
|
||||
# plus a category table; the gateway uses the same table without the grid
|
||||
# (proportional monospace is not guaranteed on messaging platforms).
|
||||
|
||||
_CATEGORY_GLYPHS = {
|
||||
"system_prompt": "■",
|
||||
"tool_definitions": "▣",
|
||||
"rules": "▩",
|
||||
"skills": "▤",
|
||||
"mcp": "▥",
|
||||
"subagent_definitions": "▦",
|
||||
"memory": "▧",
|
||||
"conversation": "▨",
|
||||
}
|
||||
_FREE_GLYPH = "·"
|
||||
_GRID_COLUMNS = 20
|
||||
_GRID_ROWS = 5 # 100 cells → 1 cell per percent of the context window
|
||||
|
||||
# Human-readable tables cap the expanded listings; nothing is dropped from
|
||||
# the underlying data.
|
||||
_DETAILS_TABLE_LIMIT = 15
|
||||
|
||||
|
||||
def _bytes_to_tokens(size: Optional[int]) -> Optional[int]:
|
||||
if size is None:
|
||||
return None
|
||||
return (int(size) + 3) // 4
|
||||
|
||||
|
||||
def compute_context_details(agent: Any) -> Dict[str, Any]:
|
||||
"""Expanded per-skill / per-toolset cost listing for ``/context all``.
|
||||
|
||||
Reuses the ``hermes prompt-size`` attribution mechanism (PR #66656):
|
||||
per-skill index-line bytes parsed from the live ``<available_skills>``
|
||||
block, and per-toolset schema bytes attributed via the tool registry's
|
||||
canonical tool→toolset map. Byte figures are converted to the same
|
||||
chars/4 token heuristic the categories above use.
|
||||
"""
|
||||
from hermes_cli.prompt_size import (
|
||||
_compute_skills_breakdown,
|
||||
_compute_toolsets_breakdown,
|
||||
)
|
||||
from agent.system_prompt import build_system_prompt_parts
|
||||
|
||||
parts = build_system_prompt_parts(agent)
|
||||
stable = parts.get("stable", "") or ""
|
||||
skills_match = _SKILLS_BLOCK_RE.search(stable)
|
||||
skills_block = skills_match.group(0) if skills_match else ""
|
||||
|
||||
skills: List[Dict[str, Any]] = []
|
||||
if skills_block:
|
||||
for entry in _compute_skills_breakdown(skills_block):
|
||||
skills.append({
|
||||
"name": entry.get("name", ""),
|
||||
"index_tokens": _bytes_to_tokens(entry.get("index_line_bytes")) or 0,
|
||||
"skill_md_tokens": _bytes_to_tokens(entry.get("skill_md_bytes")),
|
||||
})
|
||||
|
||||
toolsets: List[Dict[str, Any]] = []
|
||||
tools = list(getattr(agent, "tools", None) or [])
|
||||
if tools:
|
||||
for group in _compute_toolsets_breakdown(tools):
|
||||
toolsets.append({
|
||||
"toolset": group.get("toolset", ""),
|
||||
"tool_count": int(group.get("tool_count", 0) or 0),
|
||||
"schema_tokens": _bytes_to_tokens(group.get("json_bytes")) or 0,
|
||||
})
|
||||
|
||||
return {"skills": skills, "toolsets": toolsets}
|
||||
|
||||
|
||||
def render_context_grid(payload: Dict[str, Any]) -> List[str]:
|
||||
"""Render the payload as a Claude Code-style glyph block grid.
|
||||
|
||||
100 cells (5×20), each one percent of the model context window. Categories
|
||||
fill in declaration order; the remainder renders as free space.
|
||||
"""
|
||||
context_max = int(payload.get("context_max") or 0)
|
||||
categories = payload.get("categories") or []
|
||||
total_cells = _GRID_COLUMNS * _GRID_ROWS
|
||||
|
||||
cells: List[str] = []
|
||||
if context_max > 0:
|
||||
for cat in categories:
|
||||
tokens = int(cat.get("tokens") or 0)
|
||||
n = round(tokens / context_max * total_cells)
|
||||
if tokens > 0 and n == 0:
|
||||
n = 1 # never render a nonzero category as invisible
|
||||
glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪")
|
||||
cells.extend([glyph] * n)
|
||||
cells = cells[:total_cells]
|
||||
cells.extend([_FREE_GLYPH] * (total_cells - len(cells)))
|
||||
|
||||
return [
|
||||
" ".join(cells[row * _GRID_COLUMNS:(row + 1) * _GRID_COLUMNS])
|
||||
for row in range(_GRID_ROWS)
|
||||
]
|
||||
|
||||
|
||||
def render_context_category_lines(payload: Dict[str, Any]) -> List[str]:
|
||||
"""Render the 'Estimated usage by category' table as plain-text lines."""
|
||||
categories = payload.get("categories") or []
|
||||
context_max = int(payload.get("context_max") or 0)
|
||||
estimated_total = int(payload.get("estimated_total") or 0)
|
||||
denom = context_max or estimated_total
|
||||
|
||||
lines = ["Estimated usage by category"]
|
||||
if not categories:
|
||||
lines.append(" (no data yet — send a message first)")
|
||||
return lines
|
||||
|
||||
width = max(len(str(cat.get("label") or "")) for cat in categories)
|
||||
width = max(width, len("Free space"))
|
||||
for cat in categories:
|
||||
tokens = int(cat.get("tokens") or 0)
|
||||
glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪")
|
||||
pct = tokens / denom * 100 if denom else 0.0
|
||||
label = str(cat.get("label") or cat.get("id") or "")
|
||||
lines.append(f"{glyph} {label:<{width}} {tokens:>9,} tokens {pct:>5.1f}%")
|
||||
if context_max > 0:
|
||||
free = max(0, context_max - estimated_total)
|
||||
pct = free / context_max * 100
|
||||
lines.append(f"{_FREE_GLYPH} {'Free space':<{width}} {free:>9,} tokens {pct:>5.1f}%")
|
||||
return lines
|
||||
|
||||
|
||||
def render_context_details_lines(details: Dict[str, Any]) -> List[str]:
|
||||
"""Render the expanded ``/context all`` per-skill / per-toolset tables."""
|
||||
lines: List[str] = []
|
||||
|
||||
toolsets = details.get("toolsets") or []
|
||||
if toolsets:
|
||||
lines.append("Toolsets by schema cost (largest first)")
|
||||
for group in toolsets[:_DETAILS_TABLE_LIMIT]:
|
||||
lines.append(
|
||||
f" {group['toolset']:<24} {group['tool_count']:>3} tools"
|
||||
f" {group['schema_tokens']:>8,} tokens"
|
||||
)
|
||||
remaining = len(toolsets) - _DETAILS_TABLE_LIMIT
|
||||
if remaining > 0:
|
||||
lines.append(f" … and {remaining} more")
|
||||
|
||||
skills = details.get("skills") or []
|
||||
if skills:
|
||||
if lines:
|
||||
lines.append("")
|
||||
lines.append("Skills by cost (index = always-on; SKILL.md = cost when loaded)")
|
||||
for entry in skills[:_DETAILS_TABLE_LIMIT]:
|
||||
name = str(entry.get("name") or "")
|
||||
if len(name) > 28:
|
||||
name = name[:27] + "…"
|
||||
md = entry.get("skill_md_tokens")
|
||||
md_str = f"{md:>8,}" if md is not None else f"{'n/a':>8}"
|
||||
lines.append(
|
||||
f" {name:<28} index {entry['index_tokens']:>6,}"
|
||||
f" SKILL.md {md_str} tokens"
|
||||
)
|
||||
remaining = len(skills) - _DETAILS_TABLE_LIMIT
|
||||
if remaining > 0:
|
||||
lines.append(f" … and {remaining} more")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def render_context_breakdown_lines(
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
grid: bool = True,
|
||||
) -> List[str]:
|
||||
"""Render the full /context view as plain-text lines.
|
||||
|
||||
``grid=True`` (CLI) prepends the glyph block grid; the gateway passes
|
||||
``grid=False`` and keeps its own gauge. ``details`` (from
|
||||
:func:`compute_context_details`) appends the expanded listings.
|
||||
"""
|
||||
lines: List[str] = []
|
||||
if grid:
|
||||
lines.extend(render_context_grid(payload))
|
||||
lines.append("")
|
||||
lines.extend(render_context_category_lines(payload))
|
||||
|
||||
context_max = int(payload.get("context_max") or 0)
|
||||
context_used = int(payload.get("context_used") or 0)
|
||||
if context_max > 0:
|
||||
pct = int(payload.get("context_percent") or 0)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"Context window: {context_used:,} / {context_max:,} tokens ({pct}%)"
|
||||
)
|
||||
|
||||
if details is not None:
|
||||
detail_lines = render_context_details_lines(details)
|
||||
if detail_lines:
|
||||
lines.append("")
|
||||
lines.extend(detail_lines)
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("Use /context all for per-skill and per-toolset costs.")
|
||||
return lines
|
||||
|
|
|
|||
|
|
@ -213,8 +213,12 @@ async def preprocess_context_references_async(
|
|||
f"@ context injection warning: {injected_tokens} tokens exceeds the 25% soft limit ({soft_limit})."
|
||||
)
|
||||
|
||||
stripped = _remove_reference_tokens(message, refs)
|
||||
final = stripped
|
||||
# Leave the `@file:`/`@folder:` tokens where the user typed them. The token
|
||||
# IS the reference, not scaffolding around it: clients render each one as an
|
||||
# inline chip, so stripping them left a sentence with a hole in it ("review
|
||||
# and ship") and made the desktop re-derive the refs from the attached block
|
||||
# to show them as a detached list above the prose.
|
||||
final = message
|
||||
if warnings:
|
||||
final = f"{final}\n\n--- Context Warnings ---\n" + "\n".join(f"- {warning}" for warning in warnings)
|
||||
if blocks:
|
||||
|
|
@ -473,19 +477,6 @@ def _parse_file_reference_value(value: str) -> tuple[str, int | None, int | None
|
|||
return _strip_reference_wrappers(value), None, None
|
||||
|
||||
|
||||
def _remove_reference_tokens(message: str, refs: list[ContextReference]) -> str:
|
||||
pieces: list[str] = []
|
||||
cursor = 0
|
||||
for ref in refs:
|
||||
pieces.append(message[cursor:ref.start])
|
||||
cursor = ref.end
|
||||
pieces.append(message[cursor:])
|
||||
text = "".join(pieces)
|
||||
text = re.sub(r"\s{2,}", " ", text)
|
||||
text = re.sub(r"\s+([,.;:!?])", r"\1", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _is_binary_file(path: Path) -> bool:
|
||||
mime, _ = mimetypes.guess_type(path.name)
|
||||
if mime and not mime.startswith("text/") and not any(
|
||||
|
|
|
|||
|
|
@ -1402,7 +1402,10 @@ def compress_context(
|
|||
# `name #N` renumber, no contextvar/env/logging re-sync, no memory/context-
|
||||
# engine session-switch. The conversation keeps one durable id for life,
|
||||
# eliminating the session-rotation bug cluster. Default True (2107b86024).
|
||||
in_place = bool(getattr(agent, "compression_in_place", False))
|
||||
# Default True matches DEFAULT_CONFIG / #38763. A missing attribute must
|
||||
# NOT fall back to rotation mode — that re-enables the pre-lease drift
|
||||
# path and can wedge busy sessions that never set the flag.
|
||||
in_place = bool(getattr(agent, "compression_in_place", True))
|
||||
# Set True once the in-place DB write actually completes (the DB block can
|
||||
# raise and skip it). Surfaced to the gateway via agent._last_compaction_in_place.
|
||||
compacted_in_place = False
|
||||
|
|
@ -1712,6 +1715,13 @@ def compress_context(
|
|||
# non-destructive — pre-compaction rows are soft-archived (active=0,
|
||||
# compacted=1), stay searchable and recoverable, so snapshot/durable
|
||||
# drift cannot lose data there and must not abort compaction.
|
||||
#
|
||||
# When durable DID grow, ADOPT it and continue rather than aborting.
|
||||
# Aborting returned the stale snapshot unchanged, so busy sessions
|
||||
# (memory review / shared session_id writers) stayed permanently
|
||||
# behind the DB: every /compress and auto-compress saw
|
||||
# "changed before lease acquisition", surfaced as the misleading
|
||||
# "No changes from compression", and never reclaimed tokens.
|
||||
if not in_place and _lock_db is not None and _lock_sid:
|
||||
durable_loader = getattr(
|
||||
type(_lock_db), "get_messages_as_conversation", None
|
||||
|
|
@ -1719,16 +1729,19 @@ def compress_context(
|
|||
if callable(durable_loader):
|
||||
durable_parent = durable_loader(_lock_db, _lock_sid)
|
||||
if isinstance(durable_parent, list) and len(durable_parent) > len(messages):
|
||||
logger.warning(
|
||||
"compression aborted: session=%s changed before lease "
|
||||
"acquisition; preserving newer durable messages",
|
||||
logger.info(
|
||||
"compression: session=%s grew before lease "
|
||||
"(%d → %d msgs); adopting durable snapshot",
|
||||
_lock_sid,
|
||||
len(messages),
|
||||
len(durable_parent),
|
||||
)
|
||||
_release_lock()
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
messages = durable_parent
|
||||
_pre_msg_count = len(messages)
|
||||
# Token estimate was for the stale snapshot; clear it so
|
||||
# the compressor re-derives from the adopted transcript
|
||||
# instead of under-counting the newly visible rows.
|
||||
approx_tokens = 0
|
||||
|
||||
# Notify external memory provider before compression discards context.
|
||||
# The provider's on_pre_compress() may return a string of insights it
|
||||
|
|
@ -2027,14 +2040,13 @@ def compress_context(
|
|||
# (same startswith gate as the restore path); otherwise the
|
||||
# request layer falls back to the legacy single-breakpoint
|
||||
# layout with the prompt bytes untouched.
|
||||
try:
|
||||
from agent.system_prompt import build_system_prompt_parts as _build_parts
|
||||
from agent.system_prompt import reconstruct_static_prefix
|
||||
|
||||
_static = _build_parts(agent, system_message=system_message)["stable"]
|
||||
if _static and cached_system_prompt.startswith(_static):
|
||||
agent._cached_system_prompt_static = _static
|
||||
except Exception:
|
||||
pass
|
||||
reconstruct_static_prefix(
|
||||
agent,
|
||||
system_message=system_message,
|
||||
log_label="compression keep-prompt",
|
||||
)
|
||||
else:
|
||||
new_system_prompt = agent._build_system_prompt(system_message)
|
||||
agent._cached_system_prompt = new_system_prompt
|
||||
|
|
|
|||
|
|
@ -72,7 +72,10 @@ from agent.model_metadata import (
|
|||
save_context_length,
|
||||
)
|
||||
from agent.process_bootstrap import _install_safe_stdio
|
||||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.prompt_caching import (
|
||||
apply_anthropic_cache_control,
|
||||
strip_anthropic_cache_control,
|
||||
)
|
||||
from agent.retry_utils import (
|
||||
adaptive_rate_limit_backoff,
|
||||
is_zai_coding_overload_error,
|
||||
|
|
@ -118,22 +121,44 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text
|
|||
|
||||
Incomplete provider reasoning blocks are not valid replay items (Anthropic
|
||||
signs them; Responses reasoning items require their following output).
|
||||
Preserve only what Hermes actually displayed, demoted to ordinary text,
|
||||
then add the correction as a real user message. This keeps role alternation
|
||||
Preserve only the *visible* response text, demoted to ordinary text, then
|
||||
add the correction as a real user message. This keeps role alternation
|
||||
valid and leaves every previously cached message byte-for-byte unchanged.
|
||||
|
||||
INVARIANT — raw chain-of-thought must never be serialized into replayable
|
||||
message content. Streamed reasoning is display-only state: it may be shown
|
||||
live, but it does not re-enter the transcript as assistant (or user) text.
|
||||
An assistant turn whose content inlines its own chain-of-thought reads to
|
||||
Anthropic's output classifier as reasoning-injection/prefill jailbreak,
|
||||
and because the poisoned checkpoint is persisted and replayed on every
|
||||
subsequent call, the session dies permanently with deterministic
|
||||
"Provider returned an empty response" storms that no retry, nudge, or
|
||||
empty-recovery branch can escape (July 2026: four sessions bricked this
|
||||
way; every reasoning-free checkpoint that week was untouched — same
|
||||
mechanism as the ~/.hermes/prefill.json incident, 20/20 blocked with
|
||||
assistant-exposed CoT vs 0/20 without). The interrupted reasoning was
|
||||
incomplete by definition; the model regenerates it on the retried turn.
|
||||
If a future path needs to preserve interrupted thinking, carry it in a
|
||||
provider-gated reasoning *field*, never in content.
|
||||
INVARIANT — the scaffolding is provider-replay text, not transcript text.
|
||||
``[This response was interrupted by a user correction.]`` and its
|
||||
``Visible response before the interruption:`` header exist so the MODEL
|
||||
understands its own reply was cut off. They are not prose the user wrote
|
||||
or the agent said. Persisting them into ``content`` painted the raw
|
||||
machinery as an assistant bubble on every reload (and merged it into the
|
||||
preceding tool-call bubble), which is what made a steered transcript
|
||||
unreadable. Carry the scaffolded form in the ``api_content`` sidecar --
|
||||
the exact bytes replayed to the provider -- and keep ``content`` clean.
|
||||
When nothing was on screen there is no clean form at all, so the row is
|
||||
marked ``display_kind="hidden"``: still replayed to the model, dropped by
|
||||
every transcript surface (desktop, TUI, CLI resume), exactly like the
|
||||
compaction-reference rows.
|
||||
"""
|
||||
reasoning = str(
|
||||
getattr(agent, "_current_streamed_reasoning_text", "") or ""
|
||||
).strip()
|
||||
visible = agent._strip_think_blocks(
|
||||
getattr(agent, "_current_streamed_assistant_text", "") or ""
|
||||
).strip()
|
||||
|
||||
checkpoint_parts = ["[This response was interrupted by a user correction.]"]
|
||||
if reasoning:
|
||||
checkpoint_parts.extend(
|
||||
["Reasoning shown before the interruption:", reasoning]
|
||||
)
|
||||
if visible:
|
||||
checkpoint_parts.extend(
|
||||
["Visible response before the interruption:", visible]
|
||||
|
|
@ -150,13 +175,25 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text
|
|||
f"{checkpoint}\n\n"
|
||||
f"{text}"
|
||||
)
|
||||
messages.append({"role": "user", "content": correction})
|
||||
# Transcript shows the user's own words; the provider replays the
|
||||
# scaffolded form so it still sees the interrupted context.
|
||||
messages.append(
|
||||
{"role": "user", "content": text, "api_content": correction}
|
||||
)
|
||||
else:
|
||||
messages.append({"role": "assistant", "content": checkpoint})
|
||||
entry: Dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": visible or checkpoint,
|
||||
"api_content": checkpoint,
|
||||
}
|
||||
if not visible:
|
||||
# Nothing reached the screen — this row carries no assistant prose
|
||||
# at all, only the cut-off notice for the model.
|
||||
entry["display_kind"] = "hidden"
|
||||
messages.append(entry)
|
||||
messages.append({"role": "user", "content": text})
|
||||
|
||||
agent._current_streamed_assistant_text = ""
|
||||
agent._current_streamed_reasoning_text = ""
|
||||
agent._stream_needs_break = True
|
||||
|
||||
|
||||
|
|
@ -444,30 +481,13 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
|
|||
# first turn — flip-flopping the wire shape mid-conversation and
|
||||
# silently degrading to the legacy single-breakpoint layout.
|
||||
#
|
||||
# Safety: the rebuilt stable tier is used ONLY when the restored
|
||||
# prompt literally starts with it (checked here AND re-checked by
|
||||
# ``_apply_system_cache_markers``'s ``startswith`` gate). If any
|
||||
# stable-tier input changed since the prompt was persisted (skills
|
||||
# edited, identity changed), the prefix mismatches, ``_static``
|
||||
# stays None, and the request falls back to the legacy layout with
|
||||
# the restored prompt bytes untouched — never a rewritten prompt.
|
||||
#
|
||||
# Gated on ``_use_prompt_caching`` so non-Anthropic routes skip the
|
||||
# rebuild entirely (the static prefix is only consumed by
|
||||
# ``apply_anthropic_cache_control``).
|
||||
if getattr(agent, "_use_prompt_caching", False):
|
||||
try:
|
||||
from agent.system_prompt import build_system_prompt_parts as _build_parts
|
||||
# ``reconstruct_static_prefix`` gates on ``_use_prompt_caching`` (so
|
||||
# non-Anthropic routes skip the rebuild), applies the startswith
|
||||
# safety gate (stored prompt bytes are never rewritten), and
|
||||
# fails open to the legacy cache layout.
|
||||
from agent.system_prompt import reconstruct_static_prefix
|
||||
|
||||
_static = _build_parts(agent, system_message=system_message)["stable"]
|
||||
if _static and stored_prompt.startswith(_static):
|
||||
agent._cached_system_prompt_static = _static
|
||||
except Exception:
|
||||
# Fail-open: restore continues with the legacy cache layout.
|
||||
logger.debug(
|
||||
"static system-prefix reconstruction failed on restore",
|
||||
exc_info=True,
|
||||
)
|
||||
reconstruct_static_prefix(agent, system_message=system_message)
|
||||
return
|
||||
if stored_prompt:
|
||||
stored_state = "stale_runtime"
|
||||
|
|
@ -774,6 +794,41 @@ def _compression_deferred_result(
|
|||
}
|
||||
|
||||
|
||||
def _rewrite_system_content_blocks(system_message: dict, effective: str) -> bool:
|
||||
"""Rewrite a cache-decorated system message in place, keeping its blocks.
|
||||
|
||||
``apply_anthropic_cache_control`` runs once per call block, *before* the
|
||||
retry loop, and splits the system prompt into ``[static prefix, volatile
|
||||
tail]`` text blocks carrying the cache_control breakpoints. Assigning a bare
|
||||
string over that list drops both breakpoints, so the failover retry ships
|
||||
the whole system prompt uncached and re-bills it in full.
|
||||
|
||||
``rewrite_prompt_model_identity`` only touches the LAST ``Model:`` /
|
||||
``Provider:`` lines, and those live in the volatile tail — so the static
|
||||
prefix stays byte-identical and its cache entry keeps matching. Returns
|
||||
False when the shape is not one we can safely patch, so the caller falls
|
||||
back to the plain-string assignment.
|
||||
"""
|
||||
content = system_message.get("content")
|
||||
if not isinstance(content, list) or not content:
|
||||
return False
|
||||
if not all(
|
||||
isinstance(part, dict) and part.get("type") == "text" for part in content
|
||||
):
|
||||
return False
|
||||
if len(content) == 1:
|
||||
content[0]["text"] = effective
|
||||
return True
|
||||
if len(content) == 2:
|
||||
head = content[0].get("text") or ""
|
||||
if head and effective.startswith(head):
|
||||
tail = effective[len(head):]
|
||||
if tail:
|
||||
content[1]["text"] = tail
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _sync_failover_system_message(agent, api_messages, active_system_prompt):
|
||||
"""Refresh the in-flight system message after a provider failover.
|
||||
|
||||
|
|
@ -796,10 +851,109 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt):
|
|||
effective = sp
|
||||
if agent.ephemeral_system_prompt:
|
||||
effective = (effective + "\n\n" + agent.ephemeral_system_prompt).strip()
|
||||
api_messages[0]["content"] = effective
|
||||
if not _rewrite_system_content_blocks(api_messages[0], effective):
|
||||
api_messages[0]["content"] = effective
|
||||
return sp
|
||||
|
||||
|
||||
def _ensure_cached_system_prompt_static(agent, system_message=None) -> None:
|
||||
"""Rebuild ``_cached_system_prompt_static`` when caching becomes active.
|
||||
|
||||
Sessions restored under a cache-off primary skip the static-prefix rebuild
|
||||
(gated on ``_use_prompt_caching`` at restore time). A later failover to a
|
||||
cache-on provider would otherwise redecorate with ``static_system_prefix=
|
||||
None`` and silently fall back to the legacy system-plus-3 layout (#72626).
|
||||
|
||||
Thin wrapper over :func:`agent.system_prompt.reconstruct_static_prefix`,
|
||||
which memoizes failed rebuilds so this stays cheap on the retry-loop hot
|
||||
path (it runs at the top of every attempt).
|
||||
"""
|
||||
from agent.system_prompt import reconstruct_static_prefix
|
||||
|
||||
reconstruct_static_prefix(
|
||||
agent, system_message=system_message, log_label="failover redecoration"
|
||||
)
|
||||
|
||||
|
||||
def _peel_moa_guidance(
|
||||
messages: List[Dict[str, Any]],
|
||||
guidance: Any,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Remove MoA reference guidance previously attached by ``_attach_reference_guidance``.
|
||||
|
||||
Thin wrapper over :func:`agent.moa_loop.peel_reference_guidance` (kept
|
||||
adjacent to the attach so the forward/inverse shapes evolve together).
|
||||
Lazy import mirrors the module's other moa_loop touchpoints.
|
||||
"""
|
||||
from agent.moa_loop import peel_reference_guidance
|
||||
|
||||
return peel_reference_guidance(messages, guidance)
|
||||
|
||||
|
||||
def _redecorate_prompt_cache_for_provider(
|
||||
agent,
|
||||
api_messages: List[Dict[str, Any]],
|
||||
*,
|
||||
system_message=None,
|
||||
moa_prepared: Optional[Dict[str, Any]] = None,
|
||||
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
"""Strip and re-apply cache_control for the *current* provider policy.
|
||||
|
||||
Decoration runs once per call block before the retry loop for the primary
|
||||
provider. ``try_activate_fallback`` refreshes ``_use_prompt_caching`` /
|
||||
``_use_native_cache_layout`` but the nine failover ``continue`` paths reused
|
||||
the old ``api_messages`` (#72626). Mirror ``_reapply_reasoning_echo_for_provider``
|
||||
by reshaping at the top of each retry attempt.
|
||||
|
||||
The source list is the mutated in-flight request (image shrink / ASCII /
|
||||
reasoning_details recoveries already applied) — never a pristine
|
||||
pre-decoration snapshot. MoA guidance is peeled, the base is redecorated,
|
||||
then ``rebase_prepared_request`` re-attaches guidance outside the cached
|
||||
span.
|
||||
"""
|
||||
messages: List[Dict[str, Any]] = [
|
||||
dict(m) if isinstance(m, dict) else m for m in (api_messages or [])
|
||||
]
|
||||
prepared = moa_prepared
|
||||
guidance = prepared.get("guidance") if isinstance(prepared, dict) else None
|
||||
if guidance:
|
||||
messages = _peel_moa_guidance(messages, guidance)
|
||||
|
||||
strip_anthropic_cache_control(messages)
|
||||
|
||||
# Direct attribute access matches the call-block decoration site — the
|
||||
# flags are unconditionally initialized on AIAgent, and a getattr
|
||||
# default here would mask a real init bug as silent cache-off.
|
||||
if agent._use_prompt_caching:
|
||||
_ensure_cached_system_prompt_static(agent, system_message=system_message)
|
||||
static = getattr(agent, "_cached_system_prompt_static", None)
|
||||
messages = apply_anthropic_cache_control(
|
||||
messages,
|
||||
cache_ttl=agent._cache_ttl,
|
||||
native_anthropic=agent._use_native_cache_layout,
|
||||
static_system_prefix=static if isinstance(static, str) else None,
|
||||
)
|
||||
|
||||
if (
|
||||
prepared is not None
|
||||
and getattr(agent, "provider", None) == "moa"
|
||||
):
|
||||
# No `and guidance` here: guidance=None is a real prepared shape
|
||||
# (all-references-failed / silent degraded policy builds the
|
||||
# prepared request without attaching guidance), and the MoA facade
|
||||
# sends prepared["messages"] — not api_kwargs["messages"] — so the
|
||||
# rebase must refresh the prepared object even when there is no
|
||||
# guidance to re-attach. rebase_prepared_request handles falsy
|
||||
# guidance by copying the messages and skipping the attach.
|
||||
completions = getattr(getattr(agent.client, "chat", None), "completions", None)
|
||||
rebase = getattr(completions, "rebase_prepared_request", None)
|
||||
if callable(rebase):
|
||||
prepared = rebase(prepared, messages)
|
||||
messages = prepared["messages"]
|
||||
|
||||
return messages, prepared
|
||||
|
||||
|
||||
def _apply_context_engine_selection(
|
||||
agent: Any,
|
||||
api_messages: List[Dict[str, Any]],
|
||||
|
|
@ -939,6 +1093,8 @@ def run_conversation(
|
|||
stream_callback: Optional[callable] = None,
|
||||
persist_user_message: Optional[Any] = None,
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
persist_user_display_kind: Optional[str] = None,
|
||||
persist_user_display_metadata: Optional[Dict[str, Any]] = None,
|
||||
moa_config: Optional[dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -957,6 +1113,13 @@ def run_conversation(
|
|||
synthetic prefixes.
|
||||
persist_user_timestamp: Optional platform event timestamp to store
|
||||
as metadata on that persisted user message.
|
||||
persist_user_display_kind: Optional presentation type for a
|
||||
synthesized user turn (``auto_continue``, ``model_switch``, …).
|
||||
Display-only: transcript surfaces render the row as a timeline
|
||||
event instead of a user bubble, while the model still receives
|
||||
the message unchanged.
|
||||
persist_user_display_metadata: Optional payload for that event
|
||||
(e.g. a delegation's task count).
|
||||
or queuing follow-up prefetch work.
|
||||
|
||||
Returns:
|
||||
|
|
@ -999,6 +1162,8 @@ def run_conversation(
|
|||
stream_callback,
|
||||
persist_user_message,
|
||||
persist_user_timestamp,
|
||||
persist_user_display_kind=persist_user_display_kind,
|
||||
persist_user_display_metadata=persist_user_display_metadata,
|
||||
restore_or_build_system_prompt=_restore_or_build_system_prompt,
|
||||
install_safe_stdio=_install_safe_stdio,
|
||||
sanitize_surrogates=_sanitize_surrogates,
|
||||
|
|
@ -1025,6 +1190,9 @@ def run_conversation(
|
|||
# Commentary deduplication spans all provider continuations and tool calls
|
||||
# within one user turn, but must not suppress the same phrase next turn.
|
||||
agent._delivered_interim_texts = set()
|
||||
# A configured SessionDB append failure halts only the affected turn. A
|
||||
# cached gateway agent must recover on the next message if storage did.
|
||||
agent._incremental_persistence_failed = False
|
||||
|
||||
# Main conversation loop counters (pure locals consumed by the loop below).
|
||||
api_call_count = 0
|
||||
|
|
@ -1507,6 +1675,15 @@ def run_conversation(
|
|||
# the OpenAI SDK. Sanitizing here prevents the 3-retry cycle.
|
||||
_sanitize_messages_surrogates(api_messages)
|
||||
|
||||
# NOTE (empty-content class fix): no send-time pad loop here. The
|
||||
# single owner for "never send a turn strict wire validation rejects
|
||||
# as empty" is ``repair_empty_non_final_messages``, which runs inside
|
||||
# ``_sanitize_api_messages`` above — the unconditional pre-send
|
||||
# chokepoint shared with the summary path. Its placeholder is
|
||||
# non-whitespace, so it survives the whitespace-normalization pass
|
||||
# regardless of ordering (a single-space pad here previously had to
|
||||
# be sequenced after normalization to survive, forking the concept).
|
||||
|
||||
# Apply Anthropic prompt caching for Claude models on native
|
||||
# Anthropic, OpenRouter, and third-party Anthropic-compatible
|
||||
# gateways. Auto-detected: if ``_use_prompt_caching`` is set, inject
|
||||
|
|
@ -1870,6 +2047,18 @@ def run_conversation(
|
|||
# unless the active provider needs it) so the fallback request
|
||||
# isn't sent with stale, primary-shaped reasoning fields.
|
||||
agent._reapply_reasoning_echo_for_provider(api_messages)
|
||||
# Same story for prompt-cache decoration (#72626): try_activate_
|
||||
# fallback refreshes the policy flags, but the decorated list
|
||||
# still carries the primary's breakpoints (or none). Strip and
|
||||
# re-render for the current provider before building kwargs.
|
||||
api_messages, _moa_prepared_request = (
|
||||
_redecorate_prompt_cache_for_provider(
|
||||
agent,
|
||||
api_messages,
|
||||
system_message=system_message,
|
||||
moa_prepared=_moa_prepared_request,
|
||||
)
|
||||
)
|
||||
api_kwargs = agent._build_api_kwargs(api_messages)
|
||||
if agent._force_ascii_payload:
|
||||
_sanitize_structure_non_ascii(api_kwargs)
|
||||
|
|
@ -2335,6 +2524,17 @@ def run_conversation(
|
|||
_backoff_touch_counter = 0
|
||||
while time.time() < sleep_end:
|
||||
if agent._interrupt_requested:
|
||||
# A redirect uses the interrupt machinery to cancel
|
||||
# only the live request. Aborting the retry here
|
||||
# with clear_interrupt() would DESTROY the pending
|
||||
# correction and kill the turn with "Operation
|
||||
# interrupted" — the exact mid-stream steer loss
|
||||
# users hit when a redirect lands during provider
|
||||
# backoff. Rebuild from the correction instead,
|
||||
# mirroring the InterruptedError handler.
|
||||
if agent.clear_interrupt(preserve_redirect=True):
|
||||
_retry.restart_with_redirected_messages = True
|
||||
break
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
|
||||
_interrupt_text = f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries})."
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
|
|
@ -2356,6 +2556,8 @@ def run_conversation(
|
|||
f"retry backoff ({retry_count}/{max_retries}), "
|
||||
f"{int(sleep_end - time.time())}s remaining"
|
||||
)
|
||||
if _retry.restart_with_redirected_messages:
|
||||
break # rebuild this iteration from the correction
|
||||
continue # Retry the API call
|
||||
|
||||
agent._turn_received_provider_response = True
|
||||
|
|
@ -2661,10 +2863,27 @@ def run_conversation(
|
|||
)
|
||||
if assistant_message is not None and not _trunc_has_tool_calls:
|
||||
length_continue_retries += 1
|
||||
interim_msg = agent._build_assistant_message(assistant_message, finish_reason)
|
||||
messages.append(interim_msg)
|
||||
if assistant_message.content:
|
||||
truncated_response_parts.append(assistant_message.content)
|
||||
# An EMPTY partial-stream stub (stream dropped
|
||||
# mid tool-call before any text was delivered)
|
||||
# must not be appended as an interim assistant
|
||||
# message: it would serialize as
|
||||
# {"role": "assistant", "content": ""}, and
|
||||
# strict providers (Moonshot/Kimi via OpenRouter)
|
||||
# reject empty assistant content with HTTP 400
|
||||
# ("message ... with role 'assistant' must not be
|
||||
# empty") on the very next replay — permanently
|
||||
# poisoning the session history. There is no
|
||||
# partial text to continue from anyway, so only
|
||||
# the continuation user-message is appended.
|
||||
_is_empty_partial_stub = (
|
||||
getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID
|
||||
and not getattr(assistant_message, "content", None)
|
||||
)
|
||||
if not _is_empty_partial_stub:
|
||||
interim_msg = agent._build_assistant_message(assistant_message, finish_reason)
|
||||
messages.append(interim_msg)
|
||||
if assistant_message.content:
|
||||
truncated_response_parts.append(assistant_message.content)
|
||||
|
||||
if length_continue_retries < 4:
|
||||
_is_partial_stream_stub = (
|
||||
|
|
@ -3559,7 +3778,7 @@ def run_conversation(
|
|||
agent._buffer_vprint("🔐 Vertex AI token refreshed after 401. Retrying request...")
|
||||
continue
|
||||
if (
|
||||
agent.api_mode == "chat_completions"
|
||||
agent.api_mode in ("chat_completions", "anthropic_messages")
|
||||
and agent.provider == "nous"
|
||||
and status_code == 401
|
||||
and not _retry.nous_auth_retry_attempted
|
||||
|
|
@ -3831,6 +4050,12 @@ def run_conversation(
|
|||
|
||||
# Check for interrupt before deciding to retry
|
||||
if agent._interrupt_requested:
|
||||
# Preserve a pending redirect (mid-stream correction): the
|
||||
# user is steering, not stopping. Rebuild the turn from the
|
||||
# correction instead of aborting with a dead-end interrupt.
|
||||
if agent.clear_interrupt(preserve_redirect=True):
|
||||
_retry.restart_with_redirected_messages = True
|
||||
break
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True)
|
||||
_interrupt_text = f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))})."
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
|
|
@ -5067,6 +5292,12 @@ def run_conversation(
|
|||
_backoff_touch_counter = 0
|
||||
while time.time() < sleep_end:
|
||||
if agent._interrupt_requested:
|
||||
# Same preserve-redirect rule as the retry-wait above:
|
||||
# a steering correction must survive backoff, not die
|
||||
# as "Operation interrupted".
|
||||
if agent.clear_interrupt(preserve_redirect=True):
|
||||
_retry.restart_with_redirected_messages = True
|
||||
break
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
|
||||
_interrupt_text = f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries})."
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
|
|
@ -5088,6 +5319,11 @@ def run_conversation(
|
|||
f"error retry backoff ({retry_count}/{max_retries}), "
|
||||
f"{int(sleep_end - time.time())}s remaining"
|
||||
)
|
||||
if _retry.restart_with_redirected_messages:
|
||||
# Leave the retry loop — the check right below rebuilds this
|
||||
# iteration from the correction instead of re-firing the
|
||||
# stale request.
|
||||
break
|
||||
|
||||
if _retry.restart_with_redirected_messages:
|
||||
# The cancelled request produced no valid assistant item. Reuse the
|
||||
|
|
@ -5433,6 +5669,14 @@ def run_conversation(
|
|||
args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200]
|
||||
logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview)
|
||||
|
||||
# Uniquify duplicate tool-call ids BEFORE any downstream
|
||||
# consumer (validation error paths, dispatch, history build,
|
||||
# Responses item-id derivation). Models that reuse one id for
|
||||
# different calls in a batch otherwise lose the later call's
|
||||
# result: the pre-API sanitizer keeps only the first
|
||||
# call/result pair per id. See _uniquify_tool_call_ids.
|
||||
agent._uniquify_tool_call_ids(assistant_message.tool_calls)
|
||||
|
||||
# Validate tool call names - detect model hallucinations
|
||||
# Repair mismatched tool names before validating
|
||||
for tc in assistant_message.tool_calls:
|
||||
|
|
@ -5741,8 +5985,6 @@ def run_conversation(
|
|||
and previous_interim_visible == current_interim_visible
|
||||
)
|
||||
messages.append(assistant_msg)
|
||||
if not duplicate_previous_interim:
|
||||
agent._emit_interim_assistant_message(assistant_msg)
|
||||
|
||||
# Mixed batch: error-result the invalid calls and strip them
|
||||
# from the execution set. The assistant message above keeps
|
||||
|
|
@ -5764,13 +6006,17 @@ def run_conversation(
|
|||
if tc.function.name in agent.valid_tool_names
|
||||
]
|
||||
|
||||
_tool_turn_persisted = None
|
||||
try:
|
||||
# Persist the assistant tool-call turn before any tool
|
||||
# side effects run. If a destructive tool restarts or
|
||||
# terminates Hermes mid-turn, resume logic still sees the
|
||||
# exact tool-call block that already executed.
|
||||
agent._flush_messages_to_session_db(messages, conversation_history)
|
||||
_tool_turn_persisted = agent._flush_messages_to_session_db(
|
||||
messages, conversation_history
|
||||
)
|
||||
except Exception as exc:
|
||||
_tool_turn_persisted = False
|
||||
logger.warning(
|
||||
"Incremental tool-call persistence failed before execution "
|
||||
"(session=%s): %s",
|
||||
|
|
@ -5778,6 +6024,22 @@ def run_conversation(
|
|||
exc,
|
||||
)
|
||||
|
||||
if _tool_turn_persisted is False:
|
||||
# The canonical append failed. Do not project the row or
|
||||
# run side-effecting tools from state that exists only in
|
||||
# this process. Breaking also avoids retrying the same
|
||||
# unpersisted turn until the iteration budget is exhausted.
|
||||
_turn_exit_reason = "session_persistence_failed"
|
||||
final_response = ""
|
||||
failed = True
|
||||
break
|
||||
|
||||
# A UI must never observe an assistant/tool-call row that is
|
||||
# still only an ephemeral in-memory projection. Emit interim
|
||||
# commentary only after the canonical SessionDB append above.
|
||||
if not duplicate_previous_interim:
|
||||
agent._emit_interim_assistant_message(assistant_msg)
|
||||
|
||||
# Close any open streaming display (response box, reasoning
|
||||
# box) before tool execution begins. Intermediate turns may
|
||||
# have streamed early content that opened the response box;
|
||||
|
|
@ -5792,6 +6054,15 @@ def run_conversation(
|
|||
|
||||
agent._execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count)
|
||||
|
||||
if getattr(agent, "_incremental_persistence_failed", False):
|
||||
# A tool result could not be made canonical. Do not send
|
||||
# the in-memory result back to the model or project any
|
||||
# later events from this turn.
|
||||
_turn_exit_reason = "session_persistence_failed"
|
||||
final_response = ""
|
||||
failed = True
|
||||
break
|
||||
|
||||
if agent._tool_guardrail_halt_decision is not None:
|
||||
decision = agent._tool_guardrail_halt_decision
|
||||
_turn_exit_reason = "guardrail_halt"
|
||||
|
|
@ -6264,7 +6535,28 @@ def run_conversation(
|
|||
". No fallback providers configured.")
|
||||
)
|
||||
|
||||
final_response = "(empty)"
|
||||
# Deliver a labeled reasoning excerpt instead of a bare
|
||||
# "(empty)" when the model DID think but never produced
|
||||
# visible text. This is delivery-only: the persisted
|
||||
# assistant message above keeps the "(empty)" sentinel
|
||||
# (its replay semantics prevent empty-response loops),
|
||||
# and raw chain-of-thought is never promoted to a normal
|
||||
# answer earlier in the ladder — prefill continuation,
|
||||
# empty-content retries, and provider fallback all run
|
||||
# first. Only at this terminal, where the alternative is
|
||||
# returning nothing, is showing the model's own reasoning
|
||||
# (clearly labeled as such) strictly more useful.
|
||||
# Idea credit: PR #48795 (@ligl0325).
|
||||
if reasoning_text:
|
||||
final_response = (
|
||||
"⚠️ The model produced only internal reasoning and "
|
||||
"no final answer, despite retries"
|
||||
+ (" and fallback" if agent._fallback_chain else "")
|
||||
+ ". Its last reasoning, which may contain the "
|
||||
"answer:\n\n" + reasoning_preview
|
||||
)
|
||||
else:
|
||||
final_response = "(empty)"
|
||||
break
|
||||
|
||||
# Reset retry counter/signature on successful content
|
||||
|
|
|
|||
|
|
@ -159,6 +159,14 @@ _RATE_LIMIT_PATTERNS = [
|
|||
"throttlingexception",
|
||||
"too many concurrent requests",
|
||||
"servicequotaexceededexception",
|
||||
# Generic throttle prefix — Bedrock (and some proxies) surface throttling
|
||||
# as "Throttling error: Too many tokens, please wait before trying
|
||||
# again." Without this entry the message falls through to the
|
||||
# context-overflow list (which contains "too many tokens") and the retry
|
||||
# loop compresses a healthy session instead of backing off. Matched
|
||||
# BEFORE _CONTEXT_OVERFLOW_PATTERNS in the message-only path, so the
|
||||
# throttle wins. (port of anomalyco/opencode#37848's exclusion guard)
|
||||
"throttling",
|
||||
]
|
||||
|
||||
# Patterns that indicate provider-side overload, NOT a per-credential rate
|
||||
|
|
@ -212,6 +220,12 @@ _PAYLOAD_TOO_LARGE_PATTERNS = [
|
|||
"request entity too large",
|
||||
"payload too large",
|
||||
"error code: 413",
|
||||
# Anthropic's structured 413 error type. Normally arrives with an HTTP
|
||||
# 413 status (handled by the status path), but aggregators/proxies can
|
||||
# re-wrap it into a plain message with no status attribute — route it to
|
||||
# the same compression recovery. (port of anomalyco/opencode#37848)
|
||||
"request_too_large",
|
||||
"request exceeds the maximum size",
|
||||
]
|
||||
|
||||
# Image-size patterns. Matched against 400 bodies (not 413) because most
|
||||
|
|
@ -298,6 +312,10 @@ _CONTEXT_OVERFLOW_PATTERNS = [
|
|||
"max input token",
|
||||
"input token",
|
||||
"exceeds the maximum number of input tokens",
|
||||
# Together/Fireworks-style: "Input length 131393 exceeds the maximum
|
||||
# allowed input length of 131040 tokens." No other pattern in this list
|
||||
# matches that wording. (port of anomalyco/opencode#37848)
|
||||
"maximum allowed input length",
|
||||
]
|
||||
|
||||
# Model not found patterns
|
||||
|
|
@ -321,6 +339,30 @@ _MODEL_NOT_FOUND_PATTERNS = [
|
|||
"no endpoints found that support tool use",
|
||||
]
|
||||
|
||||
# Malformed-message-array 400s. Deterministic request-shape rejections that
|
||||
# describe the *transcript* being invalid, not a parameter. The canonical
|
||||
# case: a stream dies mid-response and Hermes persists a content-less
|
||||
# assistant stub; on the next turn the Anthropic message schema (and the
|
||||
# litellm/Bedrock proxies in front of it) reject the whole request with
|
||||
# "all messages must have non-empty content except for the optional final
|
||||
# assistant message" / errorCode INVALID_REQUEST_BODY
|
||||
# These are NOT context overflow — the input may be tiny — but a large
|
||||
# session used to mis-route them into the compression loop via the generic
|
||||
# "400 + large session" heuristic below, ending in "Cannot compress further"
|
||||
# every retry (the input is unchanged, so compression cannot help). Match
|
||||
# the message-shape signals explicitly and fail fast as a format_error so the
|
||||
# loop stops looping. The empty-stub creation is the root cause (fixed in
|
||||
# chat_completion_helpers); this pattern stops the misclassification symptom
|
||||
# for transcripts that already contain a poisoned stub.
|
||||
_INVALID_MESSAGE_BODY_PATTERNS = [
|
||||
"must have non-empty content",
|
||||
"messages must have non-empty",
|
||||
"invalid_request_body",
|
||||
"text content blocks must be non-empty",
|
||||
"content field is required",
|
||||
"messages: at least one message is required",
|
||||
]
|
||||
|
||||
# Request-validation patterns — the request is malformed and will fail
|
||||
# identically on every retry. Some OpenAI-compatible gateways (notably
|
||||
# codex.nekos.me) return these as 5xx instead of the standard 4xx, which
|
||||
|
|
@ -1271,6 +1313,33 @@ def _classify_400(
|
|||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Malformed message array (empty-content assistant stub, etc.). Must be
|
||||
# checked BEFORE context_overflow: the input can be tiny, so the generic
|
||||
# "400 + large session" heuristic would otherwise mis-route it into the
|
||||
# compression loop and thrash until "Cannot compress further" on every
|
||||
# retry (the request is unchanged, so compression cannot fix it). This is
|
||||
# a deterministic request-shape rejection — fail fast as a non-retryable
|
||||
# format_error and fall back. Checked against the message text AND the
|
||||
# structured error code, since proxies (litellm/Bedrock) surface the
|
||||
# signal in errorCode=INVALID_REQUEST_BODY.
|
||||
if (
|
||||
any(p in error_msg for p in _INVALID_MESSAGE_BODY_PATTERNS)
|
||||
or error_code_lower == "invalid_request_body"
|
||||
):
|
||||
logger.warning(
|
||||
"Malformed message array 400 (invalid request body) classified as "
|
||||
"format_error, NOT context overflow — failing fast + falling back "
|
||||
"instead of entering the compression loop. This usually means an "
|
||||
"empty-content assistant stub is in the transcript; num_messages=%s "
|
||||
"approx_tokens=%s. error=%.200s",
|
||||
num_messages, approx_tokens, error_msg,
|
||||
)
|
||||
return result_fn(
|
||||
FailoverReason.format_error,
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Empty-provider-response advisories must not enter compression. They
|
||||
# often mention "max_tokens" as a possible cause and used to match the
|
||||
# bare overflow pattern, then thrash compress until "Cannot compress
|
||||
|
|
@ -1331,6 +1400,18 @@ def _classify_400(
|
|||
# Responses API (and some providers) use flat body: {"message": "..."}
|
||||
if not err_body_msg:
|
||||
err_body_msg = str(body.get("message") or "").strip().lower()
|
||||
# litellm / Bedrock proxies use a custom shape: {"errorMessage": "...",
|
||||
# "errorCode": "...", "errorArgs": {"reason": "..."}}. Without these
|
||||
# keys err_body_msg stays "" and a long, descriptive rejection is
|
||||
# wrongly treated as a "generic" (bare) error below, which — on a
|
||||
# large session — mis-routes into the compression loop. Recognize
|
||||
# them so the is_generic heuristic sees the real message length.
|
||||
if not err_body_msg:
|
||||
err_body_msg = str(body.get("errorMessage") or "").strip().lower()
|
||||
if not err_body_msg:
|
||||
_args = body.get("errorArgs")
|
||||
if isinstance(_args, dict):
|
||||
err_body_msg = str(_args.get("reason") or "").strip().lower()
|
||||
is_generic = len(err_body_msg) < 30 or err_body_msg in {"error", ""}
|
||||
# Absolute token/message-count thresholds are only a proxy for smaller
|
||||
# context windows. Large-context sessions can have many messages while
|
||||
|
|
@ -1629,7 +1710,7 @@ def _extract_error_code(body: dict) -> str:
|
|||
return nested_code
|
||||
|
||||
# Top-level code
|
||||
code = body.get("code") or body.get("error_code") or ""
|
||||
code = body.get("code") or body.get("error_code") or body.get("errorCode") or ""
|
||||
if isinstance(code, (str, int)):
|
||||
text = str(code).strip()
|
||||
if text and text != "400":
|
||||
|
|
@ -1649,6 +1730,16 @@ def _extract_message(error: Exception, body: dict) -> str:
|
|||
msg = body.get("message", "")
|
||||
if isinstance(msg, str) and msg.strip():
|
||||
return msg.strip()[:500]
|
||||
# litellm / Bedrock proxy shape: {"errorMessage": "...",
|
||||
# "errorArgs": {"reason": "..."}}.
|
||||
msg = body.get("errorMessage", "")
|
||||
if isinstance(msg, str) and msg.strip():
|
||||
return msg.strip()[:500]
|
||||
args = body.get("errorArgs")
|
||||
if isinstance(args, dict):
|
||||
reason = args.get("reason", "")
|
||||
if isinstance(reason, str) and reason.strip():
|
||||
return reason.strip()[:500]
|
||||
# Fallback to str(error)
|
||||
return str(error)[:500]
|
||||
|
||||
|
|
|
|||
|
|
@ -163,6 +163,42 @@ _FREE_TIER_GUIDANCE = (
|
|||
)
|
||||
|
||||
|
||||
def is_standard_key_auth_error(
|
||||
status: int, error_message: str, reason: str = ""
|
||||
) -> bool:
|
||||
"""Return True when a Gemini 401 indicates Google rejected the key TYPE.
|
||||
|
||||
Google began rejecting unrestricted legacy "Standard" Google Cloud API
|
||||
keys on the Gemini API on June 19, 2026, and ALL Standard keys stop
|
||||
working in September 2026. The rejection surfaces as a misleading 401
|
||||
telling the user to supply an OAuth 2 access token ("Request had invalid
|
||||
authentication credentials. Expected OAuth 2 access token, login cookie
|
||||
or other valid authentication credential."), optionally carrying
|
||||
``google.rpc.ErrorInfo`` reason ``ACCESS_TOKEN_TYPE_UNSUPPORTED``.
|
||||
|
||||
Scoped narrowly so a plain bad key (reason ``API_KEY_INVALID``,
|
||||
"API key not valid") keeps its existing message.
|
||||
"""
|
||||
if status != 401:
|
||||
return False
|
||||
if reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED":
|
||||
return True
|
||||
return "expected oauth 2 access token" in (error_message or "").lower()
|
||||
|
||||
|
||||
_STANDARD_KEY_GUIDANCE = (
|
||||
"\n\nGoogle Gemini rejected this API key's type — you do NOT need OAuth. "
|
||||
"Google began rejecting legacy 'Standard' Google Cloud keys for the "
|
||||
"Gemini API on June 19, 2026, and all Standard keys stop working in "
|
||||
"September 2026. Open https://aistudio.google.com/api-keys, check the "
|
||||
"key's type and status, and create a replacement Gemini API key (or, as "
|
||||
"a temporary bridge, restrict the Standard key to "
|
||||
"generativelanguage.googleapis.com). Then update GEMINI_API_KEY / "
|
||||
"GOOGLE_API_KEY in ~/.hermes/.env and restart your session. "
|
||||
"Details: https://ai.google.dev/gemini-api/docs/api-key"
|
||||
)
|
||||
|
||||
|
||||
class GeminiAPIError(Exception):
|
||||
"""Error shape compatible with Hermes retry/error classification."""
|
||||
|
||||
|
|
@ -824,6 +860,12 @@ def gemini_http_error(
|
|||
if status == 429 and is_free_tier_quota_error(err_message or body_text):
|
||||
message = message + _FREE_TIER_GUIDANCE
|
||||
|
||||
# Legacy "Standard" Google Cloud key rejection (June 19, 2026 onward) ->
|
||||
# Google's raw 401 misleadingly tells the user to use OAuth. Append the
|
||||
# actual fix (mint a new Gemini API key in AI Studio).
|
||||
if is_standard_key_auth_error(status, err_message or body_text, reason):
|
||||
message = message + _STANDARD_KEY_GUIDANCE
|
||||
|
||||
return GeminiAPIError(
|
||||
message,
|
||||
code=code,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Dict
|
||||
|
||||
# Gemini's ``FunctionDeclaration.parameters`` field accepts the ``Schema``
|
||||
|
|
@ -76,15 +77,31 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
|
|||
|
||||
# Gemini's Schema validator requires every ``enum`` entry to be a string,
|
||||
# even when the parent ``type`` is ``integer`` / ``number`` / ``boolean``.
|
||||
# OpenAI / OpenRouter / Anthropic accept typed enums (e.g. Discord's
|
||||
# ``auto_archive_duration: {type: integer, enum: [60, 1440, 4320, 10080]}``),
|
||||
# so we only drop the ``enum`` when it would collide with Gemini's rule.
|
||||
# Keeping ``type: integer`` plus the human-readable description gives the
|
||||
# model enough guidance; the tool handler still validates the value.
|
||||
# Preserve those constraints by stringifying scalar values while keeping
|
||||
# the declared type intact; Gemini uses the strings as schema metadata and
|
||||
# still emits typed tool arguments at runtime.
|
||||
enum_val = cleaned.get("enum")
|
||||
type_val = cleaned.get("type")
|
||||
if isinstance(enum_val, list) and type_val in {"integer", "number", "boolean"}:
|
||||
if any(not isinstance(item, str) for item in enum_val):
|
||||
stringified = []
|
||||
for item in enum_val:
|
||||
if isinstance(item, str):
|
||||
value = item
|
||||
elif isinstance(item, bool):
|
||||
value = "true" if item else "false"
|
||||
elif (
|
||||
isinstance(item, (int, float))
|
||||
and not isinstance(item, bool)
|
||||
and math.isfinite(item)
|
||||
):
|
||||
value = str(item)
|
||||
else:
|
||||
continue
|
||||
if value not in stringified:
|
||||
stringified.append(value)
|
||||
if stringified:
|
||||
cleaned["enum"] = stringified
|
||||
else:
|
||||
cleaned.pop("enum", None)
|
||||
|
||||
# Gemini validates ``required`` strictly against the same node's
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Extracted from ``run_agent.py``. Each ``AIAgent`` instance (parent or
|
||||
subagent) holds an :class:`IterationBudget`; the parent's cap comes from
|
||||
``max_iterations`` (default 90), each subagent's cap comes from
|
||||
``max_iterations`` (default 500), each subagent's cap comes from
|
||||
``delegation.max_iterations`` (default 50).
|
||||
|
||||
``run_agent`` re-exports ``IterationBudget`` so existing
|
||||
|
|
@ -18,7 +18,7 @@ class IterationBudget:
|
|||
"""Thread-safe iteration counter for an agent.
|
||||
|
||||
Each agent (parent or subagent) gets its own ``IterationBudget``.
|
||||
The parent's budget is capped at ``max_iterations`` (default 90).
|
||||
The parent's budget is capped at ``max_iterations`` (default 500).
|
||||
Each subagent gets an independent budget capped at
|
||||
``delegation.max_iterations`` (default 50) — this means total
|
||||
iterations across parent + subagents can exceed the parent's cap.
|
||||
|
|
|
|||
|
|
@ -1337,6 +1337,63 @@ def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str
|
|||
agg_messages.append({"role": "user", "content": guidance})
|
||||
|
||||
|
||||
def peel_reference_guidance(
|
||||
messages: list[dict[str, Any]],
|
||||
guidance: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Remove reference guidance previously attached by ``_attach_reference_guidance``.
|
||||
|
||||
Exact inverse of the three attach shapes above (string merge, trailing
|
||||
text part, appended user message) — kept adjacent so the two evolve
|
||||
together; a drifting separator or shape would make the peel silently
|
||||
no-op and let a cache breakpoint land on the turn-varying guidance
|
||||
block (the bug class #72626 fixes).
|
||||
|
||||
Used by the failover redecoration chokepoint: redecoration must run on
|
||||
the base transcript so the last cache breakpoint does not land on the
|
||||
guidance; callers then rebase via ``rebase_prepared_request``.
|
||||
|
||||
Returns a new list (input list and its messages are not mutated).
|
||||
"""
|
||||
if not guidance or not messages:
|
||||
return messages
|
||||
guidance_text = str(guidance)
|
||||
last = messages[-1]
|
||||
if not isinstance(last, dict) or last.get("role") != "user":
|
||||
return messages
|
||||
content = last.get("content")
|
||||
if content == guidance_text:
|
||||
# Attach shape (c): guidance was appended as its own user message.
|
||||
return list(messages[:-1])
|
||||
suffix = "\n\n" + guidance_text
|
||||
if isinstance(content, str) and content.endswith(suffix):
|
||||
# Attach shape (a): merged into a trailing string user turn.
|
||||
peeled = dict(last)
|
||||
peeled["content"] = content[: -len(suffix)]
|
||||
return [*messages[:-1], peeled]
|
||||
if isinstance(content, list) and content:
|
||||
last_part = content[-1]
|
||||
if isinstance(last_part, dict) and last_part.get("type", "text") == "text":
|
||||
text = last_part.get("text") or ""
|
||||
if text == suffix or text == guidance_text:
|
||||
# Attach shape (b): guidance rode as its own trailing part.
|
||||
peeled = dict(last)
|
||||
peeled["content"] = list(content[:-1])
|
||||
if not peeled["content"]:
|
||||
# The guidance part was the only content — mirror the
|
||||
# string shape (c) and drop the whole message rather
|
||||
# than leaving an empty-content user turn behind.
|
||||
return list(messages[:-1])
|
||||
return [*messages[:-1], peeled]
|
||||
if text.endswith(suffix):
|
||||
new_part = dict(last_part)
|
||||
new_part["text"] = text[: -len(suffix)]
|
||||
peeled = dict(last)
|
||||
peeled["content"] = [*content[:-1], new_part]
|
||||
return [*messages[:-1], peeled]
|
||||
return messages
|
||||
|
||||
|
||||
class MoAChatCompletions:
|
||||
"""OpenAI-chat-compatible facade where the aggregator is the acting model."""
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,59 @@ def _apply_system_cache_markers(
|
|||
return 1
|
||||
|
||||
|
||||
def strip_anthropic_cache_control(
|
||||
api_messages: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Remove ``cache_control`` markers and undo decoration-produced list shapes.
|
||||
|
||||
Used before re-applying decoration after a mid-turn provider failover so
|
||||
the mutated, undecorated shape (image shrink / ASCII cleanup / etc.) is
|
||||
preserved while markers match the *new* provider's cache policy (#72626).
|
||||
|
||||
Flattening back to a plain string is restricted to the exact shapes
|
||||
:func:`apply_anthropic_cache_control` produces from string content —
|
||||
a single ``{"type": "text"}`` part, or the two-part ``[static, volatile]``
|
||||
system split — so the ``""``-join is provably byte-exact. Organic
|
||||
multi-part text (merged user turns, imported transcripts) and parts
|
||||
carrying extra keys (``citations`` etc.) keep their structure; only
|
||||
per-part markers are removed. Marker removal is copy-on-write on the
|
||||
part dicts: content parts may alias the persistent conversation history
|
||||
(the per-call copy is shallow), and stripping must never rewrite the
|
||||
stored transcript.
|
||||
|
||||
Mutates the top-level message dicts of ``api_messages`` in place and
|
||||
returns the same list.
|
||||
"""
|
||||
for msg in api_messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg.pop("cache_control", None)
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
if any(isinstance(part, dict) and "cache_control" in part for part in content):
|
||||
content = [
|
||||
{k: v for k, v in part.items() if k != "cache_control"}
|
||||
if isinstance(part, dict) and "cache_control" in part
|
||||
else part
|
||||
for part in content
|
||||
]
|
||||
msg["content"] = content
|
||||
decoration_shape = content and all(
|
||||
isinstance(part, dict)
|
||||
and part.get("type", "text") == "text"
|
||||
and isinstance(part.get("text"), str)
|
||||
and set(part.keys()) <= {"type", "text"}
|
||||
for part in content
|
||||
) and (
|
||||
len(content) == 1
|
||||
or (msg.get("role") == "system" and len(content) == 2)
|
||||
)
|
||||
if decoration_shape:
|
||||
msg["content"] = "".join(part["text"] for part in content)
|
||||
return api_messages
|
||||
|
||||
|
||||
def apply_anthropic_cache_control(
|
||||
api_messages: List[Dict[str, Any]],
|
||||
cache_ttl: str = "5m",
|
||||
|
|
|
|||
|
|
@ -106,6 +106,14 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
|
|||
("claude-sonnet-5", 180),
|
||||
("claude-sonnet-4.5", 180),
|
||||
("claude-sonnet-4.6", 180),
|
||||
# Anthropic Mythos-class named reasoning models (claude-fable-5, …).
|
||||
# 1M context + 128K output — heavier thinking phase than the
|
||||
# numbered Claude line, so the floor is in the deep-reasoning tier
|
||||
# alongside o1 / deepseek-r1 / nemotron-3-ultra. Without this
|
||||
# entry the stale-stream detector kills fable-5's thinking phase
|
||||
# at the default 180s (300s with context scaling), tripping the
|
||||
# cross-turn circuit breaker after 5 consecutive stale kills.
|
||||
("claude-fable", 600),
|
||||
# xAI Grok reasoning variants. Explicit reasoning-only keys
|
||||
# plus one for the ``non-reasoning`` variant so users picking
|
||||
# the fast variant don't get the 300s floor. Bare ``grok-3``,
|
||||
|
|
@ -207,6 +215,8 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]:
|
|||
300.0
|
||||
>>> get_reasoning_stale_timeout_floor("anthropic/claude-opus-4-6")
|
||||
240.0
|
||||
>>> get_reasoning_stale_timeout_floor("anthropic/claude-fable-5")
|
||||
600.0
|
||||
>>> get_reasoning_stale_timeout_floor("gpt-4o") is None
|
||||
True
|
||||
>>> get_reasoning_stale_timeout_floor("olmo-1") is None
|
||||
|
|
|
|||
|
|
@ -175,6 +175,85 @@ _YAML_ASSIGN_RE = re.compile(
|
|||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# Word-boundary validation for the mixed/lowercase key patterns above
|
||||
# (_CFG_DOTTED_RE, _CFG_ANCHORED_RE, _YAML_ASSIGN_RE).
|
||||
#
|
||||
# Those key classes allow arbitrary alphanumeric affixes around the secret
|
||||
# keyword so real key names like ``client_secret``, ``clientSecret``, and
|
||||
# ``s3.secret-key`` match. The side effect: ordinary prose/document words that
|
||||
# merely CONTAIN a keyword also matched — ``Secretary: J.Smith`` (secret),
|
||||
# ``tokenizer: cl100k_base`` (token), ``author=Smith`` (auth) — mangling
|
||||
# legitimate content on the surfaces that run these passes (browser snapshots,
|
||||
# log lines, kanban summaries, CLI-echoed command output). Ported from
|
||||
# nearai/ironclaw#6129, where the same substring false positive ("Secretary of
|
||||
# the Treasury" matching the ``secret`` marker) scrubbed legitimate tool
|
||||
# results from the replayed transcript and sent the model into a re-fetch
|
||||
# loop.
|
||||
#
|
||||
# A keyword occurrence only counts when it sits at a word boundary within the
|
||||
# key: at the key's edge, next to a non-letter (``_ - . 3``), or at a
|
||||
# camelCase transition (``clientSecret``, ``secretKey``, ``APIToken``). A
|
||||
# trailing plural ``s`` is treated as part of the keyword (``secrets:``,
|
||||
# ``tokens:``). Common concatenated compounds keep matching via explicit
|
||||
# alternatives (``authtoken`` ngrok, ``authkey`` tailscale, ``secretkey``
|
||||
# minio, ``apikey``). Embedded occurrences inside a larger word
|
||||
# (``secretary``, ``tokenizer``, ``authored``, ``credentialing``) no longer
|
||||
# match. ALL-CAPS keys keep the legacy embedded matching (``MYTOKEN=…``) — an
|
||||
# all-caps key is almost never prose, the same rationale as _ENV_ASSIGN_RE.
|
||||
_KEY_KEYWORD_RE = re.compile(
|
||||
r"(?:api|auth|access|refresh|session|secret)[ _.\-]?(?:key|token)"
|
||||
r"|token|secret|passwd|password|credential|auth",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_word_start(s: str, i: int) -> bool:
|
||||
"""True if position ``i`` in ``s`` begins a word (not mid-word)."""
|
||||
if i == 0:
|
||||
return True
|
||||
prev, cur = s[i - 1], s[i]
|
||||
if not prev.isalpha():
|
||||
return True
|
||||
if cur.isupper() and prev.islower():
|
||||
return True # camelCase: clientSecret
|
||||
# Acronym run ending: APIToken — the 'T' begins a new word when it is
|
||||
# followed by lowercase while the preceding run is uppercase.
|
||||
if cur.isupper() and prev.isupper() and i + 1 < len(s) and s[i + 1].islower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_word_end(s: str, j: int, *, allow_plural: bool = True) -> bool:
|
||||
"""True if position ``j`` (exclusive end) in ``s`` ends a word."""
|
||||
if j >= len(s):
|
||||
return True
|
||||
cur = s[j]
|
||||
if not cur.isalpha():
|
||||
return True
|
||||
if cur.isupper() and s[j - 1].islower():
|
||||
return True # camelCase continuation: secretKey
|
||||
if allow_plural and cur in "sS":
|
||||
return _is_word_end(s, j + 1, allow_plural=False)
|
||||
return False
|
||||
|
||||
|
||||
def _key_has_secret_keyword(key: str) -> bool:
|
||||
"""True if ``key`` contains a secret keyword at a word boundary.
|
||||
|
||||
Post-match validator for _CFG_DOTTED_RE / _CFG_ANCHORED_RE /
|
||||
_YAML_ASSIGN_RE hits — rejects prose words that merely embed a keyword
|
||||
(``secretary``, ``tokenizer``, ``authored``). Safe to call with the
|
||||
_ENV_ASSIGN_RE key too: all-caps keys short-circuit to the legacy
|
||||
embedded-match behavior.
|
||||
"""
|
||||
letters = [c for c in key if c.isalpha()]
|
||||
if letters and all(c.isupper() for c in letters):
|
||||
return True # legacy all-caps behavior (MYTOKEN=…)
|
||||
for m in _KEY_KEYWORD_RE.finditer(key):
|
||||
if _is_word_start(key, m.start()) and _is_word_end(key, m.end()):
|
||||
return True
|
||||
return False
|
||||
|
||||
# JSON field patterns: "apiKey": "value", "token": "value", etc.
|
||||
_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)"
|
||||
_JSON_FIELD_RE = re.compile(
|
||||
|
|
@ -614,6 +693,13 @@ def redact_sensitive_text(
|
|||
# prose/log contexts (issue #2852): ``KEY=os.getenv('X')``.
|
||||
if _ENV_LOOKUP_VALUE_RE.match(value):
|
||||
return m.group(0)
|
||||
# Keyword must sit at a word boundary within the key —
|
||||
# ``author=Smith`` / ``press.secretary=…`` are prose, not
|
||||
# credentials (ported from nearai/ironclaw#6129). All-caps
|
||||
# keys (the _ENV_ASSIGN_RE shape) short-circuit to legacy
|
||||
# embedded matching inside the helper.
|
||||
if not _key_has_secret_keyword(name):
|
||||
return m.group(0)
|
||||
return f"{name}={quote}{_mask_token(value)}{quote}"
|
||||
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
|
||||
# Lowercase/dotted config keys (issue #16413). Skip URLs entirely —
|
||||
|
|
@ -647,6 +733,11 @@ def redact_sensitive_text(
|
|||
# not a leaked secret value.
|
||||
if _ENV_LOOKUP_VALUE_RE.match(value):
|
||||
return m.group(0)
|
||||
# Keyword must sit at a word boundary within the key —
|
||||
# ``Secretary: J.Smith`` / ``tokenizer: cl100k_base`` are
|
||||
# document text, not credentials (nearai/ironclaw#6129).
|
||||
if not _key_has_secret_keyword(key):
|
||||
return m.group(0)
|
||||
return f"{key}{sep}{_mask_token(value)}"
|
||||
text = _YAML_ASSIGN_RE.sub(_redact_yaml, text)
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ emitted by each built-in hook site.
|
|||
child_role – role string of the child agent
|
||||
child_summary – summary of the child's work
|
||||
child_status – exit status string (e.g. "success", "error")
|
||||
tool_call_history – redacted tool name/input summary/byte counts/status list
|
||||
duration_ms – wall-clock time of the child run in milliseconds
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def describe_skill_invocation(content: Any) -> Optional[str]:
|
||||
def describe_skill_invocation(content: Any, separator: str = " — ") -> Optional[str]:
|
||||
"""Render a slash-skill-expanded turn the way the user typed it.
|
||||
|
||||
The expanded message embeds the whole skill body, so any surface that
|
||||
|
|
@ -109,6 +109,10 @@ def describe_skill_invocation(content: Any) -> Optional[str]:
|
|||
Returns ``"/work — fix the title leak"``, or ``"/work"`` for a bare
|
||||
invocation, or ``None`` when *content* is not skill scaffolding (the
|
||||
caller should then summarize it as an ordinary message).
|
||||
|
||||
*separator* joins the command and the instruction. Previews use the
|
||||
default em dash; pass ``" "`` for the literal invocation the user typed,
|
||||
which is what chat transcripts render.
|
||||
"""
|
||||
if not isinstance(content, str) or not content.startswith(_SKILL_INVOCATION_PREFIX):
|
||||
return None
|
||||
|
|
@ -127,7 +131,7 @@ def describe_skill_invocation(content: Any) -> Optional[str]:
|
|||
instruction = instruction.split(SKILL_EXCERPT_JOINT)[0]
|
||||
instruction = " ".join(instruction.split())
|
||||
if instruction:
|
||||
return f"{label} — {instruction}" if name else instruction
|
||||
return f"{label}{separator}{instruction}" if name else instruction
|
||||
|
||||
return label if name else None
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ EXCLUDED_SKILL_DIRS = frozenset(
|
|||
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
|
||||
|
||||
|
||||
def is_excluded_skill_path(path) -> bool:
|
||||
def is_excluded_skill_path(path, *, root: Optional[Path] = None) -> bool:
|
||||
"""True if *path* should be skipped by active skill scanners.
|
||||
|
||||
Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
|
||||
|
|
@ -66,11 +66,11 @@ def is_excluded_skill_path(path) -> bool:
|
|||
from pathlib import PurePath
|
||||
parts = PurePath(str(path)).parts
|
||||
return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path(
|
||||
path
|
||||
path, root=root
|
||||
)
|
||||
|
||||
|
||||
def is_skill_support_path(path) -> bool:
|
||||
def is_skill_support_path(path, *, root: Optional[Path] = None) -> bool:
|
||||
"""True if *path* is under a support dir of an actual skill root.
|
||||
|
||||
``references/``, ``templates/``, ``assets/``, and ``scripts/`` are
|
||||
|
|
@ -92,6 +92,8 @@ def is_skill_support_path(path) -> bool:
|
|||
if part not in SKILL_SUPPORT_DIRS or idx == 0:
|
||||
continue
|
||||
skill_root = Path(*parts[:idx])
|
||||
if root is not None and not path_obj.is_absolute():
|
||||
skill_root = root / skill_root
|
||||
if (skill_root / "SKILL.md").exists():
|
||||
return True
|
||||
return False
|
||||
|
|
|
|||
533
agent/subagent_lifecycle.py
Normal file
533
agent/subagent_lifecycle.py
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
"""Public, plugin-safe lifecycle API for delegated Hermes subagents.
|
||||
|
||||
This module deliberately exposes immutable contracts, not ``AIAgent`` objects.
|
||||
It is the supported boundary for plugins that need to supervise fresh child
|
||||
sessions; plugins must obtain it from ``PluginContext.subagent_lifecycle``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import dataclasses
|
||||
import enum
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import math
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
|
||||
|
||||
PUBLIC_CONTRACT_VERSION = 1
|
||||
_MAX_GOAL_CHARS = 16_000
|
||||
_MAX_CONTEXT_CHARS = 32_000
|
||||
_MAX_METADATA_BYTES = 8_192
|
||||
_MAX_RESULT_CHARS = 32_000
|
||||
_TERMINAL_RETENTION_SECONDS = 3_600
|
||||
|
||||
|
||||
class SubagentLifecycleError(ValueError):
|
||||
"""A request cannot be safely accepted by the public lifecycle API."""
|
||||
|
||||
|
||||
class SubagentState(str, enum.Enum):
|
||||
PENDING = "PENDING"
|
||||
STARTING = "STARTING"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
INTERRUPTED = "INTERRUPTED"
|
||||
CANCEL_REQUESTED = "CANCEL_REQUESTED"
|
||||
CANCELLED = "CANCELLED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentLaunchRequest:
|
||||
goal: str
|
||||
context: Optional[str] = None
|
||||
role: str = "leaf"
|
||||
model: Optional[str] = None
|
||||
allowed_toolsets: Optional[tuple[str, ...]] = None
|
||||
blocked_tools: tuple[str, ...] = ()
|
||||
working_directory: Optional[str] = None
|
||||
parent_session_id: Optional[str] = None
|
||||
correlation_id: Optional[str] = None
|
||||
metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict)
|
||||
timeout_seconds: Optional[float] = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentHandle:
|
||||
contract_version: int
|
||||
subagent_id: str
|
||||
parent_session_id: Optional[str]
|
||||
correlation_id: Optional[str]
|
||||
created_at: float
|
||||
provider: Optional[str]
|
||||
model: Optional[str]
|
||||
role: str
|
||||
depth: int
|
||||
capability: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "SubagentHandle":
|
||||
try:
|
||||
return cls(**dict(value))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SubagentLifecycleError("Malformed subagent handle.") from exc
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentStatus:
|
||||
handle: SubagentHandle
|
||||
state: SubagentState
|
||||
updated_at: float
|
||||
diagnostic: Optional[str] = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentTerminalState:
|
||||
handle: SubagentHandle
|
||||
state: SubagentState
|
||||
completed: bool
|
||||
timed_out: bool = False
|
||||
diagnostic: Optional[str] = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentCancelResult:
|
||||
accepted: bool
|
||||
already_terminal: bool = False
|
||||
unknown_handle: bool = False
|
||||
unsupported: bool = False
|
||||
state: SubagentState = SubagentState.UNKNOWN
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentResult:
|
||||
handle: SubagentHandle
|
||||
terminal_state: SubagentState
|
||||
ready: bool
|
||||
summary: Optional[str] = None
|
||||
structured_payload: Optional[Mapping[str, Any]] = None
|
||||
started_at: Optional[float] = None
|
||||
completed_at: Optional[float] = None
|
||||
error_classification: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
usage_metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict)
|
||||
tool_execution_summary: Mapping[str, Any] = dataclasses.field(default_factory=dict)
|
||||
result_hash: Optional[str] = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SubagentReconnectResult:
|
||||
connected: bool
|
||||
state: SubagentState
|
||||
diagnostic: Optional[str] = None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _Record:
|
||||
handle: SubagentHandle
|
||||
state: SubagentState
|
||||
updated_at: float
|
||||
agent: Any = None
|
||||
future: Optional[Future] = None
|
||||
started_at: Optional[float] = None
|
||||
completed_at: Optional[float] = None
|
||||
result: Optional[SubagentResult] = None
|
||||
|
||||
|
||||
class _Registry:
|
||||
"""Thread-safe terminal-retention registry; never returns live records."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.RLock()
|
||||
self.records: dict[str, _Record] = {}
|
||||
self.correlations: dict[tuple[Optional[str], str], str] = {}
|
||||
|
||||
|
||||
_REGISTRY = _Registry()
|
||||
# Daemon worker pool: a wedged/abandoned child must never block interpreter
|
||||
# exit at atexit-join time (same rationale as _run_single_child's timeout
|
||||
# executor and the async-delegation registry pool).
|
||||
from tools.daemon_pool import DaemonThreadPoolExecutor as _DaemonExecutor
|
||||
|
||||
_EXECUTOR = _DaemonExecutor(max_workers=8, thread_name_prefix="hermes-lifecycle")
|
||||
_SECRET = secrets.token_bytes(32)
|
||||
_ACTIVE_PARENT_AGENT: contextvars.ContextVar[Any] = contextvars.ContextVar(
|
||||
"hermes_subagent_lifecycle_parent", default=None
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_subagent_parent(parent_agent: Any):
|
||||
"""Bind the host-owned parent for the current agent turn."""
|
||||
token = _ACTIVE_PARENT_AGENT.set(parent_agent)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_ACTIVE_PARENT_AGENT.reset(token)
|
||||
|
||||
|
||||
def get_active_subagent_parent() -> Any:
|
||||
"""Return the parent bound to this execution context, if any."""
|
||||
return _ACTIVE_PARENT_AGENT.get()
|
||||
|
||||
|
||||
class SubagentLifecycleService:
|
||||
"""Stable public service returned by :attr:`PluginContext.subagent_lifecycle`.
|
||||
|
||||
Running children are in-process only. Completed results remain available
|
||||
until process exit; ``reconnect`` accurately reports that a serialized
|
||||
handle cannot reconnect after a restart instead of launching work again.
|
||||
"""
|
||||
|
||||
def __init__(self, parent_agent_resolver: Callable[[], Any]) -> None:
|
||||
self._parent_agent_resolver = parent_agent_resolver
|
||||
|
||||
def launch(self, request: SubagentLaunchRequest) -> SubagentHandle:
|
||||
parent = self._parent_agent_resolver()
|
||||
if parent is None:
|
||||
raise SubagentLifecycleError(
|
||||
"No active Hermes parent session is available."
|
||||
)
|
||||
self._validate_request(request, parent)
|
||||
parent_session_id = str(getattr(parent, "session_id", "") or "") or None
|
||||
if request.parent_session_id and request.parent_session_id != parent_session_id:
|
||||
raise SubagentLifecycleError(
|
||||
"parent_session_id does not match the active session."
|
||||
)
|
||||
correlation_key = (parent_session_id, request.correlation_id or "")
|
||||
with _REGISTRY.lock:
|
||||
self._cleanup_locked()
|
||||
if request.correlation_id and correlation_key in _REGISTRY.correlations:
|
||||
raise SubagentLifecycleError(
|
||||
"Duplicate correlation_id for this parent session."
|
||||
)
|
||||
|
||||
# Delegate construction remains internal so plugin code never imports
|
||||
# private delegation helpers or manipulates the active-child registry.
|
||||
from tools.delegate_tool import (
|
||||
_build_child_preserving_parent_tools,
|
||||
DEFAULT_MAX_ITERATIONS,
|
||||
)
|
||||
|
||||
child = _build_child_preserving_parent_tools(
|
||||
task_index=0,
|
||||
goal=request.goal,
|
||||
context=request.context,
|
||||
toolsets=list(request.allowed_toolsets)
|
||||
if request.allowed_toolsets
|
||||
else None,
|
||||
model=request.model,
|
||||
max_iterations=DEFAULT_MAX_ITERATIONS,
|
||||
task_count=1,
|
||||
parent_agent=parent,
|
||||
role=request.role,
|
||||
)
|
||||
subagent_id = str(getattr(child, "_subagent_id", "") or "")
|
||||
if not subagent_id:
|
||||
raise SubagentLifecycleError("Hermes failed to assign a child identity.")
|
||||
created = time.time()
|
||||
handle = SubagentHandle(
|
||||
PUBLIC_CONTRACT_VERSION,
|
||||
subagent_id,
|
||||
parent_session_id,
|
||||
request.correlation_id,
|
||||
created,
|
||||
getattr(child, "provider", None),
|
||||
getattr(child, "model", None),
|
||||
getattr(child, "_delegate_role", request.role),
|
||||
int(getattr(child, "_delegate_depth", 1) or 1),
|
||||
self._capability(subagent_id, parent_session_id, created),
|
||||
)
|
||||
record = _Record(handle, SubagentState.PENDING, created, agent=child)
|
||||
with _REGISTRY.lock:
|
||||
_REGISTRY.records[subagent_id] = record
|
||||
if request.correlation_id:
|
||||
_REGISTRY.correlations[correlation_key] = subagent_id
|
||||
record.future = _EXECUTOR.submit(self._run, record, request.goal, parent)
|
||||
return handle
|
||||
|
||||
def status(self, handle: SubagentHandle) -> SubagentStatus:
|
||||
record = self._record(handle)
|
||||
if record is None:
|
||||
return SubagentStatus(
|
||||
handle, SubagentState.UNKNOWN, time.time(), "UNKNOWN_HANDLE"
|
||||
)
|
||||
with _REGISTRY.lock:
|
||||
return SubagentStatus(record.handle, record.state, record.updated_at)
|
||||
|
||||
def wait(
|
||||
self, handle: SubagentHandle, *, timeout_seconds: Optional[float] = None
|
||||
) -> SubagentTerminalState:
|
||||
record = self._record(handle)
|
||||
if record is None:
|
||||
return SubagentTerminalState(
|
||||
handle, SubagentState.UNKNOWN, True, diagnostic="UNKNOWN_HANDLE"
|
||||
)
|
||||
future = record.future
|
||||
if future is not None:
|
||||
try:
|
||||
future.result(timeout=timeout_seconds)
|
||||
except TimeoutError:
|
||||
return SubagentTerminalState(record.handle, record.state, False, True)
|
||||
except Exception:
|
||||
pass
|
||||
with _REGISTRY.lock:
|
||||
return SubagentTerminalState(
|
||||
record.handle, record.state, record.result is not None
|
||||
)
|
||||
|
||||
def cancel(self, handle: SubagentHandle, *, reason: str) -> SubagentCancelResult:
|
||||
record = self._record(handle)
|
||||
if record is None:
|
||||
return SubagentCancelResult(False, unknown_handle=True)
|
||||
with _REGISTRY.lock:
|
||||
if record.result is not None:
|
||||
return SubagentCancelResult(
|
||||
False, already_terminal=True, state=record.state
|
||||
)
|
||||
agent = record.agent
|
||||
record.state = SubagentState.CANCEL_REQUESTED
|
||||
record.updated_at = time.time()
|
||||
if agent is None or not hasattr(agent, "interrupt"):
|
||||
return SubagentCancelResult(
|
||||
False, unsupported=True, state=SubagentState.CANCEL_REQUESTED
|
||||
)
|
||||
try:
|
||||
agent.interrupt(f"Lifecycle cancellation requested: {reason[:500]}")
|
||||
except Exception:
|
||||
return SubagentCancelResult(
|
||||
False, unsupported=True, state=SubagentState.CANCEL_REQUESTED
|
||||
)
|
||||
return SubagentCancelResult(True, state=SubagentState.CANCEL_REQUESTED)
|
||||
|
||||
def result(self, handle: SubagentHandle) -> SubagentResult:
|
||||
record = self._record(handle)
|
||||
if record is None:
|
||||
return SubagentResult(
|
||||
handle,
|
||||
SubagentState.UNKNOWN,
|
||||
False,
|
||||
error_classification="UNKNOWN_HANDLE",
|
||||
)
|
||||
with _REGISTRY.lock:
|
||||
if record.result is not None:
|
||||
return record.result
|
||||
return SubagentResult(
|
||||
record.handle, record.state, False, error_classification="NOT_READY"
|
||||
)
|
||||
|
||||
def reconnect(self, handle: SubagentHandle) -> SubagentReconnectResult:
|
||||
record = self._record(handle)
|
||||
if record is None:
|
||||
return SubagentReconnectResult(
|
||||
False, SubagentState.UNKNOWN, "RECONNECT_UNAVAILABLE"
|
||||
)
|
||||
with _REGISTRY.lock:
|
||||
return SubagentReconnectResult(True, record.state)
|
||||
|
||||
def _record(self, handle: SubagentHandle) -> Optional[_Record]:
|
||||
if (
|
||||
not isinstance(handle, SubagentHandle)
|
||||
or type(handle.contract_version) is not int
|
||||
or handle.contract_version != PUBLIC_CONTRACT_VERSION
|
||||
):
|
||||
return None
|
||||
if (
|
||||
not isinstance(handle.subagent_id, str)
|
||||
or not handle.subagent_id
|
||||
or (
|
||||
handle.parent_session_id is not None
|
||||
and not isinstance(handle.parent_session_id, str)
|
||||
)
|
||||
or (
|
||||
handle.correlation_id is not None
|
||||
and not isinstance(handle.correlation_id, str)
|
||||
)
|
||||
or isinstance(handle.created_at, bool)
|
||||
or not isinstance(handle.created_at, (int, float))
|
||||
or not math.isfinite(handle.created_at)
|
||||
or (handle.provider is not None and not isinstance(handle.provider, str))
|
||||
or (handle.model is not None and not isinstance(handle.model, str))
|
||||
or not isinstance(handle.role, str)
|
||||
or type(handle.depth) is not int
|
||||
or not isinstance(handle.capability, str)
|
||||
):
|
||||
return None
|
||||
if not hmac.compare_digest(
|
||||
handle.capability,
|
||||
self._capability(
|
||||
handle.subagent_id, handle.parent_session_id, handle.created_at
|
||||
),
|
||||
):
|
||||
return None
|
||||
parent = self._parent_agent_resolver()
|
||||
active_parent_id = str(getattr(parent, "session_id", "") or "") or None
|
||||
if active_parent_id != handle.parent_session_id:
|
||||
return None
|
||||
with _REGISTRY.lock:
|
||||
return _REGISTRY.records.get(handle.subagent_id)
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_locked() -> None:
|
||||
"""Retain terminal snapshots for a bounded period, never live work."""
|
||||
cutoff = time.time() - _TERMINAL_RETENTION_SECONDS
|
||||
expired = [
|
||||
subagent_id
|
||||
for subagent_id, record in _REGISTRY.records.items()
|
||||
if record.result is not None
|
||||
and record.completed_at is not None
|
||||
and record.completed_at < cutoff
|
||||
]
|
||||
for subagent_id in expired:
|
||||
record = _REGISTRY.records.pop(subagent_id)
|
||||
if record.handle.correlation_id:
|
||||
_REGISTRY.correlations.pop(
|
||||
(record.handle.parent_session_id, record.handle.correlation_id),
|
||||
None,
|
||||
)
|
||||
|
||||
def _run(self, record: _Record, goal: str, parent: Any) -> None:
|
||||
with _REGISTRY.lock:
|
||||
if record.state is not SubagentState.CANCEL_REQUESTED:
|
||||
record.state = SubagentState.RUNNING
|
||||
record.started_at = time.time()
|
||||
record.updated_at = record.started_at
|
||||
try:
|
||||
from tools.delegate_tool import _run_child_lifecycle
|
||||
|
||||
raw = _run_child_lifecycle(0, goal, record.agent, parent)
|
||||
status = (
|
||||
str(raw.get("status", "error")) if isinstance(raw, dict) else "error"
|
||||
)
|
||||
if status == "completed":
|
||||
state = SubagentState.SUCCEEDED
|
||||
elif status == "interrupted":
|
||||
state = (
|
||||
SubagentState.CANCELLED
|
||||
if record.state == SubagentState.CANCEL_REQUESTED
|
||||
else SubagentState.INTERRUPTED
|
||||
)
|
||||
else:
|
||||
state = SubagentState.FAILED
|
||||
summary = raw.get("summary") if isinstance(raw, dict) else None
|
||||
summary = str(summary)[:_MAX_RESULT_CHARS] if summary is not None else None
|
||||
error = raw.get("error") if isinstance(raw, dict) else None
|
||||
result = SubagentResult(
|
||||
record.handle,
|
||||
state,
|
||||
True,
|
||||
summary=summary,
|
||||
completed_at=time.time(),
|
||||
started_at=record.started_at,
|
||||
error_classification=None
|
||||
if state == SubagentState.SUCCEEDED
|
||||
else status.upper(),
|
||||
error_message=str(error)[:_MAX_RESULT_CHARS] if error else None,
|
||||
usage_metadata={"api_calls": raw.get("api_calls", 0)}
|
||||
if isinstance(raw, dict)
|
||||
else {},
|
||||
tool_execution_summary={
|
||||
"duration_seconds": raw.get("duration_seconds", 0)
|
||||
}
|
||||
if isinstance(raw, dict)
|
||||
else {},
|
||||
)
|
||||
except Exception as exc:
|
||||
result = SubagentResult(
|
||||
record.handle,
|
||||
SubagentState.FAILED,
|
||||
True,
|
||||
started_at=record.started_at,
|
||||
completed_at=time.time(),
|
||||
error_classification=type(exc).__name__,
|
||||
error_message=str(exc)[:_MAX_RESULT_CHARS],
|
||||
)
|
||||
payload = dataclasses.asdict(result)
|
||||
payload.pop("result_hash", None)
|
||||
result = dataclasses.replace(
|
||||
result,
|
||||
result_hash=hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, default=str).encode()
|
||||
).hexdigest(),
|
||||
)
|
||||
with _REGISTRY.lock:
|
||||
record.agent = None
|
||||
record.result = result
|
||||
record.state = result.terminal_state
|
||||
record.completed_at = result.completed_at
|
||||
record.updated_at = result.completed_at or time.time()
|
||||
|
||||
@staticmethod
|
||||
def _capability(
|
||||
subagent_id: str, parent_session_id: Optional[str], created_at: float
|
||||
) -> str:
|
||||
value = f"{subagent_id}|{parent_session_id or ''}|{created_at:.6f}".encode()
|
||||
return hmac.new(_SECRET, value, hashlib.sha256).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _validate_request(request: SubagentLaunchRequest, parent: Any) -> None:
|
||||
if (
|
||||
not isinstance(request, SubagentLaunchRequest)
|
||||
or not isinstance(request.goal, str)
|
||||
or not request.goal.strip()
|
||||
or len(request.goal) > _MAX_GOAL_CHARS
|
||||
):
|
||||
raise SubagentLifecycleError(
|
||||
"goal must be a non-empty string of at most 16000 characters."
|
||||
)
|
||||
if request.context is not None and (
|
||||
not isinstance(request.context, str)
|
||||
or len(request.context) > _MAX_CONTEXT_CHARS
|
||||
):
|
||||
raise SubagentLifecycleError(
|
||||
"context must be a string of at most 32000 characters."
|
||||
)
|
||||
if request.role not in {"leaf", "orchestrator"}:
|
||||
raise SubagentLifecycleError("role must be 'leaf' or 'orchestrator'.")
|
||||
if request.timeout_seconds is not None:
|
||||
raise SubagentLifecycleError(
|
||||
"Per-launch timeout is not supported; configure delegation timeout explicitly."
|
||||
)
|
||||
if request.working_directory is not None:
|
||||
raise SubagentLifecycleError(
|
||||
"working_directory is not supported because Hermes delegates use isolated task environments."
|
||||
)
|
||||
if request.blocked_tools:
|
||||
raise SubagentLifecycleError(
|
||||
"Per-tool blocking is not supported; use allowed_toolsets. Hermes always blocks unsafe child tools."
|
||||
)
|
||||
try:
|
||||
metadata_bytes = len(
|
||||
json.dumps(dict(request.metadata), sort_keys=True).encode()
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SubagentLifecycleError("metadata must be JSON-serializable.") from exc
|
||||
if metadata_bytes > _MAX_METADATA_BYTES:
|
||||
raise SubagentLifecycleError("metadata exceeds 8192 bytes.")
|
||||
if request.allowed_toolsets:
|
||||
from toolsets import TOOLSETS
|
||||
|
||||
unknown = set(request.allowed_toolsets) - set(TOOLSETS)
|
||||
if unknown:
|
||||
raise SubagentLifecycleError(
|
||||
f"Unknown toolsets: {', '.join(sorted(unknown))}."
|
||||
)
|
||||
enabled = getattr(parent, "enabled_toolsets", None)
|
||||
if enabled is not None and not set(request.allowed_toolsets).issubset(
|
||||
set(enabled)
|
||||
):
|
||||
raise SubagentLifecycleError(
|
||||
"Requested toolsets would broaden parent permissions."
|
||||
)
|
||||
|
|
@ -26,6 +26,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
|
@ -51,6 +52,8 @@ from agent.runtime_cwd import resolve_context_cwd
|
|||
from hermes_constants import get_hermes_home
|
||||
from utils import is_truthy_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ra():
|
||||
"""Lazy reference to the ``run_agent`` module.
|
||||
|
|
@ -582,6 +585,60 @@ def invalidate_system_prompt(agent: Any) -> None:
|
|||
agent._memory_store.load_from_disk()
|
||||
|
||||
|
||||
def reconstruct_static_prefix(
|
||||
agent: Any,
|
||||
system_message: Optional[str] = None,
|
||||
*,
|
||||
log_label: str = "restore",
|
||||
) -> None:
|
||||
"""Reconstruct ``_cached_system_prompt_static`` for a stored prompt.
|
||||
|
||||
The static prefix is not persisted (only the full prompt is), so any
|
||||
path that adopts a stored/kept ``_cached_system_prompt`` — session
|
||||
restore, the compression keep-prompt path, or a failover to a cache-on
|
||||
provider mid-turn (#72626) — must rebuild the stable tier to regain the
|
||||
two-block ``[static, volatile]`` system layout.
|
||||
|
||||
Safety: the rebuilt stable tier is used ONLY when the stored prompt
|
||||
literally starts with it (checked here AND re-checked by
|
||||
``_apply_system_cache_markers``'s ``startswith`` gate). If any
|
||||
stable-tier input changed since the prompt was persisted (skills
|
||||
edited, identity changed), the prefix mismatches, the static stays
|
||||
None, and requests fall back to the legacy layout with the stored
|
||||
prompt bytes untouched — never a rewritten prompt.
|
||||
|
||||
A failed reconstruction is memoized per stored prompt
|
||||
(``_static_rebuild_failed_for``): ``build_system_prompt_parts`` does
|
||||
real file I/O (SOUL.md, context files, memory), and callers on the
|
||||
retry-loop hot path must not re-run it every attempt when the inputs
|
||||
haven't changed. A legitimately changed stored prompt retries once.
|
||||
"""
|
||||
if not getattr(agent, "_use_prompt_caching", False):
|
||||
return
|
||||
stored = getattr(agent, "_cached_system_prompt", None)
|
||||
if not isinstance(stored, str) or not stored:
|
||||
return
|
||||
existing = getattr(agent, "_cached_system_prompt_static", None)
|
||||
if isinstance(existing, str) and existing and stored.startswith(existing):
|
||||
return
|
||||
if getattr(agent, "_static_rebuild_failed_for", None) == stored:
|
||||
return
|
||||
try:
|
||||
static = build_system_prompt_parts(agent, system_message=system_message)["stable"]
|
||||
if static and stored.startswith(static):
|
||||
agent._cached_system_prompt_static = static
|
||||
agent._static_rebuild_failed_for = None
|
||||
return
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"static system-prefix reconstruction failed on %s",
|
||||
log_label,
|
||||
exc_info=True,
|
||||
)
|
||||
agent._cached_system_prompt_static = None
|
||||
agent._static_rebuild_failed_for = stored
|
||||
|
||||
|
||||
def format_tools_for_system_message(agent: Any) -> str:
|
||||
"""Format tool definitions for the system message in the trajectory format.
|
||||
|
||||
|
|
|
|||
|
|
@ -140,8 +140,8 @@ def _flush_session_db_after_tool_progress(
|
|||
messages: list,
|
||||
*,
|
||||
stage: str,
|
||||
) -> None:
|
||||
"""Best-effort incremental SessionDB flush for tool-call progress.
|
||||
) -> bool:
|
||||
"""Flush tool-call progress before projecting it to any UI surface.
|
||||
|
||||
Tool execution can perform side effects that terminate or restart the
|
||||
current Hermes process before the normal turn-end persistence path runs.
|
||||
|
|
@ -149,9 +149,14 @@ def _flush_session_db_after_tool_progress(
|
|||
transcript survives destructive-but-valid tool calls.
|
||||
"""
|
||||
try:
|
||||
agent._flush_messages_to_session_db(messages)
|
||||
persisted = agent._flush_messages_to_session_db(messages) is not False
|
||||
if not persisted:
|
||||
agent._incremental_persistence_failed = True
|
||||
return persisted
|
||||
except Exception as exc:
|
||||
agent._incremental_persistence_failed = True
|
||||
logger.warning("Incremental tool-call persistence failed after %s: %s", stage, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _ra():
|
||||
|
|
@ -431,8 +436,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
||||
if not _err and _underlying:
|
||||
if _underlying in _tool_search_scoped_names(agent):
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
# Probe-validate before unwrapping (ironclaw#5149):
|
||||
# missing required args return the parameter schema
|
||||
# instead of dispatching into an opaque failure.
|
||||
_probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args)
|
||||
if _probe_err is not None:
|
||||
_ts_scope_block = _probe_err
|
||||
else:
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
else:
|
||||
_ts_scope_block = json.dumps({
|
||||
"error": (
|
||||
|
|
@ -854,6 +866,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls):
|
||||
r = results[i]
|
||||
blocked = False
|
||||
is_error = True
|
||||
progress_function_name = name
|
||||
# A worker can finish and write results[i] in the window between the
|
||||
# deadline snapshot (timed_out_indices, taken from not_done) and this
|
||||
# loop. Prefer that real result over a fabricated timeout message — the
|
||||
|
|
@ -909,6 +923,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
tool_duration = 0.0
|
||||
else:
|
||||
function_name, function_args, function_result, tool_duration, is_error, blocked, middleware_trace = r
|
||||
progress_function_name = function_name
|
||||
if blocked:
|
||||
effect_disposition = "none"
|
||||
|
||||
|
|
@ -936,44 +951,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
except Exception as _ver_err:
|
||||
logging.debug("file-mutation verifier record failed: %s", _ver_err)
|
||||
|
||||
if not blocked and agent.tool_progress_callback:
|
||||
try:
|
||||
agent.tool_progress_callback(
|
||||
"tool.completed", function_name, None, None,
|
||||
duration=tool_duration, is_error=is_error,
|
||||
result=function_result,
|
||||
)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool progress callback error: {cb_err}")
|
||||
|
||||
if agent.verbose_logging:
|
||||
logging.debug(f"Tool {function_name} completed in {tool_duration:.2f}s")
|
||||
logging.debug(f"Tool result ({len(function_result)} chars): {function_result}")
|
||||
|
||||
# Print cute message per tool
|
||||
if agent._should_emit_quiet_tool_messages():
|
||||
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
|
||||
agent._safe_print(f" {cute_msg}")
|
||||
elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
_preview_str = _multimodal_text_summary(function_result)
|
||||
if agent.verbose_logging:
|
||||
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
|
||||
print(agent._wrap_verbose("Result: ", _preview_str))
|
||||
else:
|
||||
response_preview = _preview_str[:agent.log_prefix_chars] + "..." if len(_preview_str) > agent.log_prefix_chars else _preview_str
|
||||
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s - {response_preview}")
|
||||
|
||||
agent._current_tool = None
|
||||
_status_suffix = " (error)" if is_error else ""
|
||||
agent._touch_activity(f"tool completed: {name} ({tool_duration:.1f}s){_status_suffix}")
|
||||
|
||||
if not blocked and agent.tool_complete_callback:
|
||||
try:
|
||||
display_args = _redact_tool_args_for_display(name, args) or args
|
||||
agent.tool_complete_callback(tc.id, name, display_args, function_result)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool complete callback error: {cb_err}")
|
||||
|
||||
display_function_result = function_result
|
||||
function_result = maybe_persist_tool_result(
|
||||
content=function_result,
|
||||
tool_name=name,
|
||||
|
|
@ -1008,6 +994,50 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
)
|
||||
messages.append(tool_message)
|
||||
risk_metadata = tool_message.get("_tool_output_risk")
|
||||
if not _flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"tool result {name}",
|
||||
):
|
||||
return
|
||||
|
||||
# Every completion surface is downstream of the canonical append. If
|
||||
# the UI bridge or process dies while projecting one of these events,
|
||||
# resume can reconstruct the tool result that was already visible.
|
||||
if not blocked and agent.tool_progress_callback:
|
||||
try:
|
||||
agent.tool_progress_callback(
|
||||
"tool.completed", progress_function_name, None, None,
|
||||
duration=tool_duration, is_error=is_error,
|
||||
result=display_function_result,
|
||||
)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool progress callback error: {cb_err}")
|
||||
|
||||
# Print cute message per tool
|
||||
if agent._should_emit_quiet_tool_messages():
|
||||
cute_msg = _get_cute_tool_message_impl(
|
||||
name, args, tool_duration, result=display_function_result,
|
||||
)
|
||||
agent._safe_print(f" {cute_msg}")
|
||||
elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
_preview_str = _multimodal_text_summary(display_function_result)
|
||||
if agent.verbose_logging:
|
||||
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
|
||||
print(agent._wrap_verbose("Result: ", _preview_str))
|
||||
else:
|
||||
response_preview = _preview_str[:agent.log_prefix_chars] + "..." if len(_preview_str) > agent.log_prefix_chars else _preview_str
|
||||
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s - {response_preview}")
|
||||
|
||||
if not blocked and agent.tool_complete_callback:
|
||||
try:
|
||||
display_args = _redact_tool_args_for_display(name, args) or args
|
||||
agent.tool_complete_callback(
|
||||
tc.id, name, display_args, display_function_result,
|
||||
)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool complete callback error: {cb_err}")
|
||||
|
||||
if (
|
||||
risk_metadata is not None
|
||||
and risk_metadata.get("risk") != "low"
|
||||
|
|
@ -1024,11 +1054,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
)
|
||||
except Exception as cb_err:
|
||||
logging.debug("Tool output risk callback error: %s", cb_err)
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"tool result {name}",
|
||||
)
|
||||
|
||||
# ── Per-tool /steer drain ───────────────────────────────────
|
||||
# Same as the sequential path: drain between each collected
|
||||
|
|
@ -1060,6 +1085,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
# Resolve the context-scaled tool-output budget once per turn.
|
||||
_tool_budget = _budget_for_agent(agent)
|
||||
for i, tool_call in enumerate(assistant_message.tool_calls, 1):
|
||||
if getattr(agent, "_incremental_persistence_failed", False):
|
||||
return
|
||||
# SAFETY: check interrupt BEFORE starting each tool.
|
||||
# If the user sent "stop" during a previous tool's execution,
|
||||
# do NOT start any more tools -- skip them all immediately.
|
||||
|
|
@ -1075,11 +1102,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
skipped_tc.id,
|
||||
effect_disposition="none",
|
||||
))
|
||||
_flush_session_db_after_tool_progress(
|
||||
if not _flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"cancelled tool result {skipped_name}",
|
||||
)
|
||||
):
|
||||
return
|
||||
break
|
||||
|
||||
function_name = tool_call.function.name
|
||||
|
|
@ -1095,11 +1123,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
tool_call.id,
|
||||
)
|
||||
)
|
||||
_flush_session_db_after_tool_progress(
|
||||
if not _flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"invalid tool arguments {function_name}",
|
||||
)
|
||||
):
|
||||
return
|
||||
agent._apply_pending_steer_to_tool_results(messages, 1)
|
||||
continue
|
||||
|
||||
|
|
@ -1113,8 +1142,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
||||
if not _err and _underlying:
|
||||
if _underlying in _tool_search_scoped_names(agent):
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
# Probe-validate before unwrapping (ironclaw#5149):
|
||||
# missing required args return the parameter schema
|
||||
# instead of dispatching into an opaque failure.
|
||||
_probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args)
|
||||
if _probe_err is not None:
|
||||
# This path wraps _block_msg in {"error": ...} —
|
||||
# flatten the probe payload to one plain string.
|
||||
try:
|
||||
_probe = json.loads(_probe_err)
|
||||
_ts_scope_block = (
|
||||
f"{_probe.get('error', '')} Parameters schema: "
|
||||
f"{json.dumps(_probe.get('parameters', {}), ensure_ascii=False)}. "
|
||||
f"{_probe.get('hint', '')}"
|
||||
).strip()
|
||||
except Exception:
|
||||
_ts_scope_block = _probe_err
|
||||
else:
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
else:
|
||||
_ts_scope_block = (
|
||||
f"'{_underlying}' is not available in this session. "
|
||||
|
|
@ -1360,6 +1406,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
return _clarify_tool(
|
||||
question=next_args.get("question", ""),
|
||||
choices=next_args.get("choices"),
|
||||
multi_select=next_args.get("multi_select", False),
|
||||
callback=agent.clarify_callback,
|
||||
)
|
||||
function_result, function_args = _run_agent_tool_execution_middleware(
|
||||
|
|
@ -1645,16 +1692,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
except Exception as _ver_err:
|
||||
logging.debug("file-mutation verifier record failed: %s", _ver_err)
|
||||
|
||||
if not _execution_blocked and agent.tool_progress_callback:
|
||||
try:
|
||||
agent.tool_progress_callback(
|
||||
"tool.completed", function_name, None, None,
|
||||
duration=tool_duration, is_error=_is_error_result,
|
||||
result=function_result,
|
||||
)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool progress callback error: {cb_err}")
|
||||
|
||||
agent._current_tool = None
|
||||
_status_suffix = " (error)" if _is_error_result else ""
|
||||
agent._touch_activity(f"tool completed: {function_name} ({tool_duration:.1f}s){_status_suffix}")
|
||||
|
|
@ -1664,13 +1701,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
_log_result = _multimodal_text_summary(function_result)
|
||||
logging.debug(f"Tool result ({len(_log_result)} chars): {_log_result}")
|
||||
|
||||
if not _execution_blocked and agent.tool_complete_callback:
|
||||
try:
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
agent.tool_complete_callback(tool_call.id, function_name, display_args, function_result)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool complete callback error: {cb_err}")
|
||||
|
||||
display_function_result = function_result
|
||||
function_result = maybe_persist_tool_result(
|
||||
content=function_result,
|
||||
tool_name=function_name,
|
||||
|
|
@ -1693,6 +1724,40 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
tool_message = make_tool_result_message(function_name, _tool_content, tool_call.id)
|
||||
messages.append(tool_message)
|
||||
risk_metadata = tool_message.get("_tool_output_risk")
|
||||
if not _flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"tool result {function_name}",
|
||||
):
|
||||
return
|
||||
|
||||
# UI completion/progress events are projections of the canonical tool
|
||||
# row, never a competing in-memory authority.
|
||||
if not _execution_blocked and agent.tool_progress_callback:
|
||||
try:
|
||||
agent.tool_progress_callback(
|
||||
"tool.completed", function_name, None, None,
|
||||
duration=tool_duration, is_error=_is_error_result,
|
||||
result=display_function_result,
|
||||
)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool progress callback error: {cb_err}")
|
||||
|
||||
if not _execution_blocked and agent.tool_complete_callback:
|
||||
try:
|
||||
display_args = (
|
||||
_redact_tool_args_for_display(function_name, function_args)
|
||||
or function_args
|
||||
)
|
||||
agent.tool_complete_callback(
|
||||
tool_call.id,
|
||||
function_name,
|
||||
display_args,
|
||||
display_function_result,
|
||||
)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool complete callback error: {cb_err}")
|
||||
|
||||
if (
|
||||
risk_metadata is not None
|
||||
and risk_metadata.get("risk") != "low"
|
||||
|
|
@ -1709,11 +1774,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
)
|
||||
except Exception as cb_err:
|
||||
logging.debug("Tool output risk callback error: %s", cb_err)
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"tool result {function_name}",
|
||||
)
|
||||
|
||||
# ── Per-tool /steer drain ───────────────────────────────────
|
||||
# Drain pending steer BETWEEN individual tool calls so the
|
||||
|
|
@ -1741,11 +1801,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
skipped_tc.id,
|
||||
effect_disposition="none",
|
||||
))
|
||||
_flush_session_db_after_tool_progress(
|
||||
if not _flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"skipped tool result {skipped_name}",
|
||||
)
|
||||
):
|
||||
return
|
||||
break
|
||||
|
||||
if agent.tool_delay > 0 and i < len(assistant_message.tool_calls):
|
||||
|
|
@ -1796,6 +1857,8 @@ def execute_tool_calls_segmented(agent, assistant_message, messages: list, effec
|
|||
segments = _plan_tool_batch_segments(assistant_message.tool_calls, execution_cwd=_exec_cwd)
|
||||
|
||||
for kind, calls in segments:
|
||||
if getattr(agent, "_incremental_persistence_failed", False):
|
||||
return
|
||||
segment_message = SimpleNamespace(tool_calls=list(calls))
|
||||
if kind == "parallel":
|
||||
execute_tool_calls_concurrent(
|
||||
|
|
@ -1808,6 +1871,9 @@ def execute_tool_calls_segmented(agent, assistant_message, messages: list, effec
|
|||
finalize=False,
|
||||
)
|
||||
|
||||
if getattr(agent, "_incremental_persistence_failed", False):
|
||||
return
|
||||
|
||||
# ── Whole-turn finalize (budget + /steer) ─────────────────────────
|
||||
total_tools = len(assistant_message.tool_calls)
|
||||
if total_tools > 0:
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ class ToolCallGuardrailConfig:
|
|||
no_progress_block_after: int = 5
|
||||
idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES)
|
||||
mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES)
|
||||
loop_caps: "LoopCapConfig" = field(default_factory=lambda: LoopCapConfig())
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfig":
|
||||
|
|
@ -121,6 +122,54 @@ class ToolCallGuardrailConfig:
|
|||
hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")),
|
||||
defaults.no_progress_block_after,
|
||||
),
|
||||
loop_caps=LoopCapConfig.from_mapping(data.get("loop_caps")),
|
||||
)
|
||||
|
||||
|
||||
# Default session-wide caps, matching Claude Code's v2.1.212 runaway-loop
|
||||
# Per-turn (per-agent-loop) caps on runaway-prone tool calls. Counts reset at
|
||||
# the start of every agent loop (reset_for_turn), so the limit is "within a
|
||||
# single turn" rather than cumulative over the whole session. A single loop
|
||||
# issuing dozens of web searches or spawning dozens of subagents is already
|
||||
# pathological, so the defaults are deliberately low.
|
||||
_DEFAULT_MAX_WEB_SEARCHES_PER_TURN = 50
|
||||
_DEFAULT_MAX_SUBAGENTS_PER_TURN = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoopCapConfig:
|
||||
"""Per-turn caps on runaway-prone tool calls.
|
||||
|
||||
Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added caps on
|
||||
WebSearch calls and subagent spawns to stop runaway search / delegation
|
||||
loops. Here the caps count *within a single agent loop* (one turn): the
|
||||
counters reset in ``reset_for_turn`` at the start of every
|
||||
``run_conversation``, so a legitimate multi-turn session is never starved,
|
||||
but a single turn that spirals into an unbounded search / delegation loop
|
||||
is stopped.
|
||||
|
||||
Semantics differ from the per-turn loop *detector* above (which keys on
|
||||
repeated identical/failing calls): these caps are a hard ceiling on the
|
||||
total count of a tool within the turn and fire regardless of
|
||||
``hard_stop_enabled``. A value of ``0`` disables the cap (unlimited).
|
||||
"""
|
||||
|
||||
max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_TURN
|
||||
max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_TURN
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoopCapConfig":
|
||||
"""Build config from the ``tool_loop_guardrails.loop_caps`` section."""
|
||||
if not isinstance(data, Mapping):
|
||||
return cls()
|
||||
defaults = cls()
|
||||
return cls(
|
||||
max_web_searches=_non_negative_int(
|
||||
data.get("max_web_searches"), defaults.max_web_searches
|
||||
),
|
||||
max_subagents=_non_negative_int(
|
||||
data.get("max_subagents"), defaults.max_subagents
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -233,6 +282,11 @@ class ToolCallGuardrailController:
|
|||
self._same_tool_failure_counts: dict[str, int] = {}
|
||||
self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {}
|
||||
self._halt_decision: ToolGuardrailDecision | None = None
|
||||
# Per-turn runaway-loop cap counters. Reset every turn (this method
|
||||
# runs at the start of each run_conversation), so the caps bound a
|
||||
# single agent loop rather than accumulating across the session.
|
||||
self._turn_web_search_count = 0
|
||||
self._turn_subagent_count = 0
|
||||
|
||||
@property
|
||||
def halt_decision(self) -> ToolGuardrailDecision | None:
|
||||
|
|
@ -240,6 +294,17 @@ class ToolCallGuardrailController:
|
|||
|
||||
def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision:
|
||||
signature = ToolCallSignature.from_call(tool_name, _coerce_args(args))
|
||||
|
||||
# ── Per-turn runaway-loop caps ──────────────────────────────────
|
||||
# These are hard ceilings on how many times a runaway-prone tool may
|
||||
# be called within a single agent loop (turn). They apply regardless
|
||||
# of hard_stop_enabled (which only governs the per-turn loop detector).
|
||||
# We block BEFORE the call runs once the count is already at the cap,
|
||||
# then increment for an allowed call so the (cap+1)-th is refused.
|
||||
cap_block = self._check_loop_cap(tool_name, _coerce_args(args), signature)
|
||||
if cap_block is not None:
|
||||
return cap_block
|
||||
|
||||
if not self.config.hard_stop_enabled:
|
||||
return ToolGuardrailDecision(tool_name=tool_name, signature=signature)
|
||||
|
||||
|
|
@ -379,6 +444,68 @@ class ToolCallGuardrailController:
|
|||
return False
|
||||
return tool_name in self.config.idempotent_tools
|
||||
|
||||
def _check_loop_cap(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: Mapping[str, Any],
|
||||
signature: ToolCallSignature,
|
||||
) -> ToolGuardrailDecision | None:
|
||||
"""Enforce and advance the per-turn runaway-loop counters.
|
||||
|
||||
Returns a ``block`` decision when the cap is already reached, otherwise
|
||||
increments the relevant counter for the allowed call and returns
|
||||
``None``. A cap of 0 disables that limit entirely. Counters reset each
|
||||
turn via ``reset_for_turn``.
|
||||
"""
|
||||
caps = self.config.loop_caps
|
||||
|
||||
if tool_name == "web_search":
|
||||
cap = caps.max_web_searches
|
||||
if cap and self._turn_web_search_count >= cap:
|
||||
decision = ToolGuardrailDecision(
|
||||
action="block",
|
||||
code="loop_web_search_cap",
|
||||
message=(
|
||||
f"Blocked web_search: this turn has already made {cap} "
|
||||
"web searches, the per-turn limit. This looks like a "
|
||||
"runaway search loop. Work with the results you already "
|
||||
"have and give the user your answer."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=self._turn_web_search_count,
|
||||
signature=signature,
|
||||
)
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
self._turn_web_search_count += 1
|
||||
return None
|
||||
|
||||
if tool_name == "delegate_task":
|
||||
cap = caps.max_subagents
|
||||
if not cap:
|
||||
return None
|
||||
spawn_count = _subagent_spawn_count(args)
|
||||
if self._turn_subagent_count >= cap:
|
||||
decision = ToolGuardrailDecision(
|
||||
action="block",
|
||||
code="loop_subagent_cap",
|
||||
message=(
|
||||
f"Blocked delegate_task: this turn has already spawned "
|
||||
f"{self._turn_subagent_count} subagents (limit {cap}). "
|
||||
"This looks like a runaway delegation loop. Finish the "
|
||||
"work with the results you have and answer the user."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=self._turn_subagent_count,
|
||||
signature=signature,
|
||||
)
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
self._turn_subagent_count += spawn_count
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def toolguard_synthetic_result(decision: ToolGuardrailDecision) -> str:
|
||||
"""Build a synthetic role=tool content string for a blocked tool call."""
|
||||
|
|
@ -471,6 +598,32 @@ def _positive_int(value: Any, default: int) -> int:
|
|||
return parsed if parsed >= 1 else default
|
||||
|
||||
|
||||
def _non_negative_int(value: Any, default: int) -> int:
|
||||
"""Parse a session-cap value. 0 is a valid (disable) value; negatives and
|
||||
junk fall back to the default."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed if parsed >= 0 else default
|
||||
|
||||
|
||||
def _subagent_spawn_count(args: Mapping[str, Any]) -> int:
|
||||
"""How many subagents a single delegate_task call spawns.
|
||||
|
||||
delegate_task runs in one of two modes: a batch (``tasks`` is a non-empty
|
||||
list, one child per item) or a single task (``goal``). Count the batch size
|
||||
when present, otherwise 1, so the session subagent cap reflects real spawns
|
||||
rather than delegate_task invocations.
|
||||
"""
|
||||
tasks = args.get("tasks") if isinstance(args, Mapping) else None
|
||||
if isinstance(tasks, list) and tasks:
|
||||
return len(tasks)
|
||||
return 1
|
||||
|
||||
|
||||
def _sha256(value: str) -> str:
|
||||
# surrogatepass: tool results scraped from the web can carry unpaired
|
||||
# UTF-16 surrogates (e.g. half of a mathematical-bold pair); a strict
|
||||
|
|
|
|||
|
|
@ -336,6 +336,8 @@ def build_turn_context(
|
|||
persist_user_message: Optional[Any],
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
*,
|
||||
persist_user_display_kind: Optional[str] = None,
|
||||
persist_user_display_metadata: Optional[Dict[str, Any]] = None,
|
||||
restore_or_build_system_prompt,
|
||||
install_safe_stdio,
|
||||
sanitize_surrogates,
|
||||
|
|
@ -537,6 +539,19 @@ def build_turn_context(
|
|||
# Add the current user message after the prompt/session setup has made
|
||||
# close persistence safe. The handoff above preserves any marker already
|
||||
# stamped by an earlier close flush.
|
||||
#
|
||||
# A synthesized turn (auto-continue recovery note, delegation completion)
|
||||
# declares how it should READ in a transcript. Stamp that on the live
|
||||
# message so the crash persist below writes the row already typed. Typing
|
||||
# it after the turn instead leaves the row untyped for the whole run — and
|
||||
# forever if the turn crashes — so the raw system note paints as a user
|
||||
# bubble. The model still receives role/content unchanged; the api_messages
|
||||
# build strips both fields from every outgoing copy.
|
||||
if persist_user_display_kind:
|
||||
user_msg["display_kind"] = persist_user_display_kind
|
||||
if persist_user_display_metadata:
|
||||
user_msg["display_metadata"] = persist_user_display_metadata
|
||||
|
||||
messages.append(user_msg)
|
||||
current_turn_user_idx = len(messages) - 1
|
||||
agent._persist_user_message_idx = current_turn_user_idx
|
||||
|
|
|
|||
310
agent/turn_summary.py
Normal file
310
agent/turn_summary.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"""Per-turn accounting for the interactive CLI.
|
||||
|
||||
Two display-only pieces live here:
|
||||
|
||||
* :class:`TurnSummaryCollector` — a tiny observer that rides the existing
|
||||
``tool_progress_callback`` feed (``tool.completed`` events already carry
|
||||
the tool name and its raw result) and tallies what a turn actually did.
|
||||
It holds **no** agent-loop state: the display layer already sees every
|
||||
tool call, so nothing new is threaded through the conversation loop.
|
||||
* :func:`format_turn_summary` — a pure formatter that turns a tally plus a
|
||||
wall-clock duration into one dim line, e.g.::
|
||||
|
||||
⋯ 12.4s · edited 2 files +18 -3 · read 4 files · ran 3 commands
|
||||
|
||||
Ported from Claude Code's post-turn accounting line
|
||||
("Edited 1 file +6 -2, read 1 file … Worked for 10s").
|
||||
|
||||
:func:`format_token_flow` is the spinner-side counterpart: a cumulative
|
||||
token readout appended to the live elapsed timer (``↓ 1.2k tok``).
|
||||
|
||||
Everything in this module is pure/side-effect free apart from the
|
||||
collector's own counters, which makes it directly unit-testable without a
|
||||
terminal, an agent, or a network call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"TurnSummaryCollector",
|
||||
"TurnTally",
|
||||
"format_turn_summary",
|
||||
"format_token_flow",
|
||||
"format_elapsed",
|
||||
]
|
||||
|
||||
|
||||
# Leading glyph for the summary line. Deliberately not an emoji — the line is
|
||||
# meant to read as terminal chrome, not as agent speech.
|
||||
SUMMARY_PREFIX = "⋯"
|
||||
|
||||
# A turn that called no tools and finished this fast has nothing worth
|
||||
# reporting (plain chat reply). Below the threshold the formatter returns "".
|
||||
_MIN_TOOLLESS_SECONDS = 2.0
|
||||
|
||||
# Max number of "verb + count" segments rendered before collapsing the rest
|
||||
# into a "+N more" tail, so a 12-tool turn cannot blow past one line.
|
||||
_MAX_SEGMENTS = 4
|
||||
|
||||
|
||||
# Tool name -> (verb, singular noun, plural noun).
|
||||
#
|
||||
# Verbs are past tense because the line is printed *after* the turn. Tools not
|
||||
# listed here fall into a generic "called N tools" bucket rather than inventing
|
||||
# phrasing for plugin/MCP tools whose semantics we don't know.
|
||||
_VERB_GROUPS: dict[str, tuple[str, str, str]] = {
|
||||
"write_file": ("edited", "file", "files"),
|
||||
"patch": ("edited", "file", "files"),
|
||||
"read_file": ("read", "file", "files"),
|
||||
"web_extract": ("read", "page", "pages"),
|
||||
"terminal": ("ran", "command", "commands"),
|
||||
"execute_code": ("ran", "script", "scripts"),
|
||||
"search_files": ("searched", "path", "paths"),
|
||||
"web_search": ("searched the web", "time", "times"),
|
||||
"session_search": ("searched sessions", "time", "times"),
|
||||
"browser_navigate": ("browsed", "page", "pages"),
|
||||
"skill_view": ("read", "skill", "skills"),
|
||||
"skill_manage": ("updated", "skill", "skills"),
|
||||
"skills_list": ("listed skills", "time", "times"),
|
||||
"todo": ("updated", "task list", "task lists"),
|
||||
"delegate_task": ("delegated", "task", "tasks"),
|
||||
"memory": ("updated", "memory", "memories"),
|
||||
}
|
||||
|
||||
# Verb groups that carry file-edit line deltas (+X -Y) when known.
|
||||
_EDIT_VERB = "edited"
|
||||
|
||||
# Render order: edits first (the thing users most want confirmed), then reads,
|
||||
# then commands. Anything else follows in first-seen order.
|
||||
_VERB_PRIORITY: tuple[str, ...] = ("edited", "read", "ran")
|
||||
|
||||
# Tools whose results may report a unified diff we can count lines from.
|
||||
_DIFF_RESULT_TOOLS = frozenset({"patch"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnTally:
|
||||
"""What a single turn did, as observed from the tool-progress feed."""
|
||||
|
||||
# verb -> {noun_plural: count}; keeps insertion order for stable rendering.
|
||||
verbs: dict[str, dict[str, int]] = field(default_factory=dict)
|
||||
# Tools with no curated verb, counted together.
|
||||
other_tools: int = 0
|
||||
# Aggregated unified-diff line deltas across edit tools, when reported.
|
||||
lines_added: int = 0
|
||||
lines_removed: int = 0
|
||||
# True once at least one edit tool reported a countable diff, so the
|
||||
# formatter knows the difference between "+0 -0" and "unknown".
|
||||
has_line_deltas: bool = False
|
||||
|
||||
@property
|
||||
def total_tools(self) -> int:
|
||||
counted = sum(sum(nouns.values()) for nouns in self.verbs.values())
|
||||
return counted + self.other_tools
|
||||
|
||||
|
||||
def _count_diff_lines(diff: str) -> tuple[int, int]:
|
||||
"""Count added/removed lines in unified-diff text.
|
||||
|
||||
File headers (``+++``/``---``) are excluded so a one-line edit does not
|
||||
read as three additions.
|
||||
"""
|
||||
added = removed = 0
|
||||
for line in diff.splitlines():
|
||||
if line.startswith("+++") or line.startswith("---"):
|
||||
continue
|
||||
if line.startswith("+"):
|
||||
added += 1
|
||||
elif line.startswith("-"):
|
||||
removed += 1
|
||||
return added, removed
|
||||
|
||||
|
||||
def _extract_line_deltas(tool_name: str, result: Any) -> tuple[int, int] | None:
|
||||
"""Pull (added, removed) from a tool result, or None when unavailable.
|
||||
|
||||
Only tools that already report a diff in their result payload are
|
||||
inspected — we never shell out to git and never re-read files to
|
||||
synthesise a delta.
|
||||
"""
|
||||
if tool_name not in _DIFF_RESULT_TOOLS:
|
||||
return None
|
||||
payload: Any = result
|
||||
if isinstance(payload, str):
|
||||
text = payload.strip()
|
||||
if not text.startswith("{"):
|
||||
return None
|
||||
try:
|
||||
import json
|
||||
|
||||
# strict=False tolerates literal control characters inside strings
|
||||
# (raw newlines in an embedded diff), which some tool serialisers
|
||||
# emit. A tally line is never worth failing over formatting.
|
||||
payload = json.loads(text, strict=False)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
diff = payload.get("diff")
|
||||
if not isinstance(diff, str) or not diff.strip():
|
||||
return None
|
||||
added, removed = _count_diff_lines(diff)
|
||||
# A diff that carries no +/- content lines (e.g. a bare hunk header) tells
|
||||
# us nothing — report it as unknown rather than rendering a misleading
|
||||
# "+0 -0" next to a real edit.
|
||||
if added == 0 and removed == 0:
|
||||
return None
|
||||
return added, removed
|
||||
|
||||
|
||||
class TurnSummaryCollector:
|
||||
"""Accumulate per-turn tool tallies from the tool-progress feed.
|
||||
|
||||
Wired into the CLI's existing ``_on_tool_progress`` handler: the display
|
||||
layer already receives every ``tool.completed`` event with the tool name
|
||||
and raw result, so no agent-loop bookkeeping is added.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tally = TurnTally()
|
||||
|
||||
def begin(self) -> None:
|
||||
"""Start a fresh turn (drops any prior tally)."""
|
||||
self._tally = TurnTally()
|
||||
|
||||
def record_tool(
|
||||
self,
|
||||
tool_name: str | None,
|
||||
*,
|
||||
result: Any = None,
|
||||
is_error: bool = False,
|
||||
) -> None:
|
||||
"""Record one completed tool call.
|
||||
|
||||
Failed calls are skipped: a summary claiming "edited 2 files" when one
|
||||
write was denied would be exactly the over-claim the file-mutation
|
||||
verifier exists to catch.
|
||||
"""
|
||||
if not tool_name or is_error:
|
||||
return
|
||||
# Internal/pseudo tools (``_thinking``) are not user-visible work.
|
||||
if tool_name.startswith("_"):
|
||||
return
|
||||
|
||||
group = _VERB_GROUPS.get(tool_name)
|
||||
if group is None:
|
||||
self._tally.other_tools += 1
|
||||
return
|
||||
|
||||
verb, _singular, plural = group
|
||||
nouns = self._tally.verbs.setdefault(verb, {})
|
||||
nouns[plural] = nouns.get(plural, 0) + 1
|
||||
|
||||
if verb == _EDIT_VERB:
|
||||
deltas = _extract_line_deltas(tool_name, result)
|
||||
if deltas is not None:
|
||||
added, removed = deltas
|
||||
self._tally.lines_added += added
|
||||
self._tally.lines_removed += removed
|
||||
self._tally.has_line_deltas = True
|
||||
|
||||
@property
|
||||
def tally(self) -> TurnTally:
|
||||
return self._tally
|
||||
|
||||
def render(self, elapsed_seconds: float) -> str:
|
||||
"""Render this turn's summary line (see :func:`format_turn_summary`)."""
|
||||
return format_turn_summary(elapsed_seconds, self._tally)
|
||||
|
||||
|
||||
def format_elapsed(seconds: float) -> str:
|
||||
"""Format a wall-clock duration compactly (``12.4s`` / ``2m05s``)."""
|
||||
if seconds < 0:
|
||||
seconds = 0.0
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes, rest = divmod(int(round(seconds)), 60)
|
||||
return f"{minutes}m{rest:02d}s"
|
||||
|
||||
|
||||
def _pluralize(count: int, plural_noun: str) -> str:
|
||||
"""Return ``"1 file"`` / ``"3 files"`` from a plural noun form."""
|
||||
if count == 1:
|
||||
singular = plural_noun
|
||||
if plural_noun.endswith("ies"):
|
||||
singular = plural_noun[:-3] + "y"
|
||||
elif plural_noun.endswith("ses"):
|
||||
singular = plural_noun[:-2]
|
||||
elif plural_noun.endswith("s"):
|
||||
singular = plural_noun[:-1]
|
||||
return f"1 {singular}"
|
||||
return f"{count} {plural_noun}"
|
||||
|
||||
|
||||
def _ordered_verbs(tally: TurnTally) -> list[str]:
|
||||
"""Verbs in render order: priority verbs first, then first-seen order."""
|
||||
seen = list(tally.verbs.keys())
|
||||
ranked = [v for v in _VERB_PRIORITY if v in tally.verbs]
|
||||
ranked += [v for v in seen if v not in _VERB_PRIORITY]
|
||||
return ranked
|
||||
|
||||
|
||||
def format_turn_summary(
|
||||
elapsed_seconds: float,
|
||||
tally: TurnTally | None,
|
||||
*,
|
||||
max_segments: int = _MAX_SEGMENTS,
|
||||
) -> str:
|
||||
"""Render the per-turn accounting line, or ``""`` when there's nothing to say.
|
||||
|
||||
Pure function — no config lookups, no terminal access, no I/O. Gating
|
||||
(``display.turn_summary``, quiet mode, CLI-only) is the caller's job.
|
||||
"""
|
||||
if tally is None:
|
||||
tally = TurnTally()
|
||||
|
||||
segments: list[str] = []
|
||||
for verb in _ordered_verbs(tally):
|
||||
nouns = tally.verbs[verb]
|
||||
parts = [_pluralize(count, plural) for plural, count in nouns.items() if count]
|
||||
if not parts:
|
||||
continue
|
||||
segment = f"{verb} {', '.join(parts)}"
|
||||
if verb == _EDIT_VERB and tally.has_line_deltas:
|
||||
segment += f" +{tally.lines_added} -{tally.lines_removed}"
|
||||
segments.append(segment)
|
||||
|
||||
if tally.other_tools:
|
||||
segments.append(f"called {_pluralize(tally.other_tools, 'tools')}")
|
||||
|
||||
if not segments and tally.total_tools == 0 and elapsed_seconds < _MIN_TOOLLESS_SECONDS:
|
||||
return ""
|
||||
|
||||
if max_segments > 0 and len(segments) > max_segments:
|
||||
hidden = len(segments) - max_segments
|
||||
segments = segments[:max_segments] + [f"+{hidden} more"]
|
||||
|
||||
pieces = [format_elapsed(elapsed_seconds)] + segments
|
||||
return f"{SUMMARY_PREFIX} " + " · ".join(pieces)
|
||||
|
||||
|
||||
def format_token_flow(output_tokens: Any, *, arrow: str = "↓") -> str:
|
||||
"""Render cumulative turn tokens for the live spinner (``↓ 1.2k tok``).
|
||||
|
||||
Returns ``""`` for a non-positive count so the spinner shows nothing
|
||||
rather than a misleading ``↓ 0 tok`` before the first API response lands.
|
||||
"""
|
||||
try:
|
||||
count = int(output_tokens)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if count <= 0:
|
||||
return ""
|
||||
if count < 1000:
|
||||
return f"{arrow} {count} tok"
|
||||
if count < 1_000_000:
|
||||
return f"{arrow} {count / 1000:.1f}k tok"
|
||||
return f"{arrow} {count / 1_000_000:.1f}M tok"
|
||||
|
|
@ -12,11 +12,11 @@
|
|||
//! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`.
|
||||
//! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
|
@ -260,6 +260,107 @@ pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool {
|
|||
&& resolve_hermes_desktop_exe(install_root).is_some()
|
||||
}
|
||||
|
||||
fn resolve_marker_commit(install_root: &Path, pin: &Pin) -> Option<String> {
|
||||
if let Some(commit) = pin
|
||||
.commit
|
||||
.as_ref()
|
||||
.filter(|commit| !commit.trim().is_empty())
|
||||
{
|
||||
return Some(commit.clone());
|
||||
}
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(install_root)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if commit.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(commit)
|
||||
}
|
||||
}
|
||||
|
||||
fn write_bootstrap_complete_marker(install_root: &Path, pin: &Pin) -> Result<serde_json::Value> {
|
||||
use std::io::Write;
|
||||
|
||||
let marker_path = crate::paths::likely_bootstrap_marker(install_root);
|
||||
if let Some(parent) = marker_path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"could not create bootstrap marker directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let completed_at_unix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default();
|
||||
let marker = serde_json::json!({
|
||||
"schemaVersion": 1,
|
||||
"pinnedCommit": resolve_marker_commit(install_root, pin),
|
||||
"pinnedBranch": pin.branch.clone(),
|
||||
"completedAtUnix": completed_at_unix,
|
||||
});
|
||||
let mut body = serde_json::to_vec_pretty(&marker)?;
|
||||
body.push(b'\n');
|
||||
|
||||
// Atomic publish (temp sibling + flush + rename), matching Electron's
|
||||
// writeFileAtomic(). hermes_is_installed() only checks existence, so a
|
||||
// partial direct write would incorrectly enable the launcher fast path.
|
||||
let tmp_path = install_root.join(".hermes-bootstrap-complete.tmp");
|
||||
{
|
||||
let mut file = std::fs::File::create(&tmp_path).with_context(|| {
|
||||
format!(
|
||||
"could not create temp bootstrap marker {}",
|
||||
tmp_path.display()
|
||||
)
|
||||
})?;
|
||||
file.write_all(&body).with_context(|| {
|
||||
format!(
|
||||
"could not write temp bootstrap marker {}",
|
||||
tmp_path.display()
|
||||
)
|
||||
})?;
|
||||
file.sync_all().with_context(|| {
|
||||
format!(
|
||||
"could not flush temp bootstrap marker {}",
|
||||
tmp_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
// Windows rename fails if the destination already exists; drop any prior
|
||||
// marker first so a re-run can still publish a fresh payload.
|
||||
if marker_path.exists() {
|
||||
std::fs::remove_file(&marker_path).with_context(|| {
|
||||
format!(
|
||||
"could not replace existing bootstrap marker {}",
|
||||
marker_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if let Err(err) = std::fs::rename(&tmp_path, &marker_path) {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
return Err(err).with_context(|| {
|
||||
format!(
|
||||
"could not publish bootstrap marker {} → {}",
|
||||
tmp_path.display(),
|
||||
marker_path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(path = %marker_path.display(), "bootstrap marker written");
|
||||
Ok(marker)
|
||||
}
|
||||
|
||||
/// Spawn the already-built desktop app, detached. Returns Err if no built app
|
||||
/// exists or the spawn fails, so the caller can fall back to showing the
|
||||
/// installer UI.
|
||||
|
|
@ -644,6 +745,23 @@ async fn run_bootstrap(
|
|||
.unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned());
|
||||
let install_root = PathBuf::from(&hermes_home).join("hermes-agent");
|
||||
|
||||
// Marker publish is terminal for this run: a write failure must emit Failed
|
||||
// so the UI leaves the progress state (it does not poll get_bootstrap_status).
|
||||
let marker = match write_bootstrap_complete_marker(&install_root, &pin) {
|
||||
Ok(marker) => marker,
|
||||
Err(err) => {
|
||||
let msg = format!("write bootstrap marker failed: {err:#}");
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
};
|
||||
|
||||
// Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can
|
||||
// re-invoke us with `--update` and shortcuts have a stable target. This is
|
||||
// a one-shot install concern; an `--update` re-invocation no-ops because
|
||||
|
|
@ -660,10 +778,7 @@ async fn run_bootstrap(
|
|||
&app,
|
||||
BootstrapEvent::Complete {
|
||||
install_root: install_root.to_string_lossy().into_owned(),
|
||||
marker: Some(serde_json::json!({
|
||||
"pinnedCommit": pin.commit,
|
||||
"pinnedBranch": pin.branch,
|
||||
})),
|
||||
marker: Some(marker),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -903,4 +1018,103 @@ mod tests {
|
|||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_complete_marker_uses_desktop_compatible_schema() {
|
||||
let root = unique_tmp_dir("marker-schema");
|
||||
let pin = Pin {
|
||||
commit: Some("abcdef1234567890".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
|
||||
let marker =
|
||||
write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed");
|
||||
let marker_path = root.join(".hermes-bootstrap-complete");
|
||||
let from_disk: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap();
|
||||
|
||||
assert_eq!(marker, from_disk);
|
||||
assert_eq!(from_disk["schemaVersion"], 1);
|
||||
assert_eq!(from_disk["pinnedCommit"], "abcdef1234567890");
|
||||
assert_eq!(from_disk["pinnedBranch"], "main");
|
||||
assert!(
|
||||
from_disk["completedAtUnix"].as_u64().is_some(),
|
||||
"marker must carry a completion timestamp"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_complete_marker_is_published_atomically() {
|
||||
let root = unique_tmp_dir("marker-atomic");
|
||||
make_release_tree(&root);
|
||||
let pin = Pin {
|
||||
commit: Some("abcdef1234567890".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
|
||||
write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed");
|
||||
|
||||
let marker_path = root.join(".hermes-bootstrap-complete");
|
||||
let tmp_path = root.join(".hermes-bootstrap-complete.tmp");
|
||||
assert!(
|
||||
marker_path.is_file(),
|
||||
"final marker must exist after atomic publish"
|
||||
);
|
||||
assert!(
|
||||
!tmp_path.exists(),
|
||||
"temp sibling must not remain after atomic publish"
|
||||
);
|
||||
assert!(
|
||||
hermes_is_installed(&root),
|
||||
"atomically published marker must enable the installer fast path"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_is_installed_treats_marker_existence_as_sufficient() {
|
||||
// Documents why write_bootstrap_complete_marker must publish atomically:
|
||||
// the launcher predicate only checks existence, so a partial/corrupt
|
||||
// final marker would still enable the fast path.
|
||||
let root = unique_tmp_dir("marker-existence-only");
|
||||
make_release_tree(&root);
|
||||
std::fs::write(root.join(".hermes-bootstrap-complete"), b"").unwrap();
|
||||
|
||||
assert!(
|
||||
hermes_is_installed(&root),
|
||||
"empty/partial marker content still counts as installed"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_write_failure_leaves_no_final_marker() {
|
||||
// install_root is a regular file → create_dir_all on its path fails
|
||||
// before any marker bytes are published under the final name.
|
||||
let base = unique_tmp_dir("marker-fail");
|
||||
let not_a_dir = base.join("not-a-dir");
|
||||
std::fs::write(¬_a_dir, b"not a directory").unwrap();
|
||||
let pin = Pin {
|
||||
commit: Some("abcdef1234567890".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
|
||||
let err = write_bootstrap_complete_marker(¬_a_dir, &pin)
|
||||
.expect_err("marker write against a non-directory root must fail");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("bootstrap marker"),
|
||||
"error should mention the marker path: {msg}"
|
||||
);
|
||||
assert!(
|
||||
!not_a_dir.join(".hermes-bootstrap-complete").exists(),
|
||||
"failed write must not leave a final marker that enables the fast path"
|
||||
);
|
||||
assert!(
|
||||
!not_a_dir.join(".hermes-bootstrap-complete.tmp").exists(),
|
||||
"failed write must not leave a temp marker sibling either"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,8 +149,8 @@ fn repair_macos_installer_helper(path: &Path) {
|
|||
#[cfg(not(target_os = "macos"))]
|
||||
fn repair_macos_installer_helper(_path: &Path) {}
|
||||
|
||||
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
|
||||
/// the Electron app also checks). Per main.ts:
|
||||
/// Where the bootstrap-complete marker lives (existence-only for the Rust
|
||||
/// installer fast path; JSON schema-checked by the Electron app). Per main.ts:
|
||||
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||||
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
|
||||
/// this is a probe helper, not a definitive path.
|
||||
|
|
|
|||
|
|
@ -117,20 +117,31 @@ that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro`
|
|||
(status-stack/table-footers), and the icon family `icon` / `icon-xs` /
|
||||
`icon-sm` / `icon-lg` / `icon-titlebar`.
|
||||
|
||||
**Icon-only buttons must have a tooltip.** Every button with an `icon*` size
|
||||
carries no visible text label, so it must be wrapped in `<Tip label={...}>`
|
||||
with a descriptive label (matching the button's `aria-label`). Never use the
|
||||
native HTML `title=` attribute — it's unstyled, delayed (~500ms OS default),
|
||||
and visually inconsistent with the instant themed `Tip`. An enforcement test
|
||||
(`src/components/ui/__tests__/no-native-title.test.ts`) fails on any `<button>`
|
||||
or `<Button>` that still carries `title=`.
|
||||
**Tooltips only when hover teaches something new.** `<Tip>` is for discovery,
|
||||
not a tax on every icon. Ask: does hover reveal something the user cannot
|
||||
already see or infer? If not, skip the tip; keep an `aria-label` for a11y.
|
||||
|
||||
**Keybind hints in tooltips.** When a button corresponds to a rebindable
|
||||
hotkey, use `<TipKeybindLabel actionId="..." />` as the `Tip` label — it
|
||||
auto-reads both the i18n label and the current keybind combo from the store,
|
||||
so the hint stays live when the user rebinds. Pass `text={...}` only when the
|
||||
tooltip is context-dependent (e.g. "Show" / "Hide" based on state). Never
|
||||
hardcode combos in components — always read from the `$bindings` store via
|
||||
Tip unlabeled chrome when the job (or a keybind / truncated path / host /
|
||||
other detail) is not already on screen — toolbar / titlebar / statusbar icons,
|
||||
`TipKeybindLabel` shortcuts, ownership chips, unlabeled icon grids.
|
||||
|
||||
Do **not** tip:
|
||||
|
||||
- Menu triggers (kebabs / ⋯ / `ActionsMenu` / `DropdownMenuTrigger`) — the
|
||||
affordance is "open menu"; verbs live in the menu. Never tip
|
||||
`"Actions for ${row title}"` / `"Project actions"` / `"Actions"`.
|
||||
- Close / dismiss X buttons — the glyph is the label (`aria-label` only).
|
||||
- Controls whose visible label already says what the tip would ("click to…",
|
||||
paraphrases of the same words, timer labels restating "Running").
|
||||
|
||||
Never use native HTML `title=` on buttons — unstyled, ~500ms OS delay, clashes
|
||||
with the themed `Tip`. `src/components/ui/__tests__/no-native-title.test.ts`
|
||||
fails on any `<button>` / `<Button>` that still carries `title=`.
|
||||
|
||||
**Keybind hints in tooltips.** On a tipped button bound to a rebindable hotkey,
|
||||
use `<TipKeybindLabel actionId="..." />` — it reads the i18n label and the
|
||||
current combo from `$bindings`. Pass `text={...}` only when the label is
|
||||
context-dependent (e.g. "Show" / "Hide"). Never hardcode combos; always use
|
||||
`useKeybindHint` or `TipKeybindLabel`.
|
||||
|
||||
Notes:
|
||||
|
|
@ -192,7 +203,12 @@ Notes:
|
|||
semantics when unifying appearance.
|
||||
- Respect `AppShell` overlay ownership. Persistent terminal/content layers,
|
||||
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
|
||||
z-index literals.
|
||||
z-index literals. Pick a rung of the ladder in `styles.css` instead —
|
||||
`--z-modal-backdrop` / `--z-modal` / `--z-modal-popover`, `--z-over-modal`
|
||||
(toasts, tooltips, command surfaces) and `--z-over-modal-content`,
|
||||
`--z-switcher-backdrop` / `--z-switcher`, then the boot chain
|
||||
`--z-connecting` → `--z-onboarding` → `--z-setup` → `--z-crash`. Plain
|
||||
`z-10`/`z-20` are still right for stacking *within* one component.
|
||||
|
||||
## Iconography & brand
|
||||
|
||||
|
|
@ -294,9 +310,10 @@ The detailed state contract lives in the scoped
|
|||
- [ ] Tokens (`--ui-*`, `shadow-nous`, `--stroke-nous`) — zero raw colors /
|
||||
one-off shadows?
|
||||
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
|
||||
- [ ] Icon-only buttons wrapped in `<Tip>` with a descriptive label?
|
||||
- [ ] No native `title=` on buttons — use `<Tip>` instead?
|
||||
- [ ] Keybind hints read from the store via `useKeybindHint` / `TipKeybindLabel`?
|
||||
- [ ] Tips only where hover teaches something new (no kebab / menu-trigger
|
||||
tips; unlabeled chrome that needs discovery gets `<Tip>` + `aria-label`)?
|
||||
- [ ] No native `title=` on buttons?
|
||||
- [ ] Keybind hints on tipped buttons use `useKeybindHint` / `TipKeybindLabel`?
|
||||
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
|
||||
- [ ] Flat — no card-in-card, no gratuitous row dividers?
|
||||
- [ ] No automatic navigation, focus steal, or pane opening from background
|
||||
|
|
|
|||
|
|
@ -120,6 +120,17 @@ export function createSandbox(prefix: string): Sandbox {
|
|||
'utf8',
|
||||
)
|
||||
|
||||
// Pin Chromium actual-size zoom (level 0) for the suite. Fresh installs
|
||||
// ship DEFAULT_ZOOM_LEVEL at the Appearance 90% preset, but Playwright
|
||||
// click hit-testing and the committed visual baselines were calibrated at
|
||||
// 100%. Without this file every sandbox would inherit the product default
|
||||
// and fail pointer interception + snapshot diffs.
|
||||
fs.writeFileSync(
|
||||
path.join(userDataDir, 'zoom-state.json'),
|
||||
JSON.stringify({ zoomLevel: 0 }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
return {
|
||||
root,
|
||||
hermesHome,
|
||||
|
|
@ -230,6 +241,10 @@ export function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}
|
|||
HERMES_DESKTOP_IGNORE_EXISTING: '1',
|
||||
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
|
||||
HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
|
||||
// `app.close()` in teardown must exit even when a spec leaves a turn
|
||||
// mid-flight — otherwise the quit confirmation waits on a click that no
|
||||
// one is there to make, and the worker dies on a teardown timeout.
|
||||
HERMES_DESKTOP_SKIP_QUIT_CONFIRM: '1',
|
||||
// Clear dev-server override — we want the built dist/, not a vite server.
|
||||
// The dev-server check in main.ts looks for this env var; if it's set,
|
||||
// it loads from the vite URL instead of the local file.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ import { RealSessionBuilder } from './real-session-builder'
|
|||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const SESSION_TITLE = 'E2E large persisted session'
|
||||
const EXPECTED_TEXT = 'E2E persisted user message 52'
|
||||
// The oldest seeded turn (HISTORY_TURNS[0]). The transcript first paints only
|
||||
// the newest turns (FIRST_PAINT_BUDGET) and backfills the rest in a rAF; a
|
||||
// baseline count taken before that backfill sees a clipped transcript and
|
||||
// falsely reports duplicates once the full list mounts. Waiting for this
|
||||
// oldest row means the baseline reflects the fully-mounted transcript.
|
||||
const OLDEST_SEEDED_TEXT = 'E2E persisted user message 0: audit the compatibility matrix'
|
||||
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
|
||||
const HISTORY_TURNS = Array.from(
|
||||
{ length: 27 },
|
||||
|
|
@ -210,6 +216,16 @@ test.describe('large session resume', () => {
|
|||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
// The transcript first paints only the newest turns (FIRST_PAINT_BUDGET)
|
||||
// and backfills older turns in a rAF. Wait for the oldest seeded row to
|
||||
// mount before taking the baseline so it reflects the full transcript —
|
||||
// otherwise a clipped baseline makes the backfilled rows look like
|
||||
// duplicates of the completed reply.
|
||||
await fixture.page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
OLDEST_SEEDED_TEXT,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY)
|
||||
await submitPrompt(fixture.page, BACKGROUND_PROMPT)
|
||||
await fixture.mock.waitForHeldStream()
|
||||
|
|
|
|||
|
|
@ -4,11 +4,7 @@
|
|||
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server'
|
||||
|
||||
async function send(page: Page, text: string, delay = 15): Promise<void> {
|
||||
|
|
@ -25,12 +21,11 @@ async function pasteAndSend(page: Page, text: string): Promise<void> {
|
|||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
|
||||
async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false,
|
||||
text,
|
||||
{ timeout },
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -62,23 +57,16 @@ test.describe('session compression', () => {
|
|||
await send(page, 'E2E_COMPRESSION_THIRD')
|
||||
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1)
|
||||
|
||||
// Commit the command before typing its argument. This waits for the async
|
||||
// completion request on cold CI workers, then uses the composer's own
|
||||
// keyboard accept path to replace the `/compress` trigger with a command
|
||||
// chip. Clicking a later completion after typing the argument can insert a
|
||||
// second command token (for example `//compress ...`) as plain text.
|
||||
// This test covers compression and continuation, not slash completion.
|
||||
// Insert the complete command atomically and click Send so an async
|
||||
// completion response cannot consume Enter as a picker acceptance.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type('/compress', { delay: 15 })
|
||||
await page.getByText('/compress').first().waitFor({ state: 'visible' })
|
||||
await page.keyboard.press('Enter')
|
||||
await composer.type(' preserve the three test turns', { delay: 15 })
|
||||
await page.keyboard.press('Enter')
|
||||
await page.keyboard.insertText('/compress preserve the three test turns')
|
||||
await expect.poll(() => composer.textContent()).toContain('preserve the three test turns')
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click()
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator('[data-slot="aui_thread-viewport"]').textContent(),
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.poll(() => page.locator('[data-slot="aui_thread-viewport"]').textContent(), { timeout: 90_000 })
|
||||
.toMatch(/Compressed|No changes from compression/)
|
||||
|
||||
// Compression rotates the agent's live session id. A post-compression
|
||||
|
|
@ -105,7 +93,7 @@ auxiliary:
|
|||
provider: custom
|
||||
model: mock-model`,
|
||||
mockServer: {
|
||||
holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.',
|
||||
holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.'
|
||||
}
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
|
|
|||
60
apps/desktop/electron/active-runtime-state.test.ts
Normal file
60
apps/desktop/electron/active-runtime-state.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { classifyActiveRuntime, hasValidBootstrapMarker } from './active-runtime-state'
|
||||
|
||||
const VALID_MARKER = {
|
||||
pinnedCommit: '1234567890abcdef1234567890abcdef12345678',
|
||||
schemaVersion: 1
|
||||
}
|
||||
|
||||
test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => {
|
||||
assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true)
|
||||
})
|
||||
|
||||
test('hasValidBootstrapMarker rejects missing, wrong-schema, and too-short markers', () => {
|
||||
assert.equal(hasValidBootstrapMarker(null, 1), false)
|
||||
assert.equal(hasValidBootstrapMarker({ schemaVersion: 2, pinnedCommit: VALID_MARKER.pinnedCommit }, 1), false)
|
||||
assert.equal(hasValidBootstrapMarker({ schemaVersion: 1, pinnedCommit: 'abc123' }, 1), false)
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime uses a healthy active runtime even when the bootstrap marker is missing', () => {
|
||||
assert.deepEqual(classifyActiveRuntime(null, 1, true), {
|
||||
hasValidMarker: false,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
})
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime uses a healthy active runtime even when the marker is stale or malformed', () => {
|
||||
assert.deepEqual(classifyActiveRuntime({ schemaVersion: 999, pinnedCommit: 'abc1234' }, 1, true), {
|
||||
hasValidMarker: false,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
})
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime refuses an unusable runtime even if a valid marker exists', () => {
|
||||
assert.deepEqual(classifyActiveRuntime(VALID_MARKER, 1, false), {
|
||||
hasValidMarker: true,
|
||||
shouldUseActiveRuntime: false,
|
||||
usabilityReason: 'unusable'
|
||||
})
|
||||
})
|
||||
|
||||
test('a CLI-installed runtime with no marker launches instead of re-running bootstrap', () => {
|
||||
// The reported symptom (#60721): install.sh / install.ps1 produced a healthy
|
||||
// repo+venv, no desktop-managed marker was ever written, and every launch
|
||||
// dropped the user back into the first-run installer.
|
||||
const state = classifyActiveRuntime(null, 1, true)
|
||||
|
||||
assert.equal(state.shouldUseActiveRuntime, true, 'a usable runtime must launch')
|
||||
assert.equal(state.hasValidMarker, false, 'marker provenance stays honest')
|
||||
})
|
||||
|
||||
test('a repair that deleted the marker does not strand a healthy install', () => {
|
||||
// #72166: the repair handler clears the marker unconditionally. Runtime
|
||||
// usability, not marker presence, must decide the next boot.
|
||||
assert.equal(classifyActiveRuntime(null, 1, true).shouldUseActiveRuntime, true)
|
||||
})
|
||||
58
apps/desktop/electron/active-runtime-state.ts
Normal file
58
apps/desktop/electron/active-runtime-state.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export interface BootstrapMarkerLike {
|
||||
pinnedCommit?: unknown
|
||||
schemaVersion?: unknown
|
||||
}
|
||||
|
||||
export interface ActiveRuntimeState {
|
||||
hasValidMarker: boolean
|
||||
shouldUseActiveRuntime: boolean
|
||||
usabilityReason: 'usable' | 'unusable'
|
||||
}
|
||||
|
||||
export function hasValidBootstrapMarker(
|
||||
marker: BootstrapMarkerLike | null | undefined,
|
||||
schemaVersion: number
|
||||
): boolean {
|
||||
if (!marker || typeof marker !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (marker.schemaVersion !== schemaVersion) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// The active install at ~/.hermes/hermes-agent can be real and runnable even if
|
||||
// Desktop never wrote its first-run bootstrap marker (for example when Hermes
|
||||
// was installed by the CLI first, or when a past desktop build forgot the
|
||||
// marker). Runtime usability is authoritative for "can we launch local Hermes
|
||||
// right now?"; the marker is only provenance about how that install was
|
||||
// created. A missing/stale marker must never force a healthy local install into
|
||||
// the first-run bootstrap UI.
|
||||
export function classifyActiveRuntime(
|
||||
marker: BootstrapMarkerLike | null | undefined,
|
||||
schemaVersion: number,
|
||||
runtimeUsable: boolean
|
||||
): ActiveRuntimeState {
|
||||
const hasValidMarker = hasValidBootstrapMarker(marker, schemaVersion)
|
||||
|
||||
if (!runtimeUsable) {
|
||||
return {
|
||||
hasValidMarker,
|
||||
shouldUseActiveRuntime: false,
|
||||
usabilityReason: 'unusable'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasValidMarker,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
resolveAuthMode,
|
||||
resolveProfileBackendRoute,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
savedProfileSsh,
|
||||
|
|
@ -187,6 +188,65 @@ test('saved SSH drafts are inactive and explicit overrides take precedence', ()
|
|||
assert.equal(profileHasRemoteConnection(config, 'coder'), true)
|
||||
})
|
||||
|
||||
// --- resolveProfileBackendRoute ---
|
||||
|
||||
const ROUTES = [
|
||||
{
|
||||
name: 'the primary profile owns the window backend',
|
||||
profile: 'default',
|
||||
opts: { primaryProfile: 'default' },
|
||||
expected: { backend: 'primary', descriptorProfile: null, scopePath: false }
|
||||
},
|
||||
{
|
||||
name: 'a renamed primary profile still owns the window backend',
|
||||
profile: ' coder ',
|
||||
opts: { primaryProfile: 'coder', globalRemote: true },
|
||||
expected: { backend: 'primary', descriptorProfile: null, scopePath: false }
|
||||
},
|
||||
{
|
||||
name: 'an unset profile resolves to the primary',
|
||||
profile: '',
|
||||
opts: { primaryProfile: 'default', globalRemote: true },
|
||||
expected: { backend: 'primary', descriptorProfile: null, scopePath: false }
|
||||
},
|
||||
{
|
||||
name: 'a profile inheriting the app-global remote shares the primary backend, scoped per request',
|
||||
profile: 'coder',
|
||||
opts: { primaryProfile: 'default', globalRemote: true, profileRemoteOverride: false },
|
||||
expected: { backend: 'primary', descriptorProfile: 'coder', scopePath: true }
|
||||
},
|
||||
{
|
||||
name: 'a profile with its own remote override gets a pooled descriptor for that host',
|
||||
profile: 'coder',
|
||||
opts: { primaryProfile: 'default', globalRemote: true, profileRemoteOverride: true },
|
||||
expected: { backend: 'pool', descriptorProfile: null, scopePath: false }
|
||||
},
|
||||
{
|
||||
name: 'a local non-primary profile gets its own pooled backend',
|
||||
profile: 'coder',
|
||||
opts: { primaryProfile: 'default', globalRemote: false, profileRemoteOverride: false },
|
||||
expected: { backend: 'pool', descriptorProfile: null, scopePath: false }
|
||||
}
|
||||
]
|
||||
|
||||
for (const route of ROUTES) {
|
||||
test(`resolveProfileBackendRoute: ${route.name}`, () => {
|
||||
assert.deepEqual(resolveProfileBackendRoute(route.profile, route.opts), route.expected)
|
||||
})
|
||||
}
|
||||
|
||||
test('resolveProfileBackendRoute only tags a descriptor when the backend is shared', () => {
|
||||
// A pooled backend is already scoped to its profile, so tagging it would
|
||||
// imply a second scope the caller must reconcile. Only the shared
|
||||
// global-remote route carries one.
|
||||
for (const route of ROUTES) {
|
||||
const resolved = resolveProfileBackendRoute(route.profile, route.opts)
|
||||
|
||||
assert.equal(Boolean(resolved.descriptorProfile), resolved.scopePath)
|
||||
assert.ok(!resolved.descriptorProfile || resolved.backend === 'primary')
|
||||
}
|
||||
})
|
||||
|
||||
// --- pathWithGlobalRemoteProfile ---
|
||||
|
||||
test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
|
||||
|
|
@ -199,6 +259,17 @@ test('pathWithGlobalRemoteProfile appends profile in global remote mode', () =>
|
|||
)
|
||||
})
|
||||
|
||||
test('pathWithGlobalRemoteProfile skips the primary profile, which the remote already serves', () => {
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/info', 'coder', {
|
||||
globalRemote: true,
|
||||
primaryProfile: 'coder',
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
'/api/model/info'
|
||||
)
|
||||
})
|
||||
|
||||
test('pathWithGlobalRemoteProfile preserves existing query params', () => {
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', {
|
||||
|
|
|
|||
|
|
@ -350,16 +350,68 @@ function profileRemoteOverride(config, profile) {
|
|||
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
|
||||
}
|
||||
|
||||
export interface ProfileRouteOptions {
|
||||
globalRemote?: boolean
|
||||
primaryProfile?: null | string
|
||||
profileRemoteOverride?: boolean
|
||||
}
|
||||
|
||||
export interface ProfileBackendRoute {
|
||||
/** Which backend serves this profile: the window backend, or a pooled one. */
|
||||
backend: 'pool' | 'primary'
|
||||
/**
|
||||
* Profile to tag on the returned descriptor when the backend is shared and
|
||||
* therefore not itself scoped to that profile. Null when the backend already
|
||||
* belongs to the profile.
|
||||
*/
|
||||
descriptorProfile: null | string
|
||||
/** Whether REST paths on this route must carry `?profile=` to be scoped. */
|
||||
scopePath: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* In global-remote mode one backend serves every Desktop profile, so REST calls
|
||||
* that are scoped by renderer-side `request.profile` must carry that scope as a
|
||||
* query parameter. Local pooled backends and per-profile remote overrides do not
|
||||
* need this: they already run against a backend scoped to the target profile.
|
||||
* The one place that answers "which backend serves profile P, and does its
|
||||
* REST path need a profile scope?". Four routes, in precedence order:
|
||||
*
|
||||
* 1. The primary profile owns the window backend outright.
|
||||
* 2. A profile with its own remote override gets a pooled descriptor for that
|
||||
* host, which is already scoped to it.
|
||||
* 3. A profile inheriting the app-global remote shares the primary backend —
|
||||
* one host serves every profile — so it is scoped per request instead.
|
||||
* 4. Any other local profile gets its own pooled backend, spawned with
|
||||
* `--profile`, so its `HERMES_HOME` scopes it.
|
||||
*
|
||||
* Routing used to be spread across three overlapping predicates that each
|
||||
* re-derived part of this table, which is how case 3 ended up registering
|
||||
* reapable pool entries for backends it never owned.
|
||||
*/
|
||||
function pathWithGlobalRemoteProfile(path, profile, opts: any = {}) {
|
||||
function resolveProfileBackendRoute(profile, opts: ProfileRouteOptions = {}): ProfileBackendRoute {
|
||||
const scopedProfile = connectionScopeKey(profile)
|
||||
const primaryProfile = connectionScopeKey(opts.primaryProfile) || 'default'
|
||||
|
||||
if (!scopedProfile || scopedProfile === primaryProfile) {
|
||||
return { backend: 'primary', descriptorProfile: null, scopePath: false }
|
||||
}
|
||||
|
||||
if (opts.profileRemoteOverride) {
|
||||
return { backend: 'pool', descriptorProfile: null, scopePath: false }
|
||||
}
|
||||
|
||||
if (opts.globalRemote) {
|
||||
return { backend: 'primary', descriptorProfile: scopedProfile, scopePath: true }
|
||||
}
|
||||
|
||||
return { backend: 'pool', descriptorProfile: null, scopePath: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add renderer-side `request.profile` to a REST path when the route says the
|
||||
* serving backend is not already scoped to that profile.
|
||||
*/
|
||||
function pathWithGlobalRemoteProfile(path, profile, opts: ProfileRouteOptions = {}) {
|
||||
const scopedProfile = connectionScopeKey(profile)
|
||||
|
||||
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
|
||||
if (!resolveProfileBackendRoute(profile, opts).scopePath) {
|
||||
return path
|
||||
}
|
||||
|
||||
|
|
@ -506,6 +558,7 @@ export {
|
|||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
resolveAuthMode,
|
||||
resolveProfileBackendRoute,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
savedProfileSsh,
|
||||
|
|
|
|||
70
apps/desktop/electron/crash-forensics.test.ts
Normal file
70
apps/desktop/electron/crash-forensics.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { describeCrashReason, installCrashForensics } from './crash-forensics'
|
||||
|
||||
const harness = () => {
|
||||
const listeners = new Map<string, (value: unknown) => void>()
|
||||
const flush = vi.fn()
|
||||
const log = vi.fn()
|
||||
|
||||
installCrashForensics({
|
||||
flush,
|
||||
log,
|
||||
target: { on: (event, listener) => listeners.set(event, listener) }
|
||||
})
|
||||
|
||||
return { flush, listeners, log }
|
||||
}
|
||||
|
||||
describe('describeCrashReason', () => {
|
||||
it('prefers a stack, then a message, for thrown errors', () => {
|
||||
const withStack = new Error('boom')
|
||||
withStack.stack = 'Error: boom\n at somewhere'
|
||||
|
||||
expect(describeCrashReason(withStack)).toBe('Error: boom\n at somewhere')
|
||||
|
||||
const withoutStack = new Error('boom')
|
||||
withoutStack.stack = ''
|
||||
|
||||
expect(describeCrashReason(withoutStack)).toBe('boom')
|
||||
})
|
||||
|
||||
it('renders non-error rejections without throwing', () => {
|
||||
expect(describeCrashReason('plain string')).toBe('plain string')
|
||||
expect(describeCrashReason({ code: 'ECONNRESET' })).toBe('{"code":"ECONNRESET"}')
|
||||
expect(describeCrashReason(undefined)).toBe('undefined')
|
||||
|
||||
const circular: Record<string, unknown> = {}
|
||||
circular.self = circular
|
||||
|
||||
expect(describeCrashReason(circular)).toBe('[object Object]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('installCrashForensics', () => {
|
||||
it('records and synchronously flushes an uncaught exception', () => {
|
||||
const { flush, listeners, log } = harness()
|
||||
const error = new Error('renderer gone')
|
||||
error.stack = 'Error: renderer gone\n at main'
|
||||
|
||||
listeners.get('uncaughtException')?.(error)
|
||||
|
||||
expect(log).toHaveBeenCalledWith('[main] Uncaught exception: Error: renderer gone\n at main')
|
||||
expect(flush).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('records and synchronously flushes an unhandled rejection', () => {
|
||||
const { flush, listeners, log } = harness()
|
||||
|
||||
listeners.get('unhandledRejection')?.('gateway ticket mint failed')
|
||||
|
||||
expect(log).toHaveBeenCalledWith('[main] Unhandled rejection: gateway ticket mint failed')
|
||||
expect(flush).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('registers both handlers', () => {
|
||||
const { listeners } = harness()
|
||||
|
||||
expect([...listeners.keys()].sort()).toEqual(['uncaughtException', 'unhandledRejection'])
|
||||
})
|
||||
})
|
||||
51
apps/desktop/electron/crash-forensics.ts
Normal file
51
apps/desktop/electron/crash-forensics.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Last-chance forensics for the Electron main process.
|
||||
*
|
||||
* Electron installs its own `uncaughtException` listener and only warns on
|
||||
* unhandled rejections, so the app usually survives — but the reason lands on
|
||||
* stderr alone, which is discarded entirely when the app is launched from
|
||||
* Finder or the Start menu. Without a record in desktop.log, a main-process
|
||||
* fault is invisible in a `hermes debug share` bundle and the user is left
|
||||
* describing symptoms instead of showing a stack.
|
||||
*/
|
||||
|
||||
export interface CrashForensicsTarget {
|
||||
on: (event: 'uncaughtException' | 'unhandledRejection', listener: (value: unknown) => void) => unknown
|
||||
}
|
||||
|
||||
export interface CrashForensicsOptions {
|
||||
flush: () => void
|
||||
log: (message: string) => void
|
||||
target?: CrashForensicsTarget
|
||||
}
|
||||
|
||||
/** Render a thrown value for the log, preferring a stack over a bare message. */
|
||||
export function describeCrashReason(reason: unknown): string {
|
||||
if (reason instanceof Error) {
|
||||
return reason.stack || reason.message || reason.name || 'Error'
|
||||
}
|
||||
|
||||
if (typeof reason === 'string') {
|
||||
return reason
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(reason) ?? String(reason)
|
||||
} catch {
|
||||
return String(reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record main-process faults to desktop.log and flush synchronously, since a
|
||||
* fault that does prove fatal leaves no chance for the batched async flush.
|
||||
*/
|
||||
export function installCrashForensics({ flush, log, target = process }: CrashForensicsOptions): void {
|
||||
const record = (label: string) => (reason: unknown) => {
|
||||
log(`[main] ${label}: ${describeCrashReason(reason)}`)
|
||||
flush()
|
||||
}
|
||||
|
||||
target.on('uncaughtException', record('Uncaught exception'))
|
||||
target.on('unhandledRejection', record('Unhandled rejection'))
|
||||
}
|
||||
94
apps/desktop/electron/dev-cdp.test.ts
Normal file
94
apps/desktop/electron/dev-cdp.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Tests for electron/dev-cdp.ts.
|
||||
*
|
||||
* Run with: npx vitest run --project electron electron/dev-cdp.test.ts
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { DEFAULT_PORT, describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp'
|
||||
|
||||
const DEV_SERVER = 'http://127.0.0.1:5174'
|
||||
|
||||
/** The ordinary `npm run dev` / `hgui` run. */
|
||||
const devRun = { env: {}, isPackaged: false, devServer: DEV_SERVER }
|
||||
|
||||
test('a dev-server run opens the default port with no opt-in', () => {
|
||||
assert.deepEqual(resolveDevCdpPort(devRun), { port: DEFAULT_PORT, reason: null })
|
||||
})
|
||||
|
||||
test('the default matches what the scripts/ tooling reaches for', () => {
|
||||
// scripts/eval.mjs and scripts/perf/lib/cdp.mjs both default here; if this
|
||||
// drifts, `node scripts/eval.mjs ...` stops finding a live renderer.
|
||||
assert.equal(DEFAULT_PORT, 9222)
|
||||
})
|
||||
|
||||
test('a packaged build never opens the port, however loudly the env asks', () => {
|
||||
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: '9222' }, isPackaged: true })
|
||||
|
||||
assert.deepEqual(decision, { port: null, reason: 'packaged' })
|
||||
})
|
||||
|
||||
test('packaged is checked before every other gate', () => {
|
||||
// Belt-and-suspenders: dev server present, valid port requested, still shut.
|
||||
for (const value of ['9222', '', 'off', 'garbage']) {
|
||||
const decision = resolveDevCdpPort({
|
||||
env: { HERMES_DESKTOP_CDP_PORT: value },
|
||||
isPackaged: true,
|
||||
devServer: DEV_SERVER
|
||||
})
|
||||
|
||||
assert.equal(decision.port, null, `expected packaged to refuse ${JSON.stringify(value)}`)
|
||||
assert.equal(decision.reason, 'packaged')
|
||||
}
|
||||
})
|
||||
|
||||
test('an unpackaged dist run (no dev server) does not qualify', () => {
|
||||
// `electron .` against dist/ is how the packaged app gets smoke tested; it
|
||||
// should behave like the packaged app, not like a source-tree dev run.
|
||||
assert.deepEqual(resolveDevCdpPort({ ...devRun, devServer: undefined }), { port: null, reason: 'no-dev-server' })
|
||||
})
|
||||
|
||||
test('the port is overridable', () => {
|
||||
assert.equal(resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: '9333' } }).port, 9333)
|
||||
})
|
||||
|
||||
test('tolerates surrounding whitespace on the override', () => {
|
||||
assert.equal(resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: ' 9333 ' } }).port, 9333)
|
||||
})
|
||||
|
||||
test('can be switched off on a dev run', () => {
|
||||
for (const value of ['0', 'off', 'OFF', 'false', 'no']) {
|
||||
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: value } })
|
||||
|
||||
assert.equal(decision.port, null, `expected ${JSON.stringify(value)} to close the port`)
|
||||
assert.equal(decision.reason, 'opted-out')
|
||||
}
|
||||
})
|
||||
|
||||
test('refuses ports that are not usable integers', () => {
|
||||
for (const value of ['80', '-1', '70000', 'yes', '9222.5', '92 22']) {
|
||||
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: value } })
|
||||
|
||||
assert.equal(decision.port, null, `expected ${JSON.stringify(value)} to be refused`)
|
||||
assert.equal(decision.reason, 'invalid-port')
|
||||
}
|
||||
})
|
||||
|
||||
test('explains itself when an explicit setting was not honoured', () => {
|
||||
// A typo'd port or a deliberate opt-out should say so — silently doing
|
||||
// something other than what the env asked for is the bad failure mode.
|
||||
for (const value of ['garbage', 'off']) {
|
||||
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: value } })
|
||||
|
||||
assert.ok(describeDevCdpDecision(decision), `expected an explanation for ${JSON.stringify(value)}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('stays quiet when the port opened, or is closed by design', () => {
|
||||
assert.equal(describeDevCdpDecision(resolveDevCdpPort(devRun)), null)
|
||||
assert.equal(describeDevCdpDecision(resolveDevCdpPort({ ...devRun, isPackaged: true })), null)
|
||||
assert.equal(describeDevCdpDecision(resolveDevCdpPort({ ...devRun, devServer: undefined })), null)
|
||||
})
|
||||
108
apps/desktop/electron/dev-cdp.ts
Normal file
108
apps/desktop/electron/dev-cdp.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Dev Chrome DevTools Protocol exposure for the desktop renderer.
|
||||
*
|
||||
* The renderer is a Chromium page, so `--remote-debugging-port` turns it into
|
||||
* something the repo's existing CDP tooling (`scripts/eval.mjs`,
|
||||
* `scripts/perf/lib/cdp.mjs`, the `diag-*` / `probe-*` family) can attach to
|
||||
* and read the live DOM from. Every one of those scripts already defaults to
|
||||
* 9222, so a dev-server run opens 9222 and they just work.
|
||||
*
|
||||
* If you are running a dev server you are already executing arbitrary local
|
||||
* JS — vite's module graph and every postinstall in node_modules — so a
|
||||
* loopback debugging port does not meaningfully widen that. `perf:serve`
|
||||
* already opens one unconditionally. What must never happen is a *packaged*
|
||||
* app exposing it, which is the one hard gate here.
|
||||
*
|
||||
* - packaged build → always closed, whatever the env says.
|
||||
* - no HERMES_DESKTOP_DEV_SERVER → closed (an unpackaged `electron .` against
|
||||
* dist/ is how the packaged app gets smoke tested; it should behave like
|
||||
* the packaged app).
|
||||
* - otherwise → open on 9222, or HERMES_DESKTOP_CDP_PORT.
|
||||
*
|
||||
* `HERMES_DESKTOP_CDP_PORT=off` (or `0` / `false`) opts out for anyone who
|
||||
* wants the port closed on a dev run.
|
||||
*
|
||||
* The port binds to loopback (Chromium's default) and the address is
|
||||
* deliberately not configurable: there is no reason to expose a renderer
|
||||
* debugger off-host, and offering the knob invites someone to try.
|
||||
*/
|
||||
|
||||
/** Why the port is closed, for a one-line log the developer can act on. */
|
||||
type ClosedReason = 'packaged' | 'no-dev-server' | 'opted-out' | 'invalid-port'
|
||||
|
||||
type DevCdpDecision = { port: number; reason: null } | { port: null; reason: ClosedReason }
|
||||
|
||||
type DevCdpInput = {
|
||||
env: Record<string, string | undefined>
|
||||
isPackaged: boolean
|
||||
devServer: string | undefined
|
||||
}
|
||||
|
||||
/** What every script under scripts/ already reaches for. */
|
||||
const DEFAULT_PORT = 9222
|
||||
|
||||
// Below 1024 needs privileges on most platforms; 65535 is the ceiling.
|
||||
const MIN_PORT = 1024
|
||||
const MAX_PORT = 65535
|
||||
|
||||
const OPT_OUT = new Set(['0', 'off', 'false', 'no'])
|
||||
|
||||
/**
|
||||
* Decide whether this run may expose a renderer debugging port, and on which
|
||||
* port. Pure: every input is passed in, so the gate is testable without an
|
||||
* Electron app or a real environment.
|
||||
*/
|
||||
function resolveDevCdpPort({ env, isPackaged, devServer }: DevCdpInput): DevCdpDecision {
|
||||
// Packaged wins over everything. Checked first so no combination of
|
||||
// environment variables can talk a shipped build into opening the port.
|
||||
if (isPackaged) {
|
||||
return { port: null, reason: 'packaged' }
|
||||
}
|
||||
|
||||
// A dev server means a source-tree run (`npm run dev` / `hgui`).
|
||||
if (!devServer) {
|
||||
return { port: null, reason: 'no-dev-server' }
|
||||
}
|
||||
|
||||
const requested = (env.HERMES_DESKTOP_CDP_PORT ?? '').trim()
|
||||
|
||||
if (!requested) {
|
||||
return { port: DEFAULT_PORT, reason: null }
|
||||
}
|
||||
|
||||
if (OPT_OUT.has(requested.toLowerCase())) {
|
||||
return { port: null, reason: 'opted-out' }
|
||||
}
|
||||
|
||||
const port = Number(requested)
|
||||
|
||||
if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) {
|
||||
return { port: null, reason: 'invalid-port' }
|
||||
}
|
||||
|
||||
return { port, reason: null }
|
||||
}
|
||||
|
||||
/** One-line explanation for a closed port, or null when it opened. */
|
||||
function describeDevCdpDecision(decision: DevCdpDecision): string | null {
|
||||
switch (decision.reason) {
|
||||
case null:
|
||||
return null
|
||||
|
||||
case 'invalid-port':
|
||||
return `HERMES_DESKTOP_CDP_PORT is not a valid port (expected an integer ${MIN_PORT}-${MAX_PORT}, or "off"); renderer debugging is disabled.`
|
||||
|
||||
case 'opted-out':
|
||||
return 'renderer debugging disabled by HERMES_DESKTOP_CDP_PORT.'
|
||||
|
||||
// Packaged and dist-run builds are closed by design — the common case, not
|
||||
// worth a line of startup noise.
|
||||
case 'packaged':
|
||||
|
||||
case 'no-dev-server':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_PORT, describeDevCdpDecision, resolveDevCdpPort }
|
||||
export type { DevCdpDecision }
|
||||
222
apps/desktop/electron/find-in-page.test.ts
Normal file
222
apps/desktop/electron/find-in-page.test.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* Unit tests for the pure find-in-page helpers. The IPC handlers in
|
||||
* main.ts are the only consumer — the helpers below must keep the wire
|
||||
* shape stable (match counter shape, defaults, no-throw-on-destroyed).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { describe, test } from 'vitest'
|
||||
|
||||
import { formatFoundInPage, installFoundInPageForwarder, performFind, stopFind } from './find-in-page'
|
||||
|
||||
// Minimal webContents stub. The Electron.WebContents type is huge, so we
|
||||
// model just the slice the helpers touch (`isDestroyed`, `findInPage`,
|
||||
// `stopFindInPage`, `on`/`off`, `send`, `destroyed`, `emit`) and cast through
|
||||
// `asWC()` at call sites.
|
||||
interface FakeWebContents {
|
||||
calls: {
|
||||
find: Array<{ query: string; options: { forward: boolean; findNext: boolean } }>
|
||||
stop: Array<'clearSelection' | 'keepSelection' | 'activateSelection'>
|
||||
send: Array<{ channel: string; payload: unknown }>
|
||||
}
|
||||
isDestroyed: () => boolean
|
||||
destroy: () => void
|
||||
findInPage: (query: string, options: { forward: boolean; findNext: boolean }) => void
|
||||
stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => void
|
||||
send: (channel: string, payload: unknown) => void
|
||||
on: typeof EventEmitter.prototype.on
|
||||
off: typeof EventEmitter.prototype.off
|
||||
emit: (event: string | symbol, ...args: unknown[]) => boolean
|
||||
}
|
||||
|
||||
function makeFakeWebContents(): FakeWebContents {
|
||||
const emitter = new EventEmitter()
|
||||
|
||||
const calls = {
|
||||
find: [] as Array<{ query: string; options: { forward: boolean; findNext: boolean } }>,
|
||||
stop: [] as Array<'clearSelection' | 'keepSelection' | 'activateSelection'>,
|
||||
send: [] as Array<{ channel: string; payload: unknown }>
|
||||
}
|
||||
|
||||
let destroyed = false
|
||||
|
||||
return {
|
||||
calls,
|
||||
isDestroyed: () => destroyed,
|
||||
destroy() {
|
||||
destroyed = true
|
||||
emitter.emit('destroyed')
|
||||
},
|
||||
findInPage(query: string, options: { forward: boolean; findNext: boolean }) {
|
||||
calls.find.push({ query, options })
|
||||
},
|
||||
stopFindInPage(action: 'clearSelection' | 'keepSelection' | 'activateSelection') {
|
||||
calls.stop.push(action)
|
||||
},
|
||||
send(channel: string, payload: unknown) {
|
||||
calls.send.push({ channel, payload })
|
||||
},
|
||||
on: emitter.on.bind(emitter),
|
||||
off: emitter.off.bind(emitter),
|
||||
emit: emitter.emit.bind(emitter)
|
||||
}
|
||||
}
|
||||
|
||||
function asWC(fake: FakeWebContents): Electron.WebContents {
|
||||
return fake as unknown as Electron.WebContents
|
||||
}
|
||||
|
||||
describe('formatFoundInPage', () => {
|
||||
test('maps activeMatchOrdinal + matches onto the wire payload', () => {
|
||||
assert.deepEqual(formatFoundInPage({ activeMatchOrdinal: 3, matches: 12 }), {
|
||||
activeMatchOrdinal: 3,
|
||||
count: 12
|
||||
})
|
||||
})
|
||||
|
||||
test('coerces missing fields to zero so the renderer never sees NaN', () => {
|
||||
assert.deepEqual(formatFoundInPage({}), { activeMatchOrdinal: 0, count: 0 })
|
||||
assert.deepEqual(formatFoundInPage({ activeMatchOrdinal: 0, matches: 0 }), {
|
||||
activeMatchOrdinal: 0,
|
||||
count: 0
|
||||
})
|
||||
})
|
||||
|
||||
test('null / undefined inputs still produce a well-formed payload', () => {
|
||||
assert.deepEqual(formatFoundInPage(null as unknown as { activeMatchOrdinal?: number; matches?: number }), {
|
||||
activeMatchOrdinal: 0,
|
||||
count: 0
|
||||
})
|
||||
assert.deepEqual(formatFoundInPage(undefined), { activeMatchOrdinal: 0, count: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('performFind', () => {
|
||||
test('forwards the query and options to webContents.findInPage', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 'hello', { forward: true, findNext: false })
|
||||
assert.deepEqual(wc.calls.find, [{ query: 'hello', options: { forward: true, findNext: false } }])
|
||||
})
|
||||
|
||||
test('defaults forward to true when omitted', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 'x', { findNext: true })
|
||||
assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: true, findNext: true } }])
|
||||
})
|
||||
|
||||
test('defaults findNext to false when omitted', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 'x', { forward: false })
|
||||
assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: false, findNext: false } }])
|
||||
})
|
||||
|
||||
test('treats null / non-object options as "all defaults"', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 'x', null)
|
||||
assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: true, findNext: false } }])
|
||||
})
|
||||
|
||||
test('coerces a non-string query to string (defensive against bad renderer payloads)', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 42 as unknown as string, null)
|
||||
assert.equal(wc.calls.find[0].query, '42')
|
||||
})
|
||||
|
||||
test('is a no-op when webContents is null', () => {
|
||||
assert.doesNotThrow(() => performFind(null, 'q', null))
|
||||
})
|
||||
|
||||
test('is a no-op when webContents is destroyed (does not throw across IPC)', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
wc.destroy()
|
||||
performFind(asWC(wc), 'q', null)
|
||||
assert.equal(wc.calls.find.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopFind', () => {
|
||||
test('calls stopFindInPage with the default action (clearSelection)', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
stopFind(asWC(wc))
|
||||
assert.deepEqual(wc.calls.stop, ['clearSelection'])
|
||||
})
|
||||
|
||||
test('honors an explicit action argument', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
stopFind(asWC(wc), 'keepSelection')
|
||||
assert.deepEqual(wc.calls.stop, ['keepSelection'])
|
||||
})
|
||||
|
||||
test('is a no-op when webContents is null or destroyed', () => {
|
||||
assert.doesNotThrow(() => stopFind(null))
|
||||
const wc = makeFakeWebContents()
|
||||
wc.destroy()
|
||||
stopFind(asWC(wc))
|
||||
assert.equal(wc.calls.stop.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installFoundInPageForwarder', () => {
|
||||
test('forwards found-in-page to the sender as a formatted payload', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
installFoundInPageForwarder(asWC(wc))
|
||||
// Drive the fake's emit directly — this exercises the same code path
|
||||
// as Electron's actual `webContents.emit('found-in-page', …)`.
|
||||
wc.emit('found-in-page', {}, { activeMatchOrdinal: 2, matches: 5 })
|
||||
assert.deepEqual(wc.calls.send, [{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 2, count: 5 } }])
|
||||
})
|
||||
|
||||
test('handles missing fields without throwing', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
installFoundInPageForwarder(asWC(wc))
|
||||
wc.emit('found-in-page', {}, {})
|
||||
assert.deepEqual(wc.calls.send, [{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 0, count: 0 } }])
|
||||
})
|
||||
|
||||
test('skips send when webContents is destroyed at fire time', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
installFoundInPageForwarder(asWC(wc))
|
||||
wc.destroy()
|
||||
wc.emit('found-in-page', {}, { activeMatchOrdinal: 1, matches: 1 })
|
||||
assert.equal(wc.calls.send.length, 0, 'destroyed webContents must not be sent to')
|
||||
})
|
||||
|
||||
test('returned uninstall removes the listener', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
const uninstall = installFoundInPageForwarder(asWC(wc))
|
||||
uninstall()
|
||||
wc.emit('found-in-page', {}, { activeMatchOrdinal: 9, matches: 9 })
|
||||
assert.equal(wc.calls.send.length, 0, 'uninstalled listener must not fire')
|
||||
})
|
||||
|
||||
test('returned uninstall on a null webContents is a safe no-op', () => {
|
||||
const uninstall = installFoundInPageForwarder(null)
|
||||
assert.doesNotThrow(() => uninstall())
|
||||
})
|
||||
|
||||
test('returned uninstall on a destroyed webContents is a safe no-op', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
wc.destroy()
|
||||
const uninstall = installFoundInPageForwarder(asWC(wc))
|
||||
assert.doesNotThrow(() => uninstall())
|
||||
})
|
||||
|
||||
// Regression: the original PR scoped the forwarder to the global mainWindow,
|
||||
// so Cmd+F pressed in a secondary session window routed results back to the
|
||||
// primary. Pin that the helper does NOT close over any window other than the
|
||||
// webContents it was given — two forwarders installed on two distinct fakes
|
||||
// must each send only to their own sender.
|
||||
test('two forwarders installed on distinct webContents do not cross-fire', () => {
|
||||
const wcA = makeFakeWebContents()
|
||||
const wcB = makeFakeWebContents()
|
||||
installFoundInPageForwarder(asWC(wcA))
|
||||
installFoundInPageForwarder(asWC(wcB))
|
||||
wcA.emit('found-in-page', {}, { activeMatchOrdinal: 1, matches: 1 })
|
||||
assert.deepEqual(wcA.calls.send, [
|
||||
{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 1, count: 1 } }
|
||||
])
|
||||
assert.equal(wcB.calls.send.length, 0, 'wcB must not receive wcA results')
|
||||
})
|
||||
})
|
||||
120
apps/desktop/electron/find-in-page.ts
Normal file
120
apps/desktop/electron/find-in-page.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Pure helpers for the desktop find-in-page bridge (Ctrl/Cmd+F).
|
||||
*
|
||||
* The renderer drives an Electron `webContents.findInPage` over IPC so it can
|
||||
* reuse the native "find-in-page" experience (incremental search, match
|
||||
* highlight, Enter to step, Shift+Enter to step backwards, Escape to clear)
|
||||
* across chat transcripts and editor panels. Everything in this module is
|
||||
* pure with respect to its inputs so the routing + payload shaping can be
|
||||
* unit-tested without booting a BrowserWindow.
|
||||
*
|
||||
* Multi-window correctness: the IPC handlers in main.ts resolve the
|
||||
* requesting window via `BrowserWindow.fromWebContents(event.sender)` so a
|
||||
* Cmd+F pressed in a secondary session window searches THAT window, not the
|
||||
* primary. The `found-in-page` results are forwarded back to the same sender
|
||||
* — see {@link installFoundInPageForwarder}.
|
||||
*/
|
||||
|
||||
/** Match options accepted by the renderer's `findInPage` bridge call. */
|
||||
export interface FindInPageOptions {
|
||||
/** Step direction. Defaults to `true` (forward). */
|
||||
forward?: boolean
|
||||
/**
|
||||
* `true` to advance to the next/previous match using the previous query;
|
||||
* `false` to (re)search the current `query` from scratch. The renderer
|
||||
* passes `false` on a fresh query and `true` on Enter / Shift+Enter.
|
||||
*/
|
||||
findNext?: boolean
|
||||
}
|
||||
|
||||
/** Payload shape sent back to the renderer on every `found-in-page` event. */
|
||||
export interface FoundInPagePayload {
|
||||
/** 1-indexed ordinal of the active match, or 0 when none. */
|
||||
activeMatchOrdinal: number
|
||||
/** Total matches in the document for the current query. */
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive projection of Electron's `found-in-page` event result. Electron
|
||||
* exposes more fields (finalUpdate, selectionArea, etc.) that we don't need;
|
||||
* keeping the projection explicit makes the wire shape auditable and keeps
|
||||
* tests independent of the runtime type.
|
||||
*/
|
||||
export function formatFoundInPage(result: { activeMatchOrdinal?: number; matches?: number }): FoundInPagePayload {
|
||||
return {
|
||||
activeMatchOrdinal: Number(result?.activeMatchOrdinal ?? 0),
|
||||
count: Number(result?.matches ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a `findInPage` against the given `webContents`. No-op when the
|
||||
* webContents is missing or destroyed — surfaces as a silent miss rather
|
||||
* than throwing across the IPC boundary, matching Electron's own semantics
|
||||
* for a destroyed renderer.
|
||||
*/
|
||||
export function performFind(
|
||||
webContents: Electron.WebContents | null | undefined,
|
||||
query: string,
|
||||
options: FindInPageOptions | null | undefined
|
||||
): void {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const opts = options && typeof options === 'object' ? options : {}
|
||||
|
||||
webContents.findInPage(String(query ?? ''), {
|
||||
forward: opts.forward !== false,
|
||||
findNext: Boolean(opts.findNext)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the current find and clear highlights. The default `action` matches
|
||||
* what the renderer sends on Escape / close.
|
||||
*/
|
||||
export function stopFind(
|
||||
webContents: Electron.WebContents | null | undefined,
|
||||
action: 'clearSelection' | 'keepSelection' | 'activateSelection' = 'clearSelection'
|
||||
): void {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.stopFindInPage(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a `found-in-page` listener on the given sender `webContents` and
|
||||
* forward each result back to the SAME renderer (via `webContents.send`).
|
||||
*
|
||||
* Returns an uninstall function. Call it from `webContents.on('destroyed', …)`
|
||||
* to avoid leaking the listener when the window goes away — Electron does
|
||||
* not auto-detach webContents listeners on close.
|
||||
*
|
||||
* The forwarder is intentionally bound to a single sender rather than the
|
||||
* primary window: a Cmd+F pressed in a secondary session window must
|
||||
* highlight matches in THAT window, and the match counter must reflect
|
||||
* THAT window's DOM, not the primary's.
|
||||
*/
|
||||
export function installFoundInPageForwarder(webContents: Electron.WebContents | null | undefined): () => void {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const handler = (_event: Electron.Event, result: Parameters<typeof formatFoundInPage>[0]) => {
|
||||
if (webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:found-in-page', formatFoundInPage(result))
|
||||
}
|
||||
|
||||
webContents.on('found-in-page', handler)
|
||||
|
||||
return () => {
|
||||
webContents.off('found-in-page', handler)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -36,6 +36,41 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
|
||||
}
|
||||
},
|
||||
// Quick Entry: the global-hotkey mini composer window. Main owns the OS
|
||||
// shortcut + the persisted preference; the quick window only captures text
|
||||
// and hands it back, and the primary renderer submits it through the normal
|
||||
// prompt path.
|
||||
quickEntry: {
|
||||
getSettings: () => ipcRenderer.invoke('hermes:quick-entry:settings:get'),
|
||||
setSettings: patch => ipcRenderer.invoke('hermes:quick-entry:settings:set', patch),
|
||||
submit: payload => ipcRenderer.send('hermes:quick-entry:submit', payload),
|
||||
dismiss: () => ipcRenderer.send('hermes:quick-entry:dismiss'),
|
||||
// Primary renderer → main → quick window: gateway connection state + the
|
||||
// recent-session options the target picker offers. Main caches the latest
|
||||
// payload so a freshly spawned quick window starts from truth.
|
||||
pushState: payload => ipcRenderer.send('hermes:quick-entry:state', payload),
|
||||
// Quick window subscribes to those pushes.
|
||||
onState: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:quick-entry:state', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:quick-entry:state', listener)
|
||||
},
|
||||
// Main → primary renderer: a submit captured by the quick window.
|
||||
onSubmit: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:quick-entry:submit', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:quick-entry:submit', listener)
|
||||
},
|
||||
// Main → quick window: you were just summoned (reset draft + refocus).
|
||||
onShown: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:quick-entry:shown', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:quick-entry:shown', listener)
|
||||
}
|
||||
},
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
|
|
@ -79,6 +114,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
|
||||
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
|
||||
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
|
||||
setActiveWork: payload => ipcRenderer.send('hermes:active-work', payload),
|
||||
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
|
||||
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
|
||||
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
|
||||
|
|
@ -268,5 +304,19 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
themes: {
|
||||
fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id),
|
||||
searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query)
|
||||
},
|
||||
// Find-in-page (Ctrl/Cmd+F): delegates to Electron's
|
||||
// webContents.findInPage on the IPC sender's window so a Cmd+F pressed
|
||||
// in a secondary session window searches THAT window, not the primary.
|
||||
// `onFoundInPage` returns the unsubscribe fn; the renderer wires it via
|
||||
// `initFindInPageListener` in store/find-in-page.ts and tears it down
|
||||
// when the FindBar unmounts.
|
||||
findInPage: (query, options) => ipcRenderer.invoke('hermes:find-in-page', query, options),
|
||||
stopFindInPage: () => ipcRenderer.invoke('hermes:stop-find-in-page'),
|
||||
onFoundInPage: callback => {
|
||||
const listener = (_event, result) => callback(result)
|
||||
ipcRenderer.on('hermes:found-in-page', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:found-in-page', listener)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
30
apps/desktop/electron/profile-session-routing.test.ts
Normal file
30
apps/desktop/electron/profile-session-routing.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { fetchPrimaryProfileSessions } from './profile-session-routing'
|
||||
|
||||
test('primary session reads use the profile-aware request path', async () => {
|
||||
const calls: Array<{ profile: string | null; path: string }> = []
|
||||
const expected = { sessions: [{ id: 'session-1' }], total: 1, profile_totals: { default: 1 } }
|
||||
|
||||
const result = await fetchPrimaryProfileSessions(
|
||||
new URLSearchParams({ profile: 'default', limit: '20' }),
|
||||
async (profile, path) => {
|
||||
calls.push({ profile, path })
|
||||
|
||||
return expected
|
||||
}
|
||||
)
|
||||
|
||||
assert.deepEqual(calls, [{ profile: null, path: '/api/profiles/sessions?profile=default&limit=20' }])
|
||||
assert.equal(result, expected)
|
||||
})
|
||||
|
||||
test('primary session reads preserve the empty-list fallback', async () => {
|
||||
const result = await fetchPrimaryProfileSessions(new URLSearchParams({ profile: 'all' }), async () => {
|
||||
throw new Error('remote unavailable')
|
||||
})
|
||||
|
||||
assert.deepEqual(result, { sessions: [], total: 0, profile_totals: {} })
|
||||
})
|
||||
19
apps/desktop/electron/profile-session-routing.ts
Normal file
19
apps/desktop/electron/profile-session-routing.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export interface ProfileSessionsResponse {
|
||||
sessions: unknown[]
|
||||
total: number
|
||||
profile_totals: Record<string, number>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type FetchJsonForProfile = (profile: string | null, path: string) => Promise<unknown>
|
||||
|
||||
export async function fetchPrimaryProfileSessions(
|
||||
searchParams: URLSearchParams,
|
||||
fetchJsonForProfile: FetchJsonForProfile
|
||||
): Promise<ProfileSessionsResponse> {
|
||||
try {
|
||||
return (await fetchJsonForProfile(null, `/api/profiles/sessions?${searchParams}`)) as ProfileSessionsResponse
|
||||
} catch {
|
||||
return { sessions: [], total: 0, profile_totals: {} }
|
||||
}
|
||||
}
|
||||
240
apps/desktop/electron/quick-entry.test.ts
Normal file
240
apps/desktop/electron/quick-entry.test.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
createQuickEntryShortcut,
|
||||
DEFAULT_QUICK_ENTRY_SHORTCUT,
|
||||
type GlobalShortcutLike,
|
||||
parseQuickEntryShortcut,
|
||||
quickEntryWindowBounds,
|
||||
sanitizeQuickEntrySettings
|
||||
} from './quick-entry'
|
||||
|
||||
function fakeGlobalShortcut(options: { register?: boolean; taken?: string[] } = {}) {
|
||||
const held = new Set(options.taken ?? [])
|
||||
|
||||
const globalShortcut: GlobalShortcutLike = {
|
||||
isRegistered: vi.fn((accelerator: string) => held.has(accelerator)),
|
||||
register: vi.fn((accelerator: string) => {
|
||||
if (options.register === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
held.add(accelerator)
|
||||
|
||||
return true
|
||||
}),
|
||||
unregister: vi.fn((accelerator: string) => void held.delete(accelerator))
|
||||
}
|
||||
|
||||
return { globalShortcut, held }
|
||||
}
|
||||
|
||||
describe('parseQuickEntryShortcut', () => {
|
||||
it('normalizes casing, aliases, and modifier order', () => {
|
||||
expect(parseQuickEntryShortcut('cmdorctrl+shift+space')).toEqual({
|
||||
accelerator: 'CommandOrControl+Shift+Space',
|
||||
ok: true
|
||||
})
|
||||
expect(parseQuickEntryShortcut(' Shift + CTRL + k ')).toEqual({ accelerator: 'Control+Shift+K', ok: true })
|
||||
expect(parseQuickEntryShortcut('Alt+f5')).toEqual({ accelerator: 'Alt+F5', ok: true })
|
||||
expect(parseQuickEntryShortcut('Meta+/')).toEqual({ accelerator: 'Super+/', ok: true })
|
||||
})
|
||||
|
||||
it('collapses duplicate modifiers', () => {
|
||||
expect(parseQuickEntryShortcut('Ctrl+Control+Shift+J')).toEqual({ accelerator: 'Control+Shift+J', ok: true })
|
||||
})
|
||||
|
||||
it('requires a modifier so a global bind cannot swallow a bare key', () => {
|
||||
expect(parseQuickEntryShortcut('K')).toEqual({ ok: false, reason: 'no-modifier' })
|
||||
expect(parseQuickEntryShortcut('Space')).toEqual({ ok: false, reason: 'no-modifier' })
|
||||
})
|
||||
|
||||
it('requires exactly one non-modifier key', () => {
|
||||
expect(parseQuickEntryShortcut('Shift+Control')).toEqual({ ok: false, reason: 'no-key' })
|
||||
expect(parseQuickEntryShortcut('Shift+A+B')).toEqual({ ok: false, reason: 'invalid-key' })
|
||||
expect(parseQuickEntryShortcut('A+Shift')).toEqual({ ok: false, reason: 'invalid-modifier' })
|
||||
})
|
||||
|
||||
it('rejects empty, junk, and the reserved Escape key', () => {
|
||||
expect(parseQuickEntryShortcut('')).toEqual({ ok: false, reason: 'empty' })
|
||||
expect(parseQuickEntryShortcut(' ')).toEqual({ ok: false, reason: 'empty' })
|
||||
expect(parseQuickEntryShortcut(null)).toEqual({ ok: false, reason: 'empty' })
|
||||
expect(parseQuickEntryShortcut('Ctrl+NotAKey')).toEqual({ ok: false, reason: 'invalid-key' })
|
||||
// Escape hides the window; binding it globally would make it un-toggleable.
|
||||
expect(parseQuickEntryShortcut('Ctrl+Escape')).toEqual({ ok: false, reason: 'reserved' })
|
||||
})
|
||||
|
||||
it('accepts the shipped default unchanged', () => {
|
||||
expect(parseQuickEntryShortcut(DEFAULT_QUICK_ENTRY_SHORTCUT)).toEqual({
|
||||
accelerator: DEFAULT_QUICK_ENTRY_SHORTCUT,
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeQuickEntrySettings', () => {
|
||||
it('defaults to enabled with the default shortcut', () => {
|
||||
expect(sanitizeQuickEntrySettings(undefined)).toEqual({ enabled: true, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT })
|
||||
expect(sanitizeQuickEntrySettings('not an object')).toEqual({
|
||||
enabled: true,
|
||||
shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an explicit disable and normalizes a stored shortcut', () => {
|
||||
expect(sanitizeQuickEntrySettings({ enabled: false, shortcut: 'alt+j' })).toEqual({
|
||||
enabled: false,
|
||||
shortcut: 'Alt+J'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the default when the stored shortcut is unusable', () => {
|
||||
expect(sanitizeQuickEntrySettings({ enabled: true, shortcut: 'Q' })).toEqual({
|
||||
enabled: true,
|
||||
shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a non-boolean enabled as off (only `true` opts in once present)', () => {
|
||||
expect(sanitizeQuickEntrySettings({ enabled: 'yes' }).enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createQuickEntryShortcut', () => {
|
||||
it('registers the normalized accelerator when enabled', () => {
|
||||
const { globalShortcut } = fakeGlobalShortcut()
|
||||
const onTrigger = vi.fn()
|
||||
const controller = createQuickEntryShortcut(globalShortcut, onTrigger)
|
||||
|
||||
const state = controller.apply({ enabled: true, shortcut: 'cmdorctrl+shift+space' })
|
||||
|
||||
expect(state).toEqual({ error: null, registered: true, shortcut: 'CommandOrControl+Shift+Space' })
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('CommandOrControl+Shift+Space', onTrigger)
|
||||
expect(controller.current()).toEqual(state)
|
||||
})
|
||||
|
||||
it('never registers while the setting is disabled', () => {
|
||||
const { globalShortcut } = fakeGlobalShortcut()
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
const state = controller.apply({ enabled: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT })
|
||||
|
||||
expect(globalShortcut.register).not.toHaveBeenCalled()
|
||||
expect(state).toEqual({ error: null, registered: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT })
|
||||
})
|
||||
|
||||
it('releases the old accelerator before registering a new one', () => {
|
||||
const { globalShortcut, held } = fakeGlobalShortcut()
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
controller.apply({ enabled: true, shortcut: 'Alt+J' })
|
||||
controller.apply({ enabled: true, shortcut: 'Alt+K' })
|
||||
|
||||
expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+J')
|
||||
expect(held.has('Alt+J')).toBe(false)
|
||||
expect(held.has('Alt+K')).toBe(true)
|
||||
})
|
||||
|
||||
it('turning the feature off releases the live accelerator', () => {
|
||||
const { globalShortcut, held } = fakeGlobalShortcut()
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
controller.apply({ enabled: true, shortcut: 'Alt+J' })
|
||||
const off = controller.apply({ enabled: false, shortcut: 'Alt+J' })
|
||||
|
||||
expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+J')
|
||||
expect(held.size).toBe(0)
|
||||
expect(off.registered).toBe(false)
|
||||
expect(off.error).toBeNull()
|
||||
})
|
||||
|
||||
it("surfaces 'taken' when another app already owns the chord", () => {
|
||||
const { globalShortcut } = fakeGlobalShortcut({ taken: ['Alt+J'] })
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
const state = controller.apply({ enabled: true, shortcut: 'alt+j' })
|
||||
|
||||
expect(globalShortcut.register).not.toHaveBeenCalled()
|
||||
expect(state).toEqual({ error: 'taken', registered: false, shortcut: 'Alt+J' })
|
||||
})
|
||||
|
||||
it("surfaces 'taken' when the OS refuses the registration", () => {
|
||||
const { globalShortcut } = fakeGlobalShortcut({ register: false })
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
expect(controller.apply({ enabled: true, shortcut: 'Alt+J' })).toEqual({
|
||||
error: 'taken',
|
||||
registered: false,
|
||||
shortcut: 'Alt+J'
|
||||
})
|
||||
})
|
||||
|
||||
it("surfaces 'invalid' for an unusable shortcut without asking the OS", () => {
|
||||
const { globalShortcut } = fakeGlobalShortcut()
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
expect(controller.apply({ enabled: true, shortcut: 'J' })).toEqual({
|
||||
error: 'invalid',
|
||||
registered: false,
|
||||
shortcut: 'J'
|
||||
})
|
||||
expect(globalShortcut.register).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('survives a throwing globalShortcut', () => {
|
||||
const globalShortcut: GlobalShortcutLike = {
|
||||
isRegistered: () => false,
|
||||
register: () => {
|
||||
throw new Error('x11 grab failed')
|
||||
},
|
||||
unregister: () => {}
|
||||
}
|
||||
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
expect(controller.apply({ enabled: true, shortcut: 'Alt+J' }).error).toBe('taken')
|
||||
})
|
||||
|
||||
it('dispose releases the accelerator and is idempotent', () => {
|
||||
const { globalShortcut, held } = fakeGlobalShortcut()
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
controller.apply({ enabled: true, shortcut: 'Alt+J' })
|
||||
controller.dispose()
|
||||
controller.dispose()
|
||||
|
||||
expect(globalShortcut.unregister).toHaveBeenCalledTimes(1)
|
||||
expect(held.size).toBe(0)
|
||||
expect(controller.current().registered).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('quickEntryWindowBounds', () => {
|
||||
it('centers horizontally and sits below the top edge of the work area', () => {
|
||||
const bounds = quickEntryWindowBounds({ height: 1000, width: 1600, x: 0, y: 0 })
|
||||
|
||||
expect(bounds.width).toBe(640)
|
||||
expect(bounds.x).toBe((1600 - 640) / 2)
|
||||
expect(bounds.y).toBeGreaterThan(0)
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(1000)
|
||||
})
|
||||
|
||||
it('respects a display origin offset (second monitor)', () => {
|
||||
const bounds = quickEntryWindowBounds({ height: 900, width: 1440, x: 1600, y: -200 })
|
||||
|
||||
expect(bounds.x).toBe(1600 + (1440 - 640) / 2)
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(-200)
|
||||
})
|
||||
|
||||
it('stays inside a tiny work area', () => {
|
||||
const bounds = quickEntryWindowBounds({ height: 120, width: 320, x: 0, y: 0 })
|
||||
|
||||
expect(bounds.width).toBeLessThanOrEqual(320)
|
||||
expect(bounds.height).toBeLessThanOrEqual(120)
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(120)
|
||||
})
|
||||
|
||||
it('falls back to the origin without a work area', () => {
|
||||
expect(quickEntryWindowBounds()).toEqual({ height: 168, width: 640, x: 0, y: 0 })
|
||||
})
|
||||
})
|
||||
426
apps/desktop/electron/quick-entry.ts
Normal file
426
apps/desktop/electron/quick-entry.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
/**
|
||||
* Quick Entry — the global-hotkey mini composer.
|
||||
*
|
||||
* A small frameless always-on-top window that a global shortcut summons from
|
||||
* anywhere so the user can fire a prompt at Hermes without raising the whole
|
||||
* app. The window carries NO gateway connection of its own: it forwards the
|
||||
* text to the primary renderer, which sends it through the SAME prompt-submit
|
||||
* path the normal composer uses (see app/contrib/hooks/use-quick-entry-bridge).
|
||||
*
|
||||
* Everything Electron-free lives here so the parts that actually break a user —
|
||||
* accelerator validation, "disabled means never register", and surfacing a
|
||||
* shortcut another app already owns — are unit-testable without booting
|
||||
* Electron. main.ts owns the BrowserWindow, the file I/O, and the real
|
||||
* `globalShortcut`.
|
||||
*/
|
||||
|
||||
// Default matches the muscle memory of the apps this ports from (Claude
|
||||
// Desktop's quick entry / ChatGPT's Quick Chat sit on a Cmd+Shift chord).
|
||||
const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CommandOrControl+Shift+Space'
|
||||
|
||||
// Compact capture surface: wide enough for a sentence, short enough to read as
|
||||
// a HUD rather than a second app window. Height covers the composer row plus
|
||||
// the session-target picker row; the renderer never grows the OS window in v1.
|
||||
const QUICK_ENTRY_WINDOW_WIDTH = 640
|
||||
const QUICK_ENTRY_WINDOW_HEIGHT = 168
|
||||
|
||||
// Spotlight-ish placement: horizontally centered on the active display, a
|
||||
// comfortable fraction down from the top rather than dead center.
|
||||
const QUICK_ENTRY_TOP_FRACTION = 0.22
|
||||
|
||||
// Electron accelerator vocabulary (electronjs.org/docs/latest/api/accelerator).
|
||||
// Kept as data so validation and the settings UI agree on one list.
|
||||
const ACCELERATOR_MODIFIERS = new Set([
|
||||
'alt',
|
||||
'altgr',
|
||||
'cmd',
|
||||
'cmdorctrl',
|
||||
'command',
|
||||
'commandorcontrol',
|
||||
'control',
|
||||
'ctrl',
|
||||
'meta',
|
||||
'option',
|
||||
'shift',
|
||||
'super'
|
||||
])
|
||||
|
||||
const ACCELERATOR_KEYS = new Set([
|
||||
'backspace',
|
||||
'delete',
|
||||
'down',
|
||||
'end',
|
||||
'enter',
|
||||
'escape',
|
||||
'home',
|
||||
'insert',
|
||||
'left',
|
||||
'medianexttrack',
|
||||
'mediaplaypause',
|
||||
'mediaprevioustrack',
|
||||
'mediastop',
|
||||
'pagedown',
|
||||
'pageup',
|
||||
'plus',
|
||||
'printscreen',
|
||||
'return',
|
||||
'right',
|
||||
'space',
|
||||
'tab',
|
||||
'up',
|
||||
'volumedown',
|
||||
'volumemute',
|
||||
'volumeup'
|
||||
])
|
||||
|
||||
// Single printable characters Electron accepts verbatim, plus 0-9 / A-Z below.
|
||||
const ACCELERATOR_PUNCTUATION = new Set([
|
||||
'!',
|
||||
'"',
|
||||
'#',
|
||||
'$',
|
||||
'%',
|
||||
'&',
|
||||
"'",
|
||||
'(',
|
||||
')',
|
||||
'*',
|
||||
'+',
|
||||
',',
|
||||
'-',
|
||||
'.',
|
||||
'/',
|
||||
':',
|
||||
';',
|
||||
'<',
|
||||
'=',
|
||||
'>',
|
||||
'?',
|
||||
'@',
|
||||
'[',
|
||||
'\\',
|
||||
']',
|
||||
'^',
|
||||
'_',
|
||||
'`',
|
||||
'{',
|
||||
'|',
|
||||
'}',
|
||||
'~'
|
||||
])
|
||||
|
||||
/** Why a shortcut string was rejected. The renderer maps these to copy. */
|
||||
export type QuickEntryShortcutError =
|
||||
| 'empty'
|
||||
| 'invalid-key'
|
||||
| 'invalid-modifier'
|
||||
| 'no-key'
|
||||
| 'no-modifier'
|
||||
| 'reserved'
|
||||
|
||||
export type QuickEntryShortcutParse = { ok: false; reason: QuickEntryShortcutError } | { accelerator: string; ok: true }
|
||||
|
||||
function isAcceleratorKey(token: string): boolean {
|
||||
if (ACCELERATOR_KEYS.has(token)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (/^f([1-9]|1[0-9]|2[0-4])$/.test(token)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (/^num(?:[0-9]|lock|dec|add|sub|mult|div)$/.test(token)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return token.length === 1 && (/^[a-z0-9]$/.test(token) || ACCELERATOR_PUNCTUATION.has(token))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + normalize a user-typed accelerator.
|
||||
*
|
||||
* Rules beyond Electron's own grammar, both deliberate:
|
||||
* - At least one modifier. A bare global key steals that key from EVERY app.
|
||||
* - `Escape` can't be the key: inside the window Escape means "hide", so
|
||||
* binding it globally would make the shortcut un-toggleable.
|
||||
*/
|
||||
export function parseQuickEntryShortcut(raw: unknown): QuickEntryShortcutParse {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return { ok: false, reason: 'empty' }
|
||||
}
|
||||
|
||||
const parts = raw
|
||||
.split('+')
|
||||
.map(part => part.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (parts.length === 0) {
|
||||
return { ok: false, reason: 'empty' }
|
||||
}
|
||||
|
||||
const modifiers: string[] = []
|
||||
let key: null | string = null
|
||||
|
||||
for (const part of parts) {
|
||||
const lower = part.toLowerCase()
|
||||
|
||||
if (ACCELERATOR_MODIFIERS.has(lower)) {
|
||||
if (key) {
|
||||
// A modifier after the key ("A+Shift") is not a valid accelerator.
|
||||
return { ok: false, reason: 'invalid-modifier' }
|
||||
}
|
||||
|
||||
modifiers.push(lower)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (key) {
|
||||
// Two non-modifier keys ("Shift+A+B").
|
||||
return { ok: false, reason: 'invalid-key' }
|
||||
}
|
||||
|
||||
if (!isAcceleratorKey(lower)) {
|
||||
return { ok: false, reason: 'invalid-key' }
|
||||
}
|
||||
|
||||
key = lower
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
return { ok: false, reason: 'no-key' }
|
||||
}
|
||||
|
||||
if (modifiers.length === 0) {
|
||||
return { ok: false, reason: 'no-modifier' }
|
||||
}
|
||||
|
||||
if (key === 'escape') {
|
||||
return { ok: false, reason: 'reserved' }
|
||||
}
|
||||
|
||||
// Canonical casing so a saved shortcut round-trips identically no matter how
|
||||
// the user typed it, and duplicate modifiers collapse.
|
||||
const seen = new Set<string>()
|
||||
|
||||
const normalizedModifiers = modifiers
|
||||
.map(modifier => CANONICAL_MODIFIER[modifier] ?? modifier)
|
||||
.filter(modifier => (seen.has(modifier) ? false : (seen.add(modifier), true)))
|
||||
// Stable display order (Electron itself is order-insensitive).
|
||||
.sort((left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right))
|
||||
|
||||
return { accelerator: [...normalizedModifiers, canonicalKey(key)].join('+'), ok: true }
|
||||
}
|
||||
|
||||
const CANONICAL_MODIFIER: Record<string, string> = {
|
||||
alt: 'Alt',
|
||||
altgr: 'AltGr',
|
||||
cmd: 'Command',
|
||||
cmdorctrl: 'CommandOrControl',
|
||||
command: 'Command',
|
||||
commandorcontrol: 'CommandOrControl',
|
||||
control: 'Control',
|
||||
ctrl: 'Control',
|
||||
meta: 'Super',
|
||||
option: 'Option',
|
||||
shift: 'Shift',
|
||||
super: 'Super'
|
||||
}
|
||||
|
||||
const MODIFIER_ORDER = ['CommandOrControl', 'Command', 'Control', 'Super', 'Alt', 'Option', 'AltGr', 'Shift']
|
||||
|
||||
const CANONICAL_KEY: Record<string, string> = {
|
||||
backspace: 'Backspace',
|
||||
delete: 'Delete',
|
||||
down: 'Down',
|
||||
end: 'End',
|
||||
enter: 'Enter',
|
||||
escape: 'Escape',
|
||||
home: 'Home',
|
||||
insert: 'Insert',
|
||||
medianexttrack: 'MediaNextTrack',
|
||||
mediaplaypause: 'MediaPlayPause',
|
||||
mediaprevioustrack: 'MediaPreviousTrack',
|
||||
mediastop: 'MediaStop',
|
||||
pagedown: 'PageDown',
|
||||
pageup: 'PageUp',
|
||||
plus: 'Plus',
|
||||
printscreen: 'PrintScreen',
|
||||
return: 'Return',
|
||||
right: 'Right',
|
||||
space: 'Space',
|
||||
tab: 'Tab',
|
||||
up: 'Up',
|
||||
volumedown: 'VolumeDown',
|
||||
volumemute: 'VolumeMute',
|
||||
volumeup: 'VolumeUp',
|
||||
left: 'Left'
|
||||
}
|
||||
|
||||
function canonicalKey(key: string): string {
|
||||
if (CANONICAL_KEY[key]) {
|
||||
return CANONICAL_KEY[key]
|
||||
}
|
||||
|
||||
if (/^f([1-9]|1[0-9]|2[0-4])$/.test(key)) {
|
||||
return key.toUpperCase()
|
||||
}
|
||||
|
||||
if (key.length === 1 && /^[a-z]$/.test(key)) {
|
||||
return key.toUpperCase()
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
/** The persisted shape of `quick-entry.json` (main-process owned). */
|
||||
export interface QuickEntrySettings {
|
||||
enabled: boolean
|
||||
shortcut: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw persisted JSON → usable settings. A malformed/absent file, or a shortcut
|
||||
* that no longer validates (hand-edited, or from a future build), falls back to
|
||||
* the default shortcut rather than leaving the feature un-summonable.
|
||||
*/
|
||||
export function sanitizeQuickEntrySettings(raw: unknown): QuickEntrySettings {
|
||||
const record = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
|
||||
const parsed = parseQuickEntryShortcut(record.shortcut)
|
||||
|
||||
return {
|
||||
// Default ON: the feature is inert until the shortcut is pressed.
|
||||
enabled: record.enabled === undefined ? true : record.enabled === true,
|
||||
shortcut: parsed.ok ? parsed.accelerator : DEFAULT_QUICK_ENTRY_SHORTCUT
|
||||
}
|
||||
}
|
||||
|
||||
/** The slice of Electron's `globalShortcut` we use (injected for testing). */
|
||||
export interface GlobalShortcutLike {
|
||||
isRegistered(accelerator: string): boolean
|
||||
register(accelerator: string, callback: () => void): boolean
|
||||
unregister(accelerator: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* What Settings shows. `registered` is the ground truth (we asked the OS);
|
||||
* `error` distinguishes "you turned it off" from "another app owns that chord",
|
||||
* which is the failure this feature must never swallow.
|
||||
*/
|
||||
export interface QuickEntryRegistration {
|
||||
error: null | QuickEntryRegistrationError
|
||||
registered: boolean
|
||||
shortcut: string
|
||||
}
|
||||
|
||||
export type QuickEntryRegistrationError = 'invalid' | 'taken'
|
||||
|
||||
export interface QuickEntryShortcutController {
|
||||
/** Registration state as of the last apply. */
|
||||
current(): QuickEntryRegistration
|
||||
/** Release the shortcut (quit / feature off). Idempotent. */
|
||||
dispose(): void
|
||||
/** Re-register to match `settings`. Returns the resulting state. */
|
||||
apply(settings: QuickEntrySettings): QuickEntryRegistration
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the one live global accelerator. Single resolver so every caller — boot,
|
||||
* the settings write, quit — gets the same answer and we can never leak two
|
||||
* registrations for one feature.
|
||||
*
|
||||
* Disabled settings never touch `register()` at all: a user who turned Quick
|
||||
* Entry off must not have their chord silently held hostage.
|
||||
*/
|
||||
export function createQuickEntryShortcut(
|
||||
globalShortcut: GlobalShortcutLike,
|
||||
onTrigger: () => void
|
||||
): QuickEntryShortcutController {
|
||||
let active: null | string = null
|
||||
let state: QuickEntryRegistration = { error: null, registered: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT }
|
||||
|
||||
const release = () => {
|
||||
if (active) {
|
||||
try {
|
||||
globalShortcut.unregister(active)
|
||||
} catch {
|
||||
// Best effort — a dead accelerator must not block a re-register.
|
||||
}
|
||||
|
||||
active = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
apply(settings) {
|
||||
const parsed = parseQuickEntryShortcut(settings.shortcut)
|
||||
const shortcut = parsed.ok ? parsed.accelerator : settings.shortcut
|
||||
|
||||
release()
|
||||
|
||||
if (!settings.enabled) {
|
||||
state = { error: null, registered: false, shortcut }
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
if (!parsed.ok) {
|
||||
state = { error: 'invalid', registered: false, shortcut }
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// `isRegistered` catches the common conflict before we ask, and
|
||||
// `register()` returning false catches the rest (another process owns it
|
||||
// OS-wide). Both land in the same surfaced 'taken' state.
|
||||
let ok = false
|
||||
|
||||
try {
|
||||
ok = globalShortcut.isRegistered(parsed.accelerator)
|
||||
? false
|
||||
: globalShortcut.register(parsed.accelerator, onTrigger)
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
|
||||
active = ok ? parsed.accelerator : null
|
||||
state = { error: ok ? null : 'taken', registered: ok, shortcut: parsed.accelerator }
|
||||
|
||||
return state
|
||||
},
|
||||
current() {
|
||||
return state
|
||||
},
|
||||
dispose() {
|
||||
release()
|
||||
state = { ...state, error: null, registered: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the quick window opens on a given display work area. Centered
|
||||
* horizontally, a fraction down from the top, and clamped so it stays fully
|
||||
* inside the work area on small/odd displays.
|
||||
*/
|
||||
export function quickEntryWindowBounds(workArea?: { height: number; width: number; x: number; y: number }): {
|
||||
height: number
|
||||
width: number
|
||||
x: number
|
||||
y: number
|
||||
} {
|
||||
const width = Math.min(QUICK_ENTRY_WINDOW_WIDTH, workArea?.width ?? QUICK_ENTRY_WINDOW_WIDTH)
|
||||
const height = Math.min(QUICK_ENTRY_WINDOW_HEIGHT, workArea?.height ?? QUICK_ENTRY_WINDOW_HEIGHT)
|
||||
|
||||
if (!workArea) {
|
||||
return { height, width, x: 0, y: 0 }
|
||||
}
|
||||
|
||||
const x = Math.round(workArea.x + (workArea.width - width) / 2)
|
||||
const maxY = workArea.y + workArea.height - height
|
||||
const y = Math.round(Math.min(Math.max(workArea.y, workArea.y + workArea.height * QUICK_ENTRY_TOP_FRACTION), maxY))
|
||||
|
||||
return { height, width, x, y }
|
||||
}
|
||||
|
||||
export { DEFAULT_QUICK_ENTRY_SHORTCUT, QUICK_ENTRY_TOP_FRACTION, QUICK_ENTRY_WINDOW_HEIGHT, QUICK_ENTRY_WINDOW_WIDTH }
|
||||
62
apps/desktop/electron/quit-guard.test.ts
Normal file
62
apps/desktop/electron/quit-guard.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard'
|
||||
|
||||
test('normalizeActiveWork drops junk and keeps the count at least the title count', () => {
|
||||
assert.deepEqual(normalizeActiveWork(null), { count: 0, titles: [] })
|
||||
assert.deepEqual(normalizeActiveWork({ count: 'many', titles: 'nope' }), { count: 0, titles: [] })
|
||||
assert.deepEqual(normalizeActiveWork({ count: -3, titles: [' Fix login ', '', 7] }), {
|
||||
count: 1,
|
||||
titles: ['Fix login']
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizeActiveWork keeps untitled sessions in the count', () => {
|
||||
assert.deepEqual(normalizeActiveWork({ count: 3, titles: ['Fix login'] }), { count: 3, titles: ['Fix login'] })
|
||||
})
|
||||
|
||||
test('mergeActiveWork de-dupes a session two windows both report', () => {
|
||||
const merged = mergeActiveWork([
|
||||
{ count: 2, titles: ['Fix login', 'Ship docs'] },
|
||||
{ count: 1, titles: ['Fix login'] }
|
||||
])
|
||||
|
||||
assert.deepEqual(merged, { count: 2, titles: ['Fix login', 'Ship docs'] })
|
||||
})
|
||||
|
||||
test('quitPromptFor stays out of the way when nothing is running', () => {
|
||||
assert.equal(quitPromptFor({ count: 0, titles: [] }, false), null)
|
||||
})
|
||||
|
||||
test('quitPromptFor stays out of the way during an update handoff', () => {
|
||||
assert.equal(quitPromptFor({ count: 2, titles: ['Fix login'] }, true), null)
|
||||
})
|
||||
|
||||
test('quitPromptFor names the running chats', () => {
|
||||
const prompt = quitPromptFor({ count: 2, titles: ['Fix login', 'Ship docs'] }, false)
|
||||
|
||||
assert.ok(prompt)
|
||||
assert.equal(prompt.message, 'Hermes is still working on 2 chats.')
|
||||
assert.ok(prompt.detail.includes('• Fix login'))
|
||||
assert.ok(prompt.detail.includes('• Ship docs'))
|
||||
})
|
||||
|
||||
test('quitPromptFor summarizes past the list cap and counts untitled work', () => {
|
||||
const prompt = quitPromptFor({ count: 9, titles: ['a', 'b', 'c', 'd', 'e', 'f'] }, false)
|
||||
|
||||
assert.ok(prompt)
|
||||
assert.equal(prompt.message, 'Hermes is still working on 9 chats.')
|
||||
assert.ok(prompt.detail.includes('• d'))
|
||||
assert.ok(!prompt.detail.includes('• e'))
|
||||
assert.ok(prompt.detail.includes('• 5 more'))
|
||||
})
|
||||
|
||||
test('quitPromptFor speaks singular for one chat', () => {
|
||||
const prompt = quitPromptFor({ count: 1, titles: [] }, false)
|
||||
|
||||
assert.ok(prompt)
|
||||
assert.equal(prompt.message, 'Hermes is still working on 1 chat.')
|
||||
assert.ok(prompt.detail.includes('mid-turn'))
|
||||
})
|
||||
92
apps/desktop/electron/quit-guard.ts
Normal file
92
apps/desktop/electron/quit-guard.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Quitting with a turn in flight kills the backend mid-tool-call: the work is
|
||||
// lost, and anything the agent had half-written to disk stays half-written.
|
||||
// Renderers publish what they're running; the main process asks before it lets
|
||||
// that go. The decision + copy live here (pure, testable) so main.ts only owns
|
||||
// the IPC and the dialog call.
|
||||
|
||||
const MAX_LISTED = 4
|
||||
|
||||
export interface ActiveWork {
|
||||
/** Titles of sessions running a turn. Untitled sessions contribute a count only. */
|
||||
titles: string[]
|
||||
/** Running turns, including untitled ones — always >= titles.length. */
|
||||
count: number
|
||||
}
|
||||
|
||||
export const NO_ACTIVE_WORK: ActiveWork = { count: 0, titles: [] }
|
||||
|
||||
/** Coerce an IPC payload from an untrusted renderer into an ActiveWork. */
|
||||
export function normalizeActiveWork(payload: unknown): ActiveWork {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return NO_ACTIVE_WORK
|
||||
}
|
||||
|
||||
const raw = payload as { count?: unknown; titles?: unknown }
|
||||
|
||||
const titles = Array.isArray(raw.titles)
|
||||
? raw.titles
|
||||
.filter((title): title is string => typeof title === 'string')
|
||||
.map(title => title.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
const count = typeof raw.count === 'number' && Number.isFinite(raw.count) ? Math.max(0, Math.floor(raw.count)) : 0
|
||||
|
||||
return { count: Math.max(count, titles.length), titles }
|
||||
}
|
||||
|
||||
/** Merge every window's report into one. Windows can show the same session. */
|
||||
export function mergeActiveWork(reports: Iterable<ActiveWork>): ActiveWork {
|
||||
const titles: string[] = []
|
||||
let count = 0
|
||||
|
||||
for (const report of reports) {
|
||||
count = Math.max(count, report.count)
|
||||
|
||||
for (const title of report.titles) {
|
||||
if (!titles.includes(title)) {
|
||||
titles.push(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { count: Math.max(count, titles.length), titles }
|
||||
}
|
||||
|
||||
export interface QuitPrompt {
|
||||
detail: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirmation to show, or null when quitting should just proceed.
|
||||
*
|
||||
* `quittingForHandoff` covers the update / swap / uninstall relaunches: those
|
||||
* are the app replacing itself, not the user walking away, and a modal there
|
||||
* would strand the detached script waiting on a PID that never exits.
|
||||
*/
|
||||
export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): null | QuitPrompt {
|
||||
if (quittingForHandoff || work.count < 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const listed = work.titles.slice(0, MAX_LISTED)
|
||||
const remaining = work.count - listed.length
|
||||
const lines = listed.map(title => `• ${title}`)
|
||||
|
||||
if (remaining > 0) {
|
||||
lines.push(remaining === 1 ? '• 1 more' : `• ${remaining} more`)
|
||||
}
|
||||
|
||||
return {
|
||||
detail: [
|
||||
lines.join('\n'),
|
||||
lines.length > 0 ? '' : null,
|
||||
'Quitting stops the agent mid-turn. Any work it has not finished writing is lost.'
|
||||
]
|
||||
.filter(line => line !== null)
|
||||
.join('\n')
|
||||
.trim(),
|
||||
message: work.count === 1 ? 'Hermes is still working on 1 chat.' : `Hermes is still working on ${work.count} chats.`
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
REMOTE_LIVENESS_TIMEOUT_MS,
|
||||
RemoteLivenessTracker,
|
||||
RemoteRevalidationCoordinator,
|
||||
revalidatePooledRemoteBackends,
|
||||
revalidateRemoteConnection
|
||||
} from './remote-liveness'
|
||||
|
||||
|
|
@ -251,3 +252,102 @@ describe('revalidateRemoteConnection', () => {
|
|||
expect(rejected.probe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('revalidatePooledRemoteBackends', () => {
|
||||
const harness = (entries: Array<[string, { process?: unknown; remoteBaseUrl?: null | string }]>) => {
|
||||
const unreachable = new Set<string>()
|
||||
const log = vi.fn()
|
||||
const stopBackend = vi.fn()
|
||||
|
||||
const probe = vi.fn(async (url: string) => {
|
||||
if ([...unreachable].some(base => url.startsWith(base))) {
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
return {
|
||||
log,
|
||||
probe,
|
||||
stopBackend,
|
||||
unreachable,
|
||||
run: (tracker: RemoteLivenessTracker) =>
|
||||
revalidatePooledRemoteBackends({ entries, log, probe, stopBackend, tracker })
|
||||
}
|
||||
}
|
||||
|
||||
it('probes only pooled entries backed by a remote host', async () => {
|
||||
const local = { process: {}, remoteBaseUrl: null }
|
||||
const spawning = { process: null, remoteBaseUrl: null }
|
||||
const remote = { process: null, remoteBaseUrl: 'https://remote.example.com' }
|
||||
|
||||
const pool = harness([
|
||||
['local', local],
|
||||
['spawning', spawning],
|
||||
['remote', remote]
|
||||
])
|
||||
|
||||
await pool.run(new RemoteLivenessTracker())
|
||||
|
||||
expect(pool.probe).toHaveBeenCalledTimes(1)
|
||||
expect(pool.probe).toHaveBeenCalledWith('https://remote.example.com/api/status', {
|
||||
timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS
|
||||
})
|
||||
expect(pool.stopBackend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a descriptor only after the shared failure limit', async () => {
|
||||
const pool = harness([['coder', { process: null, remoteBaseUrl: 'https://remote.example.com/' }]])
|
||||
pool.unreachable.add('https://remote.example.com')
|
||||
|
||||
const tracker = new RemoteLivenessTracker()
|
||||
|
||||
for (let attempt = 1; attempt < REMOTE_LIVENESS_FAILURE_LIMIT; attempt += 1) {
|
||||
await expect(pool.run(tracker)).resolves.toEqual({ dropped: [] })
|
||||
expect(pool.stopBackend).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
await expect(pool.run(tracker)).resolves.toEqual({ dropped: ['coder'] })
|
||||
expect(pool.stopBackend).toHaveBeenCalledWith('coder')
|
||||
})
|
||||
|
||||
it('clears the streak when the host answers again', async () => {
|
||||
const pool = harness([['coder', { process: null, remoteBaseUrl: 'https://remote.example.com' }]])
|
||||
const tracker = new RemoteLivenessTracker()
|
||||
|
||||
pool.unreachable.add('https://remote.example.com')
|
||||
await pool.run(tracker)
|
||||
|
||||
pool.unreachable.clear()
|
||||
await pool.run(tracker)
|
||||
|
||||
pool.unreachable.add('https://remote.example.com')
|
||||
|
||||
for (let attempt = 1; attempt < REMOTE_LIVENESS_FAILURE_LIMIT; attempt += 1) {
|
||||
await expect(pool.run(tracker)).resolves.toEqual({ dropped: [] })
|
||||
}
|
||||
|
||||
expect(pool.stopBackend).not.toHaveBeenCalled()
|
||||
await expect(pool.run(tracker)).resolves.toEqual({ dropped: ['coder'] })
|
||||
})
|
||||
|
||||
it('keeps a healthy sibling when another profile on a different host dies', async () => {
|
||||
const pool = harness([
|
||||
['coder', { process: null, remoteBaseUrl: 'https://dead.example.com' }],
|
||||
['writer', { process: null, remoteBaseUrl: 'https://live.example.com' }]
|
||||
])
|
||||
|
||||
pool.unreachable.add('https://dead.example.com')
|
||||
|
||||
const tracker = new RemoteLivenessTracker()
|
||||
|
||||
for (let attempt = 1; attempt < REMOTE_LIVENESS_FAILURE_LIMIT; attempt += 1) {
|
||||
await pool.run(tracker)
|
||||
}
|
||||
|
||||
await expect(pool.run(tracker)).resolves.toEqual({ dropped: ['coder'] })
|
||||
expect(pool.stopBackend).toHaveBeenCalledTimes(1)
|
||||
expect(pool.stopBackend).toHaveBeenCalledWith('coder')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -117,6 +117,68 @@ export class RemoteLivenessTracker {
|
|||
}
|
||||
}
|
||||
|
||||
export interface PooledRemoteEntry {
|
||||
process?: unknown
|
||||
remoteBaseUrl?: null | string
|
||||
}
|
||||
|
||||
export interface RevalidatePooledRemoteBackendsOptions {
|
||||
entries: Iterable<[string, PooledRemoteEntry]>
|
||||
log: (message: string) => void
|
||||
probe: (url: string, options: { timeoutMs: number }) => Promise<unknown>
|
||||
stopBackend: (profile: string) => void
|
||||
tracker: RemoteLivenessTracker
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe pooled REMOTE descriptors and drop the dead ones.
|
||||
*
|
||||
* A pooled entry backed by a remote host has no child process, so the 'exit'
|
||||
* handler that clears a dead local backend never fires, and the renderer's
|
||||
* keepalive touch keeps the idle reaper off it. Without this the pool serves a
|
||||
* descriptor for an unreachable host indefinitely.
|
||||
*
|
||||
* Entries share the primary's failure policy, keyed per base URL, so a profile
|
||||
* pointing at the same host as another does not burn the streak twice as fast.
|
||||
*/
|
||||
export async function revalidatePooledRemoteBackends({
|
||||
entries,
|
||||
log,
|
||||
probe,
|
||||
stopBackend,
|
||||
tracker
|
||||
}: RevalidatePooledRemoteBackendsOptions): Promise<{ dropped: string[] }> {
|
||||
const remotes = [...entries].filter(([, entry]) => !entry.process && entry.remoteBaseUrl)
|
||||
const dropped: string[] = []
|
||||
|
||||
await Promise.all(
|
||||
remotes.map(async ([profile, entry]) => {
|
||||
const baseUrl = String(entry.remoteBaseUrl).replace(/\/+$/, '')
|
||||
|
||||
try {
|
||||
await probe(`${baseUrl}/api/status`, { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS })
|
||||
tracker.recordSuccess(baseUrl)
|
||||
} catch {
|
||||
const failure = tracker.recordFailure(baseUrl)
|
||||
|
||||
if (!failure.shouldReset) {
|
||||
log(
|
||||
`Pooled remote backend for profile "${profile}" failed liveness probe (${failure.failures}/${REMOTE_LIVENESS_FAILURE_LIMIT}); keeping descriptor for retry.`
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log(`Pooled remote backend for profile "${profile}" failed liveness probe; dropping stale descriptor.`)
|
||||
stopBackend(profile)
|
||||
dropped.push(profile)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return { dropped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the cached primary remote connection and apply the failure policy.
|
||||
* The caller owns single-flight coordination; identity checks here ensure an
|
||||
|
|
|
|||
|
|
@ -193,8 +193,8 @@ test('registry trims the session id before keying', () => {
|
|||
|
||||
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
|
||||
// Regression: secondary session windows used to omit this flag, so a streamed
|
||||
// answer stalled until the window regained focus (Chromium pauses the
|
||||
// requestAnimationFrame-gated transcript flush for backgrounded windows).
|
||||
// answer stalled until the window regained focus (Chromium clamps the
|
||||
// transcript flush timer for backgrounded windows).
|
||||
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
|
||||
|
||||
assert.equal(prefs.backgroundThrottling, false)
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
|
|||
// false`, so a streamed answer stalled until the window regained focus.
|
||||
//
|
||||
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
|
||||
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
|
||||
// blurred/occluded windows. A streaming chat app must keep painting in the
|
||||
// screen through a bounded timer flush, which Chromium clamps for blurred/
|
||||
// occluded windows. A streaming chat app must keep painting in the
|
||||
// background, so every chat window opts out. The preload path is injected
|
||||
// because it depends on the Electron entry's __dirname.
|
||||
function chatWindowWebPreferences(preloadPath: string) {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ import { test, vi } from 'vitest'
|
|||
import {
|
||||
applyZoomLevel,
|
||||
clampZoomLevel,
|
||||
DEFAULT_ZOOM_LEVEL,
|
||||
installZoomReassertOnWindowEvents,
|
||||
percentToZoomLevel,
|
||||
ZOOM_RESIZE_REASSERT_DELAY_MS,
|
||||
ZOOM_STEP,
|
||||
ZOOM_STORAGE_KEY,
|
||||
zoomLevelToPercent,
|
||||
zoomReassertWindowEvents,
|
||||
|
|
@ -24,26 +26,32 @@ test('storage key stays stable so persisted zoom survives upgrades', () => {
|
|||
assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel')
|
||||
})
|
||||
|
||||
test('default zoom matches the Appearance 90% preset', () => {
|
||||
assert.equal(ZOOM_STEP, 0.1)
|
||||
assert.equal(zoomLevelToPercent(DEFAULT_ZOOM_LEVEL), 90)
|
||||
assert.equal(DEFAULT_ZOOM_LEVEL, percentToZoomLevel(90))
|
||||
})
|
||||
|
||||
test('clampZoomLevel rejects garbage and enforces bounds', () => {
|
||||
assert.equal(clampZoomLevel(NaN), 0)
|
||||
assert.equal(clampZoomLevel(Infinity), 0)
|
||||
assert.equal(clampZoomLevel(undefined), 0)
|
||||
assert.equal(clampZoomLevel('2'), 0)
|
||||
assert.equal(clampZoomLevel(NaN), DEFAULT_ZOOM_LEVEL)
|
||||
assert.equal(clampZoomLevel(Infinity), DEFAULT_ZOOM_LEVEL)
|
||||
assert.equal(clampZoomLevel(undefined), DEFAULT_ZOOM_LEVEL)
|
||||
assert.equal(clampZoomLevel('2'), DEFAULT_ZOOM_LEVEL)
|
||||
assert.equal(clampZoomLevel(0.3), 0.3)
|
||||
assert.equal(clampZoomLevel(-42), -9)
|
||||
assert.equal(clampZoomLevel(42), 9)
|
||||
})
|
||||
|
||||
test('level 0 is exactly 100 percent', () => {
|
||||
test('level 0 is exactly 100 percent (Chromium actual-size baseline)', () => {
|
||||
assert.equal(zoomLevelToPercent(0), 100)
|
||||
assert.equal(percentToZoomLevel(100), 0)
|
||||
})
|
||||
|
||||
test('percentToZoomLevel rejects garbage', () => {
|
||||
assert.equal(percentToZoomLevel(NaN), 0)
|
||||
assert.equal(percentToZoomLevel(0), 0)
|
||||
assert.equal(percentToZoomLevel(-50), 0)
|
||||
assert.equal(percentToZoomLevel(undefined), 0)
|
||||
test('percentToZoomLevel rejects garbage by falling back to the shipped default', () => {
|
||||
assert.equal(percentToZoomLevel(NaN), DEFAULT_ZOOM_LEVEL)
|
||||
assert.equal(percentToZoomLevel(0), DEFAULT_ZOOM_LEVEL)
|
||||
assert.equal(percentToZoomLevel(-50), DEFAULT_ZOOM_LEVEL)
|
||||
assert.equal(percentToZoomLevel(undefined), DEFAULT_ZOOM_LEVEL)
|
||||
})
|
||||
|
||||
test('preset percentages roundtrip within rounding', () => {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
* Pure helpers for window zoom. The main process owns webContents.setZoomLevel,
|
||||
* so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel
|
||||
* through this one clamped scale. Percent is the user-facing unit (100 = the
|
||||
* default size); Chromium's internal unit is the zoom level, where
|
||||
* factor = 1.2 ^ level.
|
||||
* Chromium actual-size baseline); Chromium's internal unit is the zoom level,
|
||||
* where factor = 1.2 ^ level.
|
||||
*
|
||||
* Our shipped default is the Appearance 90% preset — tight enough to feel
|
||||
* denser than Chromium 100%, and selected in the UI Scale control on first run.
|
||||
*/
|
||||
|
||||
export const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
|
||||
|
|
@ -12,9 +15,15 @@ const ZOOM_FACTOR_BASE = 1.2
|
|||
const MIN_ZOOM_LEVEL = -9
|
||||
const MAX_ZOOM_LEVEL = 9
|
||||
|
||||
/** Half Chromium's default step; matching the shortcuts and View menu. */
|
||||
export const ZOOM_STEP = 0.1
|
||||
|
||||
/** Appearance 90% preset. Fresh installs + Actual Size / Ctrl+0. */
|
||||
export const DEFAULT_ZOOM_LEVEL = Math.log(0.9) / Math.log(ZOOM_FACTOR_BASE)
|
||||
|
||||
export function clampZoomLevel(value) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0
|
||||
return DEFAULT_ZOOM_LEVEL
|
||||
}
|
||||
|
||||
return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
|
||||
|
|
@ -26,7 +35,7 @@ export function zoomLevelToPercent(level) {
|
|||
|
||||
export function percentToZoomLevel(percent) {
|
||||
if (!Number.isFinite(percent) || percent <= 0) {
|
||||
return 0
|
||||
return DEFAULT_ZOOM_LEVEL
|
||||
}
|
||||
|
||||
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
|
||||
|
|
@ -90,15 +99,16 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc
|
|||
|
||||
/**
|
||||
* Zoom-wiring decision per window kind. Chat windows (main + session) keep
|
||||
* global UI zoom; the pet overlay opts out because it sizes its own OS window
|
||||
* to the sprite and inheriting zoom would crop it.
|
||||
* global UI zoom; the pet overlay and the Quick Entry composer opt out because
|
||||
* they size their own OS window and inheriting zoom would crop/overflow them.
|
||||
*
|
||||
* Extracted so the "pet opts out, everything else opts in" contract is
|
||||
* Extracted so the "helper windows opt out, everything else opts in" contract is
|
||||
* unit-testable without booting a BrowserWindow or reading source.
|
||||
*/
|
||||
export const ZOOM_WINDOW_CONFIG = {
|
||||
chat: { zoom: true },
|
||||
petOverlay: { zoom: false }
|
||||
petOverlay: { zoom: false },
|
||||
quickEntry: { zoom: false }
|
||||
} as const
|
||||
|
||||
export function zoomWiringForWindowKind(kind) {
|
||||
|
|
|
|||
|
|
@ -106,7 +106,6 @@
|
|||
"hast-util-to-text": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
"katex": "^0.16.45",
|
||||
"leva": "^0.10.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanostores": "^1.3.0",
|
||||
|
|
@ -147,6 +146,7 @@
|
|||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"bippy": "0.5.43",
|
||||
"concurrently": "^10.0.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "40.10.2",
|
||||
|
|
|
|||
25
apps/desktop/scripts/diag-code-live.mjs
Normal file
25
apps/desktop/scripts/diag-code-live.mjs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Is the tree-split preview path actually active in the running renderer?
|
||||
// Checks the served source (what vite compiled) rather than guessing.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const out = await cdp.eval(`(async () => {
|
||||
const res = await fetch('/src/components/pane-shell/tree/renderer/tree-split.tsx')
|
||||
const src = await res.text()
|
||||
return JSON.stringify({
|
||||
previewShift: src.includes('previewShift'),
|
||||
adaptiveFloor: (await (await fetch('/src/app/session/hooks/use-message-stream/index.ts')).text()).includes('adaptiveFloor'),
|
||||
structuralSignature: (await (await fetch('/src/components/assistant-ui/thread/list.tsx')).text()).includes('structuralSignature'),
|
||||
sharedRO: (await (await fetch('/src/hooks/use-resize-observer.ts')).text()).includes('sharedObserver'),
|
||||
rootTipProvider: (await (await fetch('/src/main.tsx')).text()).includes('RootTooltipProvider')
|
||||
})
|
||||
})()`)
|
||||
|
||||
console.log(out)
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
158
apps/desktop/scripts/diag-drag-churn.mjs
Normal file
158
apps/desktop/scripts/diag-drag-churn.mjs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// Who re-renders the transcript during a sash drag?
|
||||
//
|
||||
// Standalone probe, not a benchmark: seeds tiles, then drags the sash while
|
||||
// recording (a) render attribution and (b) every nanostores atom that notifies
|
||||
// during the gesture. The idle-cost scenario proved the transcript re-renders
|
||||
// ~18x above baseline during a drag but that the sash HANDLER is not the cause
|
||||
// (identical counts at 0px and 60px displacement) — so this names the store
|
||||
// that actually fires.
|
||||
//
|
||||
// node scripts/perf/diag-drag-churn.mjs [--port 9222]
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const TILES = 5
|
||||
const TURNS = 20
|
||||
|
||||
const setup = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nSome prose with **bold** and \`code\`.\\n' }] }
|
||||
])
|
||||
window.__D__ = { ids: [] }
|
||||
for (let n = 1; n <= ${TILES}; n++) {
|
||||
const sid = 'diag-tile-' + n
|
||||
const rid = 'diag-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working.' }] })
|
||||
window.__D__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
// Drag, recording renders AND atom notifications together.
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
const ac = window.__ATOM_CHURN__
|
||||
rc.start(); ac.start()
|
||||
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) { rc.stop(); ac.stop(); return JSON.stringify({ error: 'no sash' }) }
|
||||
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 30; i++) {
|
||||
x += 2
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
|
||||
rc.stop(); ac.stop()
|
||||
const all = rc.report(400)
|
||||
const named = n => all.find(r => r.name === n) || null
|
||||
return JSON.stringify({
|
||||
moved: Math.round(x - x0),
|
||||
commits: rc.commits(),
|
||||
renders: rc.report(14),
|
||||
// The suspects: who at the TOP of the transcript tree re-rendered?
|
||||
chain: ['ChatView', 'ChatRuntimeBoundary', 'AuiProvider', 'Thread', 'SessionTile', 'TileChat',
|
||||
'LayoutTreeRoot', 'TreeNode', 'TreeSplit', 'TreeGroup', 'SessionView', 'PaneShell']
|
||||
.map(n => ({ name: n, hit: named(n) })).filter(x => x.hit),
|
||||
atoms: ac.report(30)
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__D__) {
|
||||
for (const { sid, rid } of window.__D__.ids) {
|
||||
const s = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__D__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222)
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup)
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`setup failed: ${ok}`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= TILES; n++) {
|
||||
await cdp.eval(reveal(`diag-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
const data = JSON.parse(await cdp.eval(DRAG))
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
console.log(`moved ${data.moved}px, ${data.commits} commits\n`)
|
||||
console.log('RENDERS during drag:')
|
||||
|
||||
for (const r of data.renders) {
|
||||
console.log(
|
||||
` ${r.name.padEnd(28)} r=${String(r.renders).padStart(6)} wasted=${String(r.wasted).padStart(6)} ` +
|
||||
`props=${String(r.propsChanged).padStart(5)} state=${String(r.stateChanged).padStart(5)} ` +
|
||||
`ctx=${String(r.contextChanged ?? 0).padStart(4)} ms=${r.totalMs}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\nTRANSCRIPT CHAIN (who above the messages re-rendered):')
|
||||
|
||||
for (const { name, hit } of data.chain) {
|
||||
console.log(
|
||||
` ${name.padEnd(24)} r=${String(hit.renders).padStart(6)} wasted=${String(hit.wasted).padStart(6)} ` +
|
||||
`props=${String(hit.propsChanged).padStart(5)} state=${String(hit.stateChanged).padStart(5)} ms=${hit.totalMs}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\nATOMS that notified during drag:')
|
||||
|
||||
for (const a of data.atoms) {
|
||||
console.log(
|
||||
` ${a.name.padEnd(26)} notifies=${String(a.notifies).padStart(5)} wasted=${String(a.wasted).padStart(5)} ` +
|
||||
`fanout=${String(a.fanout).padStart(6)} peakListeners=${a.peakListeners}`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
217
apps/desktop/scripts/diag-drag-trace.mjs
Normal file
217
apps/desktop/scripts/diag-drag-trace.mjs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
// What is the drag actually spending time on?
|
||||
//
|
||||
// The render counters proved React is no longer the cost (commits 83 -> 12
|
||||
// after the $layoutTree fix) yet drag fps stayed ~3 while p95 halved. That
|
||||
// pattern says a fixed per-frame floor outside React. This takes a real CDP
|
||||
// trace of one sash drag and prints the category split — Recalculate Style,
|
||||
// Layout, Paint, Scripting — so the next fix targets the actual cost instead
|
||||
// of the next plausible-looking thing.
|
||||
//
|
||||
// node scripts/diag-drag-trace.mjs [--port 9222] [--tiles 5]
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const TILES = Number(arg('tiles', 5))
|
||||
const TURNS = Number(arg('turns', 20))
|
||||
|
||||
const setup = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
|
||||
])
|
||||
window.__T__ = { ids: [] }
|
||||
for (let n = 1; n <= ${TILES}; n++) {
|
||||
const sid = 'trace-tile-' + n
|
||||
const rid = 'trace-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working.' }] })
|
||||
window.__T__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
// Drive the sash WITHOUT awaiting rAF: a slow app would stretch a rAF-paced
|
||||
// loop and make the window itself a function of the slowness. Fixed wall-clock
|
||||
// pacing keeps the trace window comparable run to run.
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) return 'none'
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 40; i++) {
|
||||
x += (i < 20 ? 3 : -3)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => setTimeout(r, 16))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
return 'dragged'
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__T__) {
|
||||
for (const { sid, rid } of window.__T__.ids) {
|
||||
const s = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__T__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup)
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`setup failed: ${ok}`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= TILES; n++) {
|
||||
await cdp.eval(reveal(`trace-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
// Collect trace events for the drag window only. `cdp.on` is the client's
|
||||
// only event API (no `once`), so completion is signalled through a flag.
|
||||
const events = []
|
||||
let complete = false
|
||||
cdp.on('Tracing.dataCollected', params => events.push(...(params.value ?? [])))
|
||||
cdp.on('Tracing.tracingComplete', () => {
|
||||
complete = true
|
||||
})
|
||||
|
||||
await cdp.send('Tracing.start', {
|
||||
transferMode: 'ReportEvents',
|
||||
traceConfig: { includedCategories: ['devtools.timeline', 'blink.user_timing'] }
|
||||
})
|
||||
|
||||
const dragged = await cdp.eval(DRAG)
|
||||
|
||||
await cdp.send('Tracing.end')
|
||||
|
||||
for (let waited = 0; !complete && waited < 10000; waited += 200) {
|
||||
await sleep(200)
|
||||
}
|
||||
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
// Sum self-time per timeline category. Nested events would double-count, so
|
||||
// attribute each event's duration minus the duration of its direct children.
|
||||
const INTERESTING = new Set([
|
||||
'UpdateLayoutTree', // Recalculate Style
|
||||
'Layout',
|
||||
'Paint',
|
||||
'PaintImage',
|
||||
'Layerize',
|
||||
'UpdateLayer',
|
||||
'CompositeLayers',
|
||||
'FunctionCall',
|
||||
'EvaluateScript',
|
||||
'TimerFire',
|
||||
'EventDispatch',
|
||||
'HitTest',
|
||||
'ParseHTML',
|
||||
'CommitLoad'
|
||||
])
|
||||
|
||||
const totals = new Map()
|
||||
let traced = 0
|
||||
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || typeof e.dur !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
traced += 1
|
||||
const name = e.name
|
||||
|
||||
if (!INTERESTING.has(name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
totals.set(name, (totals.get(name) ?? 0) + e.dur / 1000)
|
||||
}
|
||||
|
||||
console.log(`drag=${dragged} trace events=${events.length} (complete=${traced})\n`)
|
||||
console.log('TIMELINE COST (ms, total duration by event):')
|
||||
|
||||
const rows = [...totals.entries()].sort((a, b) => b[1] - a[1])
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log(' (no timeline events — category filter or tracing domain unavailable)')
|
||||
}
|
||||
|
||||
for (const [name, ms] of rows) {
|
||||
console.log(` ${name.padEnd(20)} ${ms.toFixed(1)}ms`)
|
||||
}
|
||||
|
||||
const style = totals.get('UpdateLayoutTree') ?? 0
|
||||
const layout = totals.get('Layout') ?? 0
|
||||
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
|
||||
|
||||
console.log(`\nVERDICT: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms`)
|
||||
|
||||
// Script dominates -> name the functions. Timeline FunctionCall events carry
|
||||
// the callsite in args.data, so the top offenders can be attributed without
|
||||
// a separate CPU profile.
|
||||
const byFn = new Map()
|
||||
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || e.name !== 'FunctionCall' || typeof e.dur !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
const d = e.args?.data ?? {}
|
||||
const key = `${d.functionName || '(anonymous)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
|
||||
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
|
||||
}
|
||||
|
||||
console.log('\nTOP SCRIPT CALLSITES (ms):')
|
||||
|
||||
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15)) {
|
||||
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
47
apps/desktop/scripts/diag-key-latency.mjs
Normal file
47
apps/desktop/scripts/diag-key-latency.mjs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Typing latency, isolated: keystroke -> next paint, with and without an
|
||||
// active stream. Distinguishes "input is slow" from "the frame budget is
|
||||
// consumed by streaming flushes" — the fix differs completely.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
const TYPE = `
|
||||
(async () => {
|
||||
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
|
||||
if (!el) return JSON.stringify({ error: 'no composer' })
|
||||
el.focus()
|
||||
const perKey = []
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const ch = 'abcdefghij'[i % 10]
|
||||
const t0 = performance.now()
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
|
||||
el.textContent += ch
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
perKey.push(performance.now() - t0)
|
||||
// Human-ish 80ms cadence so streaming flushes interleave realistically.
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
}
|
||||
el.textContent = ''
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
|
||||
const sorted = [...perKey].sort((a, b) => a - b)
|
||||
const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
|
||||
const busy = (() => { try { return document.querySelectorAll('[data-status="running"]').length } catch { return -1 } })()
|
||||
return JSON.stringify({
|
||||
keyToPaint_p50: Math.round(pct(0.5) * 10) / 10,
|
||||
keyToPaint_p95: Math.round(pct(0.95) * 10) / 10,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
|
||||
over16: perKey.filter(f => f > 16.7).length,
|
||||
over33: perKey.filter(f => f > 33).length,
|
||||
streamingParts: busy
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
console.log(await cdp.eval(TYPE))
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
21
apps/desktop/scripts/diag-live-state.mjs
Normal file
21
apps/desktop/scripts/diag-live-state.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Quick state probe of the running hgui instance via CDP.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const state = await cdp.eval(`(() => {
|
||||
const rc = !!window.__RENDER_COUNTS__
|
||||
const pl = !!window.__PERF_LIVE__
|
||||
const tiles = window.__HERMES_SESSION_TILES__ ? Object.keys(window.__HERMES_SESSION_TILES__.states()).length : -1
|
||||
const gw = document.querySelector('[data-slot="statusbar"]')?.textContent?.slice(0, 120) ?? '(no statusbar)'
|
||||
const sidebarRows = document.querySelectorAll('[data-slot="sidebar"] [data-session-id], [data-tree-group] a').length
|
||||
return JSON.stringify({ rc, pl, tiles, gw, sidebarRows, title: document.title, url: location.href.slice(0, 80) })
|
||||
})()`)
|
||||
|
||||
console.log(state)
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
149
apps/desktop/scripts/diag-real-loop.mjs
Normal file
149
apps/desktop/scripts/diag-real-loop.mjs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// The real-app perf loop: drive HER hgui instance (real profile, real
|
||||
// sessions) through the three interactions that matter — session switch,
|
||||
// sidebar drag, composer typing — and report honest single-clock numbers.
|
||||
//
|
||||
// node scripts/diag-real-loop.mjs [--port 9222] [--switches 6]
|
||||
//
|
||||
// Unlike the synthetic scenarios this clicks REAL sidebar rows, so session
|
||||
// switching is measured as the user feels it: click -> transcript painted.
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const SWITCHES = Number(arg('switches', 6))
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session switch: click a sidebar session row, await the transcript settling.
|
||||
// Measures click -> first paint of the new transcript AND click -> settled
|
||||
// (two rAFs with no further DOM mutation in the thread viewport).
|
||||
// ---------------------------------------------------------------------------
|
||||
const SWITCH = swaps => `
|
||||
(async () => {
|
||||
const rows = [...document.querySelectorAll('[data-slot="row-button"]')]
|
||||
.filter(el => el.offsetParent && (el.textContent ?? '').trim())
|
||||
if (rows.length < 2) return JSON.stringify({ error: 'need 2+ visible session rows, found ' + rows.length })
|
||||
|
||||
const results = []
|
||||
for (let i = 0; i < ${swaps}; i++) {
|
||||
const row = rows[i % Math.min(rows.length, 4)]
|
||||
const viewport = () => document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const t0 = performance.now()
|
||||
row.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1 }))
|
||||
row.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 }))
|
||||
row.click()
|
||||
|
||||
// First paint: next two rAFs after the click.
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
const firstPaint = performance.now() - t0
|
||||
|
||||
// Settled: no mutations in the viewport for 2 consecutive frames, capped 3s.
|
||||
let lastMutation = performance.now()
|
||||
const target = viewport() ?? document.body
|
||||
const mo = new MutationObserver(() => { lastMutation = performance.now() })
|
||||
mo.observe(target, { childList: true, subtree: true, characterData: true })
|
||||
const deadline = performance.now() + 3000
|
||||
while (performance.now() < deadline) {
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
if (performance.now() - lastMutation > 120) break
|
||||
}
|
||||
mo.disconnect()
|
||||
results.push({ firstPaint: Math.round(firstPaint), settled: Math.round(performance.now() - t0 - 120) })
|
||||
await new Promise(r => setTimeout(r, 250))
|
||||
}
|
||||
return JSON.stringify(results)
|
||||
})()
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drag the first visible sash, single-clock frames.
|
||||
// ---------------------------------------------------------------------------
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const handle = [...document.querySelectorAll('[role="separator"]')].find(el => el.offsetParent || el.getBoundingClientRect().width > 0)
|
||||
if (!handle) return JSON.stringify({ error: 'no sash' })
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...o, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 60; i++) {
|
||||
x += (i < 30 ? 2 : -2)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...o, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
const now = performance.now(); frames.push(now - last); last = now
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...o, buttons: 0, clientX: x, clientY: y }))
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
return JSON.stringify({
|
||||
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
|
||||
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
|
||||
slow33: frames.filter(f => f > 33).length
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type into the composer, single-clock frames (one mark per keystroke frame).
|
||||
// ---------------------------------------------------------------------------
|
||||
const TYPE = `
|
||||
(async () => {
|
||||
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
|
||||
if (!el) return JSON.stringify({ error: 'no composer' })
|
||||
el.focus()
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const ch = 'the quick brown fox '[i % 20]
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
|
||||
el.textContent += ch
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
const now = performance.now(); frames.push(now - last); last = now
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
}
|
||||
// Clear what we typed.
|
||||
el.textContent = ''
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
|
||||
const moving = frames
|
||||
const total = moving.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...moving].sort((a, b) => a - b)
|
||||
return JSON.stringify({
|
||||
fps: Math.round((moving.length / total) * 1000 * 10) / 10,
|
||||
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
|
||||
slow33: moving.filter(f => f > 33).length
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
console.log('== SESSION SWITCH (click -> paint / settled ms) ==')
|
||||
console.log(await cdp.eval(SWITCH(SWITCHES)))
|
||||
|
||||
await sleep(500)
|
||||
console.log('\n== SIDEBAR DRAG ==')
|
||||
console.log(await cdp.eval(DRAG))
|
||||
|
||||
await sleep(500)
|
||||
console.log('\n== COMPOSER TYPING ==')
|
||||
console.log(await cdp.eval(TYPE))
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
170
apps/desktop/scripts/diag-ro-storm.mjs
Normal file
170
apps/desktop/scripts/diag-ro-storm.mjs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
// How many ResizeObserver callbacks does one sash drag actually fire, and for
|
||||
// how many DISTINCT elements? The trace named use-resize-observer.ts at 977ms
|
||||
// but not whether that's a few expensive calls or a great many cheap ones —
|
||||
// and the fix differs completely between those.
|
||||
//
|
||||
// node scripts/diag-ro-storm.mjs [--port 9222] [--tiles 5]
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const TILES = Number(arg('tiles', 5))
|
||||
const TURNS = Number(arg('turns', 20))
|
||||
|
||||
const setup = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff and its error path.' }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
|
||||
])
|
||||
window.__R__ = { ids: [] }
|
||||
for (let n = 1; n <= ${TILES}; n++) {
|
||||
const sid = 'ro-tile-' + n
|
||||
const rid = 'ro-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working.' }] })
|
||||
window.__R__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
// Patch ResizeObserver to count callbacks + distinct observed targets, then
|
||||
// drag and report. Counting happens in the page so nothing crosses CDP per call.
|
||||
//
|
||||
// NOTE: the app's shared observer (hooks/use-resize-observer.ts) is created
|
||||
// lazily on first use, so this patch must be installed BEFORE any surface
|
||||
// mounts — otherwise the shared instance is a native one this wrapper never
|
||||
// sees and every counter reads zero. `constructed` is the tell: a run showing
|
||||
// a handful of constructions and zero callbacks means the patch landed late,
|
||||
// not that the app stopped observing.
|
||||
const INSTRUMENT = `
|
||||
(() => {
|
||||
if (window.__ROSTATS__) return 'already'
|
||||
const Native = window.ResizeObserver
|
||||
const stats = { constructed: 0, observed: 0, callbacks: 0, entries: 0, targets: new Set(), on: false }
|
||||
window.__ROSTATS__ = stats
|
||||
window.ResizeObserver = class extends Native {
|
||||
constructor(cb) {
|
||||
super((entries, obs) => {
|
||||
if (stats.on) {
|
||||
stats.callbacks += 1
|
||||
stats.entries += entries.length
|
||||
for (const e of entries) stats.targets.add(e.target)
|
||||
}
|
||||
return cb(entries, obs)
|
||||
})
|
||||
stats.constructed += 1
|
||||
}
|
||||
observe(...args) {
|
||||
stats.observed += 1
|
||||
return super.observe(...args)
|
||||
}
|
||||
}
|
||||
return 'patched'
|
||||
})()
|
||||
`
|
||||
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const s = window.__ROSTATS__
|
||||
s.callbacks = 0; s.entries = 0; s.targets = new Set(); s.on = true
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) { s.on = false; return JSON.stringify({ error: 'no sash' }) }
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
const t0 = performance.now()
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 40; i++) {
|
||||
x += (i < 20 ? 3 : -3)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => setTimeout(r, 16))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
s.on = false
|
||||
return JSON.stringify({
|
||||
ms: Math.round(performance.now() - t0),
|
||||
moves: 40,
|
||||
constructed: s.constructed,
|
||||
observed: s.observed,
|
||||
callbacks: s.callbacks,
|
||||
entries: s.entries,
|
||||
distinctTargets: s.targets.size,
|
||||
userBubbles: document.querySelectorAll('[data-slot="aui_user-message-root"]').length
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__R__) {
|
||||
for (const { sid, rid } of window.__R__.ids) {
|
||||
const s = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__R__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
await cdp.eval(INSTRUMENT)
|
||||
|
||||
const ok = await cdp.eval(setup)
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`setup failed: ${ok}`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= TILES; n++) {
|
||||
await cdp.eval(reveal(`ro-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
const r = JSON.parse(await cdp.eval(DRAG))
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
console.log(JSON.stringify(r, null, 2))
|
||||
|
||||
if (r.moves) {
|
||||
console.log(`\nper pointermove: ${(r.entries / r.moves).toFixed(1)} RO entries`)
|
||||
console.log(`distinct elements resized: ${r.distinctTargets} (user bubbles in DOM: ${r.userBubbles})`)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
27
apps/desktop/scripts/diag-sidebar-dom.mjs
Normal file
27
apps/desktop/scripts/diag-sidebar-dom.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Dump the sidebar's actual DOM shape so selectors stop being guesses.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const out = await cdp.eval(`(() => {
|
||||
const sidebar = document.querySelector('[data-slot="sidebar"]') ?? document.querySelector('aside')
|
||||
if (!sidebar) return '(no sidebar el)'
|
||||
// Find clickable rows: anchors or buttons with text, depth-limited sample.
|
||||
const clickables = [...sidebar.querySelectorAll('a, button, [role="button"], [data-slot]')].slice(0, 60)
|
||||
const rows = clickables.map(el => ({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
slot: el.getAttribute('data-slot') ?? '',
|
||||
cls: (el.className?.baseVal ?? el.className ?? '').toString().slice(0, 40),
|
||||
text: (el.textContent ?? '').trim().slice(0, 30),
|
||||
visible: !!el.offsetParent
|
||||
})).filter(r => r.text)
|
||||
return JSON.stringify(rows.slice(0, 30), null, 1)
|
||||
})()`)
|
||||
|
||||
console.log(out)
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
52
apps/desktop/scripts/diag-switch-autopsy.mjs
Normal file
52
apps/desktop/scripts/diag-switch-autopsy.mjs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// Session-switch autopsy: click between the two heaviest rows repeatedly,
|
||||
// recording per-switch (a) settled ms, (b) React commits, (c) top rendered
|
||||
// components — so slow switches name themselves.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const ROUNDS = Number(arg('rounds', 8))
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
const SWITCH_ONE = index => `
|
||||
(async () => {
|
||||
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
|
||||
if (rows.length < 2) return JSON.stringify({ error: 'rows' })
|
||||
const row = rows[${index} % 2]
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
rc.start()
|
||||
const t0 = performance.now()
|
||||
row.click()
|
||||
let lastMutation = performance.now()
|
||||
const mo = new MutationObserver(() => { lastMutation = performance.now() })
|
||||
mo.observe(document.body, { childList: true, subtree: true, characterData: true })
|
||||
const deadline = performance.now() + 4000
|
||||
while (performance.now() < deadline) {
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
if (performance.now() - lastMutation > 150) break
|
||||
}
|
||||
mo.disconnect()
|
||||
rc.stop()
|
||||
const settled = Math.round(performance.now() - t0 - 150)
|
||||
const report = rc.report(6).map(r => r.name + ':' + r.renders + '(' + Math.round(r.totalMs) + 'ms)')
|
||||
return JSON.stringify({ label: (row.textContent ?? '').slice(0, 24), settled, commits: rc.commits(), top: report })
|
||||
})()
|
||||
`
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
for (let i = 0; i < ROUNDS; i++) {
|
||||
console.log(await cdp.eval(SWITCH_ONE(i)))
|
||||
await sleep(400)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
74
apps/desktop/scripts/diag-switch-trace.mjs
Normal file
74
apps/desktop/scripts/diag-switch-trace.mjs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// What happens during a SLOW session switch? Click a heavy row with tracing
|
||||
// on, dump the style/layout/script split plus top callsites.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
const CLICK_HEAVIEST = `
|
||||
(() => {
|
||||
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
|
||||
if (rows.length < 2) return 'need rows'
|
||||
// Alternate between the first two rows so every run actually switches.
|
||||
const current = location.hash
|
||||
const target = rows.find(r => !r.getAttribute('data-active')) ?? rows[1]
|
||||
target.click()
|
||||
return 'clicked: ' + (target.textContent ?? '').slice(0, 40)
|
||||
})()
|
||||
`
|
||||
|
||||
const events = []
|
||||
let complete = false
|
||||
cdp.on('Tracing.dataCollected', p => events.push(...(p.value ?? [])))
|
||||
cdp.on('Tracing.tracingComplete', () => {
|
||||
complete = true
|
||||
})
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
await cdp.send('Tracing.start', {
|
||||
transferMode: 'ReportEvents',
|
||||
traceConfig: { includedCategories: ['devtools.timeline'] }
|
||||
})
|
||||
|
||||
console.log(await cdp.eval(CLICK_HEAVIEST))
|
||||
await sleep(2500)
|
||||
console.log(await cdp.eval(CLICK_HEAVIEST))
|
||||
await sleep(2500)
|
||||
|
||||
await cdp.send('Tracing.end')
|
||||
|
||||
for (let w = 0; !complete && w < 10000; w += 200) {
|
||||
await sleep(200)
|
||||
}
|
||||
|
||||
const totals = new Map()
|
||||
const byFn = new Map()
|
||||
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || typeof e.dur !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
totals.set(e.name, (totals.get(e.name) ?? 0) + e.dur / 1000)
|
||||
|
||||
if (e.name === 'FunctionCall') {
|
||||
const d = e.args?.data ?? {}
|
||||
const key = `${d.functionName || '(anon)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
|
||||
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const style = totals.get('UpdateLayoutTree') ?? 0
|
||||
const layout = totals.get('Layout') ?? 0
|
||||
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
|
||||
console.log(`\nVERDICT over 2 switches: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms paint=${(totals.get('Paint') ?? 0).toFixed(0)}ms`)
|
||||
console.log('\nTOP CALLSITES:')
|
||||
|
||||
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)) {
|
||||
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
|
|
@ -1,6 +1,31 @@
|
|||
// Simple eval helper — runs an expression and returns the result.value.
|
||||
const targets = await (await fetch('http://127.0.0.1:9222/json')).json()
|
||||
const t = targets.find((t) => t.url.includes('5174'))
|
||||
//
|
||||
// node scripts/eval.mjs "document.title"
|
||||
// HERMES_DESKTOP_CDP_PORT=9333 node scripts/eval.mjs "document.title"
|
||||
//
|
||||
// Needs a renderer with a debugging port: launch `hgui` / `npm run dev` with
|
||||
// HERMES_DESKTOP_CDP_PORT set (see electron/dev-cdp.ts).
|
||||
const port = Number(process.env.HERMES_DESKTOP_CDP_PORT || 9222)
|
||||
let targets
|
||||
|
||||
try {
|
||||
targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
|
||||
} catch {
|
||||
console.error(
|
||||
`no renderer debugging port on 127.0.0.1:${port}. ` +
|
||||
'Dev-server runs (`npm run dev` / `hgui`) open one automatically — check the app is running, ' +
|
||||
'and that HERMES_DESKTOP_CDP_PORT is not set to "off" or another port.'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const t = targets.find((t) => t.url.includes('5174')) ?? targets.find((t) => t.type === 'page')
|
||||
|
||||
if (!t) {
|
||||
console.error(`no page target on 127.0.0.1:${port} (found ${targets.length} target(s))`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ws = new WebSocket(t.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
|
|
|
|||
290
apps/desktop/scripts/live-drive.mjs
Normal file
290
apps/desktop/scripts/live-drive.mjs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
// Live-drive harness for the REAL hgui instance on :9222.
|
||||
//
|
||||
// node scripts/live-drive.mjs status — targets, session count, perf-live armed?
|
||||
// node scripts/live-drive.mjs fps [seconds] — raw rAF fps over N seconds (default 4)
|
||||
// node scripts/live-drive.mjs drag — drag the sidebar sash, report fps + LoAF
|
||||
// node scripts/live-drive.mjs type — type into composer, report fps + LoAF
|
||||
// node scripts/live-drive.mjs switch — cycle through sidebar sessions, per-switch ms
|
||||
// node scripts/live-drive.mjs send "msg" — submit a prompt in the focused session
|
||||
// node scripts/live-drive.mjs eval "expr" — arbitrary page eval
|
||||
//
|
||||
// Attaches to the page target directly (no perf-harness deps) so it works on
|
||||
// the app Brooklyn actually runs, with her profile, her sessions, her layout.
|
||||
|
||||
import { WebSocket } from 'ws'
|
||||
|
||||
const PORT = Number(process.env.CDP_PORT ?? 9222)
|
||||
|
||||
async function attach() {
|
||||
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()
|
||||
const page = list.find(t => t.type === 'page' && !/devtools/.test(t.url))
|
||||
|
||||
if (!page) {
|
||||
throw new Error('no page target on :' + PORT)
|
||||
}
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 })
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.once('open', resolve)
|
||||
ws.once('error', reject)
|
||||
})
|
||||
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.on('message', raw => {
|
||||
const msg = JSON.parse(raw)
|
||||
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result)
|
||||
}
|
||||
})
|
||||
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const mid = ++id
|
||||
pending.set(mid, { resolve, reject })
|
||||
ws.send(JSON.stringify({ id: mid, method, params }))
|
||||
})
|
||||
|
||||
await send('Runtime.enable')
|
||||
|
||||
const evaluate = async expression => {
|
||||
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
|
||||
|
||||
if (r.exceptionDetails) {
|
||||
throw new Error(r.exceptionDetails.exception?.description ?? 'eval failed')
|
||||
}
|
||||
|
||||
return r.result?.value
|
||||
}
|
||||
|
||||
return { evaluate, close: () => ws.close(), send }
|
||||
}
|
||||
|
||||
const FPS = seconds => `
|
||||
(async () => {
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
const end = last + ${seconds * 1000}
|
||||
while (performance.now() < end) {
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
const now = performance.now()
|
||||
frames.push(now - last)
|
||||
last = now
|
||||
}
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
|
||||
return {
|
||||
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
|
||||
p95: Math.round(pct(0.95) * 10) / 10,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
|
||||
slow33: frames.filter(f => f > 33).length,
|
||||
n: frames.length
|
||||
}
|
||||
})()
|
||||
`
|
||||
|
||||
// LoAF recorder for a window of work driven inside `body`.
|
||||
const WITH_LOAF = body => `
|
||||
(async () => {
|
||||
const lofs = []
|
||||
const po = new PerformanceObserver(list => {
|
||||
for (const e of list.getEntries()) {
|
||||
lofs.push({
|
||||
ms: Math.round(e.duration),
|
||||
block: Math.round(e.blockingDuration ?? 0),
|
||||
style: e.styleAndLayoutStart ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) : 0,
|
||||
scripts: (e.scripts ?? []).filter(s => s.duration >= 5).map(s =>
|
||||
(s.invokerType ?? '') + ':' + (s.invoker ?? s.sourceFunctionName ?? '?') + '@' +
|
||||
((s.sourceURL ?? '').split('/').pop() ?? '') + ' ' + Math.round(s.duration) + 'ms')
|
||||
})
|
||||
}
|
||||
})
|
||||
po.observe({ type: 'long-animation-frame', buffered: false })
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
let stop = false
|
||||
const tick = () => {
|
||||
if (stop) return
|
||||
const now = performance.now()
|
||||
frames.push(now - last)
|
||||
last = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
${body}
|
||||
stop = true
|
||||
po.disconnect()
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
|
||||
return {
|
||||
fps: total ? Math.round((frames.length / total) * 1000 * 10) / 10 : 0,
|
||||
p95: Math.round(pct(0.95) * 10) / 10,
|
||||
worst: sorted.length ? Math.round(sorted[sorted.length - 1] * 10) / 10 : 0,
|
||||
slow33: frames.filter(f => f > 33).length,
|
||||
longFrames: lofs.slice(0, 10)
|
||||
}
|
||||
})()
|
||||
`
|
||||
|
||||
const DRAG_BODY = `
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) throw new Error('no sash')
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 60; i++) {
|
||||
x += (i < 30 ? 3 : -3)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
`
|
||||
|
||||
const TYPE_BODY = `
|
||||
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]'))
|
||||
if (!el) throw new Error('no composer')
|
||||
el.focus()
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const ch = 'the quick brown fox '[i % 20]
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
|
||||
el.textContent += ch
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
}
|
||||
`
|
||||
|
||||
// Session switching: click each sidebar session row, time route->settled.
|
||||
const SWITCH = `
|
||||
(async () => {
|
||||
const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')]
|
||||
.filter(el => el.offsetParent !== null).slice(0, 8)
|
||||
if (pick().length < 2) {
|
||||
throw new Error('found ' + pick().length + ' session rows')
|
||||
}
|
||||
const times = []
|
||||
const labels = []
|
||||
for (let i = 0; i < Math.min(pick().length, 6); i++) {
|
||||
// Re-query each iteration: a switch can re-render the sidebar and
|
||||
// detach the previously captured nodes.
|
||||
const row = pick()[i]
|
||||
if (!row) break
|
||||
labels.push((row.textContent || '').slice(0, 24))
|
||||
const t0 = performance.now()
|
||||
row.click()
|
||||
let calm = 0
|
||||
while (calm < 2 && performance.now() - t0 < 5000) {
|
||||
const f0 = performance.now()
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
const dt = performance.now() - f0
|
||||
calm = dt < 20 ? calm + 1 : 0
|
||||
}
|
||||
times.push(Math.round(performance.now() - t0))
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
}
|
||||
return { switches: times, labels, avg: Math.round(times.reduce((a, b) => a + b, 0) / times.length) }
|
||||
})()
|
||||
`
|
||||
|
||||
const cmd = process.argv[2] ?? 'status'
|
||||
const arg = process.argv[3]
|
||||
const { evaluate, close } = await attach()
|
||||
|
||||
try {
|
||||
if (cmd === 'status') {
|
||||
const r = await evaluate(`JSON.stringify({
|
||||
url: location.hash,
|
||||
perfLive: typeof window.__PERF_LIVE__ !== 'undefined',
|
||||
renderCounts: typeof window.__RENDER_COUNTS__ !== 'undefined',
|
||||
tiles: document.querySelectorAll('[data-tree-group]').length,
|
||||
sessions: document.querySelectorAll('[data-slot*="session-row"], [data-session-row]').length,
|
||||
composers: [...document.querySelectorAll('[contenteditable="true"]')].length
|
||||
})`)
|
||||
console.log(r)
|
||||
} else if (cmd === 'fps') {
|
||||
console.log(JSON.stringify(await evaluate(FPS(Number(arg ?? 4)))))
|
||||
} else if (cmd === 'drag') {
|
||||
const r = await evaluate(WITH_LOAF(DRAG_BODY))
|
||||
console.log('drag', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 }))
|
||||
for (const lf of r.longFrames) {
|
||||
console.log(` ⏱ ${lf.ms}ms block=${lf.block} style=${lf.style} → ${lf.scripts.join(' | ') || '(no script ≥5ms)'}`)
|
||||
}
|
||||
} else if (cmd === 'type') {
|
||||
const r = await evaluate(WITH_LOAF(TYPE_BODY))
|
||||
console.log('type', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 }))
|
||||
for (const lf of r.longFrames) {
|
||||
console.log(` ⏱ ${lf.ms}ms block=${lf.block} style=${lf.style} → ${lf.scripts.join(' | ') || '(no script ≥5ms)'}`)
|
||||
}
|
||||
} else if (cmd === 'switch') {
|
||||
console.log(JSON.stringify(await evaluate(SWITCH)))
|
||||
} else if (cmd === 'eval') {
|
||||
console.log(JSON.stringify(await evaluate(arg)))
|
||||
} else if (cmd === 'profile') {
|
||||
// CPU-profile one session switch via CDP Profiler (Document Policy blocks
|
||||
// the in-page Profiler API, CDP is exempt). arg = row label prefix.
|
||||
await send('Profiler.enable')
|
||||
await send('Profiler.setSamplingInterval', { interval: 200 })
|
||||
await send('Profiler.start')
|
||||
const r = await evaluate(`
|
||||
(async () => {
|
||||
const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent !== null)
|
||||
const row = pick().find(el => (el.textContent || '').startsWith(${JSON.stringify(arg ?? 'GUI')}))
|
||||
if (!row) return 'row not found'
|
||||
const t0 = performance.now()
|
||||
row.click()
|
||||
let calm = 0
|
||||
while (calm < 3 && performance.now() - t0 < 6000) {
|
||||
const f0 = performance.now()
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
calm = (performance.now() - f0) < 20 ? calm + 1 : 0
|
||||
}
|
||||
return Math.round(performance.now() - t0)
|
||||
})()
|
||||
`)
|
||||
const { profile } = await send('Profiler.stop')
|
||||
// Self-time per function.
|
||||
const hitById = new Map()
|
||||
for (let i = 0; i < profile.samples.length; i++) {
|
||||
const id = profile.samples[i]
|
||||
const dt = profile.timeDeltas[i] ?? 0
|
||||
hitById.set(id, (hitById.get(id) ?? 0) + dt)
|
||||
}
|
||||
const rows = []
|
||||
for (const node of profile.nodes) {
|
||||
const us = hitById.get(node.id)
|
||||
if (!us || us < 5000) continue
|
||||
const f = node.callFrame
|
||||
rows.push([Math.round(us / 1000), `${f.functionName || '(anon)'} @ ${(f.url || '').split('/').pop()}:${f.lineNumber}`])
|
||||
}
|
||||
rows.sort((a, b) => b[0] - a[0])
|
||||
console.log('switch took', r, 'ms — top self-time:')
|
||||
for (const [ms, name] of rows.slice(0, 18)) {
|
||||
console.log(` ${String(ms).padStart(6)}ms ${name}`)
|
||||
}
|
||||
} else if (cmd === 'send') {
|
||||
const r = await evaluate(`
|
||||
(async () => {
|
||||
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]'))
|
||||
if (!el) return 'no composer'
|
||||
el.focus()
|
||||
document.execCommand('insertText', false, ${JSON.stringify(arg ?? 'hello')})
|
||||
await new Promise(r => setTimeout(r, 120))
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter' }))
|
||||
return 'sent'
|
||||
})()
|
||||
`)
|
||||
console.log(r)
|
||||
} else {
|
||||
console.log('unknown cmd', cmd)
|
||||
}
|
||||
} finally {
|
||||
close()
|
||||
}
|
||||
|
|
@ -51,6 +51,8 @@ directly via `window.__PERF_DRIVE__`, so no LLM credits are spent.
|
|||
| `stream --real` | backend | same, from a real LLM stream | measure-real-stream, profile-real-stream |
|
||||
| `keystroke` | ci | composer keystroke → paint latency | measure-latency, profile-typing, leak-typing |
|
||||
| `transcript` | ci | large-transcript mount + paint cost | (new) |
|
||||
| `render-churn` | ci | per-component render attribution + store churn while N tabs stream | (new) |
|
||||
| `idle-cost` | report | busy-but-silent tiles: idle commit rate, + fps while resizing / typing | (new) |
|
||||
| `cold-start` | cold | launch → CDP → driver → first paint (fresh spawn/run) | (new) |
|
||||
| `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) |
|
||||
| `submit` | backend | Enter → cleared → user msg painted, scroll jump | measure-submit, measure-jump |
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"_meta": {
|
||||
"note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot — no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.",
|
||||
"note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot \u2014 no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.",
|
||||
"platform": "darwin-arm64",
|
||||
"node": "v24.11.0",
|
||||
"updated": "2026-07-19T23:16:01.227Z"
|
||||
"updated": "2026-07-27T00:30:11.290Z"
|
||||
},
|
||||
"scenarios": {
|
||||
"stream": {
|
||||
|
|
@ -55,6 +55,25 @@
|
|||
"dom_content_loaded_ms": 574,
|
||||
"nav_to_read_ms": 721
|
||||
}
|
||||
},
|
||||
"multitab": {
|
||||
"metrics": {
|
||||
"longtasks_n": 0,
|
||||
"longtask_max_ms": 0,
|
||||
"frame_p95_ms": 29.1,
|
||||
"frame_p99_ms": 36.2,
|
||||
"slow_frames_33": 9
|
||||
}
|
||||
},
|
||||
"render-churn": {
|
||||
"metrics": {
|
||||
"sidebar_renders": 0,
|
||||
"sidebar_wasted": 0,
|
||||
"wasted_renders": 1704,
|
||||
"total_renders": 8221,
|
||||
"commits": 1352,
|
||||
"wasted_notifies": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
284
apps/desktop/scripts/perf/scenarios/idle-cost.mjs
Normal file
284
apps/desktop/scripts/perf/scenarios/idle-cost.mjs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
// What does the app cost while a turn is running but NOTHING is arriving?
|
||||
//
|
||||
// The user-visible symptom: with a thread spinning, resizing the sidebar or
|
||||
// typing in the composer feels slow. That is not streaming cost — the stream
|
||||
// is idle. It is the app re-rendering on its own, competing with the
|
||||
// interaction for the main thread.
|
||||
//
|
||||
// This scenario holds N tiles in a busy state, pushes NO tokens, and measures:
|
||||
// - idle_commits_per_s the renderer's self-inflicted commit rate
|
||||
// - drag_fps fps while dragging the sidebar splitter
|
||||
// - type_fps fps while typing in the composer
|
||||
//
|
||||
// A perfectly idle app scores 0 idle commits and pins both interactions at the
|
||||
// display's refresh rate. Every idle commit is main-thread time stolen from an
|
||||
// interaction the user can feel.
|
||||
//
|
||||
// node scripts/perf/run.mjs idle-cost --spawn [--tiles 5] [--seconds 6]
|
||||
|
||||
import { sleep } from '../lib/cdp.mjs'
|
||||
|
||||
/** Seed `tiles` busy session tiles. Same publish path as `multitab` /
|
||||
* `render-churn`, but the driver never runs — the turn just stays open. */
|
||||
const setup = (tiles, seedTurns) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
|
||||
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff.' }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- Point one.\\n- Point two.\\n' }] }
|
||||
])
|
||||
|
||||
window.__IDLE__ = { ids: [] }
|
||||
for (let n = 1; n <= ${tiles}; n++) {
|
||||
const sid = 'idle-tile-' + n
|
||||
const rid = 'idle-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
|
||||
// An OPEN assistant message: the turn is running, but no tokens arrive.
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working on it.' }] })
|
||||
|
||||
window.__IDLE__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
/** Measure the app's self-inflicted commit rate with nothing happening. */
|
||||
const idleCost = seconds => `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
rc.start()
|
||||
const t0 = performance.now()
|
||||
await new Promise(r => setTimeout(r, ${seconds} * 1000))
|
||||
const elapsed = (performance.now() - t0) / 1000
|
||||
rc.stop()
|
||||
return JSON.stringify({
|
||||
elapsed,
|
||||
commits: rc.commits(),
|
||||
top: rc.report(12),
|
||||
owners: rc.report(300).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 10)
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
/** Record frame pacing across an interaction driven from the page.
|
||||
*
|
||||
* The gesture body drives itself on requestAnimationFrame, so it IS the frame
|
||||
* clock — timing is taken from those same callbacks rather than a second,
|
||||
* independent rAF ticker. Running two rAF consumers made the observer's
|
||||
* deltas count the driver's frames as well as the app's and reported ~3fps
|
||||
* where the interaction actually ran at ~23fps. `frames` is filled by the
|
||||
* body via `__MARK__`.
|
||||
*
|
||||
* `record` MUST be false for any fps number you intend to believe: the render
|
||||
* counter walks the whole fiber tree on every commit, so recording during a
|
||||
* gesture measures the instrumentation as much as the app. Attribution and
|
||||
* timing therefore run as two separate passes. */
|
||||
const withFrames = (body, record = false) => `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
${record ? 'rc.start()' : ''}
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
// The body calls this once per frame it drives.
|
||||
const __MARK__ = () => {
|
||||
const now = performance.now()
|
||||
frames.push(now - last)
|
||||
last = now
|
||||
}
|
||||
${body}
|
||||
${record ? 'rc.stop()' : ''}
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
|
||||
return JSON.stringify({
|
||||
fps: total ? (frames.length / total) * 1000 : 0,
|
||||
p95: pct(0.95),
|
||||
worst: sorted.length ? sorted[sorted.length - 1] : 0,
|
||||
slow33: frames.filter(f => f > 33).length,
|
||||
n: frames.length,
|
||||
commits: ${record ? 'rc.commits()' : '0'},
|
||||
top: ${record ? 'rc.report(10)' : '[]'}
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
/** Drag the sidebar splitter — the resize symptom.
|
||||
* Sweeps monotonically (an oscillation nets to zero and can clamp to a no-op),
|
||||
* and reports how far it actually moved so a drag that silently did nothing
|
||||
* shows up as `dragMoved: 0` instead of a confident wrong number. */
|
||||
const DRAG = withFrames(`
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
window.__DRAG_TARGET__ = handle ? 'separator' : 'none'
|
||||
window.__DRAG_MOVED__ = 0
|
||||
if (handle) {
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
// Out 60px then back — a real gesture, with a net displacement at the peak.
|
||||
for (let i = 0; i < 30; i++) {
|
||||
x += 2
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
__MARK__()
|
||||
}
|
||||
window.__DRAG_MOVED__ = Math.round(x - x0)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
x -= 2
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
__MARK__()
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
} else {
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
`)
|
||||
|
||||
/** Type into the composer — the keystroke symptom. */
|
||||
const TYPE = withFrames(`
|
||||
const el = document.querySelector('[contenteditable="true"], textarea')
|
||||
window.__TYPE_TARGET__ = el ? (el.tagName.toLowerCase()) : 'none'
|
||||
if (el) {
|
||||
el.focus()
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const ch = 'performance testing '[i % 20]
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
|
||||
if (el.tagName === 'TEXTAREA') {
|
||||
el.value += ch
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
} else {
|
||||
el.textContent += ch
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
}
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
// Wait for the frame this keystroke produces, then mark it — same clock
|
||||
// discipline as DRAG, so typing fps is comparable to drag fps.
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
__MARK__()
|
||||
await new Promise(r => setTimeout(r, 25))
|
||||
}
|
||||
} else {
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
`)
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__IDLE__) {
|
||||
for (const { sid, rid } of window.__IDLE__.ids) {
|
||||
const states = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__IDLE__ = null
|
||||
}
|
||||
window.__RENDER_COUNTS__.clear()
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const round = (n, places = 1) => Math.round(n * 10 ** places) / 10 ** places
|
||||
|
||||
export default {
|
||||
name: 'idle-cost',
|
||||
// NOT 'ci': the drag fps this reports (~0.6fps, p95 814ms) contradicts a
|
||||
// direct single-clock probe of the same gesture on the same build (57fps),
|
||||
// and I could not reconcile the two — ruled out sash selection, tile setup,
|
||||
// counter residue, and a 20s soak. Its RENDER attribution and idle commit
|
||||
// rate are trustworthy and are what this scenario is for; the interaction
|
||||
// fps is reported for investigation, not gated on, until that is explained.
|
||||
tier: 'report',
|
||||
description: 'Busy-but-silent tiles: idle commit rate, and fps while resizing / typing.',
|
||||
async run(cdp, opts = {}) {
|
||||
const tiles = Number(opts.tiles ?? 5)
|
||||
const seedTurns = Number(opts.turns ?? 20)
|
||||
const seconds = Number(opts.seconds ?? 6)
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup(tiles, seedTurns))
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`idle-cost setup failed (${ok}) — needs a dev renderer with src/debug installed.`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= tiles; n++) {
|
||||
await cdp.eval(reveal(`idle-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
const idle = JSON.parse(await cdp.eval(idleCost(seconds)))
|
||||
const drag = JSON.parse(await cdp.eval(DRAG))
|
||||
const dragTarget = await cdp.eval('window.__DRAG_TARGET__ || "unknown"')
|
||||
const dragMoved = await cdp.eval('window.__DRAG_MOVED__ ?? 0')
|
||||
const type = JSON.parse(await cdp.eval(TYPE))
|
||||
const typeTarget = await cdp.eval('window.__TYPE_TARGET__ || "unknown"')
|
||||
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
if (dragTarget === 'none') {
|
||||
throw new Error('idle-cost: no [role="separator"] sash found — the drag measured nothing.')
|
||||
}
|
||||
|
||||
if (typeTarget === 'none') {
|
||||
throw new Error('idle-cost: no composer found — the typing pass measured nothing.')
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
// Commits per second with a turn open and nothing arriving. Should be 0.
|
||||
idle_commits_per_s: round(idle.commits / idle.elapsed),
|
||||
idle_renders: idle.top.reduce((a, r) => a + r.renders, 0),
|
||||
// Interaction smoothness while that churn competes for the main thread.
|
||||
// Reported as a deficit from 60fps so "lower is better" matches the
|
||||
// baseline gate's direction.
|
||||
drag_fps_deficit: round(Math.max(0, 60 - drag.fps)),
|
||||
drag_slow_frames: drag.slow33,
|
||||
type_fps_deficit: round(Math.max(0, 60 - type.fps)),
|
||||
type_slow_frames: type.slow33
|
||||
},
|
||||
detail: {
|
||||
tiles,
|
||||
dragTarget,
|
||||
dragMoved,
|
||||
idleSeconds: round(idle.elapsed),
|
||||
dragFps: round(drag.fps),
|
||||
dragP95: round(drag.p95),
|
||||
dragWorst: round(drag.worst),
|
||||
typeFps: round(type.fps),
|
||||
typeP95: round(type.p95),
|
||||
typeWorst: round(type.worst),
|
||||
// Components whose OWN state changed with no prop change: the roots.
|
||||
idleOwners: idle.owners,
|
||||
idleTop: idle.top,
|
||||
dragCommits: drag.commits,
|
||||
dragTop: drag.top,
|
||||
typeCommits: type.commits,
|
||||
typeTop: type.top
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,11 @@
|
|||
|
||||
import coldStart from './cold-start.mjs'
|
||||
import firstToken from './first-token.mjs'
|
||||
import idleCost from './idle-cost.mjs'
|
||||
import keystroke from './keystroke.mjs'
|
||||
import multitab from './multitab.mjs'
|
||||
import profileSwitch from './profile-switch.mjs'
|
||||
import renderChurn from './render-churn.mjs'
|
||||
import sessionSwitch from './session-switch.mjs'
|
||||
import stream from './stream.mjs'
|
||||
import streamHistory from './stream-history.mjs'
|
||||
|
|
@ -18,6 +20,8 @@ export const SCENARIOS = {
|
|||
[keystroke.name]: keystroke,
|
||||
[transcript.name]: transcript,
|
||||
[multitab.name]: multitab,
|
||||
[renderChurn.name]: renderChurn,
|
||||
[idleCost.name]: idleCost,
|
||||
[coldStart.name]: coldStart,
|
||||
[firstToken.name]: firstToken,
|
||||
[submit.name]: submit,
|
||||
|
|
|
|||
259
apps/desktop/scripts/perf/scenarios/render-churn.mjs
Normal file
259
apps/desktop/scripts/perf/scenarios/render-churn.mjs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
// Render churn during multi-tab streaming: WHAT re-rendered and WHY, and which
|
||||
// store published the update. Frame pacing (see `multitab`) tells you the cost;
|
||||
// this tells you the cause.
|
||||
//
|
||||
// Drives the same synthetic pipeline as `multitab` — publishSessionState per
|
||||
// session per flush via `__HERMES_SESSION_TILES__`, no backend, no credits —
|
||||
// then reads the dev-only counters installed by `src/debug/`:
|
||||
//
|
||||
// window.__RENDER_COUNTS__ — per-component renders, attributed to
|
||||
// props / hook state / parent-only ("wasted")
|
||||
// window.__ATOM_CHURN__ — per-store notifications, listener fan-out, and
|
||||
// notifications whose value was deep-equal to the
|
||||
// previous one ("wasted")
|
||||
//
|
||||
// The headline metric is `sidebar_renders`: how many times the sidebar tree
|
||||
// re-rendered while agents were typing in other tabs. It should be 0.
|
||||
//
|
||||
// node scripts/perf/run.mjs render-churn --spawn [--tiles 5] [--tokens 240]
|
||||
|
||||
import { sleep } from '../lib/cdp.mjs'
|
||||
|
||||
/** Components that make up the sidebar tree. A render of any of these while
|
||||
* a background tab streams is work the user cannot see. */
|
||||
const SIDEBAR_COMPONENTS = [
|
||||
'ChatSidebar',
|
||||
'SidebarSurface',
|
||||
'SessionRow',
|
||||
'SessionsSection',
|
||||
'CronJobsSection',
|
||||
'ProfileSwitcher',
|
||||
'VirtualSessionList',
|
||||
'WorkspaceGroup',
|
||||
'OverviewRow',
|
||||
'SessionStatusDot'
|
||||
]
|
||||
|
||||
/** Page-side setup: open `tiles` session tiles, seed each with a transcript.
|
||||
* Mirrors `multitab.mjs` so the two scenarios measure the same workload. */
|
||||
const setup = (tiles, seedTurns) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
|
||||
if (!window.__ATOM_CHURN__) return 'no-atom-churn'
|
||||
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Review question ' + i + ': does the diff handle the error path?' }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- The catch block drops the error.\\n- Retries are unbounded.\\n' }] }
|
||||
])
|
||||
|
||||
const state = (sid) => {
|
||||
const messages = []
|
||||
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: '' }] })
|
||||
return {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
}
|
||||
}
|
||||
|
||||
window.__RC__ = { ids: [], timer: null }
|
||||
for (let n = 1; n <= ${tiles}; n++) {
|
||||
const sid = 'churn-tile-' + n
|
||||
const rid = 'churn-rt-' + n
|
||||
window.__RC__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, state(sid))
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
/** Grow every tile's streaming tail by `chunk` each `intervalMs`, through the
|
||||
* same publish path the gateway's delta flush uses. */
|
||||
const drive = (chunk, intervalMs, totalTokens) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
let pushed = 0
|
||||
const tick = () => {
|
||||
const states = hook.states()
|
||||
for (const { rid } of window.__RC__.ids) {
|
||||
const prev = states[rid]
|
||||
if (!prev) continue
|
||||
const messages = prev.messages.map(m => {
|
||||
if (m.id !== prev.streamId) return m
|
||||
const head = m.parts.slice(0, -1)
|
||||
const last = m.parts[m.parts.length - 1]
|
||||
return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] }
|
||||
})
|
||||
hook.publish(rid, { ...prev, messages })
|
||||
}
|
||||
pushed += 1
|
||||
if (pushed < ${totalTokens}) window.__RC__.timer = setTimeout(tick, ${intervalMs})
|
||||
else window.__RC__.done = true
|
||||
}
|
||||
window.__RC__.timer = setTimeout(tick, ${intervalMs})
|
||||
return 'driving'
|
||||
})()
|
||||
`
|
||||
|
||||
/** Wait until the renderer stops committing on its own, so the recording window
|
||||
* captures STREAMING cost and not whatever boot/hydration work happened to
|
||||
* still be in flight. Returns `quiet:N` once commits hold still for `quietMs`.
|
||||
*
|
||||
* If it returns `timeout:...` the app never went idle at all — with tiles
|
||||
* marked busy and NO driver running, that means something is ticking on its
|
||||
* own. The report of what rendered during the wait is attached so the culprit
|
||||
* is named rather than guessed at. */
|
||||
const quiesce = (quietMs, timeoutMs) => `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
rc.start()
|
||||
const deadline = Date.now() + ${timeoutMs}
|
||||
const startedAt = Date.now()
|
||||
let last = -1
|
||||
let stableSince = Date.now()
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
const n = rc.commits()
|
||||
if (n !== last) { last = n; stableSince = Date.now(); continue }
|
||||
if (Date.now() - stableSince >= ${quietMs}) { rc.stop(); return 'quiet:' + n }
|
||||
}
|
||||
const idle = {
|
||||
commits: last,
|
||||
seconds: (Date.now() - startedAt) / 1000,
|
||||
top: rc.report(8),
|
||||
// Who OWNS the update? The component whose own hook state changed with
|
||||
// no changed props is the root of a churn cascade; everything under it
|
||||
// is collateral. Naming it is the difference between fixing the cause
|
||||
// and memoizing a symptom.
|
||||
owners: rc.report(200).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 8)
|
||||
}
|
||||
rc.stop()
|
||||
return 'timeout:' + JSON.stringify(idle)
|
||||
})()
|
||||
`
|
||||
|
||||
const START = `
|
||||
(() => {
|
||||
window.__RENDER_COUNTS__.start()
|
||||
window.__ATOM_CHURN__.start()
|
||||
return 'recording'
|
||||
})()
|
||||
`
|
||||
|
||||
const COLLECT = `
|
||||
(() => {
|
||||
window.__RENDER_COUNTS__.stop()
|
||||
window.__ATOM_CHURN__.stop()
|
||||
return JSON.stringify({
|
||||
commits: window.__RENDER_COUNTS__.commits(),
|
||||
renders: window.__RENDER_COUNTS__.report(200),
|
||||
atoms: window.__ATOM_CHURN__.report(200)
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__RC__) {
|
||||
clearTimeout(window.__RC__.timer)
|
||||
for (const { sid, rid } of window.__RC__.ids) {
|
||||
const states = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__RC__ = null
|
||||
}
|
||||
window.__RENDER_COUNTS__.clear()
|
||||
window.__ATOM_CHURN__.clear()
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
export default {
|
||||
name: 'render-churn',
|
||||
tier: 'ci',
|
||||
description: 'N streaming tabs: per-component render attribution + store churn.',
|
||||
async run(cdp, opts = {}) {
|
||||
const tiles = Number(opts.tiles ?? 5)
|
||||
const seedTurns = Number(opts.turns ?? 20)
|
||||
const tokens = Number(opts.tokens ?? 240)
|
||||
// Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush.
|
||||
const intervalMs = Number(opts.intervalMs ?? 33)
|
||||
const chunk = opts.chunk ?? 'A streamed review sentence with **bold** and `code`.\n\n'
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup(tiles, seedTurns))
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(
|
||||
`render-churn setup failed (${ok}) — needs a dev renderer with src/debug installed ` +
|
||||
'(the counters are aliased out of production builds unless VITE_PERF_PROBE=1).'
|
||||
)
|
||||
}
|
||||
|
||||
// Mount every tab (keep-alive mounts on first activation), then settle.
|
||||
for (let n = 1; n <= tiles; n++) {
|
||||
await cdp.eval(reveal(`churn-tile-${n}`))
|
||||
await sleep(350)
|
||||
}
|
||||
|
||||
// Let the app go quiet before recording, so boot/hydration commits that
|
||||
// happen to still be in flight don't land in the streaming window. This is
|
||||
// what makes runs comparable — a fixed sleep let 2-4x of hydration churn
|
||||
// leak in depending on machine load.
|
||||
const settle = await cdp.eval(quiesce(600, 15000))
|
||||
await cdp.eval(START)
|
||||
await cdp.eval(drive(chunk, intervalMs, tokens))
|
||||
await sleep(tokens * intervalMs + 1500)
|
||||
|
||||
const data = JSON.parse(await cdp.eval(COLLECT))
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
const byName = new Map(data.renders.map(r => [r.name, r]))
|
||||
const sidebarRows = SIDEBAR_COMPONENTS.map(n => byName.get(n)).filter(Boolean)
|
||||
const sidebarRenders = sidebarRows.reduce((a, r) => a + r.renders, 0)
|
||||
const sidebarWasted = sidebarRows.reduce((a, r) => a + r.wasted, 0)
|
||||
const totalRenders = data.renders.reduce((a, r) => a + r.renders, 0)
|
||||
const totalWasted = data.renders.reduce((a, r) => a + r.wasted, 0)
|
||||
const atomWasted = data.atoms.reduce((a, r) => a + r.wasted, 0)
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
// The hypothesis, as a number: sidebar renders while background tabs
|
||||
// stream. Should be 0.
|
||||
sidebar_renders: sidebarRenders,
|
||||
sidebar_wasted: sidebarWasted,
|
||||
// Renders with no changed props and no changed hook state — pure
|
||||
// parent-driven work, across the whole tree.
|
||||
wasted_renders: totalWasted,
|
||||
total_renders: totalRenders,
|
||||
commits: data.commits,
|
||||
// Store notifications that published a value equal to the last one.
|
||||
wasted_notifies: atomWasted
|
||||
},
|
||||
detail: {
|
||||
tiles,
|
||||
tokens,
|
||||
// 'quiet:N' = the app went idle before recording (comparable run).
|
||||
// 'timeout:N' = it never did, so boot churn is mixed into the numbers.
|
||||
settle,
|
||||
sidebar: sidebarRows,
|
||||
topRenders: data.renders.slice(0, 15),
|
||||
topAtoms: data.atoms.slice(0, 15)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,15 @@ import { RowButton } from '@/components/ui/row-button'
|
|||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
import { resolveBrandIcon } from '@/lib/brand-icon'
|
||||
import {
|
||||
ExternalLink,
|
||||
ExternalLinkIcon,
|
||||
hostPathLabel,
|
||||
shortHostLabel,
|
||||
urlSlugTitleLabel,
|
||||
useLinkTitle
|
||||
} from '@/lib/external-link'
|
||||
import { FileImage, FileText, FolderOpen, Link2, Loader2, RefreshCw } from '@/lib/icons'
|
||||
import { downloadGatewayMediaFile, isRemoteGateway } from '@/lib/media'
|
||||
import { normalize } from '@/lib/text'
|
||||
|
|
@ -29,8 +37,8 @@ import { notifyError } from '@/store/notifications'
|
|||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { openSession } from '../open-session'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { sessionRoute } from '../routes'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import {
|
||||
|
|
@ -272,7 +280,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
|
||||
const cellCtx: CellCtx = {
|
||||
onOpen: openArtifact,
|
||||
onOpenChat: sessionId => navigate(sessionRoute(sessionId))
|
||||
onOpenChat: sessionId => openSession(sessionId, navigate)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -337,7 +345,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
failedImage={failedImageIds.has(artifact.id)}
|
||||
key={artifact.id}
|
||||
onImageError={markImageFailed}
|
||||
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
|
||||
onOpenChat={sessionId => openSession(sessionId, navigate)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -543,7 +551,8 @@ function ArtifactCellAction({
|
|||
|
||||
function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const isLink = artifact.kind === 'link'
|
||||
const Icon = isLink ? Link2 : FileText
|
||||
const brand = isLink ? resolveBrandIcon(shortHostLabel(artifact.href)) : null
|
||||
const Icon = brand ?? (isLink ? Link2 : FileText)
|
||||
const fetchedTitle = useLinkTitle(isLink ? artifact.href : null)
|
||||
const label = isLink ? fetchedTitle || urlSlugTitleLabel(artifact.href) : artifact.label
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from '@/store/layout'
|
||||
import {
|
||||
$filePreviewTabs,
|
||||
$previewTarget,
|
||||
clearSessionPreviewRegistry,
|
||||
type PreviewTarget,
|
||||
setCurrentSessionPreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
|
||||
import { $rightRailActiveTabId } from '@/store/layout'
|
||||
import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview'
|
||||
|
||||
import { closeActiveTab } from './close-tab'
|
||||
|
||||
|
|
@ -26,40 +19,34 @@ function fileTarget(path: string): PreviewTarget {
|
|||
describe('closeActiveTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('document', { activeElement: null })
|
||||
$activeSessionId.set('session-1')
|
||||
$selectedStoredSessionId.set(null)
|
||||
closeRightRail()
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
$activeSessionId.set(null)
|
||||
$selectedStoredSessionId.set(null)
|
||||
clearSessionPreviewRegistry()
|
||||
closeRightRail()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('closes the active file preview tab (⌘W happy path)', () => {
|
||||
setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual')
|
||||
openPreview(fileTarget('/work/notes.md'), 'manual')
|
||||
|
||||
expect($filePreviewTabs.get()).toHaveLength(1)
|
||||
expect($previewTabs.get()).toHaveLength(1)
|
||||
expect($rightRailActiveTabId.get()).toBe('file:file:///work/notes.md')
|
||||
|
||||
expect(closeActiveTab()).toBe(true)
|
||||
expect($filePreviewTabs.get()).toHaveLength(0)
|
||||
expect($previewTabs.get()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('closes the visible file tab when active selection is a ghost preview', () => {
|
||||
// Active tab id stuck on live-preview after that target was cleared, while
|
||||
// file tabs remain (UI falls back to tabs[0] until React syncs). ⌘W must
|
||||
// close the visible file tab instead of no-op'ing via closeWorkspaceTab().
|
||||
setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual')
|
||||
$previewTarget.set(null)
|
||||
$rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
it('closes the visible tab when the active selection points at a tab that is gone', () => {
|
||||
// The rail falls back to tabs[0] until React syncs the selection, so ⌘W has
|
||||
// to act on what is actually on screen rather than no-op'ing.
|
||||
openPreview(fileTarget('/work/notes.md'), 'manual')
|
||||
$rightRailActiveTabId.set('file:file:///work/stale.md')
|
||||
|
||||
expect($filePreviewTabs.get()).toHaveLength(1)
|
||||
expect($previewTabs.get()).toHaveLength(1)
|
||||
expect(closeActiveTab()).toBe(true)
|
||||
expect($filePreviewTabs.get()).toHaveLength(0)
|
||||
expect($previewTabs.get()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,21 +1,24 @@
|
|||
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
|
||||
import { closeFocusedSessionTab } from '@/components/pane-shell/tree/store'
|
||||
import { isFocusWithin } from '@/lib/keybinds/combo'
|
||||
import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
|
||||
import { $previewTabs, closeActiveRightRailTab } from '@/store/preview'
|
||||
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
|
||||
|
||||
/**
|
||||
* ⌘W — close the tab of the context you're in, by precedence:
|
||||
* 1. a focused terminal → its active terminal tab,
|
||||
* 2. right-rail tabs (live preview and/or file peeks),
|
||||
* 3. the MAIN zone → its active tab (a session tile stacked into the workspace).
|
||||
* 4. the MAIN (workspace) tab itself, when session tabs are stacked with it:
|
||||
* 3. the FOCUSED chat zone → its active tab (a session tile stacked into it).
|
||||
* 4. the workspace tab itself, when session tabs are stacked with it:
|
||||
* the workspace can't close, so ⌘W shifts the NEXT session tab into main
|
||||
* (loads it as the primary + drops its now-redundant tile).
|
||||
* Returns false when nothing closes, so ⌘W is a no-op — it never closes the
|
||||
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
|
||||
* and the macOS menu-accelerator IPC.
|
||||
*
|
||||
* Steps 3-4 follow the same focused zone ⌘1…⌘9 indexes, so a second chat zone
|
||||
* with its own tab strip closes ITS tab instead of main's.
|
||||
*
|
||||
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
|
||||
* into main" (the two call sites have router access); omitting it disables the
|
||||
* step-4 promotion (⌘W stays the pre-existing no-op on the main tab).
|
||||
|
|
@ -27,18 +30,16 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri
|
|||
return true
|
||||
}
|
||||
|
||||
// Prefer tab *presence* over the derived active file target. After the live
|
||||
// preview is cleared, `$rightRailActiveTabId` can stay on `preview` while
|
||||
// file tabs remain (the rail UI falls back to tabs[0]). Gating only on
|
||||
// `$filePreviewTarget` made ⌘W fall through to closeWorkspaceTab() and look
|
||||
// broken with a file tab still on screen.
|
||||
if ($previewTarget.get() || $filePreviewTabs.get().length > 0) {
|
||||
// Gate on tab *presence*, not on the selection: a stale `$rightRailActiveTabId`
|
||||
// would otherwise make ⌘W fall through to closeFocusedSessionTab() and look
|
||||
// broken with a tab still on screen. The store resolves which tab that is.
|
||||
if ($previewTabs.get().length > 0) {
|
||||
return closeActiveRightRailTab()
|
||||
}
|
||||
|
||||
// A closeable main-zone tab (a session tile that's the active tab) closes
|
||||
// outright; the uncloseable workspace tab returns false and falls through.
|
||||
if (closeWorkspaceTab()) {
|
||||
// A closeable tab in the focused chat zone (a session tile that's the active
|
||||
// tab) closes outright; the uncloseable workspace tab falls through.
|
||||
if (closeFocusedSessionTab()) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
180
apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx
Normal file
180
apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComposerTrigger } from './hooks/use-composer-trigger'
|
||||
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
/**
|
||||
* Folder navigation in the `@` popover, driven through the REAL hook against a
|
||||
* real contentEditable.
|
||||
*
|
||||
* Tab and Enter used to be the same branch, so picking a folder always
|
||||
* committed a chip and closed the menu — there was no way to walk into a
|
||||
* subdirectory from the list. Tab now descends, Enter still commits, and
|
||||
* Backspace climbs back out one segment.
|
||||
*/
|
||||
function folderItem(path: string): Unstable_TriggerItem {
|
||||
return {
|
||||
id: `@folder:${path}|0`,
|
||||
type: 'folder',
|
||||
label: path.split('/').filter(Boolean).pop() ?? path,
|
||||
metadata: {
|
||||
icon: 'folder',
|
||||
display: path,
|
||||
meta: 'dir',
|
||||
rawText: `@folder:${path}`,
|
||||
insertId: path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setup(initialText: string) {
|
||||
const editor = document.createElement('div')
|
||||
editor.contentEditable = 'true'
|
||||
// The real composer marks its editor with this slot; `composerPlainText`
|
||||
// keys off it to decide whether a DIV contributes a trailing newline.
|
||||
// Without it the harness would silently diverge from production text.
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
renderComposerContents(editor, initialText)
|
||||
|
||||
// Caret at the end, which is where a typed trigger always leaves it.
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
const sel = window.getSelection()
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
|
||||
const editorRef = createRef<HTMLDivElement>() as { current: HTMLDivElement | null }
|
||||
editorRef.current = editor
|
||||
|
||||
const draftRef = { current: initialText }
|
||||
const setComposerText = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useComposerTrigger({
|
||||
at: { adapter: null, loading: false },
|
||||
draftRef,
|
||||
editorRef,
|
||||
requestMainFocus: vi.fn(),
|
||||
setComposerText,
|
||||
slash: { adapter: null, loading: false }
|
||||
})
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.refreshTrigger()
|
||||
})
|
||||
|
||||
return { editor, result, setComposerText }
|
||||
}
|
||||
|
||||
describe('@ folder navigation', () => {
|
||||
it('Tab on a folder walks into it and keeps the popover open', () => {
|
||||
const { editor, result } = setup('@app')
|
||||
|
||||
expect(result.current.trigger).toMatchObject({ kind: '@', query: 'app' })
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true })
|
||||
})
|
||||
|
||||
// Plain text, not a chip — the token is still being typed.
|
||||
expect(composerPlainText(editor)).toBe('@apps/')
|
||||
expect(editor.querySelector('[data-ref-text]')).toBeNull()
|
||||
})
|
||||
|
||||
it('descends repeatedly, one level per Tab', () => {
|
||||
const { editor, result } = setup('@apps/desk')
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true })
|
||||
})
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@apps/desktop/')
|
||||
})
|
||||
|
||||
it('Enter on a folder commits it as a chip instead of descending', () => {
|
||||
const { editor, result } = setup('@app')
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(folderItem('apps'))
|
||||
})
|
||||
|
||||
const chip = editor.querySelector('[data-ref-text]')
|
||||
|
||||
expect(chip).not.toBeNull()
|
||||
expect(chip?.getAttribute('data-ref-kind')).toBe('folder')
|
||||
expect(composerPlainText(editor)).toContain('@folder:')
|
||||
})
|
||||
|
||||
it('only descends for `@` folders — a file pick still commits', () => {
|
||||
const { editor, result } = setup('@main')
|
||||
|
||||
const file: Unstable_TriggerItem = {
|
||||
id: '@file:src/main.tsx|0',
|
||||
type: 'file',
|
||||
label: 'main.tsx',
|
||||
metadata: {
|
||||
icon: 'file',
|
||||
display: 'main.tsx',
|
||||
meta: 'src',
|
||||
rawText: '@file:src/main.tsx',
|
||||
insertId: 'src/main.tsx'
|
||||
}
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(file, { descend: true })
|
||||
})
|
||||
|
||||
expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('Backspace climbs out one segment at a time', () => {
|
||||
const { editor, result } = setup('@apps/desktop/')
|
||||
|
||||
let handled = false
|
||||
act(() => {
|
||||
handled = result.current.ascendTriggerPath()
|
||||
})
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(composerPlainText(editor)).toBe('@apps/')
|
||||
})
|
||||
|
||||
it('Backspace drops a partially typed segment before its parent', () => {
|
||||
const { editor, result } = setup('@apps/desk')
|
||||
|
||||
act(() => {
|
||||
result.current.ascendTriggerPath()
|
||||
})
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@apps/')
|
||||
})
|
||||
|
||||
it('leaves Backspace alone when there is no path to climb', () => {
|
||||
const { result } = setup('@apps')
|
||||
|
||||
let handled = true
|
||||
act(() => {
|
||||
handled = result.current.ascendTriggerPath()
|
||||
})
|
||||
|
||||
// No `/` in the query — normal character deletion must still happen.
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves text typed before the mention', () => {
|
||||
const { editor, result } = setup('look at @app')
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true })
|
||||
})
|
||||
|
||||
expect(composerPlainText(editor)).toBe('look at @apps/')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { $previewTabs } from '@/store/preview'
|
||||
|
||||
import { AttachmentList } from './attachments'
|
||||
|
||||
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
|
||||
|
||||
function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
|
||||
return { id, kind: 'file', label }
|
||||
}
|
||||
|
|
@ -66,4 +69,43 @@ describe('AttachmentList', () => {
|
|||
|
||||
expect(screen.getByText('valid.txt')).toBeDefined()
|
||||
})
|
||||
|
||||
it('opens an attached image in the lightbox, not the preview rail', async () => {
|
||||
$previewTabs.set([])
|
||||
|
||||
const image: ComposerAttachment = {
|
||||
id: 'img',
|
||||
kind: 'image',
|
||||
label: 'shot.png',
|
||||
path: '/tmp/shot.png',
|
||||
previewUrl: DATA_URL
|
||||
}
|
||||
|
||||
await renderWithI18n(<AttachmentList attachments={[image]} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /shot\.png/ }))
|
||||
})
|
||||
|
||||
// The lightbox renders the full-size image in a dialog; the rail stays empty.
|
||||
const lightbox = await screen.findByRole('dialog')
|
||||
|
||||
expect(lightbox.querySelector<HTMLImageElement>('img')?.src).toBe(DATA_URL)
|
||||
expect($previewTabs.get()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('still routes a non-image attachment to the preview rail', async () => {
|
||||
$previewTabs.set([])
|
||||
|
||||
const file: ComposerAttachment = { id: 'doc', kind: 'file', label: 'notes.md', path: '/tmp/notes.md' }
|
||||
|
||||
await renderWithI18n(<AttachmentList attachments={[file]} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /notes\.md/ }))
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect($previewTabs.get().map(tab => tab.target.path)).toEqual(['/tmp/notes.md'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { ImageLightbox } from '@/components/chat/zoomable-image'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useImageDownload } from '@/hooks/use-image-download'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
import { openPreview } from '@/store/preview'
|
||||
|
||||
export function AttachmentList({
|
||||
attachments,
|
||||
|
|
@ -31,17 +34,32 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
||||
const cwd = useStore($currentCwd)
|
||||
// The tile's cwd when this pill lives in a tile composer, not the primary's:
|
||||
// a relative attachment path has to resolve against its own session's root.
|
||||
const cwd = useStore(useSessionView().$cwd)
|
||||
const isUploading = attachment.uploadState === 'uploading'
|
||||
const hasUploadError = attachment.uploadState === 'error'
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
|
||||
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||
// An attached image already holds its full bytes as a data URL, so it belongs
|
||||
// in the same lightbox the thread uses. The rail is for files you read or
|
||||
// edit — not a picture you just want to look at. Images that never resolved a
|
||||
// thumbnail still fall through to the rail rather than dead-clicking.
|
||||
const lightboxSrc = attachment.kind === 'image' && !isUploading ? attachment.previewUrl : undefined
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const { download, saving } = useImageDownload(lightboxSrc)
|
||||
|
||||
async function openPreview() {
|
||||
async function openAttachment() {
|
||||
if (!canPreview) {
|
||||
return
|
||||
}
|
||||
|
||||
if (lightboxSrc) {
|
||||
setLightboxOpen(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const rawTarget =
|
||||
attachment.path ||
|
||||
attachment.detail ||
|
||||
|
|
@ -62,85 +80,90 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
throw new Error(c.couldNotPreview(attachment.label))
|
||||
}
|
||||
|
||||
// We already hold the image bytes (the card thumbnail) — render those
|
||||
// directly so a screenshot/clipboard image previews even when its only
|
||||
// on-disk copy is a transient path the renderer can't re-read.
|
||||
const withBytes =
|
||||
attachment.kind === 'image' && attachment.previewUrl
|
||||
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
|
||||
: preview
|
||||
|
||||
setCurrentSessionPreviewTarget(withBytes, 'manual', target)
|
||||
openPreview(preview, 'manual')
|
||||
} catch (error) {
|
||||
notifyError(error, c.previewUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||
<div className="group/attachment relative min-w-0 shrink-0">
|
||||
<button
|
||||
aria-busy={isUploading || undefined}
|
||||
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
||||
className={cn(
|
||||
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
|
||||
hasUploadError
|
||||
? 'border-destructive/45 hover:border-destructive/60'
|
||||
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
|
||||
)}
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openPreview()}
|
||||
type="button"
|
||||
>
|
||||
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-full object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<Icon className="size-3.5" />
|
||||
)}
|
||||
{isUploading && (
|
||||
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
|
||||
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
|
||||
</span>
|
||||
)}
|
||||
{hasUploadError && (
|
||||
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
|
||||
<AlertCircle className="size-3.5 text-destructive" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||
{attachment.label}
|
||||
</span>
|
||||
{detail && (
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-[0.62rem] leading-3.5',
|
||||
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
|
||||
)}
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{onRemove && (
|
||||
<>
|
||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||
<div className="group/attachment relative min-w-0 shrink-0">
|
||||
<button
|
||||
aria-label={c.removeAttachment(attachment.label)}
|
||||
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
aria-busy={isUploading || undefined}
|
||||
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
||||
className={cn(
|
||||
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
|
||||
hasUploadError
|
||||
? 'border-destructive/45 hover:border-destructive/60'
|
||||
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
|
||||
)}
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openAttachment()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.625rem" />
|
||||
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-full object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<Icon className="size-3.5" />
|
||||
)}
|
||||
{isUploading && (
|
||||
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
|
||||
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
|
||||
</span>
|
||||
)}
|
||||
{hasUploadError && (
|
||||
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
|
||||
<AlertCircle className="size-3.5 text-destructive" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||
{attachment.label}
|
||||
</span>
|
||||
{detail && (
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-[0.62rem] leading-3.5',
|
||||
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
|
||||
)}
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Tip>
|
||||
{onRemove && (
|
||||
<button
|
||||
aria-label={c.removeAttachment(attachment.label)}
|
||||
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.625rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Tip>
|
||||
{lightboxSrc && (
|
||||
<ImageLightbox
|
||||
alt={attachment.label}
|
||||
copy={t.desktop}
|
||||
onClick={download}
|
||||
onOpenChange={setLightboxOpen}
|
||||
open={lightboxOpen}
|
||||
saving={saving}
|
||||
src={lightboxSrc}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
acceptsTriggerCompletion,
|
||||
isPendingDraftPersistCurrent,
|
||||
type PendingDraftPersist,
|
||||
pickPlaceholder,
|
||||
slashArgStage,
|
||||
slashChipKindForItem,
|
||||
slashCommandToken
|
||||
slashCommandToken,
|
||||
type TriggerAcceptInput
|
||||
} from './composer-utils'
|
||||
|
||||
const item = (group: string): Unstable_TriggerItem =>
|
||||
|
|
@ -39,6 +41,53 @@ describe('slashChipKindForItem', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('acceptsTriggerCompletion', () => {
|
||||
const press = (key: string, overrides: Partial<TriggerAcceptInput> = {}) =>
|
||||
acceptsTriggerCompletion({
|
||||
activeExplicit: false,
|
||||
freeTextArgStage: false,
|
||||
key,
|
||||
kind: '/',
|
||||
query: 'personality alic',
|
||||
...overrides
|
||||
})
|
||||
|
||||
it('accepts on Enter / Tab / Space for a finite option list', () => {
|
||||
expect(press('Enter')).toBe(true)
|
||||
expect(press('Tab')).toBe(true)
|
||||
expect(press(' ')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores keys that are neither navigation nor acceptance', () => {
|
||||
expect(press('a')).toBe(false)
|
||||
expect(press('Escape')).toBe(false)
|
||||
})
|
||||
|
||||
it('lets an `@` mention take a literal space', () => {
|
||||
expect(press(' ', { kind: '@', query: 'src/comp' })).toBe(false)
|
||||
expect(press('Enter', { kind: '@', query: 'src/comp' })).toBe(true)
|
||||
})
|
||||
|
||||
it('types a space on a bare `/ ` instead of accepting', () => {
|
||||
expect(press(' ', { query: '' })).toBe(false)
|
||||
})
|
||||
|
||||
// The `/goal <prose>` class: the popover may be live over free-form text, so
|
||||
// the keys that mean something else in prose must keep meaning it.
|
||||
it('sends the prose rather than the unchosen first row', () => {
|
||||
expect(press('Enter', { freeTextArgStage: true, query: 'goal ship the redesign' })).toBe(false)
|
||||
expect(press(' ', { freeTextArgStage: true, query: 'goal ship the' })).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts on Enter once the user has arrowed to a row deliberately', () => {
|
||||
expect(press('Enter', { activeExplicit: true, freeTextArgStage: true, query: 'goal stat' })).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps Tab as the explicit accept even over free text', () => {
|
||||
expect(press('Tab', { freeTextArgStage: true, query: 'goal stat' })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickPlaceholder', () => {
|
||||
it('returns a member of the pool', () => {
|
||||
const pool = ['a', 'b', 'c'] as const
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type { SlashChipKind } from '@/components/assistant-ui/directive-text'
|
|||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { setSessionPickerOpen } from '@/store/session'
|
||||
|
||||
import type { TriggerState } from './text-utils'
|
||||
|
||||
export const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||
|
||||
// Above the stack breakpoint but still cramped: the model pill sheds its label
|
||||
|
|
@ -59,6 +61,48 @@ export const slashArgStage = (query: string) => query.includes(' ')
|
|||
/** The `/command` token of a slash query (`personality x` → `/personality`). */
|
||||
export const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}`
|
||||
|
||||
export interface TriggerAcceptInput {
|
||||
/** The user moved the highlight themselves (arrow keys) rather than
|
||||
* inheriting the list's default first row. */
|
||||
activeExplicit: boolean
|
||||
/** The trigger is a slash command whose argument is arbitrary prose. */
|
||||
freeTextArgStage: boolean
|
||||
key: string
|
||||
kind: TriggerState['kind']
|
||||
query: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a keypress accepts the highlighted completion while the popover is
|
||||
* open. Tab is always an accept — it has no other meaning in the composer.
|
||||
*
|
||||
* Enter and Space are conditional, because both mean something else while a
|
||||
* free-text argument is being written (`/goal ship the redesign`). Space types
|
||||
* a space, and Enter sends the message; letting either take the popover's
|
||||
* pre-highlighted row would swap the prose the user is mid-sentence on for a
|
||||
* subcommand they never chose. Enter still accepts once the user has arrowed
|
||||
* to a row deliberately, so the highlight never lies about what Enter will do.
|
||||
*/
|
||||
export function acceptsTriggerCompletion({
|
||||
activeExplicit,
|
||||
freeTextArgStage,
|
||||
key,
|
||||
kind,
|
||||
query
|
||||
}: TriggerAcceptInput): boolean {
|
||||
if (key === 'Tab') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (key === 'Enter') {
|
||||
return !freeTextArgStage || activeExplicit
|
||||
}
|
||||
|
||||
// Space is slash-only (an `@` mention takes a literal space) and gated to a
|
||||
// non-empty query so a bare `/ ` still types a space.
|
||||
return key === ' ' && kind === '/' && Boolean(query.trim()) && !freeTextArgStage
|
||||
}
|
||||
|
||||
export interface QueueEditState {
|
||||
attachments: ComposerAttachment[]
|
||||
draft: string
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ function Harness({
|
|||
onSubmit,
|
||||
onQueue,
|
||||
onCancel,
|
||||
onDrain
|
||||
onDrain,
|
||||
onSendNow
|
||||
}: {
|
||||
busy?: boolean
|
||||
disabled?: boolean
|
||||
|
|
@ -38,6 +39,7 @@ function Harness({
|
|||
onQueue: (text: string) => void
|
||||
onCancel: () => void
|
||||
onDrain: () => void
|
||||
onSendNow?: (id: string) => void
|
||||
}) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const draftRef = useRef('')
|
||||
|
|
@ -103,6 +105,12 @@ function Harness({
|
|||
}
|
||||
|
||||
if (busy && !hasLivePayload) {
|
||||
const head = queued[0]
|
||||
|
||||
if (head) {
|
||||
onSendNow?.(head)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -167,13 +175,14 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
|
|||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {
|
||||
it('treats an empty Enter while busy with nothing queued as a no-op (never an accidental Stop)', async () => {
|
||||
const onCancel = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
const onQueue = vi.fn()
|
||||
const onSendNow = vi.fn()
|
||||
|
||||
const { getByTestId } = render(
|
||||
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
|
||||
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSendNow={onSendNow} onSubmit={onSubmit} />
|
||||
)
|
||||
|
||||
const editor = getByTestId('editor')
|
||||
|
|
@ -186,6 +195,35 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
|
|||
expect(onCancel).not.toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onQueue).not.toHaveBeenCalled()
|
||||
expect(onSendNow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('double-send: an empty Enter while busy with a queued turn sends that turn now', async () => {
|
||||
const onCancel = vi.fn()
|
||||
const onSendNow = vi.fn()
|
||||
|
||||
const { getByTestId } = render(
|
||||
<Harness
|
||||
busy
|
||||
onCancel={onCancel}
|
||||
onDrain={vi.fn()}
|
||||
onQueue={vi.fn()}
|
||||
onSendNow={onSendNow}
|
||||
onSubmit={vi.fn()}
|
||||
queued={['queued-1', 'queued-2']}
|
||||
/>
|
||||
)
|
||||
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
editor.textContent = ''
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
// Head of the queue, and NOT a bare cancel — send-now promotes + interrupts.
|
||||
expect(onSendNow).toHaveBeenCalledWith('queued-1')
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import { useEffect, useRef } from 'react'
|
|||
import { playSpeechText } from '@/lib/voice-playback'
|
||||
import { ownsAmbientCue } from '@/store/ambient'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
import { $autoSpeakReplies } from '@/store/voice-prefs'
|
||||
|
||||
import { useComposerScope } from '../scope'
|
||||
|
||||
interface AutoSpeakReply {
|
||||
id: string
|
||||
pending: boolean
|
||||
|
|
@ -40,6 +41,9 @@ export function useAutoSpeakReplies({
|
|||
sessionId
|
||||
}: UseAutoSpeakReplies) {
|
||||
const enabled = useStore($autoSpeakReplies)
|
||||
// Wake on THIS composer's transcript: a tile subscribed to the primary's
|
||||
// would never fire on its own replies (and would fire on someone else's).
|
||||
const { $messages } = useComposerScope()
|
||||
const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply })
|
||||
latest.current = { conversationActive, failureLabel, markSpoken, pendingReply }
|
||||
|
||||
|
|
@ -83,5 +87,5 @@ export function useAutoSpeakReplies({
|
|||
const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)]
|
||||
|
||||
return () => stops.forEach(f => f())
|
||||
}, [enabled, sessionId])
|
||||
}, [$messages, enabled, sessionId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -408,6 +408,7 @@ export function useComposerDraft({
|
|||
requestMainFocus,
|
||||
sessionIdRef,
|
||||
setComposerText,
|
||||
stashAt
|
||||
stashAt,
|
||||
syncDraftFromEditor
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import {
|
|||
} from '@/app/chat/surface-vars'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { $composerPoppedOut } from '@/store/composer-popout'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
|
||||
|
||||
|
|
@ -82,6 +80,11 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
|
|||
const lastBucketedSurfaceHeightRef = useRef(0)
|
||||
const lastTightRef = useRef<boolean | null>(null)
|
||||
const lastCompactPillRef = useRef<boolean | null>(null)
|
||||
// Mirrored into a ref so `syncComposerMetrics` stays referentially stable —
|
||||
// it's the shared ResizeObserver's handler, and a new identity every render
|
||||
// would re-register the observation.
|
||||
const poppedOutRef = useRef(poppedOut)
|
||||
poppedOutRef.current = poppedOut
|
||||
|
||||
const syncComposerMetrics = useCallback(() => {
|
||||
const composer = composerRef.current
|
||||
|
|
@ -92,9 +95,10 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
|
|||
|
||||
// Floating composer is out of the thread's flow — it must not reserve any
|
||||
// bottom clearance. Zero the measured vars so the thread reclaims the space.
|
||||
// (Read globals here so the callback stays stable; mirror the popoutAllowed
|
||||
// gate since secondary windows are forced docked.)
|
||||
if ($composerPoppedOut.get() && !isSecondaryWindow()) {
|
||||
// Read through a ref so the callback stays stable, and read THIS surface's
|
||||
// own state: pop-out is per layout zone, so a float in the left split must
|
||||
// not zero the right split's clearance.
|
||||
if (poppedOutRef.current) {
|
||||
lastBucketedHeightRef.current = 0
|
||||
lastBucketedSurfaceHeightRef.current = 0
|
||||
setSurfaceVar(composer, COMPOSER_HEIGHT_VAR, '0px')
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type RefObject, useCallback, useEffect } from 'react'
|
||||
import { type RefObject, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { usePaneGroup, usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
$composerPopoutPosition,
|
||||
$composerPoppedOut,
|
||||
$composerPopoutZone,
|
||||
clampPopoutPosition,
|
||||
getComposerPopoutZone,
|
||||
popoutBoundsElement,
|
||||
type PopoutPosition,
|
||||
readPopoutBounds,
|
||||
setComposerPopoutPosition,
|
||||
setComposerPoppedOut
|
||||
} from '@/store/composer-popout'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { useComposerScope } from '../scope'
|
||||
|
||||
import { useComposerPopoutGestures } from './use-popout-drag'
|
||||
|
||||
interface UseComposerPopoutOptions {
|
||||
|
|
@ -20,32 +22,122 @@ interface UseComposerPopoutOptions {
|
|||
}
|
||||
|
||||
/**
|
||||
* Pop-out engine: the docked↔floating state (a shared, persisted atom), the
|
||||
* dock/float/toggle actions, the drag gestures, and the on-screen re-clamp.
|
||||
* Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) can't
|
||||
* pop out — a floating composer makes no sense there and would yank the main
|
||||
* window's composer out via the shared atom.
|
||||
* This surface's on-screen placement, derived from its zone's drag intent.
|
||||
*
|
||||
* A zone stores one intent for its whole tab stack — drag the box in any tab and
|
||||
* it moves in all of them — but each surface owns a different rect, so the
|
||||
* intent is clamped per surface. Clamping into the store instead would have
|
||||
* every keep-alive-mounted tab overwrite the others with a position bounded by
|
||||
* ITS geometry, last writer winning: that's how a drag in one tab used to get
|
||||
* lost in another.
|
||||
*
|
||||
* Re-placing is skipped while this surface drags (the gesture already clamped
|
||||
* against this rect) and while it's an inactive tab (still mounted, so a live
|
||||
* drag would otherwise force a reflow per background tab per frame).
|
||||
*/
|
||||
function usePopoutPlacement(
|
||||
composerRef: RefObject<HTMLFormElement | null>,
|
||||
groupId: string,
|
||||
intent: PopoutPosition,
|
||||
dragging: boolean,
|
||||
poppedOut: boolean
|
||||
): PopoutPosition {
|
||||
const [placement, setPlacement] = useState(intent)
|
||||
const visible = usePaneVisible()
|
||||
// Re-place while this surface is the visible tab and isn't itself dragging.
|
||||
const live = poppedOut && visible && !dragging
|
||||
|
||||
// Resolved before the shared ResizeObserver below registers (hook order puts
|
||||
// this layout effect first), so the observer always has this surface's own
|
||||
// bounds element rather than a document-wide first match.
|
||||
const boundsRef = useRef<Element | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
boundsRef.current = popoutBoundsElement(composerRef.current)
|
||||
})
|
||||
|
||||
const reclamp = useCallback(() => {
|
||||
const el = composerRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const size = { height: el.offsetHeight, width: el.offsetWidth }
|
||||
const next = clampPopoutPosition(getComposerPopoutZone(groupId).position, size, readPopoutBounds(el))
|
||||
|
||||
// Bail on an unchanged placement: a sash drag resizes the surface every
|
||||
// frame, and a fresh object each time re-renders the whole composer.
|
||||
setPlacement(prev => (prev.bottom === next.bottom && prev.right === next.right ? prev : next))
|
||||
}, [composerRef, groupId])
|
||||
|
||||
// The surface resizing (sash drag, sidebar open, tab split) re-places the box
|
||||
// against its new rect; the composer resizing (a growing draft) re-places it
|
||||
// against its new height.
|
||||
useResizeObserver(
|
||||
useCallback(() => {
|
||||
if (live) {
|
||||
reclamp()
|
||||
}
|
||||
}, [live, reclamp]),
|
||||
composerRef,
|
||||
boundsRef
|
||||
)
|
||||
|
||||
// useLayoutEffect, not useEffect: a tab revealed after the box was dragged in
|
||||
// another one must not paint a frame at its stale placement before catching
|
||||
// up. Runs before paint, and no-ops for hidden tabs (`live`).
|
||||
useLayoutEffect(() => {
|
||||
if (!live) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
reclamp()
|
||||
// A second pass after layout settles (sidebar widths, fonts): anyone
|
||||
// restored out of bounds is pulled back even if the first measure was
|
||||
// premature.
|
||||
const raf = requestAnimationFrame(reclamp)
|
||||
window.addEventListener('resize', reclamp)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', reclamp)
|
||||
}
|
||||
}, [intent, live, reclamp])
|
||||
|
||||
return dragging ? intent : placement
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop-out engine: the docked↔floating state, the dock/float/toggle actions, the
|
||||
* drag gestures, and this surface's placement.
|
||||
*
|
||||
* State is scoped to the surface's layout ZONE (its tab stack): tabs in the same
|
||||
* zone share one float, so switching tabs keeps the box exactly where you put
|
||||
* it, while a split zone beside them keeps its own — popping out on the left
|
||||
* doesn't fling a composer out of the right.
|
||||
*
|
||||
* Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) stay
|
||||
* docked: a floating composer makes no sense in a scratch window.
|
||||
*/
|
||||
export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
|
||||
// The floating composer is a window-level singleton: only the main scope
|
||||
// (not tiles) in a primary window may pop out.
|
||||
const scope = useComposerScope()
|
||||
const popoutAllowed = !isSecondaryWindow() && scope.popoutAllowed
|
||||
const poppedOut = useStore($composerPoppedOut) && popoutAllowed
|
||||
const popoutPosition = useStore($composerPopoutPosition)
|
||||
const popoutAllowed = !isSecondaryWindow()
|
||||
const groupId = usePaneGroup()
|
||||
const zone = useStore(useMemo(() => $composerPopoutZone(groupId), [groupId]))
|
||||
const poppedOut = zone.poppedOut && popoutAllowed
|
||||
|
||||
const handleComposerPopOut = useCallback(() => {
|
||||
triggerHaptic('open')
|
||||
setComposerPoppedOut(true)
|
||||
}, [])
|
||||
setComposerPoppedOut(groupId, true)
|
||||
}, [groupId])
|
||||
|
||||
const handleComposerDock = useCallback(() => {
|
||||
triggerHaptic('success')
|
||||
setComposerPoppedOut(false)
|
||||
}, [])
|
||||
setComposerPoppedOut(groupId, false)
|
||||
}, [groupId])
|
||||
|
||||
// Double-click the grab area toggles dock/float. Undocking restores the last
|
||||
// position (the persisted atom is never cleared on dock).
|
||||
// position (a zone's stored position is never cleared on dock).
|
||||
const handleComposerToggle = useCallback(() => {
|
||||
poppedOut ? handleComposerDock() : handleComposerPopOut()
|
||||
}, [handleComposerDock, handleComposerPopOut, poppedOut])
|
||||
|
|
@ -56,39 +148,14 @@ export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
|
|||
onPointerDown: onComposerGesturePointerDown
|
||||
} = useComposerPopoutGestures({
|
||||
composerRef,
|
||||
groupId,
|
||||
onDock: handleComposerDock,
|
||||
onPopOut: handleComposerPopOut,
|
||||
poppedOut,
|
||||
position: popoutPosition
|
||||
position: zone.position
|
||||
})
|
||||
|
||||
// Keep the floating box on-screen: re-clamp (with the real measured size +
|
||||
// thread bounds) when it pops out and on every window resize — so a position
|
||||
// persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar
|
||||
// can never strand it. The rAF pass re-clamps after layout settles (sidebar
|
||||
// widths, fonts), so anyone loading in out of bounds is pulled back + saved
|
||||
// even if the first measure was premature.
|
||||
useEffect(() => {
|
||||
if (!poppedOut) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const reclamp = (persist: boolean) => {
|
||||
const el = composerRef.current
|
||||
const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined
|
||||
setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size })
|
||||
}
|
||||
|
||||
reclamp(true)
|
||||
const raf = requestAnimationFrame(() => reclamp(true))
|
||||
const onResize = () => reclamp(false)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', onResize)
|
||||
}
|
||||
}, [composerRef, poppedOut])
|
||||
const popoutPosition = usePopoutPlacement(composerRef, groupId, zone.position, dragging, poppedOut)
|
||||
|
||||
return {
|
||||
dockProximity,
|
||||
|
|
|
|||
|
|
@ -106,7 +106,9 @@ export function useComposerQueue({
|
|||
entryId: entry.id,
|
||||
sessionKey: activeQueueSessionKey
|
||||
})
|
||||
loadIntoComposer(entry.text, entry.attachments)
|
||||
// Edit what the panel SHOWS. A queued `/skill` entry's text is the
|
||||
// expanded skill body — never drop that into the composer.
|
||||
loadIntoComposer(entry.displayText ?? entry.text, entry.attachments)
|
||||
triggerHaptic('selection')
|
||||
focusInput()
|
||||
}
|
||||
|
|
@ -135,7 +137,7 @@ export function useComposerQueue({
|
|||
|
||||
if (next) {
|
||||
setQueueEditSnapshot({ ...queueEdit, entryId: next.id })
|
||||
loadIntoComposer(next.text, next.attachments)
|
||||
loadIntoComposer(next.displayText ?? next.text, next.attachments)
|
||||
} else {
|
||||
setQueueEditSnapshot(null)
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
|
|
@ -213,6 +215,7 @@ export function useComposerQueue({
|
|||
const accepted = await Promise.resolve(
|
||||
onSubmit(entry.text, {
|
||||
attachments: entry.attachments,
|
||||
...(entry.displayText ? { displayText: entry.displayText } : {}),
|
||||
fromQueue: true,
|
||||
sessionId: drainRuntimeSessionId,
|
||||
storedSessionId: drainQueueSessionKey
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $clarifyRequests } from '@/store/clarify'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
|
||||
import { useComposerSubmit } from './use-composer-submit'
|
||||
|
||||
|
|
@ -184,3 +186,80 @@ describe('useComposerSubmit busy-turn routing', () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useComposerSubmit with a clarify parked on the session', () => {
|
||||
const gatewayRequest = vi.fn(async () => ({ ok: true }))
|
||||
|
||||
const parkClarify = (sessionId: string) => {
|
||||
$clarifyRequests.set({
|
||||
[sessionId]: { requestId: `req-${sessionId}`, question: 'which one?', choices: ['a', 'b'], sessionId }
|
||||
})
|
||||
$gateway.set({ request: gatewayRequest } as unknown as ReturnType<typeof $gateway.get>)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
gatewayRequest.mockClear()
|
||||
$clarifyRequests.set({})
|
||||
$gateway.set(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('skips the question and still sends the typed message on an idle session', async () => {
|
||||
parkClarify('runtime-session')
|
||||
const { hook, onSubmit } = renderSubmitHook({ text: 'actually do this instead' })
|
||||
|
||||
act(() => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', {
|
||||
request_id: 'req-runtime-session',
|
||||
answer: ''
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(onSubmit).toHaveBeenCalledWith('actually do this instead', expect.objectContaining({ attachments: [] }))
|
||||
)
|
||||
expect($clarifyRequests.get()['runtime-session']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips the question before steering a busy turn', async () => {
|
||||
parkClarify('runtime-session')
|
||||
const { hook, onSteer } = renderSubmitHook({ busy: true, text: 'change course' })
|
||||
|
||||
act(() => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course'))
|
||||
expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-runtime-session', answer: '' })
|
||||
})
|
||||
|
||||
it('leaves the question alone for an empty Enter (Stop, not an answer)', () => {
|
||||
parkClarify('runtime-session')
|
||||
const { hook, onCancel } = renderSubmitHook({ busy: true })
|
||||
|
||||
act(() => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
expect(gatewayRequest).not.toHaveBeenCalled()
|
||||
expect($clarifyRequests.get()['runtime-session']).toBeDefined()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("leaves another session's question alone", async () => {
|
||||
parkClarify('other-session')
|
||||
const { hook, onSubmit } = renderSubmitHook({ text: 'unrelated message' })
|
||||
|
||||
act(() => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
||||
expect(gatewayRequest).not.toHaveBeenCalled()
|
||||
expect($clarifyRequests.get()['other-session']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import { type RefObject, useEffect, useRef } from 'react'
|
|||
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify'
|
||||
import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
|
||||
|
||||
import { cloneAttachments, type QueueEditState } from '../composer-utils'
|
||||
import { onComposerSubmitRequest } from '../focus'
|
||||
import { pathifyRefs } from '../path-refs'
|
||||
import { composerPlainText } from '../rich-editor'
|
||||
import { useComposerScope } from '../scope'
|
||||
import type { ChatBarProps } from '../types'
|
||||
|
|
@ -138,9 +140,27 @@ export function useComposerSubmit({
|
|||
}
|
||||
}
|
||||
|
||||
const text = draftRef.current
|
||||
// A path that never got its committing space (`@apps/desktop/` left by a Tab
|
||||
// descend, then Enter) is still the reference the user picked — promote it
|
||||
// on the way out so it attaches instead of submitting as inert text.
|
||||
const text = pathifyRefs(draftRef.current)
|
||||
const payloadPresent = text.trim().length > 0 || attachments.length > 0
|
||||
|
||||
// A clarify card parked on this session owns the turn: the agent is blocked
|
||||
// inside its tool batch waiting on `clarify.respond`, so a follow-up routed
|
||||
// through steer/queue sits undelivered until the clarify's own timeout
|
||||
// (default 5 min) — the message looks sent and nothing happens. Typing a
|
||||
// real message instead of picking an option IS the answer "none of these":
|
||||
// skip the question so the tool returns, then route the words normally.
|
||||
//
|
||||
// Fire-and-forget, not awaited: the skip clears the card synchronously and
|
||||
// both RPCs ride the same socket in call order, so the gateway resolves the
|
||||
// clarify before it sees the follow-up. Awaiting first would leave the draft
|
||||
// live for a tick — long enough for a second Enter to send it twice.
|
||||
if (payloadPresent && !queueEdit && hasClarifyRequest(sessionId)) {
|
||||
void skipClarifyRequest(sessionId)
|
||||
}
|
||||
|
||||
if (queueEdit) {
|
||||
exitQueuedEdit('save')
|
||||
} else if (busy) {
|
||||
|
|
|
|||
|
|
@ -120,4 +120,106 @@ describe('useComposerTrigger — slash anywhere in the prompt', () => {
|
|||
|
||||
expect(hook.result.current.trigger).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the list for a second slash after a leading command', () => {
|
||||
// `/work /cle`: the command regex's argument tail would otherwise swallow
|
||||
// `/cle` as an argument to `/work`, and a no-arg command suppresses the
|
||||
// popover — so every slash after the first went dead.
|
||||
const editor = mountEditor('/work /cle')
|
||||
const { hook } = mountTrigger(editor, [item('/clean')])
|
||||
|
||||
act(() => hook.result.current.refreshTrigger())
|
||||
|
||||
expect(hook.result.current.trigger).toMatchObject({ kind: '/', inline: true, query: 'cle' })
|
||||
expect(hook.result.current.triggerItems).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('inserts the second command without disturbing the first', () => {
|
||||
const editor = mountEditor('/work rewrite the composer /cle')
|
||||
const { hook } = mountTrigger(editor, [item('/clean')])
|
||||
|
||||
act(() => hook.result.current.refreshTrigger())
|
||||
act(() => hook.result.current.replaceTriggerWithChip(item('/clean')))
|
||||
|
||||
expect(composerPlainText(editor)).toBe('/work rewrite the composer /clean ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useComposerTrigger — free-text slash arguments', () => {
|
||||
it('keeps a picked /goal command as editable text while retaining subcommand completion', () => {
|
||||
const editor = mountEditor('/go')
|
||||
const goal = item('/goal', 'Commands')
|
||||
const { hook } = mountTrigger(editor, [goal])
|
||||
|
||||
act(() => hook.result.current.refreshTrigger())
|
||||
act(() => hook.result.current.replaceTriggerWithChip(goal))
|
||||
|
||||
expect(composerPlainText(editor)).toBe('/goal ')
|
||||
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
|
||||
expect(hook.result.current.trigger).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not seal a multi-word /goal into a chip when the option list runs empty', () => {
|
||||
const editor = mountEditor('/goal finish the full prompt')
|
||||
const { hook } = mountTrigger(editor, [])
|
||||
|
||||
act(() => hook.result.current.refreshTrigger())
|
||||
|
||||
expect(hook.result.current.slashFreeTextArgStage).toBe(true)
|
||||
expect(hook.result.current.commitTypedSlashDirective()).toBe(false)
|
||||
expect(composerPlainText(editor)).toBe('/goal finish the full prompt')
|
||||
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats the default highlight as a suggestion until the user arrows to a row', () => {
|
||||
const editor = mountEditor('/goal stat')
|
||||
const { hook } = mountTrigger(editor, [item('/goal status', 'Options')])
|
||||
|
||||
act(() => hook.result.current.refreshTrigger())
|
||||
expect(hook.result.current.triggerActiveExplicit).toBe(false)
|
||||
|
||||
act(() => hook.result.current.moveTriggerActive(1))
|
||||
expect(hook.result.current.triggerActiveExplicit).toBe(true)
|
||||
})
|
||||
|
||||
it('drops a deliberate selection once the query moves on', () => {
|
||||
const editor = mountEditor('/goal stat')
|
||||
const { hook } = mountTrigger(editor, [item('/goal status', 'Options')])
|
||||
|
||||
act(() => hook.result.current.refreshTrigger())
|
||||
act(() => hook.result.current.moveTriggerActive(1))
|
||||
|
||||
renderComposerContents(editor, '/goal start the migration')
|
||||
act(() => hook.result.current.refreshTrigger())
|
||||
|
||||
expect(hook.result.current.triggerActiveExplicit).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a multi-word /resume search typeable instead of firing the picker action', () => {
|
||||
// The session list always ends in a "Browse all sessions…" action row, so
|
||||
// an accept here doesn't insert a chip — it empties the composer and opens
|
||||
// the overlay, taking the half-typed query with it.
|
||||
const editor = mountEditor('/resume my new')
|
||||
const { hook } = mountTrigger(editor, [item('/resume', 'Sessions')])
|
||||
|
||||
act(() => hook.result.current.refreshTrigger())
|
||||
|
||||
expect(hook.result.current.slashFreeTextArgStage).toBe(true)
|
||||
expect(hook.result.current.commitTypedSlashDirective()).toBe(false)
|
||||
expect(composerPlainText(editor)).toBe('/resume my new')
|
||||
})
|
||||
|
||||
it('still commits a fully typed finite option as one directive chip', () => {
|
||||
const editor = mountEditor('/personality creative')
|
||||
const { hook } = mountTrigger(editor, [])
|
||||
|
||||
act(() => hook.result.current.refreshTrigger())
|
||||
|
||||
expect(hook.result.current.slashFreeTextArgStage).toBe(false)
|
||||
act(() => {
|
||||
expect(hook.result.current.commitTypedSlashDirective()).toBe(true)
|
||||
})
|
||||
expect(composerPlainText(editor)).toBe('/personality creative ')
|
||||
expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/personality creative')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
|
|||
import { type MutableRefObject, type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
|
||||
import { desktopSlashCommandArgumentMode } from '@/lib/desktop-slash-commands'
|
||||
|
||||
import {
|
||||
COMPLETION_ACTIONS,
|
||||
|
|
@ -53,6 +53,11 @@ export function useComposerTrigger({
|
|||
}: UseComposerTriggerOptions) {
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
// The list highlights its first row on open, which is a suggestion rather
|
||||
// than a choice. This records that the user moved the highlight themselves,
|
||||
// which is what lets Enter accept a completion in a free-text argument stage
|
||||
// without stealing prose from everyone who never touched the arrows.
|
||||
const [triggerActiveExplicit, setTriggerActiveExplicit] = useState(false)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// Set synchronously in keydown when the open trigger popover consumes a
|
||||
// navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
|
||||
|
|
@ -63,6 +68,11 @@ export function useComposerTrigger({
|
|||
// re-rendered and the handler closure sees the post-keydown state.
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
|
||||
const resetTriggerActive = useCallback(() => {
|
||||
setTriggerActive(0)
|
||||
setTriggerActiveExplicit(false)
|
||||
}, [])
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
|
|
@ -80,7 +90,7 @@ export function useComposerTrigger({
|
|||
if (!rawText.includes('@') && !rawText.includes('/')) {
|
||||
if (trigger) {
|
||||
setTrigger(null)
|
||||
setTriggerActive(0)
|
||||
resetTriggerActive()
|
||||
}
|
||||
|
||||
return
|
||||
|
|
@ -89,11 +99,16 @@ export function useComposerTrigger({
|
|||
const before = textBeforeCaret(editor)
|
||||
const found = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
// The arg-stage popover is only useful for commands with an options screen.
|
||||
// For a no-arg command it would dead-end on "No matches", so drop it — the
|
||||
// directive is already complete.
|
||||
// A text-only command has no completion screen once its prose begins. Mixed
|
||||
// commands such as /goal stay live so their finite subcommands can still be
|
||||
// suggested, while arbitrary goal text remains valid.
|
||||
const argumentMode =
|
||||
found?.kind === '/' && slashArgStage(found.query)
|
||||
? desktopSlashCommandArgumentMode(slashCommandToken(found.query))
|
||||
: null
|
||||
|
||||
const detected =
|
||||
found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query))
|
||||
found?.kind === '/' && slashArgStage(found.query) && argumentMode !== 'options' && argumentMode !== 'mixed'
|
||||
? null
|
||||
: found
|
||||
|
||||
|
|
@ -104,9 +119,9 @@ export function useComposerTrigger({
|
|||
// caret move (mouseup) or a stray refresh — must preserve the user's
|
||||
// current selection instead of snapping back to the first item.
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
resetTriggerActive()
|
||||
}
|
||||
}, [editorRef, trigger])
|
||||
}, [editorRef, resetTriggerActive, trigger])
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
|
@ -134,10 +149,23 @@ export function useComposerTrigger({
|
|||
// Space/Tab — neither should dead-end on a popover.
|
||||
const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
|
||||
|
||||
const slashArgumentMode =
|
||||
trigger?.kind === '/' && slashArgStage(trigger.query)
|
||||
? desktopSlashCommandArgumentMode(slashCommandToken(trigger.query))
|
||||
: null
|
||||
|
||||
const slashFreeTextArgStage = slashArgumentMode === 'mixed' || slashArgumentMode === 'text'
|
||||
|
||||
const closeTrigger = () => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
setTriggerActive(0)
|
||||
resetTriggerActive()
|
||||
}
|
||||
|
||||
/** Step the highlight, marking it as the user's own deliberate pick. */
|
||||
const moveTriggerActive = (delta: number) => {
|
||||
setTriggerActiveExplicit(true)
|
||||
setTriggerActive(idx => (idx + delta + triggerItems.length) % triggerItems.length)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -148,9 +176,16 @@ export function useComposerTrigger({
|
|||
// the completion list is empty because the arg is already fully typed (the
|
||||
// backend completer drops exact matches). Reuses the chip path via a
|
||||
// synthetic item whose serialized form is the verbatim text.
|
||||
const commitTypedSlashDirective = () => {
|
||||
const commitTypedSlashDirective = (): boolean => {
|
||||
if (trigger?.kind !== '/') {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Free prose must stay ordinary contentEditable text. This guard also
|
||||
// protects against a stale completion result reaching the keydown path
|
||||
// before refreshTrigger has caught up with the latest DOM input.
|
||||
if (desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) !== 'options') {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = `/${trigger.query.trimEnd()}`
|
||||
|
|
@ -168,9 +203,11 @@ export function useComposerTrigger({
|
|||
rawText: text
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem, options?: { descend?: boolean }) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || !trigger) {
|
||||
|
|
@ -200,6 +237,31 @@ export function useComposerTrigger({
|
|||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
|
||||
// Tab on a folder walks INTO it instead of committing it: re-type the
|
||||
// token as the bare path so the next `complete.path` lists that folder's
|
||||
// children, exactly as typing the path by hand would. Enter still commits
|
||||
// the folder itself — the two intents are distinct, so the keys are too.
|
||||
// Only `@` folders descend; a slash command's arg list has no hierarchy.
|
||||
const descendInto =
|
||||
options?.descend && trigger.kind === '@' && item.type === 'folder'
|
||||
? String((item.metadata as { insertId?: unknown } | undefined)?.insertId ?? '')
|
||||
: ''
|
||||
|
||||
if (descendInto) {
|
||||
const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/`
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
renderComposerContents(editor, `${prefix}@${path}`)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = composerPlainText(editor)
|
||||
setComposerText(draftRef.current)
|
||||
requestMainFocus()
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
|
||||
// it — expand to its options step so the popover shows the inline list, just
|
||||
// as typing `/personality ` by hand would. A serialized value with a space is
|
||||
|
|
@ -208,15 +270,15 @@ export function useComposerTrigger({
|
|||
// there's no command invocation for the args to belong to.
|
||||
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
|
||||
|
||||
const expandsToArgs =
|
||||
trigger.kind === '/' && !trigger.inline && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
|
||||
const argumentMode = desktopSlashCommandArgumentMode(command)
|
||||
const expandsToArgs = trigger.kind === '/' && !trigger.inline && !serialized.includes(' ') && argumentMode !== null
|
||||
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
// No pill while expanding — the bare command stays plain text until an arg
|
||||
// is picked, at which point a single pill is emitted for the full command.
|
||||
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
|
||||
const keepTriggerOpen = starter || expandsToArgs
|
||||
const keepTriggerOpen = starter || (expandsToArgs && argumentMode !== 'text')
|
||||
|
||||
const finish = () => {
|
||||
draftRef.current = composerPlainText(editor)
|
||||
|
|
@ -281,15 +343,47 @@ export function useComposerTrigger({
|
|||
finish()
|
||||
}
|
||||
|
||||
/** Backspace inside an `@` path drops the last segment (`a/b/` → `a/`)
|
||||
* instead of one character. Descending is one Tab per level, so climbing
|
||||
* back out should cost one key too rather than a held delete. Returns
|
||||
* false when the caret isn't in a path, so keydown falls through. */
|
||||
const ascendTriggerPath = () => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || trigger?.kind !== '@' || !trigger.query.includes('/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trailing slash means we're listing a folder's children: drop that
|
||||
// folder. Otherwise a partial segment is typed — drop just that.
|
||||
const trimmed = trigger.query.replace(/\/$/, '')
|
||||
const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1)
|
||||
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
renderComposerContents(editor, `${prefix}@${parent}`)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = composerPlainText(editor)
|
||||
setComposerText(draftRef.current)
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
argStageEmpty,
|
||||
ascendTriggerPath,
|
||||
closeTrigger,
|
||||
commitTypedSlashDirective,
|
||||
moveTriggerActive,
|
||||
refreshTrigger,
|
||||
replaceTriggerWithChip,
|
||||
setTriggerActive,
|
||||
slashFreeTextArgStage,
|
||||
trigger,
|
||||
triggerActive,
|
||||
triggerActiveExplicit,
|
||||
triggerItems,
|
||||
triggerKeyConsumedRef,
|
||||
triggerLoading
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue