mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 03:30:36 +00:00
Merge branch 'master' into smooth-text-fade
This commit is contained in:
@@ -4,6 +4,71 @@
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2130** by @dso2ng — Lazy lineage-report fetch on sidebar segment-badge expand. The sidebar already showed `N segments` for collapsed compression lineage rows (refs #1906, #1943) and the backend report endpoint is now shipped (refs #2012), but some rows only had a backend `_compression_segment_count` from `/api/sessions` while the browser hadn't materialized the older segment rows — clicking the badge couldn't reveal the full bounded list. Adds a small per-sidebar-cache lineage-report cache/inflight map in `static/sessions.js`, invalidates it on each fresh `/api/sessions` refresh, and on expand fetches `GET /api/session/lineage/report?session_id=<tip>` only when `_sessionSegmentCount(s)` exceeds the locally-materialized `_lineage_segments` count. Merges returned report `segments` by `session_id` with existing client segments, skipping the visible tip and `child_session` rows. Leaves report `children` out of the compression-segment list so subagent/fork child semantics remain separate. 132-line regression suite covering fetch-needed detection, report-segment merging/dedup, endpoint construction, and inflight cache de-duping.
|
||||
|
||||
- **PR #2142** by @legeantbleu — French (`fr`) locale. ~938 UI strings translated via Google Translate then sanitized for JS string escaping. Inserted at the end of `static/i18n.js`'s `LOCALES` map (insertion-order convention used by every locale since `it` landed). Stage-344 maintainer fix added the matching tuple entries in `tests/test_issue1488_composer_voice_buttons.py:TestComposerVoiceButtonI18n.LOCALES` + sibling `TestVoiceModePreferenceGate.LOCALES`, plus the matching `_LOGIN_LOCALE['fr']` block in `api/routes.py` so the login page localizes for French users (issue #1442 parity contract), plus an inverted `_resolve_login_locale_key('fr')` assertion in `tests/test_login_locale_parity.py` that previously assumed fr falls back to en. Mirrors the stage-340 fix for the `it` locale (PR #2067).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2120** by @Michaelyklam (closes #2103) — Daily Tokens chart no longer overflows its card on 90/365 day ranges. Adds `_bucketDailyTokensForChart()` in `static/panels.js` that keeps ≤30 rows per-day and buckets longer ranges into summed chart rows (90→45 bars at 2-day buckets, 365→46 bars at 8-day buckets, ≤52 ceiling). Updates the Daily Tokens render loop to use bucketed chart rows, date-range labels, and summed tooltip values. Switched chart columns to shrink-safe `minmax(0,1fr)` so the bars stay inside the card. Backend `/api/insights` payload unchanged. 130-line regression suite covering short-range preservation, long-range bounding, label/title shape on bucketed rows, render-loop usage, and shrink-safe CSS.
|
||||
|
||||
- **PR #2121** by @Michaelyklam (refs #2104) — Token Breakdown + Models row stacks on mobile instead of forcing horizontal page overflow. New `insights-usage-grid` class wraps the row with a scoped `@media (max-width: 640px)` rule that flips it to `grid-template-columns: 1fr`. Contains remaining model-table overflow inside the card. 27-line regression suite covering the mobile breakpoint, single-column layout, contained `overflow-x`, and presence of the scoped rule.
|
||||
|
||||
- **PR #2123** by @Michaelyklam (closes #2112) — Portuguese (`pt`) locale parity: 5 missing session-management keys (bulk delete/archive, select mode, select all, selected count, no-selection text) added so Portuguese users stop silently falling back to English. Extended `tests/test_login_locale_parity.py` with a session-management key parity check across all locale blocks.
|
||||
|
||||
- **PR #2125** by @Michaelyklam (closes #2093) — Renamed `_patch_skill_home_modules` → `patch_skill_home_modules` in `api/profiles.py` since the helper is imported by streaming code and asserted by tests across modules. Updated streaming import/fallback/call sites in `api/streaming.py` and the env-lock regression test expectations. Expanded `api/compression_anchor.py`'s module docstring to explain manual vs automatic compression anchoring and `auto_compression=True` behavior. Documentation/rename-only — no runtime behavior change.
|
||||
|
||||
- **PR #2128** by @franksong2702 (closes #2087) — Manual `/compress` no longer fails behind reverse proxies that time out long synchronous requests. Adds `POST /api/session/compress/start` (start or reuse an in-process manual compression job keyed by `session_id`) + `GET /api/session/compress/status?session_id=...` (poll `running`/`done`/`error`/`idle`). Reuses the existing `_handle_session_compress` implementation inside the worker so the save path, provider resolution, sanitization, and the legacy synchronous endpoint stay aligned. Adds a stream-state guard before save so a compression worker can't overwrite a session that started another stream while compression was running. 10-minute cleanup for terminal job results, with successful `done` payloads released after first status consumption. `static/commands.js` `/compress` and `/compact` now start, poll, and apply the saved compressed session; session-load resume wiring picks up in-flight compression on page reload.
|
||||
|
||||
- **PR #2129** by @Michaelyklam (closes #2092) — `_purgeStaleInflightEntries()` now iterates `INFLIGHT` keys and explicitly drops ids absent from the current session list. Pre-fix the cleanup only removed entries for sessions still present in `_allSessions` and marked non-streaming, so deleted/archived/filtered-out sessions left ghost entries indefinitely. Preserves still-streaming sessions. 124-line regression suite covering absent/present-non-streaming/present-streaming cases.
|
||||
|
||||
- **PR #2135** by @franksong2702 (closes #2126, refs #2131) — `/api/models/live?provider=custom:<slug>` now only returns models from the requested named provider entry instead of every `custom_providers[].model`. Direct `/v1/models` fallback uses the matched named provider's `base_url`+`api_key` pair instead of the active profile's `model.base_url`/`model.api_key`. `custom:<slug>` reads only the matching named entry; bare `custom` reads only unnamed entries. Includes model IDs from both singular `model` and plural `models` config forms. Cache key behavior preserved (already provider-scoped). Regression coverage for named-provider scoping, bare-custom scoping, and direct fetch endpoint/key selection.
|
||||
|
||||
- **PR #2137** by @franksong2702 (closes #2122) — Login page health probe now sends `credentials: 'same-origin'` instead of `credentials: 'omit'`. Cloudflare Access and similar same-origin reverse proxies need the access cookie to reach the proxy, so the prior omit caused WebUI to falsely disable login before `/health` ever resolved. Keeps the health URL mount-relative (`health`) for subpath deployments. Static regression test pins same-origin credentials and forbids the omit variant.
|
||||
|
||||
- **PR #2138** by @dobby-d-elf — Live Hermes WebUI chats no longer get stuck with `Error: Path does not exist: ...` when the session points at a deleted workspace. Workspace fallback now looks up the live `DEFAULT_WORKSPACE` instead of using a stale import-time snapshot. Old sessions with deleted implicit workspaces are repaired to the current valid workspace during chat start, so the next send recovers instead of erroring. 71-line regression suite for both the stale-fallback and missing-session-workspace recovery paths.
|
||||
|
||||
- **PR #2139** by @Michaelyklam (refs #2097) — Turn-journal terminal-collision audit slice. `derive_turn_journal_states()` now returns `(states, terminal_collisions)`; collisions carry the `turn_id` plus terminal events in timestamp order when a turn records more than one terminal event (completed + interrupted both fire). Latest-by-timestamp derived state behavior preserved for existing callers; session recovery audit and existing tests updated to unpack the new tuple. Audit-only: no multi-process append safety in this PR.
|
||||
|
||||
- **PR #2140** by @franksong2702 (closes #2133) — WebUI fallback activation now passes `api_key` and `key_env` in the normalized fallback entry to `AIAgent`, matching what the CLI path preserves. Hermes Agent fallback resolution already knew how to use these — WebUI was dropping them, leaving env-backed fallback providers unauthenticated after a primary provider 401. Legacy single-dict `fallback_model` and list-form `fallback_providers` selection behavior unchanged.
|
||||
|
||||
- **PR #2141** by @franksong2702 (closes #2102) — Settings → System header no longer clips off the right edge on phones. Section header now stacks vertically under the existing Settings mobile breakpoint; the System update/version control group wraps to use available width; individual version badges keep their text intact while the group wraps. CSS-only change inside the existing breakpoint scope. Mobile layout static regression added.
|
||||
|
||||
- **PR #2143** by @dobby-d-elf — iPhone PWA chat bottom-scroll stutter fixed. Removed the Start/End scroll controls from the transcript scroll layout — they were sticky children inside `#messages`, which on iOS momentum/elastic scrolling perturbed the scroll surface at the bottom boundary. Now the transcript is wrapped in a `.messages-shell` and the controls render as absolute overlays outside `#messages`, so `#messages` is back to a plain native scrolling container. Adds a small visibility dead zone for the down-arrow button so elastic bottom pulls don't flash the button while already at the bottom.
|
||||
|
||||
- **PR #2132** by @Michaelyklam (refs #2096) — Docs-only: added `Synchronous durability design rationale` to `docs/rfcs/turn-journal.md`. Documents why submitted-event journaling stays synchronously fsync-backed today, qualitative fsync latency expectations for SSD/HDD/Docker-overlay filesystems, and maintainer benchmark guidance for measuring p50/p95/p99 append/fsync latency before any future async lifecycle journaling.
|
||||
|
||||
### Stage-344 maintainer fixes
|
||||
|
||||
- **`api/routes.py:_handle_session_compress_start/status` (#2128 polish)** — Opus SHOULD-FIX from stage-344 review. Two related UX bugs in the new async manual-compression flow: (1) `compress/status` popped the `done` job entry on first read, which left a second open tab with `{status:"idle"}` and a "Compression job is no longer available" toast — fixed by letting the existing 10-minute TTL handle eviction so all tabs see the same terminal payload; (2) re-invoking `compress/start` within the 10-minute TTL returned the stale prior `done` payload instead of running a new compression — fixed by always dropping the existing entry and starting a fresh worker, so a user closing a tab mid-compress and re-running `/compress` on a fresh open gets a new result. Both are 1-block tweaks; existing `tests/test_sprint46.py` 10/10 still passes. The third Opus SHOULD-FIX (#2135 `cfg["model"]` fallback when `provider=custom:X` doesn't match any entry) is deferred to a follow-up — it's strictly no-worse-than-master behavior, but worth tightening to skip the URL probe when no entry matched.
|
||||
|
||||
## [v0.51.50] — 2026-05-12 — Release Z (stage-343 — single-PR — ctl.sh bash 3.2 macOS compat fix + regression test suite)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2117** by @ayushere — `ctl.sh start` no longer crashes on macOS (bash 3.2) with `preserved[@]: unbound variable`. The dotenv-preserve loop in `_load_repo_dotenv_preserving_env()` iterated `"${preserved[@]}"` under `set -euo pipefail`, which bash 4+ silently allows on empty arrays but bash 3.2 (still the default `/usr/bin/bash` on macOS) treats as an unbound-variable error. Guards the iteration with `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` — matches the canonical bash 3.2 strict-mode pattern. This is the third bash 3.2 compat fix to land in `ctl.sh` (prior: `025f137f` guarded `CTL_BOOTSTRAP_ARGS[@]` with the `${arr[@]+...}` pass-through pattern, `630981a0` replaced `[[ -v ${key} ]]` with `[[ -n "${!key+x}" ]]`). Defense-in-depth: added `tests/test_ctl_bash32_compat.py` (5 static-pattern regressions) pinning both empty-array guards plus a denylist for bash 4+ syntax (`declare -A`, `mapfile`, `[[ -v ]]`, `${var^^}`, `${var,,}`) so the next regression surfaces in CI instead of a macOS user's terminal. Stage-343 reviewer added the regression-test file alongside the contributor's 5-LOC fix to ctl.sh.
|
||||
|
||||
## [v0.51.49] — 2026-05-12 — Release Y (stage-342 — 3-PR contributor batch — read-only worktree status endpoint + worktree-retained response preference + Codex quota credential-pool fallback)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2109** by @franksong2702 — Read-only worktree status endpoint for the #2057 lifecycle tracker. `GET /api/session/worktree/status?session_id=...` returns the session-owned worktree path, filesystem existence, dirty/untracked state, ahead/behind counts when an upstream is configured, and live stream/embedded-terminal lock flags. Uses `git worktree list --porcelain`, `git status --porcelain --untracked-files=normal`, and `rev-list --left-right --count HEAD...@{u}` only — no mutating git state, 2-second per-call timeouts (tightened from PR-submitted 5s during stage review). Session-id scoped (rejects non-worktree sessions with 400), does not accept arbitrary filesystem paths. This is the non-destructive status surface Nathan requested as the next slice before any future explicit remove-worktree action. 221-line regression suite covering clean/dirty/untracked/missing-path/live-stream-lock/embedded-terminal-lock/endpoint-success/non-worktree-rejection cases.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2113** by @franksong2702 (closes #2111) — Session archive/delete success toasts now prefer the backend `worktree_retained` response over the cached session-sidebar snapshot. Pre-fix a stale sidebar snapshot (other browser tab archived the session, server-side mutation moved the worktree, etc.) could make the success toast say "worktree preserved on disk" even when the backend response said no worktree was retained. Frontend now treats `response.worktree_retained: true/false` as source of truth and falls back to `session.worktree_path` only when the backend doesn't return the flag (older-server compatibility). Both single-session and batch (Promise.all) archive/delete paths updated; batch retained-count derived from per-response flags instead of the pre-POST cached `_worktreeSessionCount`. The pre-flight confirm dialog still uses the cached snapshot (it renders before the POST exists), but the post-POST toast now reflects backend truth.
|
||||
|
||||
- **PR #2116** by @starship-s — OpenAI Codex provider quota card no longer reports "unavailable" when Codex chat requests actually work. Runtime requests authenticate via the modern `agent.credential_pool`, but the account-usage probe only tried the legacy singleton Codex token path. Adds a Codex-only credential-pool fallback inside the existing isolated `_account_usage_subprocess`: when `agent.account_usage.fetch_account_usage()` returns no available snapshot, the fallback selects the active `openai-codex` credential-pool entry, derives the Codex usage endpoint from the runtime base URL (handles `/backend-api/codex` → `/wham/usage` and custom bases → `/api/codex/usage`), and serializes the existing snapshot shape expected by the WebUI. Stays inside the child process so active Hermes profile context remains isolated; legacy unavailable diagnostics preserved when the pool fallback can't produce a usable result; non-Codex providers unchanged. Returns only quota display data — never credential labels, access tokens, or raw exception strings. 151-line regression suite covers the success path, both URL-resolution branches, and the unavailable-fallthrough case.
|
||||
|
||||
|
||||
### Stage-342 maintainer fixes
|
||||
|
||||
- **`api/worktrees.py:_run_git` default timeout 5s → 2s** — Opus SHOULD-FIX from stage-342 review: PR #2109's new `/api/session/worktree/status` endpoint runs up to four `git` subprocess calls per request, each defaulting to a 5-second timeout. Worst case 20 seconds per polling request piling up on the `ThreadingHTTPServer` thread pool is risky given today's `_cron_env_lock` near-miss on production 8787. Status probes should fail fast — a worktree that takes longer than 2 seconds to enumerate is already in trouble, and the client can retry. Mechanical 1-LOC default-arg change; all four call sites already pass `cwd` positionally and rely on the default. ~1 LOC.
|
||||
|
||||
## [v0.51.48] — 2026-05-12 — Release X (stage-341 — 3 contributor PRs — Hermes run adapter RFC + title-retry loop fix on reasoning-only models + worktree archive/delete confirm copy)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2105** by @Michaelyklam — Hermes run adapter contract RFC at `docs/rfcs/hermes-run-adapter-contract.md` (refs #1925). 315-line spec/gap matrix that defines the event/control compatibility contract WebUI needs before browser-originated chat turns can be routed to Hermes-owned runtime execution. Documents the ownership boundary (Hermes Agent owns run creation, lifecycle, event ordering, replay, terminal state, approvals, clarify, cancellation; WebUI owns browser auth, transcript rendering, tool cards, approval/clarify widgets, workspace UX), the minimum `start_run`/`observe_run`/`get_run`/`cancel_run`/`queue_or_continue`/`respond_approval`/`respond_clarify` IPC surface, and a gap matrix mapping current `STREAMS`/`CANCEL_FLAGS`/`AGENT_INSTANCES`/callback queues to Hermes-owned targets with explicit "no private callback queue" / "no runtime surrogate" non-goals. First success criterion is restart/reattach (start a non-trivial run, restart hermes-webui, browser reconnects, replays from last cursor, cancels with Hermes-emitted terminal state) — not "basic chat streamed once." Status: Proposed.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
"""
|
||||
Shared helpers for session compression anchor metadata.
|
||||
|
||||
Manual compression anchoring versus automatic compression paths
|
||||
===============================================================
|
||||
|
||||
When ``auto_compression=True`` is passed to ``visible_messages_for_anchor()``,
|
||||
the function accepts a broader set of message content types (including
|
||||
provider-style ``input_text`` / ``output_text`` parts) and metadata markers
|
||||
(``reasoning``, ``thinking``, etc.) from any non-tool role. This enables the
|
||||
streaming auto-compression path to determine which messages should anchor
|
||||
compression UI metadata without being limited to the legacy manual-compression
|
||||
rules.
|
||||
|
||||
When ``auto_compression=False`` (the default), the function applies the
|
||||
historical manual-compression rules: only plain ``text`` content parts from
|
||||
non-assistant roles are counted.
|
||||
|
||||
Why this module exists
|
||||
======================
|
||||
|
||||
Compression anchoring needs to identify which messages in a session transcript
|
||||
are semantically significant enough to seed the compression UI metadata (e.g.,
|
||||
message count, token budget display). The original implementation hard-coded
|
||||
these rules in multiple places. This module consolidates the logic so that:
|
||||
|
||||
1. Manual compression anchoring (CLI/legacy path) uses the stricter ruleset.
|
||||
2. Automatic compression (streaming/agent path) can leverage the relaxed ruleset
|
||||
when it knows it is handling provider-style messages.
|
||||
|
||||
Callers specify ``auto_compression=True`` when the messages may originate from
|
||||
an automatic/compression-aware pipeline, and ``False`` (default) for manual
|
||||
compression contexts.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ _tls = threading.local()
|
||||
_SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool")
|
||||
|
||||
|
||||
def _patch_skill_home_modules(home: Path) -> None:
|
||||
def patch_skill_home_modules(home: Path) -> None:
|
||||
"""Patch imported skill modules that cache HERMES_HOME at import time."""
|
||||
for module_name in _SKILL_HOME_MODULES:
|
||||
module = sys.modules.get(module_name)
|
||||
@@ -628,7 +628,7 @@ def _set_hermes_home(home: Path):
|
||||
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
|
||||
os.environ['HERMES_HOME'] = str(home)
|
||||
|
||||
_patch_skill_home_modules(home)
|
||||
patch_skill_home_modules(home)
|
||||
|
||||
# Patch cron/jobs module-level cache
|
||||
try:
|
||||
|
||||
+184
-1
@@ -125,12 +125,19 @@ def _account_usage_preexec_fn() -> None:
|
||||
|
||||
|
||||
_ACCOUNT_USAGE_SUBPROCESS_CODE = r"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from urllib import request as urllib_request
|
||||
|
||||
from agent.account_usage import fetch_account_usage
|
||||
|
||||
|
||||
_CODEX_DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
|
||||
def _iso(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
@@ -165,9 +172,185 @@ def _snapshot_payload(snapshot):
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_available(snapshot):
|
||||
if snapshot is None:
|
||||
return False
|
||||
try:
|
||||
return bool(getattr(snapshot, "available", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _number(value):
|
||||
if isinstance(value, bool) or value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return value
|
||||
try:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
number = float(text)
|
||||
return int(number) if number.is_integer() else number
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_dt(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _title_case_slug(value):
|
||||
cleaned = str(value or "").strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
return cleaned.replace("_", " ").replace("-", " ").title()
|
||||
|
||||
|
||||
def _resolve_codex_usage_url(base_url):
|
||||
normalized = str(base_url or "").strip().rstrip("/") or _CODEX_DEFAULT_BASE_URL
|
||||
if normalized.endswith("/codex"):
|
||||
normalized = normalized[: -len("/codex")]
|
||||
if "/backend-api" in normalized:
|
||||
return normalized + "/wham/usage"
|
||||
return normalized + "/api/codex/usage"
|
||||
|
||||
|
||||
def _jwt_claims(token):
|
||||
if not isinstance(token, str) or token.count(".") != 2:
|
||||
return {}
|
||||
payload = token.split(".")[1]
|
||||
payload += "=" * ((4 - len(payload) % 4) % 4)
|
||||
try:
|
||||
claims = json.loads(base64.urlsafe_b64decode(payload.encode("utf-8")).decode("utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return claims if isinstance(claims, dict) else {}
|
||||
|
||||
|
||||
def _codex_usage_headers(access_token):
|
||||
headers = {
|
||||
"Authorization": "Bearer " + access_token,
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "codex_cli_rs/0.0.0 (Hermes WebUI)",
|
||||
"originator": "codex_cli_rs",
|
||||
}
|
||||
auth_claim = _jwt_claims(access_token).get("https://api.openai.com/auth")
|
||||
account_id = None
|
||||
if isinstance(auth_claim, dict):
|
||||
account_id = auth_claim.get("chatgpt_account_id")
|
||||
if isinstance(account_id, str) and account_id.strip():
|
||||
headers["ChatGPT-Account-ID"] = account_id.strip()
|
||||
return headers
|
||||
|
||||
|
||||
def _entry_value(entry, *names):
|
||||
for name in names:
|
||||
try:
|
||||
value = getattr(entry, name)
|
||||
except Exception:
|
||||
value = None
|
||||
if value in (None, ""):
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _codex_snapshot_from_usage_payload(payload):
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
rate_limit = payload.get("rate_limit")
|
||||
if not isinstance(rate_limit, dict):
|
||||
rate_limit = {}
|
||||
windows = []
|
||||
for key, label in (("primary_window", "Session"), ("secondary_window", "Weekly")):
|
||||
window = rate_limit.get(key)
|
||||
if not isinstance(window, dict):
|
||||
continue
|
||||
used = _number(window.get("used_percent"))
|
||||
if used is None:
|
||||
continue
|
||||
windows.append(SimpleNamespace(
|
||||
label=label,
|
||||
used_percent=float(used),
|
||||
reset_at=_parse_dt(window.get("reset_at")),
|
||||
detail=None,
|
||||
))
|
||||
|
||||
details = []
|
||||
credits = payload.get("credits")
|
||||
if isinstance(credits, dict) and credits.get("has_credits"):
|
||||
balance = _number(credits.get("balance"))
|
||||
if balance is not None:
|
||||
details.append("Credits balance: $" + format(float(balance), ".2f"))
|
||||
elif credits.get("unlimited"):
|
||||
details.append("Credits balance: unlimited")
|
||||
|
||||
return SimpleNamespace(
|
||||
provider="openai-codex",
|
||||
source="usage_api",
|
||||
title="Account limits",
|
||||
plan=_title_case_slug(payload.get("plan_type")),
|
||||
windows=tuple(windows),
|
||||
details=tuple(details),
|
||||
available=bool(windows or details),
|
||||
unavailable_reason=None,
|
||||
fetched_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_codex_account_usage_from_pool():
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("openai-codex")
|
||||
entry = pool.select() if pool is not None else None
|
||||
if entry is None:
|
||||
return None
|
||||
access_token = _entry_value(entry, "runtime_api_key", "access_token")
|
||||
if not access_token:
|
||||
return None
|
||||
base_url = _entry_value(entry, "runtime_base_url", "base_url") or _CODEX_DEFAULT_BASE_URL
|
||||
request = urllib_request.Request(
|
||||
_resolve_codex_usage_url(base_url),
|
||||
headers=_codex_usage_headers(access_token),
|
||||
)
|
||||
with urllib_request.urlopen(request, timeout=15.0) as response:
|
||||
payload = json.loads(response.read().decode("utf-8") or "{}")
|
||||
return _codex_snapshot_from_usage_payload(payload)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
provider = sys.argv[1]
|
||||
api_key = sys.argv[2] or None
|
||||
print(json.dumps(_snapshot_payload(fetch_account_usage(provider, api_key=api_key))))
|
||||
try:
|
||||
snapshot = fetch_account_usage(provider, api_key=api_key)
|
||||
except Exception:
|
||||
snapshot = None
|
||||
if str(provider or "").strip().lower() == "openai-codex" and not _snapshot_available(snapshot):
|
||||
fallback_snapshot = _fetch_codex_account_usage_from_pool()
|
||||
if _snapshot_available(fallback_snapshot) or snapshot is None:
|
||||
snapshot = fallback_snapshot
|
||||
print(json.dumps(_snapshot_payload(snapshot)))
|
||||
"""
|
||||
|
||||
# SECTION: Provider ↔ env var mapping
|
||||
|
||||
+319
-13
@@ -5,6 +5,7 @@ Extracted from server.py (Sprint 11) so server.py is a thin shell.
|
||||
|
||||
import html as _html
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -49,6 +50,9 @@ _CLIENT_DISCONNECT_ERRORS = (
|
||||
# Track job IDs currently being executed so the frontend can poll status.
|
||||
_RUNNING_CRON_JOBS: dict[str, float] = {} # job_id → start_timestamp
|
||||
_RUNNING_CRON_LOCK = threading.Lock()
|
||||
_MANUAL_COMPRESSION_JOBS: dict[str, dict] = {}
|
||||
_MANUAL_COMPRESSION_JOBS_LOCK = threading.Lock()
|
||||
_MANUAL_COMPRESSION_JOB_TTL_SECONDS = 10 * 60
|
||||
_CRON_OUTPUT_CONTENT_LIMIT = 8000
|
||||
_CRON_OUTPUT_HEADER_CONTEXT = 200
|
||||
_MESSAGING_RAW_SOURCES = {str(s).strip().lower() for s in MESSAGING_SOURCES}
|
||||
@@ -1924,6 +1928,15 @@ _LOGIN_LOCALE = {
|
||||
"invalid_pw": "Invalid password",
|
||||
"conn_failed": "Connection failed",
|
||||
},
|
||||
"fr": {
|
||||
"lang": "fr-FR",
|
||||
"title": "Se connecter",
|
||||
"subtitle": "Entrez votre mot de passe pour continuer",
|
||||
"placeholder": "Mot de passe",
|
||||
"btn": "Se connecter",
|
||||
"invalid_pw": "Mot de passe invalide",
|
||||
"conn_failed": "\u00c9chec de la connexion",
|
||||
},
|
||||
"es": {
|
||||
"lang": "es-ES",
|
||||
"title": "Iniciar sesi\u00f3n",
|
||||
@@ -3053,6 +3066,29 @@ def handle_get(handler, parsed) -> bool:
|
||||
if parsed.path.startswith("/static/"):
|
||||
return _serve_static(handler, parsed)
|
||||
|
||||
if parsed.path == "/api/session/worktree/status":
|
||||
query = parse_qs(parsed.query)
|
||||
sid = query.get("session_id", [""])[0]
|
||||
if not sid:
|
||||
return bad(handler, "session_id is required", status=400)
|
||||
try:
|
||||
s = get_session(sid, metadata_only=True)
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", status=404)
|
||||
try:
|
||||
from api.worktrees import worktree_status_for_session
|
||||
|
||||
return j(handler, {"status": worktree_status_for_session(s)})
|
||||
except ValueError as exc:
|
||||
return bad(handler, str(exc), status=400)
|
||||
except Exception as exc:
|
||||
logger.exception("failed to read worktree status for session %s", sid)
|
||||
return bad(handler, _sanitize_error(exc), status=500)
|
||||
|
||||
if parsed.path == "/api/session/compress/status":
|
||||
query = parse_qs(parsed.query)
|
||||
return _handle_session_compress_status(handler, query.get("session_id", [""])[0])
|
||||
|
||||
if parsed.path == "/api/session":
|
||||
import time as _time
|
||||
_t0 = _time.monotonic()
|
||||
@@ -4402,6 +4438,9 @@ def handle_post(handler, parsed) -> bool:
|
||||
"parent_session_id": source.session_id,
|
||||
})
|
||||
|
||||
if parsed.path == "/api/session/compress/start":
|
||||
return _handle_session_compress_start(handler, body)
|
||||
|
||||
if parsed.path == "/api/session/compress":
|
||||
return _handle_session_compress(handler, body)
|
||||
|
||||
@@ -6085,27 +6124,85 @@ def _handle_live_models(handler, parsed):
|
||||
ids = []
|
||||
|
||||
if not ids:
|
||||
custom_provider_entry = None
|
||||
|
||||
def _custom_provider_entries_for_request():
|
||||
if not (provider == "custom" or provider.startswith("custom:")):
|
||||
return []
|
||||
try:
|
||||
from api.config import _custom_provider_slug_from_name
|
||||
_cp_entries = cfg.get("custom_providers", [])
|
||||
if not isinstance(_cp_entries, list):
|
||||
return []
|
||||
_matches = []
|
||||
for _cp in _cp_entries:
|
||||
if not isinstance(_cp, dict):
|
||||
continue
|
||||
_slug = _custom_provider_slug_from_name(_cp.get("name", ""))
|
||||
if provider.startswith("custom:"):
|
||||
if _slug == provider:
|
||||
_matches.append(_cp)
|
||||
elif provider == "custom" and not _slug:
|
||||
_matches.append(_cp)
|
||||
return _matches
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _custom_provider_model_ids(_cp):
|
||||
_ids = []
|
||||
|
||||
def _append(_mid):
|
||||
_mid = str(_mid or "").strip()
|
||||
if _mid and _mid not in _ids:
|
||||
_ids.append(_mid)
|
||||
|
||||
_append(_cp.get("model", ""))
|
||||
_models = _cp.get("models")
|
||||
if isinstance(_models, dict):
|
||||
for _mid in _models:
|
||||
if isinstance(_mid, str):
|
||||
_append(_mid)
|
||||
elif isinstance(_models, list):
|
||||
for _item in _models:
|
||||
if isinstance(_item, str):
|
||||
_append(_item)
|
||||
elif isinstance(_item, dict):
|
||||
_append(_item.get("id") or _item.get("model") or _item.get("name"))
|
||||
return _ids
|
||||
|
||||
def _custom_provider_api_key(_cp):
|
||||
_raw = _cp.get("api_key")
|
||||
if _raw is not None:
|
||||
_key = str(_raw).strip()
|
||||
if _key.startswith("${") and _key.endswith("}") and len(_key) > 3:
|
||||
_key = os.getenv(_key[2:-1], "").strip()
|
||||
if _key:
|
||||
return _key
|
||||
_env = str(_cp.get("key_env") or "").strip()
|
||||
return os.getenv(_env, "").strip() if _env else ""
|
||||
|
||||
# For 'custom' and 'custom:*' providers, provider_model_ids()
|
||||
# returns [] because they aren't real hermes_cli endpoints.
|
||||
# Fall back to the custom_providers entries from config.yaml so
|
||||
# the live-model enrichment step can add any models that weren't
|
||||
# already in the static list (issue #1619).
|
||||
if provider == "custom" or provider.startswith("custom:"):
|
||||
try:
|
||||
_cp_entries = cfg.get("custom_providers", [])
|
||||
if isinstance(_cp_entries, list):
|
||||
ids = [
|
||||
_cp.get("model", "")
|
||||
for _cp in _cp_entries
|
||||
if isinstance(_cp, dict) and _cp.get("model", "")
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
for _cp in _custom_provider_entries_for_request():
|
||||
if custom_provider_entry is None:
|
||||
custom_provider_entry = _cp
|
||||
ids.extend(_custom_provider_model_ids(_cp))
|
||||
|
||||
# If still no ids, try fetching from base_url directly (OpenAI-compat endpoint)
|
||||
if not ids and (provider == "custom" or provider.startswith("custom:")):
|
||||
_base_url = cfg.get("model", {}).get("base_url")
|
||||
_api_key = cfg.get("model", {}).get("api_key")
|
||||
_base_url = None
|
||||
_api_key = None
|
||||
if custom_provider_entry:
|
||||
_base_url = custom_provider_entry.get("base_url")
|
||||
_api_key = _custom_provider_api_key(custom_provider_entry)
|
||||
else:
|
||||
_model_cfg = cfg.get("model", {})
|
||||
_base_url = _model_cfg.get("base_url")
|
||||
_api_key = _model_cfg.get("api_key")
|
||||
if _base_url and _api_key:
|
||||
try:
|
||||
import urllib.request
|
||||
@@ -6948,7 +7045,7 @@ def _handle_chat_start(handler, body, diag=None):
|
||||
attachments = _normalize_chat_attachments(body.get("attachments") or [])[:20]
|
||||
diag.stage("resolve_workspace") if diag else None
|
||||
try:
|
||||
workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace))
|
||||
workspace = _resolve_chat_workspace_with_recovery(s, body.get("workspace"))
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
requested_model = body.get("model") or s.model
|
||||
@@ -6981,6 +7078,24 @@ def _handle_chat_start(handler, body, diag=None):
|
||||
|
||||
|
||||
|
||||
def _resolve_chat_workspace_with_recovery(s, requested_workspace) -> str:
|
||||
"""Recover stale implicit session workspaces without hiding explicit errors."""
|
||||
explicit = requested_workspace not in (None, "")
|
||||
candidate = requested_workspace if explicit else getattr(s, "workspace", None)
|
||||
try:
|
||||
return str(resolve_trusted_workspace(candidate))
|
||||
except ValueError:
|
||||
if explicit:
|
||||
raise
|
||||
fallback = str(resolve_trusted_workspace(get_last_workspace()))
|
||||
s.workspace = fallback
|
||||
try:
|
||||
s.save()
|
||||
except Exception:
|
||||
pass
|
||||
return fallback
|
||||
|
||||
|
||||
def _normalize_chat_attachments(raw_attachments):
|
||||
"""Normalize attachment payloads from the browser.
|
||||
|
||||
@@ -7650,6 +7765,183 @@ def _handle_clarify_respond(handler, body):
|
||||
return j(handler, {"ok": True, "response": response})
|
||||
|
||||
|
||||
class _ManualCompressionMemoryHandler:
|
||||
def __init__(self):
|
||||
self.wfile = io.BytesIO()
|
||||
self.status = None
|
||||
self.sent_headers = {}
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.sent_headers[key] = value
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
def payload(self):
|
||||
raw = self.wfile.getvalue().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def _manual_compression_cleanup_locked(now=None):
|
||||
now = time.time() if now is None else now
|
||||
for sid, job in list(_MANUAL_COMPRESSION_JOBS.items()):
|
||||
if job.get("status") == "running":
|
||||
continue
|
||||
updated_at = float(job.get("updated_at") or job.get("started_at") or now)
|
||||
if now - updated_at > _MANUAL_COMPRESSION_JOB_TTL_SECONDS:
|
||||
_MANUAL_COMPRESSION_JOBS.pop(sid, None)
|
||||
|
||||
|
||||
def _manual_compression_status_payload(job):
|
||||
status = job.get("status") or "running"
|
||||
payload = {
|
||||
"ok": status not in {"error", "cancelled"},
|
||||
"status": status,
|
||||
"session_id": job.get("session_id"),
|
||||
"focus_topic": job.get("focus_topic"),
|
||||
"started_at": job.get("started_at"),
|
||||
"updated_at": job.get("updated_at"),
|
||||
}
|
||||
if status == "done":
|
||||
result = job.get("result")
|
||||
if isinstance(result, dict):
|
||||
payload.update(result)
|
||||
payload["status"] = "done"
|
||||
payload["ok"] = True
|
||||
elif status == "error":
|
||||
payload["ok"] = False
|
||||
payload["error"] = job.get("error") or "Compression failed"
|
||||
payload["error_status"] = int(job.get("error_status") or 400)
|
||||
elif status == "cancelled":
|
||||
payload["ok"] = False
|
||||
payload["error"] = job.get("error") or "Compression cancelled"
|
||||
payload["error_status"] = int(job.get("error_status") or 409)
|
||||
return payload
|
||||
|
||||
|
||||
def _run_manual_compression_job(sid, body):
|
||||
memory_handler = _ManualCompressionMemoryHandler()
|
||||
try:
|
||||
_handle_session_compress(memory_handler, body)
|
||||
status = int(memory_handler.status or 500)
|
||||
payload = memory_handler.payload()
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
job = _MANUAL_COMPRESSION_JOBS.get(sid)
|
||||
if not job:
|
||||
return
|
||||
now = time.time()
|
||||
if status >= 400 or not isinstance(payload, dict) or payload.get("error"):
|
||||
job.update(
|
||||
{
|
||||
"status": "error",
|
||||
"error": str((payload or {}).get("error") or "Compression failed"),
|
||||
"error_status": status,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
else:
|
||||
job.update(
|
||||
{
|
||||
"status": "done",
|
||||
"result": payload,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Manual compression worker failed for session %s: %s", sid, exc)
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
job = _MANUAL_COMPRESSION_JOBS.get(sid)
|
||||
if job:
|
||||
job.update(
|
||||
{
|
||||
"status": "error",
|
||||
"error": f"Compression failed: {_sanitize_error(exc)}",
|
||||
"error_status": 500,
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _handle_session_compress_start(handler, body):
|
||||
try:
|
||||
require(body, "session_id")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
|
||||
sid = str(body.get("session_id") or "").strip()
|
||||
if not sid:
|
||||
return bad(handler, "session_id is required")
|
||||
try:
|
||||
s = get_session(sid)
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
if getattr(s, "active_stream_id", None):
|
||||
return bad(handler, "Session is still streaming; wait for the current turn to finish.", 409)
|
||||
|
||||
focus_topic = str(body.get("focus_topic") or body.get("topic") or "").strip()[:500] or None
|
||||
job_body = {"session_id": sid}
|
||||
if focus_topic:
|
||||
job_body["focus_topic"] = focus_topic
|
||||
|
||||
now = time.time()
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
_manual_compression_cleanup_locked(now)
|
||||
existing = _MANUAL_COMPRESSION_JOBS.get(sid)
|
||||
if existing:
|
||||
existing_payload = _manual_compression_status_payload(existing)
|
||||
if existing_payload.get("status") == "running":
|
||||
return j(handler, existing_payload)
|
||||
# Stage-344 Opus SHOULD-FIX (#2128): always start fresh on re-invoke.
|
||||
# The prior implementation short-circuited and returned a stale `done`
|
||||
# payload for the full 10-minute TTL window when /compress/start was
|
||||
# re-invoked, so a user closing the tab mid-compress and re-running
|
||||
# /compress on a fresh open would get the previous result back rather
|
||||
# than a new compression. Drop the entry and fall through to the
|
||||
# fresh-worker path below.
|
||||
_MANUAL_COMPRESSION_JOBS.pop(sid, None)
|
||||
job = {
|
||||
"session_id": sid,
|
||||
"focus_topic": focus_topic,
|
||||
"status": "running",
|
||||
"started_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
_MANUAL_COMPRESSION_JOBS[sid] = job
|
||||
|
||||
worker = threading.Thread(
|
||||
target=_run_manual_compression_job,
|
||||
args=(sid, job_body),
|
||||
name=f"manual-compress-{sid[:8]}",
|
||||
daemon=True,
|
||||
)
|
||||
worker.start()
|
||||
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
return j(handler, _manual_compression_status_payload(_MANUAL_COMPRESSION_JOBS.get(sid, job)))
|
||||
|
||||
|
||||
def _handle_session_compress_status(handler, sid):
|
||||
sid = str(sid or "").strip()
|
||||
if not sid:
|
||||
return bad(handler, "session_id is required")
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
_manual_compression_cleanup_locked()
|
||||
job = _MANUAL_COMPRESSION_JOBS.get(sid)
|
||||
if not job:
|
||||
return j(handler, {"ok": True, "status": "idle", "session_id": sid})
|
||||
payload = _manual_compression_status_payload(job)
|
||||
# Stage-344 Opus SHOULD-FIX (#2128): do not pop the job on first
|
||||
# read of a `done` payload. The session may be open in multiple
|
||||
# tabs, and the first tab's poll would otherwise leave the second
|
||||
# tab with `idle` and a "Compression job is no longer available"
|
||||
# toast. Let the 10-minute TTL handle eviction so all open tabs
|
||||
# see the same terminal payload.
|
||||
return j(handler, payload)
|
||||
|
||||
|
||||
def _handle_session_compress(handler, body):
|
||||
def _anchor_message_key(m):
|
||||
if not isinstance(m, dict):
|
||||
@@ -7848,6 +8140,12 @@ def _handle_session_compress(handler, body):
|
||||
# Lock contract: hold for the in-memory mutation only, never across
|
||||
# network I/O.
|
||||
original_messages = list(messages)
|
||||
original_stream_state = (
|
||||
getattr(s, "active_stream_id", None),
|
||||
getattr(s, "pending_user_message", None),
|
||||
copy.deepcopy(getattr(s, "pending_attachments", None)),
|
||||
getattr(s, "pending_started_at", None),
|
||||
)
|
||||
approx_tokens = _estimate_messages_tokens_rough(original_messages)
|
||||
|
||||
agent = _run_agent.AIAgent(
|
||||
@@ -7879,6 +8177,14 @@ def _handle_session_compress(handler, body):
|
||||
with _cfg._get_session_agent_lock(sid):
|
||||
# Re-read messages to detect concurrent edits during the LLM call.
|
||||
# If the history changed, the compression result is stale — abort.
|
||||
current_stream_state = (
|
||||
getattr(s, "active_stream_id", None),
|
||||
getattr(s, "pending_user_message", None),
|
||||
copy.deepcopy(getattr(s, "pending_attachments", None)),
|
||||
getattr(s, "pending_started_at", None),
|
||||
)
|
||||
if current_stream_state != original_stream_state:
|
||||
return bad(handler, "Session stream state changed during compression; please retry.", 409)
|
||||
if _sanitize_messages_for_api(s.messages) != original_messages:
|
||||
return bad(handler, "Session was modified during compression; please retry.", 409)
|
||||
|
||||
|
||||
@@ -499,7 +499,7 @@ def audit_session_recovery(session_dir: Path, state_db_path: Path | None = None)
|
||||
|
||||
for session_id in iter_turn_journal_session_ids(session_dir):
|
||||
journal = read_turn_journal(session_id, session_dir=session_dir)
|
||||
states = derive_turn_journal_states(journal.get('events') or [])
|
||||
states, _ = derive_turn_journal_states(journal.get('events') or [])
|
||||
live_path = session_dir / f"{session_id}.json"
|
||||
live_messages = _msg_count(live_path)
|
||||
existing_user_messages: set[str] = set()
|
||||
|
||||
+6
-4
@@ -2286,7 +2286,7 @@ def _run_agent_streaming(
|
||||
# process-level active-profile global. Falls back gracefully.
|
||||
try:
|
||||
from api.profiles import (
|
||||
_patch_skill_home_modules,
|
||||
patch_skill_home_modules,
|
||||
get_hermes_home_for_profile,
|
||||
get_profile_runtime_env,
|
||||
)
|
||||
@@ -2296,7 +2296,7 @@ def _run_agent_streaming(
|
||||
except ImportError:
|
||||
_profile_home = os.environ.get('HERMES_HOME', '')
|
||||
_profile_runtime_env = {}
|
||||
_patch_skill_home_modules = None
|
||||
patch_skill_home_modules = None
|
||||
|
||||
# Capture the resolved profile name now, while profile context is
|
||||
# reliable. Used in the compression migration block to stamp s.profile
|
||||
@@ -2349,8 +2349,8 @@ def _run_agent_streaming(
|
||||
# above, so we only do lightweight sys.modules lookups and
|
||||
# attribute assignments here — no first-time import under
|
||||
# the lock (#2024).
|
||||
if _patch_skill_home_modules is not None:
|
||||
_patch_skill_home_modules(Path(_profile_home))
|
||||
if patch_skill_home_modules is not None:
|
||||
patch_skill_home_modules(Path(_profile_home))
|
||||
# Lock released — agent runs without holding it
|
||||
# ── MCP Server Discovery (lazy import, idempotent) ──
|
||||
# MUST run AFTER the HERMES_HOME mutation above — `discover_mcp_tools()`
|
||||
@@ -2770,6 +2770,8 @@ def _run_agent_streaming(
|
||||
'model': _fb_entry.get('model', ''),
|
||||
'provider': _fb_entry.get('provider', ''),
|
||||
'base_url': _fb_entry.get('base_url'),
|
||||
'api_key': _fb_entry.get('api_key'),
|
||||
'key_env': _fb_entry.get('key_env'),
|
||||
}
|
||||
|
||||
# Build kwargs defensively — guard newer params so the WebUI
|
||||
|
||||
+28
-5
@@ -102,20 +102,43 @@ def read_turn_journal(session_id: str, *, session_dir: Path | None = None) -> di
|
||||
return {"session_id": str(session_id), "events": events, "malformed": malformed}
|
||||
|
||||
|
||||
def derive_turn_journal_states(events: Iterable[dict]) -> dict[str, dict]:
|
||||
"""Return the latest event per ``turn_id``."""
|
||||
def derive_turn_journal_states(events: Iterable[dict]) -> tuple[dict[str, dict], list[dict]]:
|
||||
'''Return the latest event per ``turn_id`` and any terminal-collision entries.
|
||||
|
||||
The first element is the latest event per turn_id (same overwrite-by-timestamp
|
||||
behaviour as before). The second element is a list of collision records, one
|
||||
per turn_id that had more than one terminal event. Each collision record
|
||||
contains ``turn_id`` and the ``events`` list (in ascending created_at order).
|
||||
|
||||
A collision means the same logical turn recorded both ``completed`` and
|
||||
``interrupted`` terminal events -- the derived state still picks the latest
|
||||
by timestamp, but callers can now detect and audit the double-terminal
|
||||
situation explicitly rather than having it silently collapse.
|
||||
'''
|
||||
states: dict[str, dict] = {}
|
||||
# Collect all terminal events per turn_id to detect collisions
|
||||
terminal_events: dict[str, list[dict]] = {}
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
turn_id = str(event.get("turn_id") or "").strip()
|
||||
turn_id = str(event.get('turn_id') or '').strip()
|
||||
if not turn_id:
|
||||
continue
|
||||
# Track terminal events for collision detection
|
||||
if is_terminal_turn_event(event):
|
||||
terminal_events.setdefault(turn_id, []).append(event)
|
||||
# Existing latest-by-timestamp derivation
|
||||
previous = states.get(turn_id)
|
||||
if previous is None or float(event.get("created_at") or 0) >= float(previous.get("created_at") or 0):
|
||||
if previous is None or float(event.get('created_at') or 0) >= float(previous.get('created_at') or 0):
|
||||
states[turn_id] = event
|
||||
return states
|
||||
|
||||
# Build collision list: turn_ids with more than one terminal event
|
||||
collisions = [
|
||||
{'turn_id': tid, 'events': sorted(evts, key=lambda e: float(e.get('created_at') or 0))}
|
||||
for tid, evts in terminal_events.items()
|
||||
if len(evts) > 1
|
||||
]
|
||||
return states, collisions
|
||||
|
||||
def _latest_turn_id_for_stream(events: Iterable[dict], stream_id: str) -> str | None:
|
||||
stream = str(stream_id or "").strip()
|
||||
|
||||
+7
-2
@@ -64,7 +64,7 @@ def _profile_default_workspace() -> str:
|
||||
2. 'default_workspace' — alternate explicit key
|
||||
3. 'terminal.cwd' — hermes-agent terminal working dir (most common)
|
||||
|
||||
Falls back to the boot-time DEFAULT_WORKSPACE constant.
|
||||
Falls back to the live DEFAULT_WORKSPACE from api.config.
|
||||
"""
|
||||
try:
|
||||
from api.config import get_config
|
||||
@@ -86,7 +86,12 @@ def _profile_default_workspace() -> str:
|
||||
return str(p)
|
||||
except (ImportError, Exception):
|
||||
logger.debug("Failed to load profile default workspace config")
|
||||
return str(_BOOT_DEFAULT_WORKSPACE)
|
||||
try:
|
||||
from api.config import DEFAULT_WORKSPACE as _LIVE_DEFAULT_WORKSPACE
|
||||
|
||||
return str(Path(_LIVE_DEFAULT_WORKSPACE).expanduser().resolve())
|
||||
except Exception:
|
||||
return str(Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve())
|
||||
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,6 +13,194 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_git(args: list[str], cwd: str | Path, timeout: float = 2) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_path(path: str | Path | None) -> Path | None:
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
return Path(path).expanduser().resolve(strict=False)
|
||||
except (OSError, RuntimeError):
|
||||
return Path(path).expanduser()
|
||||
|
||||
|
||||
def _worktree_list_cwd(worktree_path: Path, repo_root: str | Path | None) -> Path | None:
|
||||
repo = _resolve_path(repo_root)
|
||||
if repo and repo.is_dir():
|
||||
return repo
|
||||
if worktree_path.is_dir():
|
||||
return worktree_path
|
||||
return None
|
||||
|
||||
|
||||
def _parse_worktree_list_porcelain(output: str) -> set[str]:
|
||||
paths: set[str] = set()
|
||||
for line in str(output or "").splitlines():
|
||||
if not line.startswith("worktree "):
|
||||
continue
|
||||
path = line[len("worktree "):].strip()
|
||||
if not path:
|
||||
continue
|
||||
resolved = _resolve_path(path)
|
||||
paths.add(str(resolved or Path(path).expanduser()))
|
||||
return paths
|
||||
|
||||
|
||||
def _worktree_listed(worktree_path: Path, repo_root: str | Path | None) -> bool:
|
||||
"""Return whether git currently lists the worktree.
|
||||
|
||||
False is a safe fallback for probe failures, not definitive orphan proof.
|
||||
Future cleanup UI must combine this with the rest of the status payload.
|
||||
"""
|
||||
cwd = _worktree_list_cwd(worktree_path, repo_root)
|
||||
if cwd is None:
|
||||
return False
|
||||
try:
|
||||
result = _run_git(["worktree", "list", "--porcelain"], cwd)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return str(worktree_path) in _parse_worktree_list_porcelain(result.stdout)
|
||||
|
||||
|
||||
def _status_porcelain(worktree_path: Path) -> tuple[bool, int]:
|
||||
try:
|
||||
result = _run_git(
|
||||
["status", "--porcelain", "--untracked-files=normal"],
|
||||
worktree_path,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False, 0
|
||||
if result.returncode != 0:
|
||||
return False, 0
|
||||
lines = [line for line in result.stdout.splitlines() if line]
|
||||
return bool(lines), sum(1 for line in lines if line.startswith("??"))
|
||||
|
||||
|
||||
def _ahead_behind(worktree_path: Path) -> dict:
|
||||
payload = {
|
||||
"ahead": 0,
|
||||
"behind": 0,
|
||||
"available": False,
|
||||
"upstream": None,
|
||||
}
|
||||
try:
|
||||
upstream = _run_git(
|
||||
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||
worktree_path,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return payload
|
||||
if upstream.returncode != 0:
|
||||
return payload
|
||||
upstream_ref = upstream.stdout.strip()
|
||||
if not upstream_ref:
|
||||
return payload
|
||||
payload["upstream"] = upstream_ref
|
||||
try:
|
||||
counts = _run_git(
|
||||
["rev-list", "--left-right", "--count", "HEAD...@{u}"],
|
||||
worktree_path,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return payload
|
||||
if counts.returncode != 0:
|
||||
return payload
|
||||
parts = counts.stdout.strip().split()
|
||||
if len(parts) != 2:
|
||||
return payload
|
||||
try:
|
||||
payload["ahead"] = max(0, int(parts[0]))
|
||||
payload["behind"] = max(0, int(parts[1]))
|
||||
payload["available"] = True
|
||||
except ValueError:
|
||||
pass
|
||||
return payload
|
||||
|
||||
|
||||
def _locked_by_stream(session) -> bool:
|
||||
stream_id = getattr(session, "active_stream_id", None)
|
||||
if not stream_id:
|
||||
return False
|
||||
try:
|
||||
from api.config import STREAMS, STREAMS_LOCK
|
||||
|
||||
with STREAMS_LOCK:
|
||||
return stream_id in STREAMS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _locked_by_terminal(session_id: str, worktree_path: Path) -> bool:
|
||||
try:
|
||||
from api.terminal import get_terminal
|
||||
|
||||
term = get_terminal(session_id)
|
||||
except Exception:
|
||||
return False
|
||||
if not term:
|
||||
return False
|
||||
try:
|
||||
if not term.is_alive():
|
||||
return False
|
||||
terminal_workspace = _resolve_path(getattr(term, "workspace", None))
|
||||
return terminal_workspace == worktree_path
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def worktree_status_for_session(session) -> dict:
|
||||
"""Return a read-only worktree status snapshot for a WebUI session."""
|
||||
raw_path = getattr(session, "worktree_path", None)
|
||||
if not raw_path:
|
||||
raise ValueError("Session is not worktree-backed")
|
||||
|
||||
worktree_path = _resolve_path(raw_path)
|
||||
if worktree_path is None:
|
||||
raise ValueError("Session is not worktree-backed")
|
||||
|
||||
exists = worktree_path.is_dir()
|
||||
status = {
|
||||
"path": str(worktree_path),
|
||||
"exists": bool(exists),
|
||||
"dirty": False,
|
||||
"untracked_count": 0,
|
||||
"ahead_behind": {
|
||||
"ahead": 0,
|
||||
"behind": 0,
|
||||
"available": False,
|
||||
"upstream": None,
|
||||
},
|
||||
"locked_by_stream": _locked_by_stream(session),
|
||||
"locked_by_terminal": _locked_by_terminal(
|
||||
getattr(session, "session_id", ""),
|
||||
worktree_path,
|
||||
),
|
||||
"listed": _worktree_listed(
|
||||
worktree_path,
|
||||
getattr(session, "worktree_repo_root", None),
|
||||
),
|
||||
}
|
||||
if not exists:
|
||||
return status
|
||||
|
||||
dirty, untracked_count = _status_porcelain(worktree_path)
|
||||
status["dirty"] = dirty
|
||||
status["untracked_count"] = untracked_count
|
||||
status["ahead_behind"] = _ahead_behind(worktree_path)
|
||||
return status
|
||||
|
||||
|
||||
def find_git_repo_root(workspace: str | Path) -> Path:
|
||||
"""Return the enclosing git repo root for *workspace*.
|
||||
|
||||
|
||||
@@ -51,9 +51,11 @@ _load_repo_dotenv_preserving_env() {
|
||||
set +a
|
||||
|
||||
local assignment
|
||||
for assignment in "${preserved[@]}"; do
|
||||
export "${assignment}"
|
||||
done
|
||||
if [[ ${#preserved[@]} -gt 0 ]]; then
|
||||
for assignment in "${preserved[@]}"; do
|
||||
export "${assignment}"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
_find_python() {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -93,6 +93,43 @@ assistant_started -> interrupted
|
||||
4. After the sidecar save that includes the assistant answer succeeds, append `completed`.
|
||||
5. On cancellation or known worker exception, append `interrupted` with a reason.
|
||||
|
||||
## Synchronous durability design rationale
|
||||
|
||||
The `submitted` event uses synchronous `fsync` on every write today. This is a deliberate tradeoff between latency and crash-safety guarantees:
|
||||
|
||||
### Why synchronous for submitted events
|
||||
|
||||
The `submitted` event is the durability anchor for the entire recovery story. If the server crashes before the worker starts, the journal must reflect that the user message was received. Async writes risk losing that guarantee: a crash shortly after a non-fsync'd write could leave the journal silent while `pending_user_message` still exists, creating ambiguity during recovery. The current design avoids that ambiguity at the cost of one extra disk round-trip per turn submission.
|
||||
|
||||
### Latency expectations by storage type
|
||||
|
||||
Reported fsync latency varies significantly across storage backends. Approximate qualitative ranges to keep in mind:
|
||||
|
||||
- **SSD (NVM/NVMe)**: Single-digit milliseconds; p99 typically well under 10 ms on modern hardware. Most turn submissions will see sub-5 ms overhead.
|
||||
- **Rotational disk (HDD)**: Seek time dominates; p50 ~5–15 ms, p99 can reach 50–100 ms under load. A busy server with many concurrent submissions may see queueing effects.
|
||||
- **Docker/overlay filesystems**: fsync latency depends on the container storage driver and the backing host filesystem. Write-through and copy-on-write semantics can introduce additional overhead; p95 may be 10–50 ms in typical containerized deployments, though exact figures vary by configuration and host load.
|
||||
|
||||
These ranges are order-of-magnitude guidance, not benchmarks. Exact figures depend on hardware, kernel version, filesystem mount options, and concurrent load. Do not commit specific millisecond claims to documentation without measured evidence.
|
||||
|
||||
### Benchmark guidance for maintainers
|
||||
|
||||
If evidence suggests the synchronous write is a bottleneck, measure before changing anything:
|
||||
|
||||
1. Instrument the `append_turn_journal_event` helper to record wall-clock time for each event type (submitted, worker_started, etc.).
|
||||
2. Capture p50/p95/p99 append/fsync latency over a representative workload (e.g., at least 1,000 submitted turns under realistic concurrency).
|
||||
3. Isolate the fsync component: on Linux, use `strace -e fsync` or kernel tracing (`ftrace`, `perf`) to confirm where time is spent.
|
||||
4. Check for patterns: if most submissions are under 5 ms but the p99 is 200 ms due to occasional disk contention, async writes help the tail but not the median. The tradeoff must be evaluated in context of your recovery guarantees.
|
||||
|
||||
### Future follow-up: async lifecycle-event journaling
|
||||
|
||||
Making journal writes asynchronous is a valid future optimization, but it requires:
|
||||
|
||||
- A reliable flush strategy (e.g., time-bounded flush every N seconds, flush on session close, flush after K pending events).
|
||||
- Recovery logic that handles partial flush windows: if a crash occurs before the flush, the last few submitted events may be missing from the journal. Recovery must account for that ambiguity.
|
||||
- Tests that verify the flush correctness under crash injection.
|
||||
|
||||
Async journal writes are **not** part of the initial implementation. They belong in a follow-up RFC once the synchronous baseline is proven stable and the recovery semantics are well-understood.
|
||||
|
||||
## Startup recovery semantics
|
||||
|
||||
On startup, for each journal file:
|
||||
|
||||
+133
-50
@@ -382,6 +382,131 @@ async function cmdNew(){
|
||||
showToast(t('new_session'));
|
||||
}
|
||||
|
||||
function _manualCompressionVisibleMessages(){
|
||||
return (S.messages||[]).filter(m=>{
|
||||
if(!m||!m.role||m.role==='tool') return false;
|
||||
if(m.role==='assistant'){
|
||||
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
||||
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
|
||||
if(hasTc||hasTu|| (typeof _messageHasReasoningPayload==='function' && _messageHasReasoningPayload(m))) return true;
|
||||
}
|
||||
return typeof msgContent==='function' ? !!msgContent(m) || !!m.attachments?.length : !!m.content || !!m.attachments?.length;
|
||||
});
|
||||
}
|
||||
|
||||
function _manualCompressionSleep(ms){
|
||||
return new Promise(resolve=>setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function _pollManualCompressionResult(sid){
|
||||
let delay=700;
|
||||
while(true){
|
||||
const data=await api(`/api/session/compress/status?session_id=${encodeURIComponent(sid)}`);
|
||||
if(data&&data.status==='done') return data;
|
||||
if(data&&data.status==='error'){
|
||||
const err=new Error(data.error||'Compression failed');
|
||||
err.status=data.error_status||400;
|
||||
throw err;
|
||||
}
|
||||
if(data&&data.status==='idle') throw new Error('Compression job is no longer available');
|
||||
await _manualCompressionSleep(delay);
|
||||
delay=Math.min(2000, delay+300);
|
||||
}
|
||||
}
|
||||
|
||||
async function _applyManualCompressionResult(data, focusTopic, visibleCount, commandText){
|
||||
if(data&&data.session){
|
||||
const currentSid=S.session&&S.session.session_id;
|
||||
if(data.session.session_id&&data.session.session_id!==currentSid){
|
||||
await loadSession(data.session.session_id);
|
||||
}else{
|
||||
S.session=data.session;
|
||||
S.messages=data.session.messages||[];
|
||||
S.toolCalls=data.session.tool_calls||[];
|
||||
clearLiveToolCards();
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||
syncTopbar();
|
||||
renderMessages();
|
||||
await renderSessionList();
|
||||
updateQueueBadge(S.session.session_id);
|
||||
}
|
||||
}
|
||||
const summary=data&&data.summary;
|
||||
if(typeof setCompressionUi==='function'&&S.session){
|
||||
const referenceMsg=(S.messages||[]).find(m=>typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m));
|
||||
const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):'';
|
||||
const summaryRef=summary&&typeof summary.reference_message==='string' ? String(summary.reference_message||'').trim() : '';
|
||||
// Prefer the persisted compaction handoff when it already exists in session state.
|
||||
// The short summary fallback is only for environments where that message is unavailable.
|
||||
const referenceText=messageRef || summaryRef;
|
||||
const effectiveFocus=(data&&data.focus_topic)||focusTopic||'';
|
||||
setCompressionUi({
|
||||
sessionId:S.session.session_id,
|
||||
phase:'done',
|
||||
focusTopic:effectiveFocus,
|
||||
commandText:effectiveFocus?`/compress ${effectiveFocus}`:(commandText||'/compress'),
|
||||
beforeCount:visibleCount,
|
||||
summary:summary||null,
|
||||
referenceText,
|
||||
anchorVisibleIdx: data?.session?.compression_anchor_visible_idx,
|
||||
anchorMessageKey: data?.session?.compression_anchor_message_key||null,
|
||||
});
|
||||
}
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
renderMessages();
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
}
|
||||
|
||||
async function resumeManualCompressionForSession(sid){
|
||||
if(!sid) return;
|
||||
try{
|
||||
const status=await api(`/api/session/compress/status?session_id=${encodeURIComponent(sid)}`);
|
||||
if(!status||status.status!=='running') return;
|
||||
const visibleMessages=_manualCompressionVisibleMessages();
|
||||
const visibleCount=visibleMessages.length;
|
||||
const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null);
|
||||
if(typeof setBusy==='function') setBusy(true);
|
||||
if(typeof setComposerStatus==='function') setComposerStatus(t('compressing'));
|
||||
if(typeof setCompressionUi==='function'){
|
||||
setCompressionUi({
|
||||
sessionId:sid,
|
||||
phase:'running',
|
||||
focusTopic:status.focus_topic||'',
|
||||
commandText:status.focus_topic?`/compress ${status.focus_topic}`:'/compress',
|
||||
beforeCount:visibleCount,
|
||||
anchorVisibleIdx:Math.max(0, visibleCount-1),
|
||||
anchorMessageKey,
|
||||
});
|
||||
}
|
||||
renderMessages();
|
||||
const done=await _pollManualCompressionResult(sid);
|
||||
if(!S.session||S.session.session_id!==sid) return;
|
||||
await _applyManualCompressionResult(done, status.focus_topic||'', visibleCount, status.focus_topic?`/compress ${status.focus_topic}`:'/compress');
|
||||
}catch(e){
|
||||
if(S.session&&S.session.session_id===sid&&typeof setCompressionUi==='function'){
|
||||
const visibleMessages=_manualCompressionVisibleMessages();
|
||||
setCompressionUi({
|
||||
sessionId:sid,
|
||||
phase:'error',
|
||||
focusTopic:'',
|
||||
commandText:'/compress',
|
||||
beforeCount:visibleMessages.length,
|
||||
errorText:`Compression failed: ${e.message}`,
|
||||
anchorVisibleIdx:Math.max(0, visibleMessages.length-1),
|
||||
anchorMessageKey:null,
|
||||
});
|
||||
renderMessages();
|
||||
}
|
||||
}finally{
|
||||
if(S.session&&S.session.session_id===sid){
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
if(typeof setBusy==='function') setBusy(false);
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _runManualCompression(focusTopic){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
let visibleCount=0;
|
||||
@@ -410,15 +535,7 @@ async function _runManualCompression(focusTopic){
|
||||
if(typeof setBusy==='function') setBusy(true);
|
||||
const body={session_id:sid};
|
||||
if(focusTopic) body.focus_topic=focusTopic;
|
||||
const visibleMessages=(S.messages||[]).filter(m=>{
|
||||
if(!m||!m.role||m.role==='tool') return false;
|
||||
if(m.role==='assistant'){
|
||||
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
||||
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
|
||||
if(hasTc||hasTu|| (typeof _messageHasReasoningPayload==='function' && _messageHasReasoningPayload(m))) return true;
|
||||
}
|
||||
return typeof msgContent==='function' ? !!msgContent(m) || !!m.attachments?.length : !!m.content || !!m.attachments?.length;
|
||||
});
|
||||
const visibleMessages=_manualCompressionVisibleMessages();
|
||||
visibleCount=visibleMessages.length;
|
||||
const anchorVisibleIdx=Math.max(0, visibleCount - 1);
|
||||
const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null);
|
||||
@@ -436,48 +553,14 @@ async function _runManualCompression(focusTopic){
|
||||
}
|
||||
if(typeof setComposerStatus==='function') setComposerStatus(t('compressing'));
|
||||
renderMessages();
|
||||
const data=await api('/api/session/compress',{method:'POST',body:JSON.stringify(body)});
|
||||
if(data&&data.session){
|
||||
const currentSid=S.session&&S.session.session_id;
|
||||
if(data.session.session_id&&data.session.session_id!==currentSid){
|
||||
await loadSession(data.session.session_id);
|
||||
}else{
|
||||
S.session=data.session;
|
||||
S.messages=data.session.messages||[];
|
||||
S.toolCalls=data.session.tool_calls||[];
|
||||
clearLiveToolCards();
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||
syncTopbar();
|
||||
renderMessages();
|
||||
await renderSessionList();
|
||||
updateQueueBadge(S.session.session_id);
|
||||
}
|
||||
const started=await api('/api/session/compress/start',{method:'POST',body:JSON.stringify(body)});
|
||||
if(started&&started.status==='error'){
|
||||
const err=new Error(started.error||'Compression failed');
|
||||
err.status=started.error_status||400;
|
||||
throw err;
|
||||
}
|
||||
const summary=data&&data.summary;
|
||||
if(typeof setCompressionUi==='function'&&S.session){
|
||||
const referenceMsg=(S.messages||[]).find(m=>typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m));
|
||||
const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):'';
|
||||
const summaryRef=summary&&typeof summary.reference_message==='string' ? String(summary.reference_message||'').trim() : '';
|
||||
// Prefer the persisted compaction handoff when it already exists in session state.
|
||||
// The short summary fallback is only for environments where that message is unavailable.
|
||||
const referenceText=messageRef || summaryRef;
|
||||
const effectiveFocus=(data&&data.focus_topic)||focusTopic||'';
|
||||
setCompressionUi({
|
||||
sessionId:S.session.session_id,
|
||||
phase:'done',
|
||||
focusTopic:effectiveFocus,
|
||||
commandText:effectiveFocus?`/compress ${effectiveFocus}`:'/compress',
|
||||
beforeCount:visibleCount,
|
||||
summary:summary||null,
|
||||
referenceText,
|
||||
anchorVisibleIdx: data?.session?.compression_anchor_visible_idx,
|
||||
anchorMessageKey: data?.session?.compression_anchor_message_key||null,
|
||||
});
|
||||
}
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
renderMessages();
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
const data=(started&&started.status==='done')?started:await _pollManualCompressionResult(sid);
|
||||
await _applyManualCompressionResult(data, focusTopic, visibleCount, commandText);
|
||||
}catch(e){
|
||||
if(typeof setCompressionUi==='function'){
|
||||
const currentSid=S.session&&S.session.session_id;
|
||||
|
||||
+948
@@ -8963,6 +8963,13 @@ const LOCALES = {
|
||||
session_deleted_worktree: 'Conversa excluída. O worktree permanece no disco.',
|
||||
session_batch_delete_worktree_confirm: 'Excluir {0} conversas? {1} conversa(s) com worktree manterão seus diretórios de worktree no disco.',
|
||||
session_batch_archive_worktree_confirm: 'Arquivar {0} conversas? {1} conversa(s) com worktree manterão seus diretórios de worktree no disco.',
|
||||
session_batch_delete_confirm: 'Excluir {0} conversas?',
|
||||
session_batch_archive_confirm: 'Arquivar {0} conversas?',
|
||||
session_select_mode: 'Selecionar',
|
||||
session_select_mode_desc: 'Selecionar conversas para gerenciamento em lote',
|
||||
session_select_all: 'Selecionar todas',
|
||||
session_selected_count: '{0} selecionadas',
|
||||
session_no_selection: 'Nenhuma conversa selecionada',
|
||||
// settings panel
|
||||
settings_heading_title: 'Control Center',
|
||||
settings_heading_subtitle: 'Preferências, ferramentas de conversa e controles do sistema.',
|
||||
@@ -10657,6 +10664,947 @@ const LOCALES = {
|
||||
voice_mode_toggle_active: 'Exit voice mode', // TODO: translate
|
||||
subagent_children: 'Subagent sessions', // TODO: translate
|
||||
},
|
||||
|
||||
fr: {
|
||||
offline_title: 'Connexion perdue',
|
||||
offline_browser_detail: 'Votre navigateur signale que cet appareil est hors ligne.',
|
||||
offline_network_detail: 'Hermes est actuellement inaccessible depuis ce navigateur.',
|
||||
offline_autorefresh: 'J\'actualiserai automatiquement cette page lorsqu\'Hermès sera à nouveau joignable.',
|
||||
offline_check_now: 'Vérifiez maintenant',
|
||||
offline_checking: 'Vérification…',
|
||||
offline_stream_waiting: 'Connexion perdue. En attendant de rafraîchir…',
|
||||
_lang: 'fr',
|
||||
_label: 'Français',
|
||||
_speech: 'fr-FR',
|
||||
cancelling: 'Annulation\u2026',
|
||||
cancel_failed: 'Échec de l\'annulation :',
|
||||
mic_denied: 'Accès au microphone refusé. Vérifiez les autorisations du navigateur.',
|
||||
mic_no_speech: 'Aucune parole détectée. Essayer à nouveau.',
|
||||
mic_network: 'Reconnaissance vocale indisponible.',
|
||||
mic_error: 'Erreur de saisie vocale :',
|
||||
voice_dictate: 'Dicter',
|
||||
voice_dictate_active: 'Arrêter la dictée',
|
||||
voice_mode_toggle: 'Mode vocal',
|
||||
voice_mode_toggle_active: 'Quitter le mode vocal',
|
||||
voice_listening: 'Écoute…',
|
||||
voice_speaking: 'Parlant…',
|
||||
voice_thinking: 'Pensée…',
|
||||
voice_error: 'Voix non prise en charge dans ce navigateur',
|
||||
voice_mode_active: 'Mode vocal activé',
|
||||
voice_mode_off: 'Mode vocal désactivé',
|
||||
session_imported: 'Session importée',
|
||||
import_failed: 'Échec de l\'importation :',
|
||||
import_invalid_json: 'JSON invalide',
|
||||
image_pasted: 'Image collée :',
|
||||
edit_message: 'Modifier le message',
|
||||
regenerate: 'Régénérer la réponse',
|
||||
copy: 'Copie',
|
||||
copied: 'Copié!',
|
||||
copy_failed: 'Échec de la copie',
|
||||
diff_loading: 'Chargement des différences',
|
||||
diff_error: 'Impossible de charger le fichier de correctif',
|
||||
diff_too_large: 'Fichier de correctif trop volumineux pour être affiché en ligne',
|
||||
tree_view: 'Arbre',
|
||||
raw_view: 'Brut',
|
||||
parse_failed_note: 'l\'analyse a échoué',
|
||||
you: 'Toi',
|
||||
mcp_servers_title: 'Serveurs MCP',
|
||||
mcp_servers_desc: 'Affichez les serveurs MCP configurés dans config.yaml.',
|
||||
mcp_no_servers: 'Aucun serveur MCP configuré.',
|
||||
mcp_add_server: '+ Ajouter un serveur',
|
||||
mcp_field_name: 'Nom du serveur',
|
||||
mcp_transport_label: 'Type de transport',
|
||||
mcp_field_command: 'Commande',
|
||||
mcp_field_args: 'Arguments (séparés par des virgules)',
|
||||
mcp_field_url: 'URL',
|
||||
mcp_field_timeout: 'Délai d\'expiration (secondes)',
|
||||
mcp_save: 'Sauvegarder',
|
||||
mcp_cancel: 'Annuler',
|
||||
mcp_name_required: 'Le nom du serveur est requis.',
|
||||
mcp_url_required: 'L\'URL est requise pour le transport HTTP.',
|
||||
mcp_command_required: 'La commande est requise pour le transport stdio.',
|
||||
mcp_saved: 'Serveur MCP enregistré.',
|
||||
mcp_save_failed: 'Échec de l\'enregistrement du serveur MCP.',
|
||||
mcp_delete_confirm_title: 'Supprimer le serveur MCP',
|
||||
mcp_delete_confirm_message: 'Supprimer le serveur MCP "{0}" ? Cette action ne peut pas être annulée.',
|
||||
mcp_deleted: 'Serveur MCP supprimé.',
|
||||
mcp_delete_failed: 'Échec de la suppression du serveur MCP.',
|
||||
mcp_load_failed: 'Échec du chargement des serveurs MCP.',
|
||||
mcp_restart_hint: 'Les modifications apportées au serveur sont en lecture seule ici pour le moment. Modifiez config.yaml et redémarrez Hermes pour que les modifications prennent effet.',
|
||||
mcp_toggle_followup: 'Les contrôles d\'activation/désactivation sont intentionnellement différés jusqu\'à ce que la sémantique de rechargement MCP soit explicite.',
|
||||
mcp_status_active: 'Actif',
|
||||
mcp_status_configured: 'Configuré',
|
||||
mcp_status_disabled: 'Désactivé',
|
||||
mcp_status_invalid_config: 'Configuration invalide',
|
||||
mcp_status_unknown: 'Inconnu',
|
||||
mcp_tool_count: '{0} outils',
|
||||
mcp_enabled_yes: 'Activé',
|
||||
mcp_enabled_no: 'Désactivé',
|
||||
mcp_tools_title: 'Outils MCP',
|
||||
mcp_tools_desc: 'Recherchez des outils connus sur les serveurs MCP actifs.',
|
||||
mcp_tools_search_placeholder: 'Outils de recherche par nom, serveur ou description…',
|
||||
mcp_tools_no_tools: 'Aucun outil MCP n\'est disponible dans l\'inventaire d\'exécution actif.',
|
||||
mcp_tools_no_matches: 'Aucun outil MCP ne correspond à votre recherche.',
|
||||
mcp_tools_load_failed: 'Échec du chargement des outils MCP.',
|
||||
mcp_tools_schema_empty: 'Aucun paramètre de schéma.',
|
||||
mcp_tools_runtime_note: 'L\'inventaire des outils utilise uniquement les données d\'exécution MCP actives déjà connues ; le WebUI ne démarre pas et ne sonde pas les serveurs.',
|
||||
pdf_loading: 'Chargement du PDF {0}…',
|
||||
pdf_too_large: 'PDF trop volumineux pour un aperçu en ligne',
|
||||
pdf_no_pages: 'Le PDF n\'a pas de pages',
|
||||
pdf_error: 'Échec de l\'affichage de l\'aperçu PDF',
|
||||
pdf_download: 'Télécharger le PDF',
|
||||
html_loading: 'Chargement de l\'aperçu HTML…',
|
||||
html_too_large: 'HTML trop volumineux pour l\'aperçu en ligne',
|
||||
html_error: 'Échec de l\'affichage de l\'aperçu HTML',
|
||||
html_open_full: 'Ouvrir la page entière',
|
||||
html_sandbox_label: 'Aperçu HTML (en bac à sable)',
|
||||
thinking: 'Pensée',
|
||||
expand_all: 'Tout développer',
|
||||
collapse_all: 'Tout réduire',
|
||||
edit_failed: 'Échec de la modification :',
|
||||
regen_failed: 'Échec de la régénération :',
|
||||
reconnect_active: 'Une réponse est toujours en cours de génération. Recharger quand vous êtes prêt ?',
|
||||
reconnect_finished: 'Une réponse était en cours lors de votre dernier départ. Les messages ont peut-être été mis à jour.',
|
||||
approval_heading: 'Approbation requise',
|
||||
approval_desc_prefix: 'Commande dangereuse détectée',
|
||||
approval_btn_once: 'Autoriser une fois',
|
||||
approval_btn_once_title: 'Autoriser cette commande (Entrée)',
|
||||
approval_btn_session: 'Autoriser la session',
|
||||
approval_btn_session_title: 'Autoriser cette session de conversation',
|
||||
approval_btn_always: 'Toujours autoriser',
|
||||
approval_btn_always_title: 'Toujours autoriser ce modèle de commande',
|
||||
approval_btn_deny: 'Refuser',
|
||||
approval_btn_deny_title: 'Refuser - n\'exécutez pas cette commande',
|
||||
approval_responding: 'Répondre\u2026',
|
||||
clarify_heading: 'Des éclaircissements sont nécessaires',
|
||||
clarify_hint: 'Choisissez un choix ou saisissez votre propre réponse ci-dessous.',
|
||||
clarify_other: 'Autre',
|
||||
clarify_send: 'Envoyer',
|
||||
clarify_input_placeholder: 'Tapez votre réponse…',
|
||||
clarify_responding: 'Répondre\u2026',
|
||||
untitled: 'Sans titre',
|
||||
load_older_messages: '↑ Faites défiler vers le haut ou cliquez pour charger les anciens messages',
|
||||
session_jump_start: 'Commencer',
|
||||
session_jump_start_label: 'Aller au début de la session',
|
||||
session_jump_end: 'Fin',
|
||||
session_jump_end_label: 'Aller à la fin de la session',
|
||||
queued_label: 'Envoie après réponse',
|
||||
queued_cancel: 'Annuler le message en file d\'attente',
|
||||
model_unavailable: '(indisponible)',
|
||||
model_unavailable_title: 'Ce modèle ne figure plus dans votre liste de fournisseurs actuelle',
|
||||
provider_mismatch_label: 'Inadéquation des fournisseurs',
|
||||
model_not_found_label: 'Modèle introuvable',
|
||||
model_custom_label: 'ID de modèle personnalisé',
|
||||
model_custom_placeholder: 'par ex. openai/gpt-5.4',
|
||||
model_search_placeholder: 'Rechercher des modèles…',
|
||||
model_search_no_results: 'Aucun modèle trouvé',
|
||||
model_group_configured: 'Configuré',
|
||||
ws_search_placeholder: 'Rechercher des espaces de travail…',
|
||||
ws_no_results: 'Aucun espace de travail trouvé',
|
||||
workspace_new_worktree_conversation: 'Nouvelle conversation dans l\'arbre de travail',
|
||||
workspace_new_worktree_conversation_meta: 'Créez un arbre de travail git isolé pour cet espace de travail.',
|
||||
workspace_worktree_created: 'Conversation Worktree créée',
|
||||
workspace_worktree_failed: 'La création de l\'arbre de travail a échoué :',
|
||||
session_worktree_badge: 'Arbre de travail',
|
||||
model_scope_advisory: 'S\'applique à cette conversation à partir de votre prochain message.',
|
||||
model_scope_toast: 'S\'applique à cette conversation à partir de votre prochain message.',
|
||||
cmd_clear: 'Messages de conversation clairs',
|
||||
cmd_compress: 'Compresser manuellement le contexte de conversation (utilisation : /compress [thème principal])',
|
||||
ctx_compress_hint: 'Compresser le contexte pour libérer de l\'espace →',
|
||||
ctx_compress_action: '⚠ Compressez maintenant pour libérer le contexte',
|
||||
cmd_compact_alias: 'Alias hérité pour /compress',
|
||||
cmd_model: 'Changer de modèle (par exemple /model gpt-4o)',
|
||||
cmd_workspace: 'Changer d\'espace de travail par nom',
|
||||
cmd_terminal: 'Ouvrez le terminal de l\'espace de travail',
|
||||
cmd_new: 'Démarrer une nouvelle session de discussion',
|
||||
cmd_usage: 'Activer/désactiver l\'affichage de l\'utilisation du jeton',
|
||||
cmd_theme: 'Changer d\'apparence (thème : système/dark/light, skin : default/ares/mono/slate/poseidon/sisyphus/charizard)',
|
||||
cmd_personality: 'Personnalité de l\'agent de commutation',
|
||||
cmd_skills: 'Lister les compétences Hermès disponibles',
|
||||
available_commands: 'Commandes disponibles :',
|
||||
type_slash: 'Tapez / pour voir les commandes',
|
||||
conversation_cleared: 'Conversation effacée',
|
||||
command_label: 'Commande',
|
||||
context_compaction_label: 'Compactage du contexte',
|
||||
preserved_task_list_label: 'Liste de tâches préservée',
|
||||
reference_only_label: 'Référence seulement',
|
||||
model_usage: 'Utilisation : /model <nom>',
|
||||
no_model_match: 'Aucun modèle correspondant "',
|
||||
switched_to: 'Passé à',
|
||||
workspace_usage: 'Utilisation : /espace de travail <nom>',
|
||||
no_workspace_match: 'Aucun espace de travail correspondant "',
|
||||
switched_workspace: 'Passé à l\'espace de travail :',
|
||||
workspace_switch_failed: 'Échec du changement d\'espace de travail :',
|
||||
new_session: 'Nouvelle session créée',
|
||||
compressing: 'Demande de compression de contexte...',
|
||||
compress_running_label: 'Compression',
|
||||
compress_complete_label: 'Compression terminée',
|
||||
auto_compress_label: 'Compression automatique',
|
||||
compress_failed_label: 'Échec de la compression',
|
||||
focus_label: 'Se concentrer',
|
||||
token_usage_on: 'Utilisation du jeton sur',
|
||||
token_usage_off: 'Utilisation des jetons désactivée',
|
||||
theme_usage: 'Utilisation : /thème',
|
||||
theme_set: 'Thème:',
|
||||
no_active_session: 'Aucune session active',
|
||||
cmd_queue: 'Mettre un message en file d\'attente pour le prochain tour',
|
||||
cmd_goal: 'Définir ou inspecter un objectif persistant',
|
||||
goal_evaluating_progress: 'Évaluer la progression des objectifs…',
|
||||
goal_working_toward: 'Travailler vers l’objectif…',
|
||||
goal_continuing_toast: 'Continuer vers l’objectif…',
|
||||
goal_status_none: 'Aucun objectif actif. Définissez-en un avec /goal <text>.',
|
||||
goal_cleared: 'But dégagé.',
|
||||
goal_no_goal: 'Aucun objectif actif.',
|
||||
cmd_interrupt: 'Annuler le tour en cours et envoyer un nouveau message',
|
||||
cmd_steer: 'Injecter une correction à mi-tour sans interrompre l\'agent',
|
||||
cmd_queue_no_msg: 'Utilisation : /queue <message>',
|
||||
cmd_queue_not_busy: 'Aucune tâche active - envoyez simplement normalement',
|
||||
cmd_queue_confirm: 'Message en file d\'attente',
|
||||
cmd_interrupt_no_msg: 'Utilisation : /interruption <message>',
|
||||
cmd_interrupt_confirm: 'Interrompu — envoi d\'un nouveau message',
|
||||
cmd_steer_no_msg: 'Utilisation : /steer <message>',
|
||||
cmd_steer_fallback: 'Steer indisponible – mis en file d’attente pour le prochain tour à la place',
|
||||
cmd_steer_delivered: 'Steer livré - l\'agent le verra sur son prochain résultat d\'outil',
|
||||
steer_leftover_queued: 'Bœuf en attente pour le prochain tour',
|
||||
busy_steer_fallback: 'Steer indisponible – en file d’attente pour le prochain tour',
|
||||
busy_interrupt_confirm: 'Interrompu — envoi d\'un nouveau message',
|
||||
settings_label_busy_input_mode: 'Mode de saisie occupé',
|
||||
settings_desc_busy_input_mode: 'Contrôle ce qui se passe lorsque vous envoyez un message pendant l\'exécution de l\'agent. La file d\'attente attend ; L\'interruption s\'annule et recommence ; Steer injecte une correction à mi-tour sans interrompre (revient en file d\'attente lorsque l\'agent ou le flux est indisponible).',
|
||||
settings_busy_input_mode_queue: 'Suivi de file d\'attente',
|
||||
settings_busy_input_mode_interrupt: 'Interrompre le tour en cours',
|
||||
settings_busy_input_mode_steer: 'Direction (correction à mi-virage)',
|
||||
status_profile: 'Profil',
|
||||
status_hermes_home: 'Maison Hermès',
|
||||
status_started: 'Commencé',
|
||||
status_updated: 'Mis à jour',
|
||||
status_tokens: 'Jetons',
|
||||
status_ephemeral: 'Instantané éphémère – non enregistré dans l’historique des transcriptions.',
|
||||
status_no_tokens: 'Aucun jeton utilisé',
|
||||
status_unknown: 'Inconnu',
|
||||
usage_personality_none: 'aucun',
|
||||
session_toolsets: 'Ensembles d\'outils de session',
|
||||
session_toolsets_desc: 'Restreindre les outils disponibles pour cette session (vide = utiliser la configuration globale)',
|
||||
session_toolsets_global: 'Global (par défaut)',
|
||||
session_toolsets_custom: 'Coutume',
|
||||
session_toolsets_placeholder: 'outil1, outil2, …',
|
||||
session_toolsets_apply: 'Appliquer',
|
||||
session_toolsets_clear: 'Effacer (utiliser global)',
|
||||
session_toolsets_applied: 'Ensembles d\'outils mis à jour',
|
||||
session_toolsets_cleared: 'Ensembles d\'outils effacés - à l\'aide de la configuration globale',
|
||||
session_toolsets_failed: 'Échec de la mise à jour des ensembles d\'outils :',
|
||||
no_personalities: 'Aucune personnalité trouvée (ajoutez-les à ~/.hermes/personalities/)',
|
||||
available_personalities: 'Personnalités disponibles :',
|
||||
personality_switch_hint: '\\n\\nUtilisez `/personality <name>` pour changer, ou `/personality none` pour effacer.',
|
||||
personalities_load_failed: 'Échec du chargement des personnalités',
|
||||
personality_cleared: 'Personnalité effacée',
|
||||
personality_set: 'Personnalité:',
|
||||
failed_colon: 'Échoué:',
|
||||
no_workspace: 'Pas d\'espace de travail',
|
||||
terminal_open_title: 'Terminal d\'espace de travail ouvert',
|
||||
terminal_no_workspace_title: 'Sélectionnez un espace de travail pour ouvrir le terminal',
|
||||
terminal_title: 'Terminal',
|
||||
terminal_clear: 'Clair',
|
||||
terminal_copy_output: 'Copier la sortie',
|
||||
terminal_restart: 'Redémarrage',
|
||||
terminal_collapse: 'Effondrement',
|
||||
terminal_expand: 'Développer',
|
||||
terminal_close: 'Fermer',
|
||||
terminal_input_placeholder: 'Exécutez une commande...',
|
||||
terminal_start_failed: 'Échec du démarrage du terminal :',
|
||||
terminal_input_failed: 'L\'entrée du terminal a échoué :',
|
||||
terminal_copy_failed: 'Échec de la copie :',
|
||||
terminal_error: 'Erreur de terminal',
|
||||
workspace_empty_no_path: 'Aucun espace de travail sélectionné. Définissez un espace de travail dans Paramètres \u2192 Espace de travail pour parcourir les fichiers.',
|
||||
workspace_empty_dir: 'Cet espace de travail est vide.',
|
||||
workspace_show_hidden_files: 'Afficher les fichiers cachés',
|
||||
workspace_show_hidden_files_desc: 'Incluez .DS_Store, .git, node_modules et d\'autres fichiers cachés/système dans l\'arborescence des fichiers.',
|
||||
workspace_hidden_files_visible: 'caché visible',
|
||||
workspace_hidden_files_visible_title: 'Les fichiers cachés sont visibles – cliquez pour les options',
|
||||
workspace_options: 'Options de l\'espace de travail',
|
||||
dialog_confirm_title: 'Confirmer l\'action',
|
||||
dialog_prompt_title: 'Entrez une valeur',
|
||||
dialog_confirm_btn: 'Confirmer',
|
||||
unsaved_confirm: 'Vous avez des modifications non enregistrées dans l\'aperçu. Supprimer et naviguer ?',
|
||||
discard: 'Jeter',
|
||||
save: 'Sauvegarder',
|
||||
edit: 'Modifier',
|
||||
clear: 'Clair',
|
||||
create: 'Créer',
|
||||
remove: 'Retirer',
|
||||
save_title: 'Enregistrer les modifications',
|
||||
edit_title: 'Modifier ce fichier',
|
||||
saved: 'Enregistré',
|
||||
save_failed: 'Échec de l\'enregistrement :',
|
||||
image_load_failed: 'Impossible de charger l\'image',
|
||||
file_open_failed: 'Impossible d\'ouvrir le fichier',
|
||||
double_click_rename: 'Double-cliquez pour renommer',
|
||||
renamed_to: 'Renommé en',
|
||||
rename_failed: 'Échec du changement de nom :',
|
||||
delete_title: 'Supprimer',
|
||||
rename_title: 'Rebaptiser',
|
||||
rename_prompt: 'Nouveau nom :',
|
||||
deleted: 'Supprimé',
|
||||
delete_failed: 'Échec de la suppression :',
|
||||
reveal_in_finder: 'Révéler dans le gestionnaire de fichiers',
|
||||
reveal_failed: 'Échec de la révélation :',
|
||||
copy_file_path: 'Copier le chemin du fichier',
|
||||
path_copied: 'Chemin du fichier copié dans le presse-papiers',
|
||||
path_copy_failed: 'Échec de la copie du chemin :',
|
||||
session_rename: 'Renommer la conversation',
|
||||
session_rename_desc: 'Modifier le titre de cette conversation',
|
||||
new_file_prompt: 'Nouveau nom de fichier (par exemple notes.md) :',
|
||||
project_name_prompt: 'Nom du projet :',
|
||||
created: 'Créé',
|
||||
create_failed: 'Échec de la création :',
|
||||
new_folder_prompt: 'Nouveau nom de dossier :',
|
||||
folder_created: 'Dossier créé',
|
||||
folder_create_failed: 'Échec de la création du dossier :',
|
||||
remove_title: 'Retirer',
|
||||
empty_dir: '(vide)',
|
||||
upload_failed: 'Échec du téléchargement :',
|
||||
session_pin: 'Épingler la conversation',
|
||||
session_unpin: 'Désépingler la conversation',
|
||||
session_pin_desc: 'Gardez cette conversation en haut',
|
||||
session_unpin_desc: 'Supprimer de l\'épinglé',
|
||||
session_pin_failed: 'Échec de la broche :',
|
||||
session_move_project: 'Passer au projet',
|
||||
session_move_project_desc_has: 'Changer le projet pour cette conversation',
|
||||
session_move_project_desc_none: 'Attribuer un projet à cette conversation',
|
||||
session_archive: 'Archiver une conversation',
|
||||
session_restore: 'Restaurer la conversation',
|
||||
session_archive_desc: 'Masquer cette conversation jusqu\'à ce que l\'archive soit affichée',
|
||||
session_archive_worktree_desc: 'Cachez cette conversation ; garder son arbre de travail sur le disque',
|
||||
session_restore_desc: 'Ramenez cette conversation dans la liste principale',
|
||||
session_archived: 'Séance archivée',
|
||||
session_archived_worktree: 'Séance archivée. Worktree reste sur le disque.',
|
||||
session_restored: 'Session restaurée',
|
||||
session_archive_failed: 'Échec de l\'archivage :',
|
||||
session_duplicate: 'Conversation en double',
|
||||
session_duplicate_desc: 'Créer une copie avec le même espace de travail et le même modèle',
|
||||
session_duplicated: 'Session dupliquée',
|
||||
session_duplicate_failed: 'Échec de la duplication :',
|
||||
session_stop_response: 'Arrêter la réponse',
|
||||
session_stop_response_desc: 'Annuler la réponse en cours pour cette conversation',
|
||||
session_delete: 'Supprimer la conversation',
|
||||
session_delete_desc: 'Supprimer définitivement cette conversation',
|
||||
session_delete_confirm: 'Supprimer cette conversation ?',
|
||||
session_delete_worktree_desc: 'Supprimez uniquement la conversation WebUI ; garder l\'arbre de travail sur le disque',
|
||||
session_deleted: 'Conversation supprimée',
|
||||
session_deleted_worktree: 'Conversation supprimée. Worktree reste sur le disque.',
|
||||
session_select_mode: 'Sélectionner',
|
||||
session_select_mode_desc: 'Sélectionnez les conversations à gérer par lots',
|
||||
session_select_all: 'Tout sélectionner',
|
||||
session_deselect_all: 'Tout désélectionner',
|
||||
session_selected_count: '{0} sélectionné',
|
||||
session_batch_archive: 'Archive',
|
||||
session_batch_delete: 'Supprimer',
|
||||
session_batch_move: 'Passer au projet',
|
||||
session_batch_delete_confirm: 'Supprimer {0} conversations ?',
|
||||
session_batch_archive_confirm: 'Archiver {0} conversations ?',
|
||||
session_batch_delete_worktree_confirm: 'Supprimer {0} conversations ? {1} conversations basées sur Worktree laisseront leurs répertoires Worktree sur le disque.',
|
||||
session_batch_archive_worktree_confirm: 'Archiver {0} conversations ? {1} conversations basées sur un arbre de travail conserveront leurs répertoires d\'arbre de travail sur le disque.',
|
||||
session_no_selection: 'Aucune conversation sélectionnée',
|
||||
settings_heading_title: 'Centre de contrôle',
|
||||
settings_heading_subtitle: 'Préférences, outils de conversation et contrôles système.',
|
||||
settings_section_conversation_title: 'Conversation',
|
||||
settings_section_appearance_title: 'Apparence',
|
||||
settings_section_appearance_meta: 'Thème, couleurs d\'accent et style visuel.',
|
||||
settings_section_preferences_title: 'Préférences',
|
||||
settings_section_preferences_meta: 'Paramètres par défaut et comportement de l\'interface utilisateur pour Hermes Web UI.',
|
||||
settings_section_system_title: 'Système',
|
||||
settings_section_system_meta: 'Version de l\'instance et contrôles d\'accès.',
|
||||
settings_check_now: 'Vérifiez maintenant',
|
||||
settings_checking: 'Vérification\u2026',
|
||||
settings_up_to_date: 'À jour \u2713',
|
||||
settings_updates_available: '{count} mises à jour disponibles',
|
||||
settings_updates_disabled: 'Vérifications de mise à jour désactivées',
|
||||
settings_update_check_failed: 'La vérification de la mise à jour a échoué',
|
||||
settings_label_workspace_panel_open: 'Garder le panneau de l\'espace de travail ouvert par défaut',
|
||||
settings_desc_workspace_panel_open: 'Lorsqu\'il est activé, le panneau de l\'espace de travail/navigateur de fichiers s\'ouvre automatiquement à chaque nouvelle session. Vous pouvez toujours le fermer manuellement à tout moment.',
|
||||
settings_label_session_jump_buttons: 'Afficher les boutons de saut de session',
|
||||
settings_desc_session_jump_buttons: 'Affichez les boutons flottants de début et de fin lors de la lecture de longs historiques de session.',
|
||||
settings_label_session_endless_scroll: 'Charger les anciens messages en faisant défiler vers le haut',
|
||||
settings_desc_session_endless_scroll: 'Lorsqu\'ils sont activés, les anciens messages se chargent automatiquement lorsque vous faites défiler vers le haut. Lorsqu\'il est désactivé, utilisez le bouton des messages plus anciens.',
|
||||
open_in_browser: 'Ouvrir dans le navigateur',
|
||||
settings_dropdown_conversation: 'Conversation',
|
||||
settings_dropdown_appearance: 'Apparence',
|
||||
settings_dropdown_preferences: 'Préférences',
|
||||
settings_dropdown_providers: 'Fournisseurs',
|
||||
settings_dropdown_system: 'Système',
|
||||
settings_tab_conversation: 'Conversation',
|
||||
settings_tab_appearance: 'Apparence',
|
||||
settings_tab_preferences: 'Préférences',
|
||||
settings_tab_system: 'Système',
|
||||
settings_title: 'Paramètres',
|
||||
settings_save_btn: 'Enregistrer les paramètres',
|
||||
settings_label_model: 'Modèle par défaut',
|
||||
settings_desc_model: 'Utilisé pour les nouvelles conversations. Les conversations existantes conservent leur modèle sélectionné.',
|
||||
settings_label_send_key: 'Envoyer la clé',
|
||||
settings_label_theme: 'Thème',
|
||||
settings_label_skin: 'Peau',
|
||||
settings_label_font_size: 'Taille de la police',
|
||||
font_size_small: 'Petit',
|
||||
font_size_default: 'Défaut',
|
||||
font_size_large: 'Grand',
|
||||
settings_autosave_saving: 'Économie…',
|
||||
settings_autosave_saved: 'Enregistré',
|
||||
settings_autosave_failed: 'Échec de l\'enregistrement',
|
||||
settings_autosave_retry: 'Réessayer',
|
||||
settings_label_language: 'Langue',
|
||||
settings_label_token_usage: 'Afficher l\'utilisation du jeton',
|
||||
settings_label_sidebar_density: 'Densité de la barre latérale',
|
||||
cmd_reasoning: 'Basculez la visibilité de la réflexion (afficher/masquer), définir le niveau d\'effort ou vérifier l\'état actuel',
|
||||
settings_label_external_sessions: 'Afficher les sessions non-WebUI',
|
||||
settings_label_sync_insights: 'Synchroniser avec les insights',
|
||||
settings_label_check_updates: 'Vérifier les mises à jour',
|
||||
settings_label_bot_name: 'Nom de l\'assistant',
|
||||
settings_label_password: 'Mot de passe d\'accès',
|
||||
settings_saved: 'Paramètres enregistrés',
|
||||
settings_save_failed: 'Échec de l\'enregistrement :',
|
||||
settings_load_failed: 'Échec du chargement des paramètres :',
|
||||
settings_saved_pw: 'Paramètres enregistrés : protection par mot de passe activée et ce navigateur reste connecté',
|
||||
settings_saved_pw_updated: 'Paramètres enregistrés – mot de passe mis à jour',
|
||||
login_title: 'Se connecter',
|
||||
login_subtitle: 'Entrez votre mot de passe pour continuer',
|
||||
login_placeholder: 'Mot de passe',
|
||||
login_btn: 'Se connecter',
|
||||
login_invalid_pw: 'Mot de passe invalide',
|
||||
login_conn_failed: 'La connexion a échoué',
|
||||
dialog_confirm_title: 'Confirmer l\'action',
|
||||
dialog_prompt_title: 'Entrez une valeur',
|
||||
dialog_confirm_btn: 'Confirmer',
|
||||
discard: 'Jeter',
|
||||
clear: 'Clair',
|
||||
create: 'Créer',
|
||||
remove: 'Retirer',
|
||||
project_name_prompt: 'Nom du projet :',
|
||||
tab_chat: 'Chat',
|
||||
tab_tasks: 'Tâches',
|
||||
tab_skills: 'Compétences',
|
||||
tab_memory: 'Mémoire',
|
||||
tab_workspaces: 'Espaces',
|
||||
tab_profiles: 'Profils',
|
||||
tab_kanban: 'Kanban',
|
||||
kanban_board: 'Conseil',
|
||||
kanban_visible_tasks: '{0} tâches visibles',
|
||||
kanban_search_tasks: 'Tâches de recherche',
|
||||
kanban_all_assignees: 'Tous les assignés',
|
||||
kanban_all_tenants: 'Tous les locataires',
|
||||
kanban_include_archived: 'Inclure archivé',
|
||||
kanban_no_matching_tasks: 'Aucune tâche correspondante',
|
||||
kanban_no_data: 'Aucune donnée Kanban',
|
||||
kanban_work_queue_hint: 'Il s\'agit de la file d\'attente de travail de l\'agent Hermes. Créez ou triez une tâche, attribuez-la, déplacez-la vers Prêt, puis laissez le répartiteur la réclamer.',
|
||||
kanban_unavailable: 'Kanban indisponible',
|
||||
kanban_read_only: 'Vue en lecture seule',
|
||||
kanban_empty: 'Vide',
|
||||
kanban_task: 'Tâche',
|
||||
kanban_no_description: 'Pas de description',
|
||||
kanban_refresh: 'Rafraîchir',
|
||||
kanban_status_triage: 'Triage',
|
||||
kanban_status_todo: 'Faire',
|
||||
kanban_status_ready: 'Prêt',
|
||||
kanban_status_running: 'En cours d\'exécution',
|
||||
kanban_status_blocked: 'Bloqué',
|
||||
kanban_status_done: 'Fait',
|
||||
kanban_comments_count: 'Commentaires ({0})',
|
||||
kanban_events_count: 'Événements ({0})',
|
||||
kanban_links: 'Links',
|
||||
kanban_parents: 'Parents',
|
||||
kanban_children: 'Enfants',
|
||||
kanban_runs_count: 'Exécutions ({0})',
|
||||
kanban_no_comments: 'Sans commentaires',
|
||||
kanban_no_events: 'Aucun événement',
|
||||
kanban_no_runs: 'Aucune course',
|
||||
kanban_title: 'Titre',
|
||||
kanban_description: 'Description',
|
||||
kanban_description_placeholder: 'Facultatif : ce qui doit se passer, les critères d\'acceptation, les liens',
|
||||
kanban_status: 'Statut',
|
||||
kanban_assignee: 'Cessionnaire',
|
||||
kanban_assignee_placeholder: 'Facultatif – laisser vide pour tout travailleur',
|
||||
kanban_tenant: 'Locataire',
|
||||
kanban_tenant_placeholder: 'Facultatif – slug de projet ou d\'équipe',
|
||||
kanban_priority: 'Priorité',
|
||||
kanban_priority_hint: 'Les nombres plus élevés sont exécutés en premier. Par défaut 0.',
|
||||
kanban_title_required: 'Le titre est requis.',
|
||||
kanban_new_task: 'Nouvelle tâche',
|
||||
kanban_edit_task: 'Modifier la tâche',
|
||||
kanban_status_original_hint: 'Statut actuel : {0}. Cette boîte de dialogue prend uniquement en charge les modifications Triage/Todo/Ready.',
|
||||
kanban_run_dispatcher: 'Exécuter le répartiteur',
|
||||
kanban_run_dispatcher_confirm: 'Cela réclamera des tâches prêtes sur ce tableau et générera des sous-processus de travail (un par tâche, jusqu\'à 8 par clic). Continuer?',
|
||||
kanban_assignee_profiles_label: 'Profils Hermès',
|
||||
kanban_assignee_other_label: 'Autre (voies CLI / profils supprimés)',
|
||||
kanban_assignee_unassigned: '— Non attribué (ne s\'exécutera pas automatiquement) —',
|
||||
kanban_ready_needs_assignee: 'Vous avez sélectionné Non attribué + Prêt. Le répartiteur ignorera cette tâche. Soumettez à nouveau pour confirmer ou choisissez un profil.',
|
||||
kanban_dispatch_preview_prefix: 'Aperçu :',
|
||||
kanban_dispatch_run_prefix: 'Expédié :',
|
||||
kanban_dispatch_spawned: 'engendré',
|
||||
kanban_dispatch_promoted: 'promu',
|
||||
kanban_dispatch_reclaimed: 'récupéré',
|
||||
kanban_dispatch_skipped_unassigned: 'ignoré (pas de destinataire)',
|
||||
kanban_dispatch_skipped_nonspawnable: 'ignoré (profil inconnu)',
|
||||
kanban_dispatch_auto_blocked: 'auto-bloqué',
|
||||
kanban_dispatch_timed_out: 'expiré',
|
||||
kanban_dispatch_crashed: 's\'est écrasé',
|
||||
kanban_add_comment: 'Ajouter un commentaire',
|
||||
kanban_status_archived: 'Archivé',
|
||||
tab_todos: 'Toutes les tâches',
|
||||
tab_insights: 'Connaissances',
|
||||
tab_dashboard: 'Tableau de bord Hermès',
|
||||
dashboard_loopback_warning: 'Le tableau de bord est en boucle uniquement sur le serveur. Naviguez depuis le serveur lui-même ou redémarrez-le avec --host 0.0.0.0 (non sécurisé).',
|
||||
tab_logs: 'Journaux',
|
||||
tab_settings: 'Paramètres',
|
||||
new_conversation: 'Nouvelle conversation',
|
||||
filter_conversations: 'Filtrer les conversations...',
|
||||
session_time_unknown: 'Inconnu',
|
||||
session_time_last_week: '1w',
|
||||
session_time_bucket_today: 'Aujourd\'hui',
|
||||
session_time_bucket_yesterday: 'Hier',
|
||||
session_time_bucket_this_week: 'Cette semaine',
|
||||
session_time_bucket_last_week: 'La semaine dernière',
|
||||
session_time_bucket_older: 'Plus vieux',
|
||||
scheduled_jobs: 'Travaux planifiés',
|
||||
new_job: 'Nouvel emploi',
|
||||
loading: 'Chargement...',
|
||||
search_skills: 'Compétences de recherche...',
|
||||
new_skill: 'Nouvelle compétence',
|
||||
personal_memory: 'Mémoire personnelle',
|
||||
current_task_list: 'Liste de tâches actuelle',
|
||||
logs_title: 'Journaux',
|
||||
logs_file: 'Déposer',
|
||||
logs_tail: 'Queue',
|
||||
logs_auto_refresh: 'Actualisation automatique (5s)',
|
||||
logs_wrap: 'Enrouler les lignes',
|
||||
logs_copy_all: 'Copier tout',
|
||||
logs_empty: 'Aucune ligne de journal pour l\'instant.',
|
||||
logs_loading: 'Chargement des journaux…',
|
||||
logs_load_failed: 'Les journaux n\'ont pas pu être chargés',
|
||||
logs_status_idle: 'Choisissez un fichier journal pour afficher les lignes récentes.',
|
||||
logs_no_mtime: 'pas encore écrit',
|
||||
logs_truncated_hint: 'Afficher la fin d\'un gros fichier journal ; les octets plus anciens ont été ignorés pour limiter la mémoire.',
|
||||
logs_copied: 'Journaux copiés',
|
||||
logs_severity: 'Gravité',
|
||||
logs_severity_all: 'Tous',
|
||||
logs_severity_errors: 'Erreurs',
|
||||
logs_severity_warnings: 'Avertissements+',
|
||||
logs_filter_active: 'affiché (filtre actif)',
|
||||
insights_title: 'Analyse d\'utilisation',
|
||||
insights_sessions: 'Séances',
|
||||
insights_messages: 'Messages',
|
||||
insights_tokens: 'Jetons',
|
||||
insights_cost: 'Coût estimé',
|
||||
insights_no_cost: 'N / A',
|
||||
insights_models: 'Modèles',
|
||||
insights_activity_by_day: 'Activité par jour',
|
||||
insights_activity_by_hour: 'Activité par heure',
|
||||
insights_peak_hour: 'Pic : {heure}',
|
||||
insights_token_breakdown: 'Répartition des jetons',
|
||||
insights_input_tokens: 'Saisir',
|
||||
insights_output_tokens: 'Sortir',
|
||||
insights_total: 'Total',
|
||||
insights_daily_tokens: 'Jetons quotidiens',
|
||||
insights_model_name: 'Modèle',
|
||||
insights_model_sessions: 'Séances',
|
||||
insights_model_tokens: 'Jetons',
|
||||
insights_model_cost: 'Coût',
|
||||
insights_model_share: 'Partager',
|
||||
insights_no_usage_data: 'Aucune donnée d\'utilisation pour l\'instant',
|
||||
insights_footer: 'Affichage des données des {days} derniers jours',
|
||||
workspace_desc: 'Ajoutez et changez d\'espace de travail pour vos sessions.',
|
||||
session_lineage_segment_untitled: 'Segment sans titre',
|
||||
session_lineage_segment_open: 'Segment de lignée ouverte',
|
||||
new_profile: 'Nouveau profil',
|
||||
transcript: 'Transcription',
|
||||
download_transcript: 'Télécharger en Markdown',
|
||||
import: 'Importer',
|
||||
settings_label_sound: 'Son de notification',
|
||||
settings_desc_sound: 'Jouez un son lorsque l\'assistant termine une réponse.',
|
||||
tts_listen: 'Écouter',
|
||||
tts_not_supported: 'La synthèse vocale n\'est pas prise en charge dans ce navigateur.',
|
||||
settings_label_tts: 'Text-to-Speech pour les réponses',
|
||||
settings_desc_tts: 'Affichez un bouton haut-parleur sur chaque message de l\'assistant pour le lire à haute voix à l\'aide de la synthèse vocale de votre navigateur.',
|
||||
settings_label_tts_auto_read: 'Lecture automatique des réponses à haute voix',
|
||||
settings_desc_tts_auto_read: 'Prononcez automatiquement chaque nouvelle réponse de l\'assistant lorsqu\'elle est terminée. S\'arrête lorsque vous commencez à taper.',
|
||||
settings_label_voice_mode: 'Bouton du mode vocal mains libres',
|
||||
settings_desc_voice_mode: 'Affichez le bouton du mode vocal (forme d\'onde audio) à côté du micro de dictée. Vous permet de parler naturellement : Hermes envoie automatiquement après une pause et lit les réponses à haute voix. Nécessite un navigateur prenant en charge à la fois la reconnaissance vocale et TTS.',
|
||||
settings_label_tts_voice: 'Voix',
|
||||
settings_desc_tts_voice: 'Voix préférée. Rempli à partir des voix disponibles dans votre navigateur.',
|
||||
settings_label_tts_rate: 'Taux de parole',
|
||||
settings_label_tts_pitch: 'Emplacement du discours',
|
||||
settings_label_notifications: 'Notifications du navigateur',
|
||||
settings_desc_notifications: 'Afficher une notification système lorsqu\'une réponse est terminée alors que l\'application est en arrière-plan.',
|
||||
settings_desc_token_usage: 'Affiche le nombre de jetons d’entrée/sortie sous chaque réponse de l’assistant. Également basculé avec /usage.',
|
||||
settings_label_api_redact: 'Expurger les données sensibles dans les réponses API',
|
||||
settings_desc_api_redact: 'Les utilisateurs auto-hébergés peuvent désactiver pour des raisons de transparence (non recommandé pour les instances partagées).',
|
||||
settings_sidebar_density_compact: 'Compact',
|
||||
settings_sidebar_density_detailed: 'Détaillé',
|
||||
settings_desc_sidebar_density: 'Contrôle la quantité de métadonnées que la liste des sessions affiche dans la barre latérale gauche.',
|
||||
settings_label_auto_title_refresh: 'Actualisation adaptative du titre',
|
||||
settings_auto_title_refresh_off: 'Désactivé',
|
||||
settings_auto_title_refresh_5: 'Tous les 5 échanges',
|
||||
settings_auto_title_refresh_10: 'Tous les 10 échanges',
|
||||
settings_auto_title_refresh_20: 'Tous les 20 échanges',
|
||||
settings_desc_auto_title_refresh: 'Régénère automatiquement le titre de la session en fonction du dernier échange, le gardant ainsi pertinent à mesure que la conversation évolue. Nécessite la configuration d’un modèle de génération de titres LLM.',
|
||||
settings_desc_external_sessions: 'Affichez les conversations de CLI, Telegram, Discord, Slack et d\'autres chaînes dans la liste des sessions. Cliquez pour importer et continuer.',
|
||||
settings_desc_sync_insights: 'Met en miroir l\'utilisation du jeton WebUI dans state.db afin que Hermes /insights inclut les données de session du navigateur. Désactivé par défaut.',
|
||||
settings_desc_check_updates: 'Afficher une bannière lorsque des versions plus récentes de WebUI ou de l\'agent sont disponibles. Exécute périodiquement une récupération git en arrière-plan.',
|
||||
settings_desc_bot_name: 'Nom d’affichage de l’assistant dans l’interface utilisateur. Par défaut, Hermès.',
|
||||
settings_desc_password: 'Saisissez un nouveau mot de passe pour le définir ou le modifier. Laissez vide pour conserver le paramètre actuel.',
|
||||
password_placeholder: 'Entrez le nouveau mot de passe…',
|
||||
password_env_var_locked: 'La variable d\'environnement HERMES_WEBUI_PASSWORD est actuellement définie et est prioritaire. Désactivez-le et redémarrez le serveur pour gérer le mot de passe à partir d\'ici.',
|
||||
password_env_var_locked_placeholder: 'Verrouillé : la variable d\'environnement HERMES_WEBUI_PASSWORD est définie',
|
||||
disable_auth: 'Désactiver l\'authentification',
|
||||
sign_out: 'Se déconnecter',
|
||||
providers_tab_title: 'Fournisseurs',
|
||||
providers_section_title: 'Fournisseurs',
|
||||
providers_section_meta: 'Gérez les clés API pour les fournisseurs d\'IA. Les modifications prennent effet immédiatement.',
|
||||
providers_status_configured: 'Clé API configurée',
|
||||
providers_status_not_configured: 'Aucune clé API',
|
||||
providers_status_oauth: 'OAuth',
|
||||
providers_status_api_key: 'Clé API',
|
||||
providers_status_not_configured_label: 'Non configuré',
|
||||
providers_oauth_hint: 'Authentifié via OAuth. Aucune clé API nécessaire.',
|
||||
providers_oauth_config_yaml_hint: 'Jeton configuré via config.yaml. Pour mettre à jour, modifiez la section des fournisseurs dans votre config.yaml ou exécutez Hermes Auth.',
|
||||
providers_oauth_not_configured_hint: 'Non authentifié. Exécutez Hermes Auth dans le terminal pour configurer ce fournisseur.',
|
||||
providers_save: 'Sauvegarder',
|
||||
providers_remove: 'Retirer',
|
||||
providers_saving: 'Économie…',
|
||||
providers_removing: 'Suppression…',
|
||||
providers_enter_key: 'Veuillez saisir une clé API',
|
||||
providers_empty: 'Aucun fournisseur configurable trouvé.',
|
||||
providers_key_updated: 'Clé API enregistrée',
|
||||
providers_key_removed: 'Clé API supprimée',
|
||||
providers_key_placeholder_new: 'sk-...',
|
||||
providers_key_placeholder_replace: 'Entrez une nouvelle clé à remplacer…',
|
||||
cancel: 'Annuler',
|
||||
create_job: 'Créer un emploi',
|
||||
save_skill: 'Enregistrer la compétence',
|
||||
editing: 'Édition',
|
||||
empty_title: 'En quoi puis-je aider ?',
|
||||
empty_subtitle: 'Demandez n\'importe quoi, exécutez des commandes, explorez des fichiers ou gérez vos tâches planifiées.',
|
||||
suggest_files: 'Quels fichiers se trouvent dans cet espace de travail ?',
|
||||
suggest_schedule: 'Quel est mon programme aujourd\'hui ?',
|
||||
suggest_plan: 'Aide-moi à planifier un petit projet.',
|
||||
onboarding_badge: 'PREMIÈRE EXÉCUTION',
|
||||
onboarding_title: 'Bienvenue dans l\'interface utilisateur Web Hermès',
|
||||
onboarding_lead: 'Une configuration guidée rapide vérifiera Hermes, enregistrera une configuration réelle du fournisseur, choisira un espace de travail et un modèle et protégera éventuellement l\'application avec un mot de passe.',
|
||||
onboarding_back: 'Dos',
|
||||
onboarding_continue: 'Continuer',
|
||||
onboarding_skip: 'Ignorer la configuration',
|
||||
onboarding_skipped: 'Configuration ignorée – en utilisant la configuration existante.',
|
||||
onboarding_open: 'Hermès ouvert',
|
||||
onboarding_step_system_title: 'Vérification du système',
|
||||
onboarding_step_system_desc: 'Vérifiez la visibilité de l\'agent Hermes et de la configuration.',
|
||||
onboarding_step_setup_title: 'Configuration du fournisseur',
|
||||
onboarding_step_setup_desc: 'Enregistrez la configuration minimale du fournisseur Hermes.',
|
||||
onboarding_step_workspace_title: 'Espace de travail + modèle',
|
||||
onboarding_step_workspace_desc: 'Choisissez les valeurs par défaut pour les nouvelles sessions et le chat.',
|
||||
onboarding_step_password_title: 'Mot de passe facultatif',
|
||||
onboarding_step_password_desc: 'Protégez l’interface utilisateur Web avant de la partager.',
|
||||
onboarding_step_finish_title: 'Finition',
|
||||
onboarding_step_finish_desc: 'Vérifiez et entrez dans l\'application.',
|
||||
onboarding_notice_system_ready: 'L\'agent Hermes semble accessible depuis l\'interface utilisateur Web.',
|
||||
onboarding_notice_system_unavailable: 'Hermes Agent n’est pas encore entièrement disponible. Bootstrap peut l\'installer, mais la configuration du fournisseur peut toujours nécessiter un terminal.',
|
||||
onboarding_check_agent: 'Agent Hermès',
|
||||
onboarding_check_agent_ready: 'Détecté et importable',
|
||||
onboarding_check_agent_missing: 'Manquant ou partiellement importable',
|
||||
onboarding_check_password: 'Mot de passe',
|
||||
onboarding_check_password_enabled: 'Déjà activé',
|
||||
onboarding_check_password_disabled: 'Pas encore activé',
|
||||
onboarding_check_provider: 'Configuration du fournisseur',
|
||||
onboarding_check_provider_ready: 'Prêt à discuter',
|
||||
onboarding_check_provider_partial: 'Enregistré mais incomplet',
|
||||
onboarding_check_provider_pending: 'Vérification nécessaire',
|
||||
onboarding_config_file: 'Fichier de configuration :',
|
||||
onboarding_env_file: 'Fichier .env :',
|
||||
onboarding_unknown: 'Inconnu',
|
||||
onboarding_current_provider: 'Configuration actuelle :',
|
||||
onboarding_missing_imports: 'Importations manquantes :',
|
||||
onboarding_notice_setup_required: 'Choisissez ici un chemin de fournisseur simple. Les flux OAuth avancés appartiennent toujours à la CLI Hermes pour le moment.',
|
||||
onboarding_notice_setup_already_ready: 'Une configuration de fournisseur Hermes fonctionnelle est déjà détectée. Vous pouvez le conserver ou le remplacer ici.',
|
||||
onboarding_oauth_provider_ready_title: 'Fournisseur déjà authentifié',
|
||||
onboarding_oauth_provider_ready_body: 'Cette instance est configurée pour utiliser un fournisseur OAuth (<strong>{provider}</strong>) configuré via la CLI Hermes. Aucune clé API n\'est nécessaire ici - cliquez sur Continuer pour terminer la configuration.',
|
||||
onboarding_oauth_provider_not_ready_title: 'Fournisseur OAuth pas encore authentifié',
|
||||
onboarding_oauth_provider_not_ready_body: 'Cette instance est configurée pour utiliser <strong>{provider}</strong>, qui utilise OAuth plutôt qu\'une clé API. Exécutez <code>hermes auth</code> ou <code>hermes model</code> dans un terminal pour vous authentifier, puis rechargez l\'interface utilisateur Web.',
|
||||
onboarding_oauth_switch_hint: 'Ou choisissez un autre fournisseur ci-dessous pour passer à une configuration par clé API :',
|
||||
oauth_login_codex: 'Connectez-vous avec Codex (ChatGPT)',
|
||||
oauth_codex_step1: 'Étape 1 : Visitez cette URL et entrez le code',
|
||||
oauth_codex_step2: 'Étape 2 : Saisissez ce code sur la page',
|
||||
oauth_codex_polling: 'En attente d\'autorisation...',
|
||||
oauth_codex_success: 'Connexion au Codex OAuth réussie !',
|
||||
oauth_codex_error: 'Échec de la connexion OAuth',
|
||||
oauth_codex_expired: 'Code expiré, veuillez réessayer',
|
||||
onboarding_notice_workspace: 'Ces valeurs réutilisent les mêmes API de paramètres que l\'application normale.',
|
||||
onboarding_workspace_label: 'Espace de travail',
|
||||
onboarding_workspace_or_path: 'Ou entrez un chemin d\'espace de travail',
|
||||
onboarding_workspace_placeholder: '/accueil/vous/espace de travail',
|
||||
onboarding_provider_label: 'Mode configuration',
|
||||
onboarding_quick_setup_badge: 'configuration rapide',
|
||||
provider_category_easy_start: 'Démarrage facile',
|
||||
provider_category_self_hosted: 'Ouvert / auto-hébergé',
|
||||
provider_category_specialized: 'Spécialisé',
|
||||
onboarding_api_key_label: 'Clé API',
|
||||
onboarding_api_key_placeholder: 'Laisser vide pour conserver une clé enregistrée existante',
|
||||
onboarding_api_key_label_optional: 'Clé API (facultatif)',
|
||||
onboarding_api_key_placeholder_optional: 'Laisser vide pour les serveurs sans clé',
|
||||
onboarding_api_key_help_keyless: 'La plupart des installations LM Studio / Ollama / vLLM s\'exécutent sans clé — laissez ce champ vide si votre serveur ne nécessite pas d\'authentification. Utilisez le bouton Tester la connexion pour vérifier.',
|
||||
onboarding_api_key_help_prefix: 'Enregistré comme secret dans votre fichier Hermes .env en utilisant',
|
||||
onboarding_base_url_label: 'URL de base',
|
||||
onboarding_base_url_placeholder: 'https://votre-endpoint.example/v1',
|
||||
onboarding_base_url_help: 'Utilisez-le pour les routeurs compatibles OpenAI, les serveurs auto-hébergés, LiteLLM, Ollama, LM Studio, vLLM ou des points de terminaison similaires.',
|
||||
onboarding_model_label: 'Modèle par défaut',
|
||||
onboarding_workspace_help: 'Choisissez le modèle qu\'Hermes doit utiliser pour les nouvelles discussions une fois la configuration terminée.',
|
||||
onboarding_custom_model_placeholder: 'votre-nom-de-modèle',
|
||||
onboarding_custom_model_help: 'Pour les points de terminaison personnalisés, entrez l’ID de modèle exact attendu par votre serveur.',
|
||||
onboarding_notice_password_enabled: 'Un mot de passe est déjà configuré. Saisissez-en un nouveau uniquement si vous souhaitez le remplacer.',
|
||||
onboarding_notice_password_recommended: 'Facultatif mais recommandé si vous exposez l\'interface utilisateur au-delà de localhost.',
|
||||
onboarding_password_label: 'Mot de passe (facultatif)',
|
||||
onboarding_password_placeholder: 'Laisser vide pour sauter',
|
||||
onboarding_password_help: 'Les mots de passe sont stockés via l\'API des paramètres existants et hachés côté serveur.',
|
||||
onboarding_notice_finish: 'Vous pouvez rouvrir les paramètres plus tard pour modifier tout cela.',
|
||||
onboarding_not_set: 'Non défini',
|
||||
onboarding_password_will_enable: 'Sera activé',
|
||||
onboarding_password_will_replace: 'Sera remplacé',
|
||||
onboarding_password_keep_existing: 'Conserver le mot de passe actuel',
|
||||
onboarding_password_remains_disabled: 'Restera invalide',
|
||||
onboarding_password_skipped: 'Ignoré pour l\'instant',
|
||||
onboarding_finish_help: 'La finition stocke <code>onboarding_completed</code> dans les paramètres et vous amène dans l\'application normale.',
|
||||
onboarding_error_choose_workspace: 'Choisissez un espace de travail avant de continuer.',
|
||||
onboarding_error_choose_model: 'Choisissez un modèle avant de continuer.',
|
||||
onboarding_error_provider_required: 'Choisissez un mode de configuration avant de continuer.',
|
||||
onboarding_error_base_url_required: 'L\'URL de base est requise pour les points de terminaison personnalisés.',
|
||||
onboarding_probe_test_button: 'Tester la connexion',
|
||||
onboarding_probe_probing: 'Test de connexion…',
|
||||
onboarding_probe_ok: 'Connecté. {n} modèle(s) disponible(s).',
|
||||
onboarding_probe_error_generic: 'Impossible d\'atteindre l\'URL de base configurée.',
|
||||
onboarding_probe_error_invalid_url: 'L\'URL de base doit commencer par http:// ou https://.',
|
||||
onboarding_probe_error_dns: 'Impossible de résoudre l\'hôte. Vérifiez l\'URL ou utilisez l\'adresse IP de l\'hôte.',
|
||||
onboarding_probe_error_connect_refused: 'Connexion refusée : le serveur ne fonctionne peut-être pas à cette adresse. Depuis Docker, essayez l’adresse IP de l’hôte au lieu de localhost.',
|
||||
onboarding_probe_error_timeout: 'Le point final n’a pas répondu à temps. Vérifiez que le serveur est en cours d\'exécution et que l\'URL est correcte.',
|
||||
onboarding_probe_error_http_4xx: 'Le point de terminaison a renvoyé une erreur client. Vérifiez l\'authentification et le chemin de l\'URL (se termine généralement par /v1).',
|
||||
onboarding_probe_error_http_5xx: 'Le point de terminaison a renvoyé une erreur de serveur. Vérifiez les journaux du serveur LM Studio / Ollama.',
|
||||
onboarding_probe_error_parse: 'Le point de terminaison n’a pas renvoyé une liste de modèles sous la forme attendue. Vérifiez que l\'URL pointe vers la racine de l\'API compatible OpenAI.',
|
||||
onboarding_probe_error_unreachable: 'Impossible d\'atteindre l\'URL de base configurée.',
|
||||
onboarding_error_probe_failed: 'Impossible de valider l\'URL de base configurée.',
|
||||
onboarding_error_workspace_required: 'Un espace de travail est requis.',
|
||||
onboarding_error_model_required: 'Un modèle est requis.',
|
||||
onboarding_complete: 'Intégration terminée',
|
||||
error_prefix: 'Erreur:',
|
||||
not_available: 'N / A',
|
||||
never: 'jamais',
|
||||
add: 'Ajouter',
|
||||
add_failed: 'Échec de l\'ajout :',
|
||||
remove_failed: 'Échec de la suppression :',
|
||||
switch_failed: 'Échec du changement :',
|
||||
name_required: 'Le nom est requis',
|
||||
content_required: 'Le contenu est requis',
|
||||
view: 'Voir',
|
||||
dismiss: 'Rejeter',
|
||||
disable: 'Désactiver',
|
||||
cron_no_jobs: 'Aucune tâche planifiée trouvée.',
|
||||
cron_status_off: 'désactivé',
|
||||
cron_status_paused: 'en pause',
|
||||
cron_status_error: 'erreur',
|
||||
cron_status_active: 'actif',
|
||||
cron_status_running: 'courir\u2026',
|
||||
cron_status_needs_attention: 'a besoin d\'attention',
|
||||
cron_attention_desc: 'Cette tâche récurrente n\'a pas de prochaine exécution. Le planificateur n\'a peut-être pas réussi à calculer sa prochaine exécution.',
|
||||
cron_attention_croniter_hint: 'Il se peut que le package croniter soit manquant dans le runtime Gateway. Redémarrez la passerelle avec la prise en charge de cron, puis reprenez ce travail.',
|
||||
cron_attention_resume: 'Reprendre et recalculer',
|
||||
cron_jobs_project: 'Emplois Cron',
|
||||
cron_attention_run_once: 'Cours une fois maintenant',
|
||||
cron_attention_copy_diagnostics: 'Copier les diagnostics',
|
||||
cron_diagnostics_copied: 'Diagnostics Cron copiés',
|
||||
cron_next: 'Suivant',
|
||||
cron_last: 'Dernier',
|
||||
cron_run_now: 'Courez maintenant',
|
||||
cron_pause: 'Pause',
|
||||
cron_resume: 'CV',
|
||||
cron_job_name_placeholder: 'Nom du travail',
|
||||
cron_schedule_placeholder: 'Calendrier',
|
||||
cron_prompt_placeholder: 'Rapide',
|
||||
cron_last_output: 'Dernière sortie',
|
||||
cron_all_runs: 'Toutes les courses',
|
||||
cron_hide_runs: 'Masquer les courses',
|
||||
cron_no_runs_yet: '(pas encore de courses)',
|
||||
cron_schedule_required_example: 'Un horaire est requis (par exemple "0 9 * * *" ou "toutes les 1h")',
|
||||
cron_schedule_required: 'Un horaire est requis',
|
||||
cron_prompt_required: 'Une invite est requise',
|
||||
cron_job_created: 'Emploi créé',
|
||||
cron_duplicate: 'Double',
|
||||
cron_duplicated: 'Tâche dupliquée (en pause)',
|
||||
cron_job_triggered: 'Tâche déclenchée',
|
||||
cron_job_paused: 'Tâche suspendue',
|
||||
cron_job_resumed: 'Travail repris',
|
||||
cron_job_updated: 'Emploi mis à jour',
|
||||
cron_delete_confirm_title: 'Supprimer la tâche cron',
|
||||
cron_delete_confirm_message: 'Cela ne peut pas être annulé.',
|
||||
cron_job_deleted: 'Travail supprimé',
|
||||
status_failed: 'échoué',
|
||||
status_completed: 'complété',
|
||||
todos_no_active: 'Aucune liste de tâches active dans cette session.',
|
||||
clear_conversation_title: 'Conversation claire',
|
||||
clear_conversation_message: 'Effacer tous les messages ? Cela ne peut pas être annulé.',
|
||||
clear_failed: 'Échec de la suppression :',
|
||||
skills_no_match: 'Aucune compétence ne correspond.',
|
||||
linked_files: 'Fichiers liés',
|
||||
skill_load_failed: 'Impossible de charger la compétence :',
|
||||
skill_file_load_failed: 'Impossible de charger le fichier :',
|
||||
skill_name_required: 'Le nom de la compétence est requis',
|
||||
skill_updated: 'Compétence mise à jour',
|
||||
skill_created: 'Compétence créée',
|
||||
skill_deleted: 'Compétence supprimée',
|
||||
skill_delete_confirm: 'Supprimer la compétence "{0}" ?',
|
||||
skills_empty_title: 'Sélectionnez une compétence',
|
||||
skills_empty_sub: 'Choisissez une compétence dans la barre latérale pour afficher son contenu ou créez-en une nouvelle.',
|
||||
skills_edit: 'Modifier',
|
||||
skills_delete: 'Supprimer',
|
||||
skills_back_to: 'Retour à {0}',
|
||||
tasks_empty_title: 'Sélectionnez une tâche planifiée',
|
||||
tasks_empty_sub: 'Choisissez une tâche dans la barre latérale pour afficher ses détails et ses exécutions, ou créez-en une nouvelle.',
|
||||
workspaces_empty_title: 'Sélectionnez un espace',
|
||||
workspaces_empty_sub: 'Choisissez un espace dans la barre latérale pour afficher ses fichiers et paramètres, ou ajoutez-en un nouveau.',
|
||||
profiles_empty_title: 'Sélectionnez un profil',
|
||||
profiles_empty_sub: 'Choisissez un profil d\'agent dans la barre latérale pour afficher et modifier ses paramètres, ou créez-en un nouveau.',
|
||||
memory_notes_label: 'mémoire (notes)',
|
||||
memory_saved: 'Mémoire sauvegardée',
|
||||
my_notes: 'Mes notes',
|
||||
user_profile: 'Profil utilisateur',
|
||||
no_notes_yet: 'Aucune note pour l\'instant.',
|
||||
no_profile_yet: 'Pas encore de profil.',
|
||||
workspace_choose_path: 'Choisir le chemin de l\'espace de travail',
|
||||
workspace_choose_path_meta: 'Ajoutez un chemin validé et changez cette conversation',
|
||||
workspace_manage: 'Gérer les espaces de travail',
|
||||
workspace_manage_meta: 'Ouvrez le panneau Espaces',
|
||||
workspace_use_title: 'Utiliser dans la session en cours',
|
||||
workspace_use: 'Utiliser',
|
||||
workspace_add_path_placeholder: 'Ajouter un chemin d\'accès à l\'espace de travail (par exemple /home/user/my-project)',
|
||||
workspace_paths_validated_hint: 'Les chemins sont validés en tant que répertoires existants avant d\'être enregistrés.',
|
||||
workspace_drag_hint: 'Faites glisser pour réorganiser',
|
||||
workspace_reorder_failed: 'Échec de la réorganisation',
|
||||
workspace_added: 'Espace de travail ajouté',
|
||||
workspace_renamed: 'Espace de travail renommé',
|
||||
workspace_remove_confirm_title: 'Supprimer l\'espace de travail',
|
||||
workspace_removed: 'Espace de travail supprimé',
|
||||
workspace_switch_prompt_title: 'Changer d\'espace de travail',
|
||||
workspace_switch_prompt_message: 'Entrez un chemin d’accès absolu à l’espace de travail vers lequel ajouter et basculer cette conversation.',
|
||||
workspace_switch_prompt_confirm: 'Changer',
|
||||
workspace_switch_prompt_placeholder: '/Utilisateurs/vous/projet',
|
||||
workspace_not_added: 'L\'espace de travail n\'a pas été ajouté',
|
||||
workspace_already_saved: 'Espace de travail déjà enregistré : choisissez-le dans la liste',
|
||||
workspace_busy_switch: 'Impossible de changer d\'espace de travail pendant que l\'agent est en cours d\'exécution',
|
||||
discard_file_edits_title: 'Supprimer les modifications du fichier ?',
|
||||
discard_file_edits_message: 'Changer d’espace de travail supprimera les modifications de fichiers non enregistrées dans l’aperçu.',
|
||||
profiles_no_profiles: 'Aucun profil trouvé.',
|
||||
profile_api_keys_configured: 'Clés API configurées',
|
||||
profile_gateway_running: 'Passerelle en cours d\'exécution',
|
||||
profile_gateway_stopped: 'Passerelle arrêtée',
|
||||
profile_active: 'ACTIF',
|
||||
profile_no_configuration: 'Aucune configuration',
|
||||
profile_use: 'Utiliser',
|
||||
profile_switch_title: 'Passer à ce profil',
|
||||
profile_delete_title: 'Supprimer ce profil',
|
||||
profile_default_label: '(défaut)',
|
||||
profile_name_placeholder: 'Nom du profil (minuscules, a-z 0-9 tirets)',
|
||||
profile_clone_label: 'Cloner la configuration du profil actif',
|
||||
profile_base_url_placeholder: 'URL de base (facultatif, par exemple http://localhost:11434)',
|
||||
profile_api_key_placeholder: 'Clé API (facultatif)',
|
||||
manage_profiles: 'Gérer les profils',
|
||||
profiles_load_failed: 'Échec du chargement des profils',
|
||||
profile_name_rule: 'Lettres minuscules, chiffres, traits d\'union et traits de soulignement uniquement',
|
||||
profile_base_url_rule: 'L\'URL de base doit commencer par http:// ou https://',
|
||||
profile_delete_confirm_message: 'Toutes les sessions, configurations, compétences et mémoire de ce profil seront définitivement supprimées. Cela ne peut pas être annulé.',
|
||||
active_conversation_none: 'Aucune conversation active sélectionnée.',
|
||||
settings_unsaved_changes: 'Vous avez des modifications non enregistrées.',
|
||||
sign_out_failed: 'Échec de la déconnexion :',
|
||||
disable_auth_confirm_title: 'Désactiver la protection par mot de passe',
|
||||
disable_auth_confirm_message: 'Tout le monde pourra accéder à cette instance.',
|
||||
auth_disabled: 'Authentification désactivée – protection par mot de passe supprimée',
|
||||
disable_auth_failed: 'Échec de la désactivation de l\'authentification :',
|
||||
skill_name: 'Nom',
|
||||
skill_category: 'Catégorie',
|
||||
skill_category_placeholder: 'Facultatif, par ex. développeurs',
|
||||
skill_content: 'Contenu SKILL.md',
|
||||
skill_content_placeholder: 'Frontmatter YAML + corps de démarque',
|
||||
skill_rename_not_supported: 'Renommer une compétence n\'est pas pris en charge. Créez une nouvelle compétence et supprimez l\'ancienne pour la renommer.',
|
||||
skill_metadata: 'Métadonnées',
|
||||
cron_name_label: 'Nom',
|
||||
cron_name_placeholder: 'Facultatif',
|
||||
cron_schedule_label: 'Calendrier',
|
||||
cron_schedule_hint: 'Utilisez « toutes les heures » ou une expression cron pour les tâches récurrentes. Les durées nues comme « 30 m » s\'exécutent une fois.',
|
||||
cron_schedule_once_warning: 'Les formulaires de durée tels que « 30 m » s\'exécutent une fois et sont supprimés après l\'exécution. Utilisez « tous les 30 mois » pour conserver une tâche récurrente.',
|
||||
cron_prompt_label: 'Rapide',
|
||||
cron_deliver_label: 'Livrer la sortie à',
|
||||
cron_deliver_local: 'Local (enregistrer la sortie uniquement)',
|
||||
cron_profile_label: 'Profil',
|
||||
cron_profile_server_default: 'serveur par défaut',
|
||||
cron_profile_server_default_hint: 'Utilise le profil par défaut du serveur WebUI au moment de l\'exécution. Les tâches existantes sans profil conservent ce comportement hérité.',
|
||||
cron_toast_notifications_label: 'Toasts d’achèvement',
|
||||
cron_toast_notifications_hint: 'Montrez un toast lorsque ce cron se termine. Le badge Tâches et le marqueur de nouvelle exécution sont toujours mis à jour lorsque cette option est désactivée.',
|
||||
cron_toast_notifications_enabled: 'Activé',
|
||||
cron_toast_notifications_disabled: 'Désactivé',
|
||||
cron_skills_label: 'Compétences',
|
||||
cron_skills_placeholder: 'Ajouter des compétences (facultatif)…',
|
||||
cron_skills_edit_hint: 'La liste de compétences n\'est pas modifiable après la création.',
|
||||
workspace_name_label: 'Nom',
|
||||
workspace_name_placeholder: 'Nom convivial facultatif',
|
||||
workspace_path_label: 'Chemin',
|
||||
workspace_path_required: 'Le chemin est obligatoire',
|
||||
workspace_path_readonly: 'Le chemin ne peut pas être modifié. Renommer uniquement.',
|
||||
workspace_new_title: 'Nouvel espace',
|
||||
profile_name_label: 'Nom',
|
||||
profile_base_url_label: 'URL de base',
|
||||
profile_api_key_label: 'Clé API',
|
||||
cmd_yolo: 'Basculer le mode YOLO (ignorer les approbations)',
|
||||
yolo_no_session: 'Aucune session active',
|
||||
yolo_enabled: '⚡ Mode YOLO activé – les approbations ont ignoré cette session',
|
||||
yolo_disabled: 'Mode YOLO désactivé',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'Mode YOLO actif — cliquez pour désactiver',
|
||||
approval_skip_all: '⚡ Passer toute cette session',
|
||||
approval_skip_all_title: 'Ignorer toutes les invites d\'approbation pour cette session',
|
||||
composer_send: 'Envoyer un message',
|
||||
composer_queue: 'Message de file d\'attente',
|
||||
composer_interrupt: 'Interrompre et envoyer',
|
||||
composer_steer: 'Piloter la réponse actuelle',
|
||||
composer_stop: 'Arrêter la génération',
|
||||
composer_disabled_clarify: 'Répondre à la demande de précisions',
|
||||
composer_disabled_compression: 'En attendant la fin de la compression',
|
||||
composer_disabled_empty: 'Tapez un message à envoyer',
|
||||
composer_mobile_workspace: 'Espace de travail',
|
||||
composer_mobile_model: 'Modèle',
|
||||
composer_mobile_reasoning: 'Raisonnement',
|
||||
composer_mobile_context: 'Contexte',
|
||||
media_audio_label: 'Audio',
|
||||
media_svg_label: 'Diagramme',
|
||||
media_video_label: 'Vidéo',
|
||||
csv_loading: 'Chargement du fichier CSV',
|
||||
csv_too_large: 'Fichier CSV trop volumineux pour le rendu en ligne',
|
||||
csv_no_data: 'Le fichier CSV ne contient pas suffisamment de données pour être affiché sous forme de tableau',
|
||||
csv_error: 'Échec du chargement du fichier CSV',
|
||||
csv_header_note: 'Première ligne affichée comme en-tête du tableau',
|
||||
excalidraw_loading: 'Diagramme de chargement',
|
||||
excalidraw_too_large: 'Fichier Excalidraw trop volumineux pour le rendu en ligne',
|
||||
excalidraw_invalid: 'Format de fichier Excalidraw invalide',
|
||||
excalidraw_error: 'Échec du chargement du fichier Excalidraw',
|
||||
excalidraw_label: 'Diagramme',
|
||||
excalidraw_download: 'Télécharger',
|
||||
excalidraw_empty: 'Diagramme vide',
|
||||
excalidraw_render_error: 'Échec du rendu du diagramme',
|
||||
excalidraw_simplified: 'Aperçu SVG simplifié — pas identique au pixel au canevas Excalidraw',
|
||||
checkpoint_title: 'Points de contrôle',
|
||||
checkpoint_empty: 'Aucun point de contrôle trouvé pour cet espace de travail.',
|
||||
checkpoint_loading: 'Chargement des points de contrôle…',
|
||||
checkpoint_error: 'Échec du chargement des points de contrôle',
|
||||
checkpoint_date: 'Date',
|
||||
checkpoint_message: 'Message',
|
||||
checkpoint_files: 'Fichiers',
|
||||
checkpoint_view_diff: 'Voir la différence',
|
||||
checkpoint_restore: 'Restaurer',
|
||||
checkpoint_restore_confirm_title: 'Restaurer le point de contrôle ?',
|
||||
checkpoint_restored: 'Point de contrôle restauré',
|
||||
checkpoint_diff_title: 'Changements au point de contrôle',
|
||||
checkpoint_diff_no_changes: 'Aucune différence trouvée entre ce point de contrôle et l\'espace de travail actuel.',
|
||||
}
|
||||
};
|
||||
|
||||
// Active locale — defaults to English; overridden by loadLocale() at boot.
|
||||
|
||||
+3
-1
@@ -304,9 +304,10 @@
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div id="mainChat" class="main-view">
|
||||
<div class="messages" id="messages">
|
||||
<div class="messages-shell">
|
||||
<button id="jumpToSessionStartBtn" class="session-jump-btn session-jump-btn--start" aria-label="Jump to beginning of session" data-i18n-aria-label="session_jump_start_label" data-i18n-title="session_jump_start_label" onclick="jumpToSessionStart()" style="display:none"><span aria-hidden="true">↑</span><span data-i18n="session_jump_start">Start</span></button>
|
||||
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" style="display:none" onclick="scrollToBottom()" aria-label="Scroll to bottom" data-i18n-aria-label="session_jump_end_label" data-i18n-title="session_jump_end_label"><span aria-hidden="true">↓</span><span class="session-jump-btn__text" data-i18n="session_jump_end">End</span></button>
|
||||
<div class="messages" id="messages">
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="empty-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="80" height="80" aria-label="Hermes caduceus">
|
||||
<defs>
|
||||
@@ -337,6 +338,7 @@
|
||||
<div id="liveCompressionCards" class="live-compression-cards"></div>
|
||||
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
|
||||
<span id="updateMsg"></span>
|
||||
|
||||
+3
-2
@@ -69,7 +69,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
// On page load, probe the server so we can distinguish "can't reach server"
|
||||
// (Tailscale off, wrong network) from "session expired / need to log in".
|
||||
// Uses /health — a public endpoint, no auth required.
|
||||
// Uses /health — public for WebUI auth, but deployment access proxies may
|
||||
// require same-origin cookies before the request reaches WebUI.
|
||||
// If unreachable, retries every 3 s and auto-reloads once the server is back.
|
||||
(function checkConnectivity() {
|
||||
var retryTimer = null;
|
||||
@@ -81,7 +82,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
}
|
||||
|
||||
function probe() {
|
||||
fetch('health', { method: 'GET', credentials: 'omit' })
|
||||
fetch('health', { method: 'GET', credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
if (r.ok) {
|
||||
// Server is reachable — if we were in retry mode, reload so the
|
||||
|
||||
+72
-9
@@ -2918,6 +2918,66 @@ function _renderLlmWikiStatus(d) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket daily token rows for chart display.
|
||||
* Returns rows unchanged when length <= 30 (per-day resolution).
|
||||
* For longer ranges, groups consecutive days into buckets:
|
||||
* 31–90 days → 2-day buckets
|
||||
* 91–180 days → 3-day buckets
|
||||
* 181–365 days → 8-day buckets
|
||||
* Result is always <= ~52 bars.
|
||||
* Each bucket row has:
|
||||
* - label: short label for axis (e.g. MM-DD or MM-DD–MM-DD)
|
||||
* - title: full tooltip title (e.g. 2026-01-01 – 2026-01-05)
|
||||
* - date: first date in bucket (used for date label slicing)
|
||||
* - input_tokens, output_tokens, sessions, cost: summed across bucket
|
||||
*/
|
||||
function _bucketDailyTokensForChart(rows) {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return [];
|
||||
const len = rows.length;
|
||||
if (len <= 30) return rows; // per-day resolution for 7/30-day ranges
|
||||
|
||||
// Target <= 75 bars; derive bucket size
|
||||
let bucketSize;
|
||||
if (len <= 90) {
|
||||
bucketSize = 2;
|
||||
} else if (len <= 180) {
|
||||
bucketSize = 3;
|
||||
} else if (len <= 365) {
|
||||
bucketSize = 8; // <=52 bars for 365 days (ceil(365/8)=46)
|
||||
} else {
|
||||
bucketSize = 8; // fallback for >365 (shouldn't occur in practice)
|
||||
}
|
||||
|
||||
const result = [];
|
||||
for (let i = 0; i < len; i += bucketSize) {
|
||||
const slice = rows.slice(i, i + bucketSize);
|
||||
const input_tokens = slice.reduce((s, r) => s + Number(r.input_tokens || 0), 0);
|
||||
const output_tokens = slice.reduce((s, r) => s + Number(r.output_tokens || 0), 0);
|
||||
const sessions = slice.reduce((s, r) => s + Number(r.sessions || 0), 0);
|
||||
const cost = slice.reduce((s, r) => s + Number(r.cost || 0), 0);
|
||||
|
||||
const firstDate = slice[0].date;
|
||||
const lastDate = slice[slice.length - 1].date;
|
||||
|
||||
// Label: short form for axis
|
||||
const firstLabel = String(firstDate).slice(5); // MM-DD
|
||||
const lastLabel = String(lastDate).slice(5);
|
||||
const label = (firstDate === lastDate) ? firstLabel : (firstLabel + '–' + lastLabel);
|
||||
|
||||
result.push({
|
||||
label,
|
||||
title: firstDate + (firstDate !== lastDate ? ' – ' + lastDate : ''),
|
||||
date: firstDate,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
sessions,
|
||||
cost,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function _renderInsights(d, box, wikiStatus) {
|
||||
const fmtNum = n => Number(n || 0).toLocaleString();
|
||||
const fmtCost = c => {
|
||||
@@ -2937,21 +2997,24 @@ function _renderInsights(d, box, wikiStatus) {
|
||||
{ label: t('insights_cost'), value: fmtCost(d.total_cost), icon: li('dollar-sign', 18) },
|
||||
];
|
||||
|
||||
// Daily token trend
|
||||
// Daily token trend — bucket long ranges to avoid horizontal overflow
|
||||
const dailyTokens = Array.isArray(d.daily_tokens) ? d.daily_tokens : [];
|
||||
const chartRows = _bucketDailyTokensForChart(dailyTokens);
|
||||
let dailyHtml = '';
|
||||
if (dailyTokens.length) {
|
||||
const maxDailyTokens = Math.max(...dailyTokens.map(r => Number(r.input_tokens || 0) + Number(r.output_tokens || 0)), 1);
|
||||
const labelEvery = Math.max(Math.ceil(dailyTokens.length / 7), 1);
|
||||
if (chartRows.length) {
|
||||
const maxDailyTokens = Math.max(...chartRows.map(r => Number(r.input_tokens || 0) + Number(r.output_tokens || 0)), 1);
|
||||
const labelEvery = Math.max(Math.ceil(chartRows.length / 7), 1);
|
||||
dailyHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_daily_tokens'))}</div><div class="insights-daily-token-chart">` +
|
||||
dailyTokens.map((r, idx) => {
|
||||
chartRows.map((r, idx) => {
|
||||
const input = Number(r.input_tokens || 0);
|
||||
const output = Number(r.output_tokens || 0);
|
||||
const inputPct = Math.max((input / maxDailyTokens) * 100, input ? 2 : 0).toFixed(1);
|
||||
const outputPct = Math.max((output / maxDailyTokens) * 100, output ? 2 : 0).toFixed(1);
|
||||
const showLabel = idx === 0 || idx === dailyTokens.length - 1 || idx % labelEvery === 0;
|
||||
const title = `${r.date} · ${fmtTokens(input)} ${t('insights_input_tokens')} · ${fmtTokens(output)} ${t('insights_output_tokens')} · ${fmtCost(r.cost)} · ${fmtNum(r.sessions)} ${t('insights_sessions')}`;
|
||||
return `<div class="insights-daily-bar" title="${esc(title)}"><div class="insights-daily-stack" aria-label="${esc(title)}"><div class="insights-daily-bar-output" style="height:${outputPct}%"></div><div class="insights-daily-bar-input" style="height:${inputPct}%"></div></div><span>${showLabel ? esc(String(r.date).slice(5)) : ''}</span></div>`;
|
||||
const showLabel = idx === 0 || idx === chartRows.length - 1 || idx % labelEvery === 0;
|
||||
const titleDate = r.title || r.date;
|
||||
const title = `${titleDate} · ${fmtTokens(input)} ${t('insights_input_tokens')} · ${fmtTokens(output)} ${t('insights_output_tokens')} · ${fmtCost(r.cost)} · ${fmtNum(r.sessions)} ${t('insights_sessions')}`;
|
||||
const labelText = r.label !== undefined ? r.label : String(r.date).slice(5);
|
||||
return `<div class="insights-daily-bar" title="${esc(title)}"><div class="insights-daily-stack" aria-label="${esc(title)}"><div class="insights-daily-bar-output" style="height:${outputPct}%"></div><div class="insights-daily-bar-input" style="height:${inputPct}%"></div></div><span>${showLabel ? esc(labelText) : ''}</span></div>`;
|
||||
}).join('') +
|
||||
`</div><div class="insights-daily-legend"><span><i class="insights-daily-legend-input"></i>${esc(t('insights_input_tokens'))}</span><span><i class="insights-daily-legend-output"></i>${esc(t('insights_output_tokens'))}</span></div></div>`;
|
||||
} else {
|
||||
@@ -3023,7 +3086,7 @@ function _renderInsights(d, box, wikiStatus) {
|
||||
${overviewCards.map(c => `<div class="insights-stat"><div class="insights-stat-icon">${c.icon}</div><div class="insights-stat-info"><div class="insights-stat-value">${c.value}</div><div class="insights-stat-label">${esc(c.label)}</div></div></div>`).join('')}
|
||||
</div>
|
||||
${dailyHtml}
|
||||
<div class="insights-row">
|
||||
<div class="insights-row insights-usage-grid">
|
||||
${tokenCards}
|
||||
${modelsHtml}
|
||||
</div>
|
||||
|
||||
+125
-15
@@ -246,7 +246,10 @@ function _isSessionEffectivelyStreaming(s) {
|
||||
function _purgeStaleInflightEntries() {
|
||||
// Clean up INFLIGHT entries for sessions the server confirms are NOT
|
||||
// streaming. This prevents the in-memory cache from growing unbounded
|
||||
// when streams end abnormally. (#2066)
|
||||
// when streams end abnormally. (#2066) Additionally, any INFLIGHT entry
|
||||
// whose session id is no longer present in the current _allSessions list
|
||||
// (deleted / archived / filtered out) is also removed so that ghost entries
|
||||
// from deleted sessions do not accumulate. (#2092)
|
||||
if (typeof INFLIGHT !== 'object' || !INFLIGHT) return;
|
||||
const sessionsById = new Map();
|
||||
if (Array.isArray(_allSessions)) {
|
||||
@@ -255,11 +258,20 @@ function _purgeStaleInflightEntries() {
|
||||
}
|
||||
}
|
||||
for (const sid of Object.keys(INFLIGHT)) {
|
||||
if (!sessionsById.has(sid)) {
|
||||
// Session is absent from _allSessions — it was deleted / archived /
|
||||
// filtered and can never stream again, so drop the entry.
|
||||
delete INFLIGHT[sid];
|
||||
if (typeof clearInflightState === 'function') clearInflightState(sid);
|
||||
continue;
|
||||
}
|
||||
const s = sessionsById.get(sid);
|
||||
if (s && !s.is_streaming) {
|
||||
if (!s.is_streaming) {
|
||||
// Session exists but is not streaming — purge it.
|
||||
delete INFLIGHT[sid];
|
||||
if (typeof clearInflightState === 'function') clearInflightState(sid);
|
||||
}
|
||||
// Sessions that exist and are still streaming are preserved.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,6 +653,7 @@ async function loadSession(sid){
|
||||
setComposerStatus('');
|
||||
updateQueueBadge(sid);
|
||||
syncTopbar();renderMessages();
|
||||
if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid);
|
||||
// Kick off loadDir first (issues network requests), then highlight code.
|
||||
// The fetch is dispatched before the CPU-bound Prism pass begins.
|
||||
const _dirP=loadDir('.');
|
||||
@@ -1272,6 +1285,9 @@ let _sessionActionAnchor = null;
|
||||
let _sessionActionSessionId = null;
|
||||
const _expandedChildSessionKeys = new Set();
|
||||
const _expandedLineageKeys = new Set();
|
||||
const _lineageReportCache = new Map();
|
||||
const _lineageReportInflight = new Map();
|
||||
let _lineageReportCacheGeneration = 0;
|
||||
let _sessionVisibleSidebarIds = [];
|
||||
const SESSION_VIRTUAL_ROW_HEIGHT = 52;
|
||||
const SESSION_VIRTUAL_BUFFER_ROWS = 12;
|
||||
@@ -1290,11 +1306,20 @@ function _worktreeSessionCount(ids){
|
||||
return count+(session&&session.worktree_path?1:0);
|
||||
},0);
|
||||
}
|
||||
function _sessionResponseRetainsWorktree(response, session){
|
||||
if(response&&typeof response.worktree_retained==='boolean') return response.worktree_retained;
|
||||
return !!(session&&session.worktree_path);
|
||||
}
|
||||
function _worktreeResponseCount(results){
|
||||
return (results||[]).reduce((count,result)=>{
|
||||
return count+(_sessionResponseRetainsWorktree(result&&result.response,result&&result.session)?1:0);
|
||||
},0);
|
||||
}
|
||||
function _sessionArchiveDescription(session){
|
||||
return session&&session.worktree_path?t('session_archive_worktree_desc'):t('session_archive_desc');
|
||||
}
|
||||
function _sessionArchiveToast(session){
|
||||
return session&&session.worktree_path?t('session_archived_worktree'):t('session_archived');
|
||||
function _sessionArchiveToast(response, session){
|
||||
return _sessionResponseRetainsWorktree(response,session)?t('session_archived_worktree'):t('session_archived');
|
||||
}
|
||||
function _sessionDeleteDescription(session){
|
||||
return session&&session.worktree_path?t('session_delete_worktree_desc'):t('session_delete_desc');
|
||||
@@ -1398,14 +1423,20 @@ function _renderBatchActionBar(){
|
||||
archiveBtn.onclick=async()=>{
|
||||
const ids=[..._selectedSessions];
|
||||
const wtCount=_worktreeSessionCount(ids);
|
||||
const sessionsById=new Map(ids.map(sid=>[sid,_sessionSnapshotById(sid)]));
|
||||
const ok=await showConfirmDialog({
|
||||
message:wtCount?t('session_batch_archive_worktree_confirm',ids.length,wtCount):t('session_batch_archive_confirm',ids.length),
|
||||
confirmLabel:t('session_batch_archive'),
|
||||
danger:true
|
||||
});
|
||||
if(!ok)return;
|
||||
try{await Promise.all(ids.map(sid=>api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:sid,archived:true})})));
|
||||
showToast(wtCount?t('session_archived_worktree'):t('session_archived'));exitSessionSelectMode();await renderSessionList();
|
||||
try{
|
||||
const results=await Promise.all(ids.map(async sid=>{
|
||||
const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:sid,archived:true})});
|
||||
return {response,session:sessionsById.get(sid)||null};
|
||||
}));
|
||||
const retainedCount=_worktreeResponseCount(results);
|
||||
showToast(retainedCount?t('session_archived_worktree'):t('session_archived'));exitSessionSelectMode();await renderSessionList();
|
||||
}catch(e){showToast('Archive failed: '+(e.message||e));}
|
||||
};bar.appendChild(archiveBtn);
|
||||
// Move
|
||||
@@ -1418,6 +1449,7 @@ function _renderBatchActionBar(){
|
||||
deleteBtn.onclick=async()=>{
|
||||
const ids=[..._selectedSessions];
|
||||
const wtCount=_worktreeSessionCount(ids);
|
||||
const sessionsById=new Map(ids.map(sid=>[sid,_sessionSnapshotById(sid)]));
|
||||
const ok=await showConfirmDialog({
|
||||
message:wtCount?t('session_batch_delete_worktree_confirm',ids.length,wtCount):t('session_batch_delete_confirm',ids.length),
|
||||
confirmLabel:t('delete_title'),
|
||||
@@ -1425,7 +1457,11 @@ function _renderBatchActionBar(){
|
||||
});
|
||||
if(!ok)return;
|
||||
try{
|
||||
await Promise.all(ids.map(sid=>api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})})));
|
||||
const results=await Promise.all(ids.map(async sid=>{
|
||||
const response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
|
||||
return {response,session:sessionsById.get(sid)||null};
|
||||
}));
|
||||
const retainedCount=_worktreeResponseCount(results);
|
||||
ids.forEach(_clearHandoffStorageForSession);
|
||||
if(S.session&&ids.includes(S.session.session_id)){
|
||||
S.session=null;S.messages=[];S.entries=[];localStorage.removeItem('hermes-webui-session');
|
||||
@@ -1433,7 +1469,7 @@ function _renderBatchActionBar(){
|
||||
if(remaining.sessions&&remaining.sessions.length){await loadSession(remaining.sessions[0].session_id);}
|
||||
else{$('msgInner').innerHTML='';$('emptyState').style.display='';}
|
||||
}
|
||||
showToast((wtCount?t('session_deleted_worktree'):t('session_delete'))+' ('+ids.length+')');exitSessionSelectMode();await renderSessionList();
|
||||
showToast((retainedCount?t('session_deleted_worktree'):t('session_delete'))+' ('+ids.length+')');exitSessionSelectMode();await renderSessionList();
|
||||
}catch(e){showToast('Delete failed: '+(e.message||e));}
|
||||
};bar.appendChild(deleteBtn);
|
||||
}
|
||||
@@ -1606,11 +1642,11 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
async()=>{
|
||||
closeSessionActionMenu();
|
||||
try{
|
||||
await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})});
|
||||
const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})});
|
||||
session.archived=!session.archived;
|
||||
if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived;
|
||||
await renderSessionList();
|
||||
showToast(session.archived?_sessionArchiveToast(session):t('session_restored'));
|
||||
showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored'));
|
||||
}catch(err){showToast(t('session_archive_failed')+err.message);}
|
||||
}
|
||||
));
|
||||
@@ -1738,6 +1774,7 @@ async function renderSessionList(){
|
||||
// without a second round-trip. Stashed on the module for renderSessionListFromCache.
|
||||
_otherProfileCount = sessData.other_profile_count || 0;
|
||||
_allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]);
|
||||
_clearLineageReportCache();
|
||||
_allProjects = projData.projects||[];
|
||||
// Capture server clock for clock-skew compensation (issue #1144).
|
||||
// server_time is epoch seconds from the server's time.time().
|
||||
@@ -2075,6 +2112,73 @@ function _sessionSegmentCount(s){
|
||||
return count>1?count:0;
|
||||
}
|
||||
|
||||
function _clearLineageReportCache(){
|
||||
_lineageReportCache.clear();
|
||||
_lineageReportInflight.clear();
|
||||
_lineageReportCacheGeneration++;
|
||||
}
|
||||
|
||||
function _lineageReportCacheKey(s,lineageKey){
|
||||
return lineageKey||_sidebarLineageKeyForRow(s)||null;
|
||||
}
|
||||
|
||||
function _lineageLocalSegmentCount(s){
|
||||
if(!s) return 0;
|
||||
if(Array.isArray(s._lineage_segments)) return s._lineage_segments.length;
|
||||
return s.session_id?1:0;
|
||||
}
|
||||
|
||||
function _lineageReportNeedsFetch(s,lineageKey,segmentCount){
|
||||
const key=_lineageReportCacheKey(s,lineageKey);
|
||||
if(!s||!s.session_id||!key) return false;
|
||||
if(_lineageReportCache.has(key)||_lineageReportInflight.has(key)) return false;
|
||||
return Number(segmentCount||0)>_lineageLocalSegmentCount(s);
|
||||
}
|
||||
|
||||
function _lineageSegmentsForRender(s,lineageKey){
|
||||
const segments=[];
|
||||
const seen=new Set();
|
||||
const currentSid=s&&s.session_id;
|
||||
const addSegment=(seg)=>{
|
||||
if(!seg||!seg.session_id||seg.session_id===currentSid||seen.has(seg.session_id)) return;
|
||||
if(seg.role==='child_session') return;
|
||||
seen.add(seg.session_id);
|
||||
segments.push({...seg});
|
||||
};
|
||||
for(const seg of (Array.isArray(s&&s._lineage_segments)?s._lineage_segments:[])) addSegment(seg);
|
||||
const cached=_lineageReportCache.get(_lineageReportCacheKey(s,lineageKey));
|
||||
if(cached&&Array.isArray(cached.segments)){
|
||||
for(const seg of cached.segments) addSegment(seg);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function _fetchLineageReportForRow(s,lineageKey){
|
||||
const key=_lineageReportCacheKey(s,lineageKey);
|
||||
if(!s||!s.session_id||!key) return Promise.resolve(null);
|
||||
if(_lineageReportCache.has(key)) return Promise.resolve(_lineageReportCache.get(key));
|
||||
if(_lineageReportInflight.has(key)) return _lineageReportInflight.get(key);
|
||||
const generation=_lineageReportCacheGeneration;
|
||||
let request;
|
||||
request=api('/api/session/lineage/report?session_id='+encodeURIComponent(s.session_id))
|
||||
.then(report=>{
|
||||
if(generation===_lineageReportCacheGeneration){
|
||||
_lineageReportCache.set(key,(report&&report.found!==false)?report:{error:true});
|
||||
}
|
||||
return report;
|
||||
})
|
||||
.catch(err=>{
|
||||
console.warn('lineage report',err);
|
||||
if(generation===_lineageReportCacheGeneration) _lineageReportCache.set(key,{error:true});
|
||||
return null;
|
||||
})
|
||||
.finally(()=>{
|
||||
if(_lineageReportInflight.get(key)===request) _lineageReportInflight.delete(key);
|
||||
});
|
||||
_lineageReportInflight.set(key,request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function _sidebarLineageKeyForRow(s){
|
||||
if(!s) return null;
|
||||
return s._lineage_key||s._lineage_root_id||s.lineage_root_id||s.parent_session_id||s.session_id||null;
|
||||
@@ -2683,8 +2787,10 @@ function renderSessionListFromCache(){
|
||||
}
|
||||
const lineageKey=_sidebarLineageKeyForRow(s);
|
||||
const segmentCount=_sessionSegmentCount(s);
|
||||
const lineageSegments=Array.isArray(s._lineage_segments)?s._lineage_segments.filter(seg=>seg&&seg.session_id&&seg.session_id!==s.session_id):[];
|
||||
const canExpandLineageSegments=Boolean(lineageKey&&segmentCount>1&&lineageSegments.length>0);
|
||||
const lineageSegments=_lineageSegmentsForRender(s,lineageKey);
|
||||
const needsLineageReport=_lineageReportNeedsFetch(s,lineageKey,segmentCount);
|
||||
const lineageReportKey=_lineageReportCacheKey(s,lineageKey);
|
||||
const canExpandLineageSegments=Boolean(lineageKey&&segmentCount>1&&(lineageSegments.length>0||needsLineageReport||_lineageReportInflight.has(lineageReportKey)));
|
||||
const lineageSegmentsExpanded=canExpandLineageSegments&&_expandedLineageKeys.has(lineageKey);
|
||||
if(segmentCount>0){
|
||||
const segmentCountEl=document.createElement('span');
|
||||
@@ -2701,7 +2807,10 @@ function renderSessionListFromCache(){
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if(_expandedLineageKeys.has(lineageKey)) _expandedLineageKeys.delete(lineageKey);
|
||||
else _expandedLineageKeys.add(lineageKey);
|
||||
else {
|
||||
_expandedLineageKeys.add(lineageKey);
|
||||
if(needsLineageReport) _fetchLineageReportForRow(s,lineageKey).then(()=>renderSessionListFromCache());
|
||||
}
|
||||
renderSessionListFromCache();
|
||||
};
|
||||
segmentCountEl.onclick=toggleLineageSegments;
|
||||
@@ -3051,8 +3160,9 @@ async function deleteSession(sid){
|
||||
danger:true
|
||||
});
|
||||
if(!ok)return;
|
||||
let response=null;
|
||||
try{
|
||||
await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
|
||||
response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
|
||||
_clearHandoffStorageForSession(sid);
|
||||
}catch(e){setStatus(`Delete failed: ${e.message}`);return;}
|
||||
if(S.session&&S.session.session_id===sid){
|
||||
@@ -3072,7 +3182,7 @@ async function deleteSession(sid){
|
||||
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
|
||||
}
|
||||
}
|
||||
showToast(session&&session.worktree_path?t('session_deleted_worktree'):t('session_deleted'));
|
||||
showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted'));
|
||||
await renderSessionList();
|
||||
}
|
||||
|
||||
|
||||
+23
-5
@@ -779,14 +779,15 @@
|
||||
.workspace-toggle-btn.active{color:var(--accent-text);border-color:var(--accent-bg);background:var(--accent-bg);}
|
||||
.workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;}
|
||||
.chip.model{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);}
|
||||
.messages-shell{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;}
|
||||
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;overflow-anchor:none;}
|
||||
/* sticky-first-child: button is early in .messages so its natural position is above viewport; sticky+bottom pins it there when visible */
|
||||
.scroll-to-bottom-btn{position:sticky;bottom:16px;align-self:flex-end;margin-right:20px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s,transform .12s;}
|
||||
/* Overlay scroll controls so they do not affect the transcript's native scroll geometry. */
|
||||
.scroll-to-bottom-btn{position:absolute;right:20px;bottom:16px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s,transform .12s;}
|
||||
.scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);}
|
||||
.session-jump-btn__text{display:none;}
|
||||
.session-jump-btn{position:sticky;align-self:flex-end;flex:0 0 32px;min-height:32px;margin-right:20px;height:32px;border-radius:999px;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 11px;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:11;transition:color .12s,border-color .12s,background .12s,opacity .12s,transform .12s;}
|
||||
.session-jump-btn{position:absolute;right:20px;height:32px;border-radius:999px;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 11px;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:11;transition:color .12s,border-color .12s,background .12s,opacity .12s,transform .12s;}
|
||||
.session-jump-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);transform:translateY(-1px);}
|
||||
.session-jump-btn--start{top:16px;margin-bottom:-36px;}
|
||||
.session-jump-btn--start{top:16px;}
|
||||
.messages.session-nav-enabled .scroll-to-bottom-btn{width:auto;min-width:32px;border-radius:999px;font-size:12px;font-weight:600;gap:5px;padding:0 11px;}
|
||||
.messages.session-nav-enabled .scroll-to-bottom-btn:hover{transform:translateY(-1px);}
|
||||
.messages.session-nav-enabled .session-jump-btn__text{display:inline;}
|
||||
@@ -2471,7 +2472,10 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
/* Responsive: tighten canvas on small screens. */
|
||||
@media (max-width: 768px){
|
||||
.settings-main{padding:16px 12px;}
|
||||
.settings-section-head{flex-direction:column;align-items:flex-start;gap:8px;}
|
||||
.settings-section-title{font-size:16px;}
|
||||
#checkUpdatesBlock{flex-wrap:wrap;row-gap:6px;width:100%;}
|
||||
.settings-version-badge{white-space:nowrap;}
|
||||
.hermes-action-grid{grid-template-columns:1fr;}
|
||||
#mainSettings .settings-field{padding:14px;}
|
||||
}
|
||||
@@ -3370,7 +3374,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.insights-model-cost,.insights-model-tokens{font-variant-numeric:tabular-nums;}
|
||||
.insights-model-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.insights-empty{font-size:12px;color:var(--muted);padding:12px 0;}
|
||||
.insights-daily-token-chart{height:180px;display:grid;grid-auto-flow:column;grid-auto-columns:minmax(10px,1fr);gap:4px;align-items:end;padding:6px 0 2px;border-bottom:1px solid var(--border);}
|
||||
.insights-daily-token-chart{height:180px;display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:4px;align-items:end;padding:6px 0 2px;border-bottom:1px solid var(--border);overflow:hidden;max-width:100%;}
|
||||
.insights-daily-bar{min-width:0;height:100%;display:flex;flex-direction:column;justify-content:flex-end;gap:4px;}
|
||||
.insights-daily-stack{height:150px;display:flex;flex-direction:column;justify-content:flex-end;background:var(--border,.15);border-radius:4px;overflow:hidden;}
|
||||
.insights-daily-bar-input{background:var(--accent);min-height:0;}
|
||||
@@ -3392,6 +3396,20 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.insights-token-label{color:var(--muted);}
|
||||
.insights-token-value{font-weight:600;}
|
||||
|
||||
/* ── Mobile layout for Token Breakdown + Models (issue #2104) ───────────── */
|
||||
@media (max-width: 640px) {
|
||||
.insights-usage-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.insights-usage-grid .insights-card {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.insights-model-table {
|
||||
min-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Checkpoints / Rollback UI (#466) ─────────────────────────────────────── */
|
||||
.checkpoint-list{display:flex;flex-direction:column;gap:8px;}
|
||||
.checkpoint-item{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:var(--surface-2);border:1px solid var(--border);border-radius:6px;font-size:12px;}
|
||||
|
||||
+7
-4
@@ -1672,12 +1672,14 @@ function _recordNonMessageScrollIntent(e){
|
||||
// input event so later scrollTop decreases caused by layout/windowing do
|
||||
// not masquerade as user intent and strand live streaming away from bottom.
|
||||
_lastMessageUpwardIntentMs=performance.now();
|
||||
_messageUserUnpinned=true;
|
||||
// User is intentionally moving in the transcript. Cancel any delayed
|
||||
// scrollToBottom settling that was scheduled by session-load/layout growth.
|
||||
_cancelBottomSettle();
|
||||
_nearBottomCount=0;
|
||||
_scrollPinned=false;
|
||||
if(typeof e.deltaY==='number'&&e.deltaY<0){
|
||||
_messageUserUnpinned=true;
|
||||
_nearBottomCount=0;
|
||||
_scrollPinned=false;
|
||||
}
|
||||
}
|
||||
}
|
||||
function _recentMessageUpwardIntent(){
|
||||
@@ -1717,7 +1719,8 @@ if (typeof window !== 'undefined') window._resetScrollDirectionTracker = _resetS
|
||||
if(_scrollPinned) _messageUserUnpinned=false;
|
||||
} // #1360
|
||||
const btn=$('scrollToBottomBtn');
|
||||
if(btn) btn.style.display=_scrollPinned?'none':'flex';
|
||||
const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80;
|
||||
if(btn) btn.style.display=showBottomButton?'flex':'none';
|
||||
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
|
||||
// Prefetch older messages before the reader hits the hard top. Prepending
|
||||
// then preserving scrollTop is seamless only if there is runway left for
|
||||
|
||||
@@ -112,3 +112,12 @@ class TestLoginJsSafeNextPath:
|
||||
assert "charAt(0) !== '/'" in src or "startsWith('/')" in src, (
|
||||
"_safeNextPath must reject non-path-absolute inputs (e.g. 'http://...')"
|
||||
)
|
||||
|
||||
def test_health_probe_sends_same_origin_credentials(self):
|
||||
"""Cloudflare Access protects /health with same-origin cookies before WebUI sees it."""
|
||||
src = self._login_js()
|
||||
assert "fetch('health', { method: 'GET', credentials: 'omit' })" not in src, (
|
||||
"login.js must not omit credentials for the health probe because "
|
||||
"deployment-level access proxies may require same-origin cookies"
|
||||
)
|
||||
assert "fetch('health', { method: 'GET', credentials: 'same-origin' })" in src
|
||||
|
||||
@@ -22,11 +22,13 @@ class TestSidebarCancelAction:
|
||||
def test_running_sidebar_sessions_get_stop_action(self):
|
||||
"""Running sessions need a context-menu cancel action even when not active pane."""
|
||||
# Window bumped from 3200 → 4400 in #1764 to accommodate the new
|
||||
# Rename action item that lands at the top of _openSessionActionMenu.
|
||||
# Rename action item, then to 5200 in #2111 for response-aware archive
|
||||
# toast handling inside _openSessionActionMenu before the stop/delete
|
||||
# actions.
|
||||
# The `session.active_stream_id` / cancelSessionStream / delete checks
|
||||
# are positional further down in the function, so growing the prefix
|
||||
# required growing this read window.
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 4400)
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 5200)
|
||||
assert "session.active_stream_id" in body, (
|
||||
"sidebar action menu must detect per-session active_stream_id instead of S.activeStreamId"
|
||||
)
|
||||
@@ -72,8 +74,9 @@ class TestSidebarCancelAction:
|
||||
|
||||
def test_cli_sessions_hide_duplicate_and_delete_in_action_menu(self):
|
||||
"""Session action menu should hide duplicate/delete for CLI-origin sessions."""
|
||||
# Window bumped 3600 → 4800 in #1764 (Rename action prepended).
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 4800)
|
||||
# Window bumped 3600 → 4800 in #1764 (Rename action prepended), then
|
||||
# to 5200 in #2111 for response-aware archive toast handling.
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 5200)
|
||||
assert "const isCliSession = _isCliSession(session);" in body
|
||||
assert "const isExternalSession = isMessagingSession || isCliSession;" in body
|
||||
assert "if(!isExternalSession)" in body
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).parent.parent
|
||||
INDEX_HTML = (REPO / 'static' / 'index.html').read_text(encoding='utf-8')
|
||||
STYLE_CSS = (REPO / 'static' / 'style.css').read_text(encoding='utf-8')
|
||||
UI_JS = (REPO / 'static' / 'ui.js').read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def test_scroll_controls_are_overlays_outside_messages_scroller():
|
||||
shell = INDEX_HTML.index('<div class="messages-shell">')
|
||||
scroller = INDEX_HTML.index('<div class="messages" id="messages">')
|
||||
assert shell < INDEX_HTML.index('id="scrollToBottomBtn"') < scroller
|
||||
assert '.messages-shell{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;}' in STYLE_CSS
|
||||
assert '.scroll-to-bottom-btn{position:absolute;' in STYLE_CSS
|
||||
assert '.session-jump-btn{position:absolute;' in STYLE_CSS
|
||||
|
||||
|
||||
def test_bottom_button_has_dead_zone_and_no_platform_scroll_shim():
|
||||
scroll_listener = UI_JS[UI_JS.index("el.addEventListener('scroll'"):UI_JS.index('function _fmtTokens')]
|
||||
assert 'const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80;' in scroll_listener
|
||||
assert '_isIosStandalonePwa' not in UI_JS
|
||||
assert '_messagePanePreferredBottomScrollTop' not in UI_JS
|
||||
@@ -177,6 +177,32 @@ class TestLiveModelsCustomProviderFallback:
|
||||
"""When provider='custom' and provider_model_ids() returns [],
|
||||
/api/models/live must fall back to custom_providers entries from config.yaml."""
|
||||
|
||||
@staticmethod
|
||||
def _install_provider_model_ids(monkeypatch, fn):
|
||||
import types
|
||||
|
||||
hermes_cli = types.ModuleType("hermes_cli")
|
||||
hermes_cli.__path__ = []
|
||||
models = types.ModuleType("hermes_cli.models")
|
||||
models.provider_model_ids = fn
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.models", models)
|
||||
|
||||
@staticmethod
|
||||
def _call_live_models(monkeypatch, cfg, provider):
|
||||
import api.config as c
|
||||
import api.routes as r
|
||||
|
||||
r._clear_live_models_cache()
|
||||
monkeypatch.setattr(c, "get_config", lambda: cfg)
|
||||
monkeypatch.setattr(c, "_resolve_provider_alias", lambda p: p)
|
||||
monkeypatch.setattr(r, "j", lambda _handler, payload, **_kw: payload)
|
||||
TestLiveModelsCustomProviderFallback._install_provider_model_ids(monkeypatch, lambda _p: [])
|
||||
|
||||
parsed = mock.MagicMock()
|
||||
parsed.query = f"provider={provider}"
|
||||
return r._handle_live_models(object(), parsed)
|
||||
|
||||
def test_custom_fallback_code_present(self):
|
||||
src = read("api/routes.py")
|
||||
m = re.search(
|
||||
@@ -241,6 +267,110 @@ class TestLiveModelsCustomProviderFallback:
|
||||
f"got {model_ids}"
|
||||
)
|
||||
|
||||
def test_named_custom_fallback_returns_only_matching_provider_models(self, monkeypatch):
|
||||
"""custom:<slug> must not leak sibling custom_providers models."""
|
||||
cfg = {
|
||||
"model": {"provider": "custom:infini-ai"},
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "rightcode-codex",
|
||||
"model": "gpt-5.5",
|
||||
"models": {"gpt-5.5-mini": {}},
|
||||
"base_url": "https://right.codes/codex/v1",
|
||||
},
|
||||
{
|
||||
"name": "infini-ai",
|
||||
"model": "glm-5.1",
|
||||
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||
},
|
||||
{
|
||||
"name": "xiaomi-mimo",
|
||||
"models": ["mimo-v2.5-pro"],
|
||||
"base_url": "https://mimo.example.com/v1",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
resp = self._call_live_models(monkeypatch, cfg, "custom:rightcode-codex")
|
||||
|
||||
assert resp["provider"] == "custom:rightcode-codex"
|
||||
assert [m["id"] for m in resp["models"]] == ["gpt-5.5", "gpt-5.5-mini"]
|
||||
|
||||
def test_bare_custom_fallback_ignores_named_custom_provider_models(self, monkeypatch):
|
||||
"""Bare custom only represents unnamed custom entries, not named siblings."""
|
||||
cfg = {
|
||||
"model": {"provider": "custom"},
|
||||
"custom_providers": [
|
||||
{"name": "rightcode-codex", "model": "gpt-5.5"},
|
||||
{"name": "infini-ai", "model": "glm-5.1"},
|
||||
{"model": "unnamed-byok-model"},
|
||||
],
|
||||
}
|
||||
|
||||
resp = self._call_live_models(monkeypatch, cfg, "custom")
|
||||
|
||||
assert resp["provider"] == "custom"
|
||||
assert [m["id"] for m in resp["models"]] == ["unnamed-byok-model"]
|
||||
|
||||
def test_named_custom_live_fetch_uses_matching_entry_endpoint(self, monkeypatch):
|
||||
"""custom:<slug> live fetch must use that entry, not the active model config."""
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
requests = []
|
||||
|
||||
class Response:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps({"data": [{"id": "right-live-model"}]}).encode("utf-8")
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
requests.append(
|
||||
{
|
||||
"url": req.full_url,
|
||||
"authorization": req.headers.get("Authorization"),
|
||||
"timeout": timeout,
|
||||
}
|
||||
)
|
||||
return Response()
|
||||
|
||||
cfg = {
|
||||
"model": {
|
||||
"provider": "custom:infini-ai",
|
||||
"base_url": "https://infini.example.com/v1",
|
||||
"api_key": "infini-key",
|
||||
},
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "rightcode-codex",
|
||||
"base_url": "https://right.codes/codex/v1",
|
||||
"api_key": "right-key",
|
||||
},
|
||||
{
|
||||
"name": "infini-ai",
|
||||
"base_url": "https://infini.example.com/v1",
|
||||
"api_key": "infini-key",
|
||||
},
|
||||
],
|
||||
}
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
resp = self._call_live_models(monkeypatch, cfg, "custom:rightcode-codex")
|
||||
|
||||
assert requests == [
|
||||
{
|
||||
"url": "https://right.codes/codex/v1/models",
|
||||
"authorization": "Bearer right-key",
|
||||
"timeout": 8,
|
||||
}
|
||||
]
|
||||
assert [m["id"] for m in resp["models"]] == ["right-live-model"]
|
||||
|
||||
|
||||
# ── Regression: known-good providers still work ───────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Regression tests pinning bash 3.2 compatibility patterns in ctl.sh.
|
||||
|
||||
macOS still ships bash 3.2 as the default ``/usr/bin/bash``. Under
|
||||
``set -euo pipefail`` (which ctl.sh sets at the top of the file), bash 3.2
|
||||
treats *empty array expansion* as referencing an unbound variable and aborts
|
||||
with ``preserved[@]: unbound variable`` (or equivalent). Bash 4+ silently
|
||||
handles empty arrays. We can't realistically run the CI suite under bash 3.2,
|
||||
so these are static-pattern assertions on the source file -- if a future PR
|
||||
introduces a raw ``"${arr[@]}"`` expansion without the established guards,
|
||||
this test fails fast.
|
||||
|
||||
Two guard patterns are used in ctl.sh:
|
||||
|
||||
1. Length-guarded ``for`` loop::
|
||||
|
||||
if [[ ${#preserved[@]} -gt 0 ]]; then
|
||||
for assignment in "${preserved[@]}"; do
|
||||
export "${assignment}"
|
||||
done
|
||||
fi
|
||||
|
||||
Used when the loop body has side effects we want to skip when empty.
|
||||
(PR #2117 introduced this pattern at the ``preserved`` site.)
|
||||
|
||||
2. Inline ``${arr[@]+...}`` expansion::
|
||||
|
||||
exec ... ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"}
|
||||
|
||||
Used when we want to pass-through the array to a command and have the
|
||||
expansion produce nothing when empty. (PR ``025f137f`` introduced this
|
||||
pattern at the ``CTL_BOOTSTRAP_ARGS`` site.)
|
||||
|
||||
Either pattern is acceptable -- a raw ``"${arr[@]}"`` without one of them is
|
||||
not.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CTL_SH = REPO_ROOT / "ctl.sh"
|
||||
|
||||
|
||||
def _read_ctl() -> str:
|
||||
return CTL_SH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_ctl_sh_sets_strict_mode() -> None:
|
||||
"""ctl.sh must keep ``set -euo pipefail`` -- the bug class only triggers under -u."""
|
||||
src = _read_ctl()
|
||||
assert "set -euo pipefail" in src, (
|
||||
"ctl.sh must use strict-mode `set -euo pipefail`; otherwise the bash 3.2 "
|
||||
"empty-array guards we're pinning here are unnecessary and the file lost "
|
||||
"its bug-class coverage."
|
||||
)
|
||||
|
||||
|
||||
def test_preserved_array_is_length_guarded_before_iteration() -> None:
|
||||
"""The dotenv-preserve loop must guard against empty `preserved=()` on bash 3.2.
|
||||
|
||||
PR #2117 (ayushere) — guards the iteration with
|
||||
``if [[ ${#preserved[@]} -gt 0 ]]; then ... fi``. Without the guard, bash
|
||||
3.2 on macOS aborts ``ctl.sh start`` before bootstrap even launches.
|
||||
"""
|
||||
src = _read_ctl()
|
||||
# Must have the length guard somewhere upstream of the for-loop iteration.
|
||||
guarded = re.search(
|
||||
r"if\s+\[\[\s+\$\{#preserved\[@\]\}\s+-gt\s+0\s+\]\];\s*then\s*"
|
||||
r"\s*for\s+\w+\s+in\s+\"\$\{preserved\[@\]\}\"",
|
||||
src,
|
||||
)
|
||||
assert guarded, (
|
||||
"Raw `for assignment in \"${preserved[@]}\"` iteration crashes under "
|
||||
"bash 3.2 + set -u when no preserved env keys overlap with .env. "
|
||||
"Wrap the loop in `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` "
|
||||
"(PR #2117)."
|
||||
)
|
||||
|
||||
|
||||
def test_ctl_bootstrap_args_uses_plus_alternate_expansion() -> None:
|
||||
"""The exec line must use ``${CTL_BOOTSTRAP_ARGS[@]+...}`` for empty-safe pass-through.
|
||||
|
||||
PR ``025f137f`` — bash 3.2 + ``set -u`` treats ``"${CTL_BOOTSTRAP_ARGS[@]}"``
|
||||
as an unbound reference when the array is empty. The ``+alt`` parameter
|
||||
expansion produces nothing when unset and our quoted expansion otherwise,
|
||||
which is the canonical bash 3.2 / strict-mode pattern.
|
||||
"""
|
||||
src = _read_ctl()
|
||||
has_plus_alt = re.search(
|
||||
r"\$\{CTL_BOOTSTRAP_ARGS\[@\]\+\"\$\{CTL_BOOTSTRAP_ARGS\[@\]\}\"\}",
|
||||
src,
|
||||
)
|
||||
assert has_plus_alt, (
|
||||
"exec line must use `${CTL_BOOTSTRAP_ARGS[@]+\"${CTL_BOOTSTRAP_ARGS[@]}\"}` "
|
||||
"so an empty CTL_BOOTSTRAP_ARGS doesn't trip bash 3.2 + set -u. "
|
||||
"See commit 025f137f."
|
||||
)
|
||||
|
||||
|
||||
def test_no_array_iteration_without_guard_in_ctl() -> None:
|
||||
"""Defense-in-depth: catch *any* future raw array expansion not protected by a guard.
|
||||
|
||||
Whitelist the two known-safe sites (preserved + CTL_BOOTSTRAP_ARGS). Any
|
||||
other ``"${SOMETHING[@]}"`` expansion in ctl.sh should also use one of the
|
||||
two established empty-safe patterns; this test surfaces the new site so the
|
||||
author can decide which.
|
||||
"""
|
||||
src = _read_ctl()
|
||||
# Match every quoted-all-elements expansion outside the +alt form.
|
||||
raw_expansions = re.findall(r'"\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\}"', src)
|
||||
# Already-allowed names (each has its own dedicated regression test above).
|
||||
allowed = {"preserved", "CTL_BOOTSTRAP_ARGS"}
|
||||
new_unguarded = [name for name in raw_expansions if name not in allowed]
|
||||
assert not new_unguarded, (
|
||||
"New raw `\"${{{name}[@]}}\"` array expansion(s) appeared in ctl.sh: "
|
||||
"{names}. On bash 3.2 + `set -u` (macOS default), iterating or "
|
||||
"expanding an empty array aborts the script. Wrap iteration in "
|
||||
"`if [[ ${{#arr[@]}} -gt 0 ]]; then ... fi` (loop-side-effect "
|
||||
"pattern, see preserved at line ~54) or use "
|
||||
"`${{arr[@]+\"${{arr[@]}}\"}}` (pass-through pattern, see "
|
||||
"CTL_BOOTSTRAP_ARGS at line ~220) — then whitelist the name in "
|
||||
"`tests/test_ctl_bash32_compat.py::test_no_array_iteration_without_guard_in_ctl`."
|
||||
).format(name=new_unguarded[0] if new_unguarded else "?", names=new_unguarded)
|
||||
|
||||
|
||||
def test_no_bash4_plus_features_in_ctl() -> None:
|
||||
"""Guard against accidental introduction of bash 4+ syntax in ctl.sh.
|
||||
|
||||
macOS bash 3.2 does not support:
|
||||
- ``declare -A`` / ``local -A`` (associative arrays)
|
||||
- ``mapfile`` / ``readarray`` (line-into-array readers)
|
||||
- ``[[ -v VAR ]]`` (variable-existence test, bash 4.2+)
|
||||
- ``${var^^}`` / ``${var,,}`` (case toggle)
|
||||
|
||||
A prior fix (commit 630981a0) replaced ``[[ -v ${key} ]]`` with
|
||||
``[[ -n "${!key+x}" ]]`` specifically because of the macOS bash 3.2 issue.
|
||||
Keep that gain by pinning the absence of the bash 4+ patterns.
|
||||
"""
|
||||
src = _read_ctl()
|
||||
|
||||
forbidden = {
|
||||
"declare -A": r"\bdeclare\s+-A\b",
|
||||
"local -A": r"\blocal\s+-A\b",
|
||||
"mapfile": r"\bmapfile\b",
|
||||
"readarray": r"\breadarray\b",
|
||||
"[[ -v VAR ]]": r"\[\[\s*-v\s+",
|
||||
"${var^^}": r"\$\{[A-Za-z_][A-Za-z0-9_]*\^\^?\}",
|
||||
"${var,,}": r"\$\{[A-Za-z_][A-Za-z0-9_]*,,?\}",
|
||||
}
|
||||
found = [name for name, pat in forbidden.items() if re.search(pat, src)]
|
||||
assert not found, (
|
||||
f"ctl.sh introduced bash 4+ feature(s) {found} — these break macOS's "
|
||||
"default bash 3.2. Use a 3.2-compatible alternative; see commit "
|
||||
"630981a0 for the `-v` → `\"${!key+x}\"` substitution pattern."
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
# Regression tests for _purgeStaleInflightEntries ghost-entry leak (#2092).
|
||||
#
|
||||
# When a session is deleted / archived / filtered out of the sidebar list,
|
||||
# _allSessions no longer contains it. Previously _purgeStaleInflightEntries()
|
||||
# only deleted an INFLIGHT entry when the session WAS present and was not
|
||||
# streaming, leaving ghost entries for absent sessions indefinitely. The fix
|
||||
# adds an explicit check: if the sid is absent from _allSessions, the entry is
|
||||
# always removed.
|
||||
#
|
||||
# These are source-level / parse-time regression tests using the same pattern
|
||||
# as test_inflight_stream_reuse.py. They verify the function body contains the
|
||||
# correct guard logic and would break if the fix regresses.
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
SESSIONS_JS = (REPO_ROOT / 'static' / 'sessions.js').read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
marker = f'function {name}('
|
||||
start = src.find(marker)
|
||||
assert start != -1, f'{name}() not found in sessions.js'
|
||||
# Find the opening { of the function body. After the ')' of the parameter
|
||||
# list there may be whitespace (space, newline) before '{'. We handle both
|
||||
# `){` and `) \n{` cases so this works whether or not the source uses a
|
||||
# newline between the closing paren and the brace.
|
||||
rparen = src.find(')', start)
|
||||
assert rparen != -1, f'{name}() closing paren not found'
|
||||
brace = src.find('{', rparen)
|
||||
assert brace != -1, f'{name}() body brace not found'
|
||||
depth = 1
|
||||
i = brace + 1
|
||||
while i < len(src) and depth:
|
||||
if src[i] == '{':
|
||||
depth += 1
|
||||
elif src[i] == '}':
|
||||
depth -= 1
|
||||
i += 1
|
||||
assert depth == 0, f'{name}() body did not close'
|
||||
return src[brace + 1:i - 1]
|
||||
|
||||
|
||||
def test_purge_removes_entry_when_sid_is_absent_from_all_sessions():
|
||||
r'''An INFLIGHT entry whose sid is missing from _allSessions must be removed.
|
||||
|
||||
The original bug: the loop condition was `if (s && !s.is_streaming)`.
|
||||
When sid was absent, `sessionsById.get(sid)` returned undefined,
|
||||
the `s &&` guard short-circuited, and no deletion occurred.
|
||||
The fix adds an explicit `if (!sessionsById.has(sid))` branch before
|
||||
the streaming check, so missing sessions are always purged.
|
||||
'''
|
||||
body = _function_body(SESSIONS_JS, '_purgeStaleInflightEntries')
|
||||
|
||||
# The function must check whether the sid exists in the sessions map.
|
||||
assert 'sessionsById.has(sid)' in body, (
|
||||
'_purgeStaleInflightEntries() must check sessionsById.has(sid) '
|
||||
'to catch sessions absent from _allSessions'
|
||||
)
|
||||
|
||||
# There must be a branch that deletes INFLIGHT[sid] for missing sessions.
|
||||
# It should appear before the `!s.is_streaming` check so that missing
|
||||
# sessions are always cleaned regardless of their streaming state.
|
||||
has_check_pos = body.find('sessionsById.has(sid)')
|
||||
assert has_check_pos != -1
|
||||
|
||||
# The deletion for absent sessions must be unconditional (no !s.is_streaming guard).
|
||||
# Walk forward from the has() check and verify delete appears without a streaming guard.
|
||||
segment = body[has_check_pos:]
|
||||
# Find the closing of the outer if block (the next unindented '}' or end of body).
|
||||
# Simpler: check the first occurrence of 'delete INFLIGHT[sid]' after has() and
|
||||
# verify the intervening code does NOT contain 'is_streaming' before that delete.
|
||||
first_delete = segment.find('delete INFLIGHT[sid]')
|
||||
assert first_delete != -1, 'No delete INFLIGHT[sid] found after sessionsById.has(sid)'
|
||||
between = segment[:first_delete]
|
||||
assert 'is_streaming' not in between, (
|
||||
'delete INFLIGHT[sid] for absent sessions must not be guarded by is_streaming'
|
||||
)
|
||||
|
||||
|
||||
def test_purge_removes_entry_when_sid_present_but_not_streaming():
|
||||
r'''An INFLIGHT entry for a session present in _allSessions with
|
||||
is_streaming:false must also be removed (existing behaviour preserved).
|
||||
'''
|
||||
body = _function_body(SESSIONS_JS, '_purgeStaleInflightEntries')
|
||||
assert '!s.is_streaming' in body, (
|
||||
'_purgeStaleInflightEntries() must still check !s.is_streaming for '
|
||||
'sessions present in _allSessions'
|
||||
)
|
||||
# Verify the delete for the non-streaming case is present.
|
||||
# The body should contain something like `if (!s.is_streaming) { delete INFLIGHT[sid]; ... }`
|
||||
ns_pos = body.find('!s.is_streaming')
|
||||
assert ns_pos != -1
|
||||
seg = body[ns_pos:]
|
||||
delete_in_ns = seg.find('delete INFLIGHT[sid]')
|
||||
assert delete_in_ns != -1, (
|
||||
'delete INFLIGHT[sid] must follow !s.is_streaming for sessions not streaming'
|
||||
)
|
||||
|
||||
|
||||
def test_purge_preserves_entry_when_sid_present_and_streaming():
|
||||
r'''An INFLIGHT entry for a session present in _allSessions with
|
||||
is_streaming:true must NOT be deleted.
|
||||
'''
|
||||
body = _function_body(SESSIONS_JS, '_purgeStaleInflightEntries')
|
||||
|
||||
# The non-streaming branch must be an if without an else that deletes.
|
||||
# If an else block deleted on streaming, the fix would be wrong.
|
||||
# We verify by checking that the body does NOT contain a pattern like:
|
||||
# `} else { delete INFLIGHT[sid]; }` immediately after an is_streaming check.
|
||||
ns_pos = body.find('!s.is_streaming')
|
||||
assert ns_pos != -1
|
||||
# The delete for non-streaming is in the same if block.
|
||||
# We confirm that there is no unconditional delete outside the two guarded paths.
|
||||
# Reconstruct the two guarded paths:
|
||||
# 1. if (!sessionsById.has(sid)) { delete INFLIGHT[sid]; }
|
||||
# 2. if (!s.is_streaming) { delete INFLIGHT[sid]; }
|
||||
# After both, there should be no third unguarded delete.
|
||||
|
||||
# Count 'delete INFLIGHT[sid]' — there should be exactly 2 (one per guarded path).
|
||||
delete_count = body.count('delete INFLIGHT[sid]')
|
||||
assert delete_count == 2, (
|
||||
f'Expected exactly 2 delete INFLIGHT[sid] statements (one per guarded path), '
|
||||
f'found {delete_count}. Streaming sessions must not be deleted.'
|
||||
)
|
||||
@@ -162,3 +162,159 @@ def test_insights_frontend_has_daily_chart_styles_and_range_switching_hooks():
|
||||
assert ".insights-daily-token-chart" in STYLE_CSS
|
||||
assert ".insights-daily-bar-output" in STYLE_CSS
|
||||
assert ".insights-model-cost" in STYLE_CSS
|
||||
|
||||
|
||||
def _make_daily_rows(n):
|
||||
rows = []
|
||||
for i in range(n):
|
||||
rows.append({
|
||||
'date': f'2026-01-{i+1:02d}',
|
||||
'input_tokens': (i + 1) * 100,
|
||||
'output_tokens': (i + 1) * 50,
|
||||
'sessions': 1,
|
||||
'cost': (i + 1) * 0.01,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
# Python reference implementation of the JS bucketing logic, so we can
|
||||
# verify the JS implementation produces the same behavior without needing
|
||||
# a JS runtime.
|
||||
def _py_bucket(rows):
|
||||
if not isinstance(rows, list) or len(rows) == 0:
|
||||
return []
|
||||
n = len(rows)
|
||||
if n <= 30:
|
||||
return list(rows) # unchanged
|
||||
|
||||
if n <= 90:
|
||||
bucket_size = 2
|
||||
elif n <= 180:
|
||||
bucket_size = 3
|
||||
elif n <= 365:
|
||||
bucket_size = 8 # ≤52 bars for 365 days; shrink-safe with minmax(0,1fr)
|
||||
else:
|
||||
bucket_size = 8 # fallback for >365 (shouldn't occur in practice)
|
||||
|
||||
result = []
|
||||
for i in range(0, n, bucket_size):
|
||||
sl = rows[i:i + bucket_size]
|
||||
inp = sum(r['input_tokens'] for r in sl)
|
||||
out = sum(r['output_tokens'] for r in sl)
|
||||
sess = sum(r['sessions'] for r in sl)
|
||||
cost = sum(r['cost'] for r in sl)
|
||||
first = sl[0]['date']
|
||||
last = sl[-1]['date']
|
||||
first_lbl = first[5:] # MM-DD
|
||||
last_lbl = last[5:]
|
||||
result.append({
|
||||
'label': (first_lbl if first == last else first_lbl + '--' + last_lbl),
|
||||
'title': first + (' -- ' + last if first != last else ''),
|
||||
'date': first,
|
||||
'input_tokens': inp,
|
||||
'output_tokens': out,
|
||||
'sessions': sess,
|
||||
'cost': cost,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def test_insights_bucketing_helper_preserves_short_ranges():
|
||||
# _bucketDailyTokensForChart must exist in panels.js
|
||||
assert '_bucketDailyTokensForChart' in PANELS_JS
|
||||
|
||||
# 7-day: unchanged (≤ 30 threshold)
|
||||
rows7 = _make_daily_rows(7)
|
||||
bucketed7 = _py_bucket(rows7)
|
||||
assert len(bucketed7) == 7, f'7-day should stay 7 bars, got {len(bucketed7)}'
|
||||
assert bucketed7[0]['input_tokens'] == 100
|
||||
|
||||
# 30-day: exactly 30 → unchanged
|
||||
rows30 = _make_daily_rows(30)
|
||||
bucketed30 = _py_bucket(rows30)
|
||||
assert len(bucketed30) == 30, f'30-day should stay 30 bars, got {len(bucketed30)}'
|
||||
|
||||
# 31-day: bucketed
|
||||
rows31 = _make_daily_rows(31)
|
||||
bucketed31 = _py_bucket(rows31)
|
||||
assert len(bucketed31) < 31, f'31-day should be bucketed, got {len(bucketed31)}'
|
||||
assert len(bucketed31) <= 16 # ceil(31/2)
|
||||
|
||||
|
||||
def test_insights_bucketing_helper_bounds_long_ranges():
|
||||
# 90-day → 2-day buckets → 45 bars
|
||||
rows90 = _make_daily_rows(90)
|
||||
bucketed90 = _py_bucket(rows90)
|
||||
assert len(bucketed90) <= 45, f'90-day should be <=45 bars, got {len(bucketed90)}'
|
||||
assert len(bucketed90) > 0
|
||||
|
||||
# 365-day → 8-day buckets → 46 bars (≤52 threshold)
|
||||
rows365 = _make_daily_rows(365)
|
||||
bucketed365 = _py_bucket(rows365)
|
||||
assert len(bucketed365) <= 52, f'365-day should be <=52 bars, got {len(bucketed365)}'
|
||||
assert len(bucketed365) > 0
|
||||
# First bucket has 8 days: 100+200+300+400+500+600+700+800 = 3600
|
||||
assert bucketed365[0]['input_tokens'] == 3600
|
||||
assert bucketed365[0]['sessions'] == 8
|
||||
|
||||
|
||||
def test_insights_bucketing_helper_preserves_label_and_title_fields():
|
||||
# Short range → rows unchanged; no .label/.title keys
|
||||
rows10 = _make_daily_rows(10)
|
||||
bucketed10 = _py_bucket(rows10)
|
||||
assert bucketed10[0]['date'] == '2026-01-01'
|
||||
assert 'label' not in bucketed10[0]
|
||||
assert 'title' not in bucketed10[0]
|
||||
|
||||
# 90-day → bucket rows have .label and .title
|
||||
rows90 = _make_daily_rows(90)
|
||||
bucketed90 = _py_bucket(rows90)
|
||||
assert 'label' in bucketed90[0], 'bucket row must have .label'
|
||||
assert 'title' in bucketed90[0], 'bucket row must have .title'
|
||||
assert '2026-01-01' in bucketed90[0]['title'], f'title should include start date, got {bucketed90[0]["title"]}'
|
||||
assert len(bucketed90[0]['label']) <= 12, f'label should be short, got {bucketed90[0]["label"]}'
|
||||
|
||||
|
||||
def test_insights_render_loop_uses_bucket_helper():
|
||||
src = PANELS_JS
|
||||
daily_section_start = src.find('// Daily token trend')
|
||||
daily_section_end = src.find('// Models table', daily_section_start)
|
||||
daily_section = src[daily_section_start:daily_section_end]
|
||||
|
||||
assert '_bucketDailyTokensForChart' in daily_section, '_bucketDailyTokensForChart must be called in the render loop'
|
||||
assert 'const chartRows' in daily_section, 'chartRows variable must be used instead of dailyTokens.map directly'
|
||||
|
||||
|
||||
def test_insights_css_chart_shrink_safe():
|
||||
assert '.insights-daily-token-chart' in STYLE_CSS
|
||||
chart_line = [line for line in STYLE_CSS.splitlines() if '.insights-daily-token-chart' in line][0]
|
||||
# minmax(0,1fr) instead of minmax(12px,1fr) lets long-range bars shrink to fit the card
|
||||
assert 'minmax(0,1fr)' in chart_line, f'chart must use minmax(0,1fr) for shrink-safe columns, got: {chart_line}'
|
||||
assert 'overflow:hidden' in chart_line, 'chart must have overflow:hidden to prevent horizontal scroll'
|
||||
assert 'max-width:100%' in chart_line or 'max-width' in chart_line, 'chart should constrain max-width'
|
||||
|
||||
|
||||
def test_insights_mobile_layout_stacks_usage_grid():
|
||||
# Regression test for issue #2104: Token Breakdown + Models should
|
||||
# stack on mobile instead of being side-by-side causing horizontal overflow
|
||||
assert 'insights-usage-grid' in PANELS_JS
|
||||
# Scoped mobile breakpoint that forces single-column layout
|
||||
assert '@media (max-width: 640px)' in STYLE_CSS
|
||||
assert '.insights-usage-grid' in STYLE_CSS
|
||||
assert 'grid-template-columns: 1fr' in STYLE_CSS
|
||||
|
||||
|
||||
def test_insights_mobile_models_table_has_contained_overflow():
|
||||
# Regression test for issue #2104: Models table should have contained
|
||||
# horizontal scrolling instead of pushing the whole page off-screen
|
||||
assert 'insights-model-table' in PANELS_JS
|
||||
# The mobile rule should include overflow-x handling for the models card/table
|
||||
# Search for the specific mobile rule that contains insights-usage-grid
|
||||
insights_mobile = '/* ── Mobile layout for Token Breakdown + Models'
|
||||
assert insights_mobile in STYLE_CSS, 'Issue #2104 mobile rules should exist in CSS'
|
||||
# Get the block from our specific mobile section to the next section comment
|
||||
section_start = STYLE_CSS.find(insights_mobile)
|
||||
section_end = STYLE_CSS.find('/* ── Checkpoints', section_start)
|
||||
section_block = STYLE_CSS[section_start:section_end]
|
||||
assert 'overflow-x' in section_block, 'Mobile rule should include overflow-x handling'
|
||||
assert 'insights-model-table' in section_block or 'insights-card' in section_block
|
||||
|
||||
@@ -123,7 +123,7 @@ class TestComposerVoiceButtonI18n:
|
||||
"voice_mode_toggle_active",
|
||||
)
|
||||
|
||||
LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")
|
||||
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")
|
||||
|
||||
def test_legacy_voice_toggle_key_removed(self):
|
||||
"""The old key whose string was 'Voice input' caused the duplicate-
|
||||
@@ -171,7 +171,7 @@ class TestComposerVoiceButtonI18n:
|
||||
class TestVoiceModePreferenceGate:
|
||||
"""boot.js must hide btnVoiceMode by default, surface it via Preferences."""
|
||||
|
||||
LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")
|
||||
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")
|
||||
|
||||
def test_voice_mode_pref_is_localstorage_backed(self):
|
||||
"""The pref reads from localStorage key 'hermes-voice-mode-button'."""
|
||||
|
||||
@@ -165,7 +165,7 @@ class TestSysModulesLookupInEnvLock:
|
||||
lock_lines.append(line)
|
||||
|
||||
lock_source = "\n".join(lock_lines)
|
||||
assert "_patch_skill_home_modules" in lock_source, (
|
||||
assert "patch_skill_home_modules" in lock_source, (
|
||||
"Inside `_ENV_LOCK`, streaming must use the shared skill module "
|
||||
"cache patch helper instead of duplicating module-specific logic "
|
||||
"(#2023/#2024)"
|
||||
@@ -179,15 +179,15 @@ class TestSysModulesLookupInEnvLock:
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "_patch_skill_home_modules"
|
||||
and node.name == "patch_skill_home_modules"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert helper is not None, "_patch_skill_home_modules() must be defined"
|
||||
assert helper is not None, "patch_skill_home_modules() must be defined"
|
||||
|
||||
helper_source = ast.get_source_segment(source, helper) or ""
|
||||
assert "sys.modules.get" in helper_source, (
|
||||
"_patch_skill_home_modules() must use sys.modules.get(), not import, "
|
||||
"patch_skill_home_modules() must use sys.modules.get(), not import, "
|
||||
"so env-lock callers do not trigger first-time imports (#2024)"
|
||||
)
|
||||
assert "HERMES_HOME" in helper_source
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
import api.models as models
|
||||
from api.models import SESSIONS, Session
|
||||
|
||||
|
||||
def _git(cwd, *args):
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_sessions(tmp_path, monkeypatch):
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
SESSIONS.clear()
|
||||
yield session_dir
|
||||
SESSIONS.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_worktree(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
remote = tmp_path / "remote.git"
|
||||
worktree = tmp_path / "hermes-status"
|
||||
repo.mkdir()
|
||||
_git(repo, "init")
|
||||
_git(repo, "config", "user.email", "test@example.com")
|
||||
_git(repo, "config", "user.name", "Hermes Test")
|
||||
_git(repo, "branch", "-M", "main")
|
||||
(repo / "README.md").write_text("hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-m", "initial")
|
||||
_git(remote.parent, "init", "--bare", remote.name)
|
||||
_git(repo, "remote", "add", "origin", str(remote))
|
||||
_git(repo, "push", "-u", "origin", "main")
|
||||
_git(repo, "worktree", "add", "-b", "hermes/status", str(worktree), "main")
|
||||
_git(worktree, "push", "-u", "origin", "hermes/status")
|
||||
return repo, worktree
|
||||
|
||||
|
||||
def _session_for_worktree(repo, worktree, **kwargs):
|
||||
return Session(
|
||||
session_id=kwargs.pop("session_id", "wtstatus001"),
|
||||
workspace=str(worktree),
|
||||
worktree_path=str(worktree),
|
||||
worktree_branch="hermes/status",
|
||||
worktree_repo_root=str(repo),
|
||||
worktree_created_at=123.0,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_worktree_status_reports_clean_existing_worktree(git_worktree):
|
||||
from api.worktrees import worktree_status_for_session
|
||||
|
||||
repo, worktree = git_worktree
|
||||
status = worktree_status_for_session(_session_for_worktree(repo, worktree))
|
||||
|
||||
assert status["path"] == str(worktree.resolve())
|
||||
assert status["exists"] is True
|
||||
assert status["listed"] is True
|
||||
assert status["dirty"] is False
|
||||
assert status["untracked_count"] == 0
|
||||
assert status["ahead_behind"]["available"] is True
|
||||
assert status["ahead_behind"]["ahead"] == 0
|
||||
assert status["ahead_behind"]["behind"] == 0
|
||||
assert status["locked_by_stream"] is False
|
||||
assert status["locked_by_terminal"] is False
|
||||
|
||||
|
||||
def test_worktree_status_reports_dirty_untracked_and_ahead(git_worktree):
|
||||
from api.worktrees import worktree_status_for_session
|
||||
|
||||
repo, worktree = git_worktree
|
||||
(worktree / "README.md").write_text("hello\nedited\n", encoding="utf-8")
|
||||
(worktree / "scratch.txt").write_text("local-only\n", encoding="utf-8")
|
||||
status = worktree_status_for_session(_session_for_worktree(repo, worktree))
|
||||
|
||||
assert status["dirty"] is True
|
||||
assert status["untracked_count"] == 1
|
||||
assert status["ahead_behind"]["ahead"] == 0
|
||||
|
||||
_git(worktree, "add", "README.md")
|
||||
_git(worktree, "commit", "-m", "local change")
|
||||
status = worktree_status_for_session(_session_for_worktree(repo, worktree))
|
||||
|
||||
assert status["dirty"] is True
|
||||
assert status["untracked_count"] == 1
|
||||
assert status["ahead_behind"]["available"] is True
|
||||
assert status["ahead_behind"]["ahead"] == 1
|
||||
assert status["ahead_behind"]["behind"] == 0
|
||||
|
||||
|
||||
def test_worktree_status_handles_missing_path_without_git_mutation(tmp_path):
|
||||
from api.worktrees import worktree_status_for_session
|
||||
|
||||
missing = tmp_path / "missing-worktree"
|
||||
status = worktree_status_for_session(
|
||||
SimpleNamespace(
|
||||
session_id="missing",
|
||||
worktree_path=str(missing),
|
||||
worktree_repo_root=str(tmp_path / "repo"),
|
||||
active_stream_id=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert status["path"] == str(missing.resolve())
|
||||
assert status["exists"] is False
|
||||
assert status["dirty"] is False
|
||||
assert status["untracked_count"] == 0
|
||||
assert status["ahead_behind"]["ahead"] == 0
|
||||
assert status["ahead_behind"]["behind"] == 0
|
||||
|
||||
|
||||
def test_worktree_status_uses_live_stream_registry(git_worktree):
|
||||
from api.config import STREAMS, STREAMS_LOCK
|
||||
from api.worktrees import worktree_status_for_session
|
||||
|
||||
repo, worktree = git_worktree
|
||||
session = _session_for_worktree(
|
||||
repo,
|
||||
worktree,
|
||||
active_stream_id="live-stream",
|
||||
)
|
||||
|
||||
with STREAMS_LOCK:
|
||||
STREAMS["live-stream"] = object()
|
||||
try:
|
||||
assert worktree_status_for_session(session)["locked_by_stream"] is True
|
||||
finally:
|
||||
with STREAMS_LOCK:
|
||||
STREAMS.pop("live-stream", None)
|
||||
|
||||
assert worktree_status_for_session(session)["locked_by_stream"] is False
|
||||
|
||||
|
||||
def test_worktree_status_reports_live_terminal_lock(git_worktree, monkeypatch):
|
||||
import api.terminal as terminal
|
||||
from api.worktrees import worktree_status_for_session
|
||||
|
||||
repo, worktree = git_worktree
|
||||
|
||||
class FakeTerminal:
|
||||
workspace = str(worktree.resolve())
|
||||
|
||||
def is_alive(self):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(terminal, "get_terminal", lambda session_id: FakeTerminal())
|
||||
|
||||
status = worktree_status_for_session(_session_for_worktree(repo, worktree))
|
||||
|
||||
assert status["locked_by_terminal"] is True
|
||||
|
||||
|
||||
def test_worktree_status_endpoint_returns_session_owned_status(git_worktree, monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
repo, worktree = git_worktree
|
||||
session = _session_for_worktree(repo, worktree, session_id="route_wt")
|
||||
session.save()
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"j",
|
||||
lambda handler, payload, status=200, extra_headers=None: captured.update(
|
||||
payload=payload,
|
||||
status=status,
|
||||
) or True,
|
||||
)
|
||||
|
||||
handled = routes.handle_get(
|
||||
object(),
|
||||
urlparse("/api/session/worktree/status?session_id=route_wt"),
|
||||
)
|
||||
|
||||
assert handled is True
|
||||
assert captured["status"] == 200
|
||||
assert captured["payload"]["status"]["path"] == str(worktree.resolve())
|
||||
assert captured["payload"]["status"]["exists"] is True
|
||||
|
||||
|
||||
def test_worktree_status_endpoint_rejects_non_worktree_session(tmp_path, monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
session = Session(session_id="plain", workspace=str(workspace))
|
||||
session.save()
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"bad",
|
||||
lambda handler, message, status=400: captured.update(
|
||||
message=message,
|
||||
status=status,
|
||||
) or True,
|
||||
)
|
||||
|
||||
handled = routes.handle_get(
|
||||
object(),
|
||||
urlparse("/api/session/worktree/status?session_id=plain"),
|
||||
)
|
||||
|
||||
assert handled is True
|
||||
assert captured["status"] == 400
|
||||
assert "not worktree-backed" in captured["message"]
|
||||
@@ -24,6 +24,7 @@ def test_batch_archive_delete_confirmations_count_worktree_sessions():
|
||||
src = read("static/sessions.js")
|
||||
i18n = read("static/i18n.js")
|
||||
assert "function _worktreeSessionCount(ids)" in src
|
||||
assert "function _worktreeResponseCount(results)" in src
|
||||
assert "session_batch_delete_worktree_confirm" in src
|
||||
assert "session_batch_archive_worktree_confirm" in src
|
||||
assert "session_batch_delete_worktree_confirm" in i18n
|
||||
@@ -43,6 +44,23 @@ def test_archive_and_delete_action_descriptions_are_worktree_specific():
|
||||
assert "session_archived_worktree: 'Session archived. Worktree remains on disk.'" in i18n
|
||||
|
||||
|
||||
def test_archive_delete_success_copy_prefers_response_worktree_retained():
|
||||
src = read("static/sessions.js")
|
||||
assert "function _sessionResponseRetainsWorktree(response, session)" in src
|
||||
assert "typeof response.worktree_retained==='boolean'" in src
|
||||
assert "return response.worktree_retained;" in src
|
||||
assert "return !!(session&&session.worktree_path);" in src
|
||||
assert src.index("return response.worktree_retained;") < src.index(
|
||||
"return !!(session&&session.worktree_path);"
|
||||
)
|
||||
assert "function _sessionArchiveToast(response, session)" in src
|
||||
assert "session.archived?_sessionArchiveToast(response,session):t('session_restored')" in src
|
||||
assert "_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree')" in src
|
||||
assert "const retainedCount=_worktreeResponseCount(results)" in src
|
||||
assert "showToast(retainedCount?t('session_archived_worktree'):t('session_archived'))" in src
|
||||
assert "showToast((retainedCount?t('session_deleted_worktree'):t('session_delete'))" in src
|
||||
|
||||
|
||||
def test_worktree_archive_delete_api_responses_are_explicit():
|
||||
src = read("api/routes.py")
|
||||
assert "def _worktree_retained_payload(session)" in src
|
||||
|
||||
@@ -33,7 +33,12 @@ def test_cache_render_purges_stale_non_streaming_inflight_entries():
|
||||
assert "if (s && s.session_id) sessionsById.set(s.session_id, s);" in purge_block
|
||||
assert "const s = sessionsById.get(sid);" in purge_block
|
||||
assert "_allSessionsById" not in purge_block
|
||||
assert "if (s && !s.is_streaming)" in purge_block
|
||||
# Non-streaming sessions that ARE in _allSessions are purged (original #2066
|
||||
# semantics). Sessions absent from _allSessions are also purged (adds #2092
|
||||
# ghost-entry cleanup); the guard check for !sessionsById.has(sid) must come
|
||||
# before the non-streaming check for code clarity and correctness.
|
||||
assert "if (!sessionsById.has(sid))" in purge_block
|
||||
assert "!s.is_streaming" in purge_block
|
||||
assert "delete INFLIGHT[sid];" in purge_block
|
||||
assert "clearInflightState(sid);" in purge_block
|
||||
assert "_purgeStaleInflightEntries();" in render_block
|
||||
@@ -62,10 +67,12 @@ console.log(JSON.stringify({{inflight: INFLIGHT, cleared}}));
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
# With #2092, sessions absent from _allSessions (like `unknown-session`)
|
||||
# are also purged and have clearInflightState called for them. `done-session`
|
||||
# remains in _allSessions with is_streaming=false so it is still purged too.
|
||||
assert payload == {
|
||||
"inflight": {
|
||||
"running-session": True,
|
||||
"unknown-session": True,
|
||||
},
|
||||
"cleared": ["done-session"],
|
||||
"cleared": sorted(["unknown-session", "done-session"]),
|
||||
}
|
||||
|
||||
@@ -112,13 +112,14 @@ class TestScrollPinningFix:
|
||||
"style.css must define .scroll-to-bottom-btn styles (#677)"
|
||||
)
|
||||
|
||||
def test_scroll_to_bottom_button_is_sticky(self):
|
||||
"""Scroll-to-bottom button must use position:sticky so it stays visible (#677)."""
|
||||
def test_scroll_to_bottom_button_is_overlayed(self):
|
||||
"""Scroll-to-bottom button stays visible as an overlay outside transcript layout (#677)."""
|
||||
btn_css_pos = STYLE_CSS.find(".scroll-to-bottom-btn")
|
||||
assert btn_css_pos != -1
|
||||
btn_css = STYLE_CSS[btn_css_pos:btn_css_pos + 300]
|
||||
assert "sticky" in btn_css, (
|
||||
".scroll-to-bottom-btn must use position:sticky to stay at bottom of viewport (#677)"
|
||||
assert "position:absolute" in btn_css, (
|
||||
".scroll-to-bottom-btn must be an overlay so it stays visible without "
|
||||
"participating in transcript scroll layout (#677)"
|
||||
)
|
||||
|
||||
def test_scroll_listener_hides_button_when_pinned(self):
|
||||
|
||||
@@ -293,8 +293,10 @@ def test_login_locale_resolver_handles_new_locales():
|
||||
assert _resolve_login_locale_key("pt-PT") == "pt"
|
||||
assert _resolve_login_locale_key("ko") == "ko"
|
||||
assert _resolve_login_locale_key("ko-KR") == "ko"
|
||||
assert _resolve_login_locale_key("fr") == "fr"
|
||||
assert _resolve_login_locale_key("fr-FR") == "fr"
|
||||
assert _resolve_login_locale_key("fr-CA") == "fr"
|
||||
# Unknown locale still falls back to en.
|
||||
assert _resolve_login_locale_key("fr") == "en"
|
||||
assert _resolve_login_locale_key("xx-YY") == "en"
|
||||
|
||||
|
||||
@@ -328,3 +330,33 @@ def test_login_flow_keys_are_translated(loc_key: str):
|
||||
f"Locale {loc_key!r} leaks English for login-flow keys: {leaks}. "
|
||||
f"Translate these in static/i18n.js (issue #1442)."
|
||||
)
|
||||
|
||||
|
||||
# ── Session-management key parity ─────────────────────────────────────────────
|
||||
#
|
||||
# Keys added for session batch operations and multi-select (#2112).
|
||||
# Every locale block must have these keys; missing them falls back to English
|
||||
# which is a regression for non-English users.
|
||||
|
||||
SESSION_MANAGEMENT_KEYS = (
|
||||
"session_batch_delete_confirm",
|
||||
"session_batch_archive_confirm",
|
||||
"session_batch_delete_worktree_confirm",
|
||||
"session_batch_archive_worktree_confirm",
|
||||
"session_select_mode",
|
||||
"session_select_mode_desc",
|
||||
"session_select_all",
|
||||
"session_selected_count",
|
||||
"session_no_selection",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"])
|
||||
def test_session_management_keys_present(loc_key: str):
|
||||
"""Every locale block must define all session-management keys (no fallback to English)."""
|
||||
seg = _i18n_locale_block(loc_key)
|
||||
missing = [k for k in SESSION_MANAGEMENT_KEYS if _value_of(seg, k) is None]
|
||||
assert not missing, (
|
||||
f"Locale {loc_key!r} is missing session-management keys: {missing}. "
|
||||
f"Add translations in static/i18n.js (issue #2112)."
|
||||
)
|
||||
|
||||
@@ -148,6 +148,29 @@ def test_mobile_breakpoint_640px_present():
|
||||
"Missing @media(max-width:640px) breakpoint in style.css"
|
||||
|
||||
|
||||
def test_settings_system_version_controls_wrap_on_phone_widths():
|
||||
"""Settings -> System version badges must wrap instead of overflowing phones."""
|
||||
mobile_css = "\n".join(_max_width_media_blocks(768))
|
||||
assert ".settings-section-head" in mobile_css, (
|
||||
"Settings section header needs a mobile rule so title and update controls stack."
|
||||
)
|
||||
assert "flex-direction:column" in mobile_css.replace(" ", ""), (
|
||||
"Settings section header should stack vertically on mobile."
|
||||
)
|
||||
assert "#checkUpdatesBlock" in mobile_css, (
|
||||
"Settings update/version controls need a mobile rule."
|
||||
)
|
||||
assert "flex-wrap:wrap" in mobile_css.replace(" ", ""), (
|
||||
"Version badges and Check now button must wrap instead of overflowing."
|
||||
)
|
||||
assert "width:100%" in mobile_css.replace(" ", ""), (
|
||||
"The update controls row should take the available mobile width."
|
||||
)
|
||||
assert ".settings-version-badge" in mobile_css and "white-space:nowrap" in mobile_css.replace(" ", ""), (
|
||||
"Individual version badges should stay intact while the group wraps."
|
||||
)
|
||||
|
||||
|
||||
def test_rightpanel_mobile_slide_over_css():
|
||||
"""Right panel must have position:fixed slide-over CSS for mobile."""
|
||||
# At max-width:900px the rightpanel should be position:fixed, off-screen right
|
||||
|
||||
@@ -70,3 +70,21 @@ def test_fallback_resolved_initialized_to_none():
|
||||
assert "_fallback_resolved = None" in block, (
|
||||
"_fallback_resolved must be initialized to None so callers can rely on its presence"
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_resolved_preserves_credential_hints():
|
||||
"""Fallback entries must keep credential hints for AIAgent fallback activation."""
|
||||
block = _extract_fallback_block()
|
||||
resolved_start = block.find("_fallback_resolved = {")
|
||||
assert resolved_start != -1, "_fallback_resolved dict not found"
|
||||
resolved_end = block.find("}", resolved_start)
|
||||
resolved_dict = block[resolved_start:resolved_end]
|
||||
|
||||
assert "'api_key': _fb_entry.get('api_key')" in resolved_dict, (
|
||||
"WebUI must preserve fallback_model/fallback_providers api_key so "
|
||||
"AIAgent._try_activate_fallback can authenticate the fallback."
|
||||
)
|
||||
assert "'key_env': _fb_entry.get('key_env')" in resolved_dict, (
|
||||
"WebUI must preserve fallback_model/fallback_providers key_env so "
|
||||
"AIAgent._try_activate_fallback can resolve env-backed fallback keys."
|
||||
)
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
import urllib.error
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
@@ -275,6 +278,154 @@ def test_codex_account_usage_unavailable_is_sanitized(monkeypatch, tmp_path):
|
||||
assert "secret" not in repr(result).lower()
|
||||
|
||||
|
||||
def test_codex_account_usage_subprocess_falls_back_to_credential_pool(monkeypatch, capsys):
|
||||
"""Codex quota probes should use credential_pool credentials when legacy auth misses."""
|
||||
import api.providers as providers
|
||||
|
||||
def b64url(payload: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii")
|
||||
|
||||
token = ".".join((
|
||||
b64url(b'{"alg":"none","typ":"JWT"}'),
|
||||
b64url(json.dumps({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acct-test-123",
|
||||
},
|
||||
}).encode("utf-8")),
|
||||
b64url(b"signature"),
|
||||
))
|
||||
|
||||
fetch_calls = []
|
||||
load_pool_calls = []
|
||||
selected = []
|
||||
seen = {}
|
||||
|
||||
agent_mod = types.ModuleType("agent")
|
||||
agent_mod.__path__ = []
|
||||
account_usage_mod = types.ModuleType("agent.account_usage")
|
||||
credential_pool_mod = types.ModuleType("agent.credential_pool")
|
||||
|
||||
def fake_fetch_account_usage(provider, *, base_url=None, api_key=None):
|
||||
fetch_calls.append((provider, base_url, api_key))
|
||||
return None
|
||||
|
||||
class FakePool:
|
||||
def select(self):
|
||||
selected.append(True)
|
||||
return SimpleNamespace(
|
||||
runtime_api_key=token,
|
||||
runtime_base_url="https://chatgpt.com/backend-api/codex",
|
||||
)
|
||||
|
||||
def fake_load_pool(provider):
|
||||
load_pool_calls.append(provider)
|
||||
return FakePool()
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
seen["url"] = req.full_url
|
||||
seen["timeout"] = timeout
|
||||
seen["headers"] = {key.lower(): value for key, value in req.header_items()}
|
||||
payload = {
|
||||
"plan_type": "pro",
|
||||
"rate_limit": {
|
||||
"primary_window": {"used_percent": 15, "reset_at": 1_900_000_000},
|
||||
"secondary_window": {"used_percent": 40, "reset_at": "2030-03-24T12:30:00Z"},
|
||||
},
|
||||
"credits": {"has_credits": True, "balance": 12.5},
|
||||
}
|
||||
return _FakeResponse(json.dumps(payload).encode("utf-8"))
|
||||
|
||||
account_usage_mod.fetch_account_usage = fake_fetch_account_usage
|
||||
credential_pool_mod.load_pool = fake_load_pool
|
||||
monkeypatch.setitem(sys.modules, "agent", agent_mod)
|
||||
monkeypatch.setitem(sys.modules, "agent.account_usage", account_usage_mod)
|
||||
monkeypatch.setitem(sys.modules, "agent.credential_pool", credential_pool_mod)
|
||||
monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen)
|
||||
monkeypatch.setattr(sys, "argv", ["quota-probe", "openai-codex", ""])
|
||||
|
||||
exec(providers._ACCOUNT_USAGE_SUBPROCESS_CODE, {"__name__": "__main__"})
|
||||
|
||||
output = capsys.readouterr().out.strip()
|
||||
snapshot = json.loads(output)
|
||||
|
||||
assert fetch_calls == [("openai-codex", None, None)]
|
||||
assert load_pool_calls == ["openai-codex"]
|
||||
assert selected == [True]
|
||||
assert seen["url"] == "https://chatgpt.com/backend-api/wham/usage"
|
||||
assert seen["timeout"] == 15.0
|
||||
headers = seen["headers"]
|
||||
assert headers["authorization"] == f"Bearer {token}"
|
||||
assert headers["accept"] == "application/json"
|
||||
assert headers["originator"] == "codex_cli_rs"
|
||||
assert headers["user-agent"].startswith("codex_cli_rs/")
|
||||
assert headers["chatgpt-account-id"] == "acct-test-123"
|
||||
assert snapshot["provider"] == "openai-codex"
|
||||
assert snapshot["source"] == "usage_api"
|
||||
assert snapshot["plan"] == "Pro"
|
||||
assert snapshot["windows"][0]["label"] == "Session"
|
||||
assert snapshot["windows"][0]["used_percent"] == 15.0
|
||||
assert snapshot["windows"][1]["label"] == "Weekly"
|
||||
assert snapshot["details"] == ["Credits balance: $12.50"]
|
||||
assert snapshot["available"] is True
|
||||
assert token not in output
|
||||
|
||||
|
||||
def test_codex_account_usage_subprocess_keeps_legacy_reason_when_pool_misses(monkeypatch, capsys):
|
||||
"""A failed pool fallback should not discard the legacy unavailable reason."""
|
||||
import api.providers as providers
|
||||
|
||||
fetch_calls = []
|
||||
load_pool_calls = []
|
||||
|
||||
agent_mod = types.ModuleType("agent")
|
||||
agent_mod.__path__ = []
|
||||
account_usage_mod = types.ModuleType("agent.account_usage")
|
||||
credential_pool_mod = types.ModuleType("agent.credential_pool")
|
||||
|
||||
def fake_fetch_account_usage(provider, *, base_url=None, api_key=None):
|
||||
fetch_calls.append((provider, base_url, api_key))
|
||||
return SimpleNamespace(
|
||||
provider="openai-codex",
|
||||
source="usage_api",
|
||||
title="Account limits",
|
||||
plan=None,
|
||||
windows=(),
|
||||
details=(),
|
||||
available=False,
|
||||
unavailable_reason="Codex account limits are not available for this credential.",
|
||||
fetched_at=datetime(2030, 3, 17, 12, 30, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
class EmptyPool:
|
||||
def select(self):
|
||||
return None
|
||||
|
||||
def fake_load_pool(provider):
|
||||
load_pool_calls.append(provider)
|
||||
return EmptyPool()
|
||||
|
||||
def explode_urlopen(*_args, **_kwargs):
|
||||
raise AssertionError("no network call should happen when the pool has no selected entry")
|
||||
|
||||
account_usage_mod.fetch_account_usage = fake_fetch_account_usage
|
||||
credential_pool_mod.load_pool = fake_load_pool
|
||||
monkeypatch.setitem(sys.modules, "agent", agent_mod)
|
||||
monkeypatch.setitem(sys.modules, "agent.account_usage", account_usage_mod)
|
||||
monkeypatch.setitem(sys.modules, "agent.credential_pool", credential_pool_mod)
|
||||
monkeypatch.setattr(providers.urllib.request, "urlopen", explode_urlopen)
|
||||
monkeypatch.setattr(sys, "argv", ["quota-probe", "openai-codex", ""])
|
||||
|
||||
exec(providers._ACCOUNT_USAGE_SUBPROCESS_CODE, {"__name__": "__main__"})
|
||||
|
||||
snapshot = json.loads(capsys.readouterr().out.strip())
|
||||
|
||||
assert fetch_calls == [("openai-codex", None, None)]
|
||||
assert load_pool_calls == ["openai-codex"]
|
||||
assert snapshot["available"] is False
|
||||
assert snapshot["unavailable_reason"] == "Codex account limits are not available for this credential."
|
||||
assert snapshot["fetched_at"] == "2030-03-17T12:30:00Z"
|
||||
|
||||
|
||||
def test_anthropic_oauth_usage_unavailable_reason_is_reported(monkeypatch, tmp_path):
|
||||
"""Hermes Agent can report why account limits are not available."""
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||||
|
||||
@@ -32,7 +32,8 @@ def test_session_jump_buttons_are_opt_in_and_keep_existing_bottom_button():
|
||||
assert "session_jump_buttons: !!($('settingsSessionJumpButtons')||{}).checked" in PANELS_JS
|
||||
|
||||
scroll_listener = UI_JS[UI_JS.index("el.addEventListener('scroll'") : UI_JS.index("})();", UI_JS.index("el.addEventListener('scroll'"))]
|
||||
assert "if(btn) btn.style.display=_scrollPinned?'none':'flex'" in scroll_listener
|
||||
assert "const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80" in scroll_listener
|
||||
assert "if(btn) btn.style.display=showBottomButton?'flex':'none'" in scroll_listener
|
||||
assert "!_isSessionJumpButtonsEnabled()||_scrollPinned" not in UI_JS
|
||||
|
||||
|
||||
|
||||
@@ -338,11 +338,16 @@ def test_lineage_segment_expansion_static_contract():
|
||||
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
||||
css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
assert "const _expandedLineageKeys = new Set();" in js
|
||||
assert "const _lineageReportCache = new Map();" in js
|
||||
assert "const _lineageReportInflight = new Map();" in js
|
||||
assert "session-lineage-count,.session-lineage-segments,.session-lineage-segment" in js
|
||||
assert "segmentCountEl.setAttribute('aria-expanded'" in js
|
||||
assert "_expandedLineageKeys.has(lineageKey)" in js
|
||||
assert "_expandedLineageKeys.add(lineageKey)" in js
|
||||
assert "_expandedLineageKeys.delete(lineageKey)" in js
|
||||
assert "_fetchLineageReportForRow(s,lineageKey).then" in js
|
||||
assert "'/api/session/lineage/report?session_id='" in js
|
||||
assert "encodeURIComponent(s.session_id)" in js
|
||||
assert "className='session-lineage-segments'" in js
|
||||
assert "className='session-lineage-segment'" in js
|
||||
assert "const segTitle=seg.title||t('session_lineage_segment_untitled');" in js
|
||||
@@ -354,6 +359,133 @@ def test_lineage_segment_expansion_static_contract():
|
||||
assert ".session-lineage-segment{" in css
|
||||
|
||||
|
||||
def test_lineage_report_fetch_is_needed_only_when_backend_count_exceeds_materialized_segments():
|
||||
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
||||
source = f"""
|
||||
const src = {js!r};
|
||||
function extractFunc(name) {{
|
||||
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
const _lineageReportCache = new Map();
|
||||
const _lineageReportInflight = new Map();
|
||||
eval(extractFunc('_lineageReportCacheKey'));
|
||||
eval(extractFunc('_lineageLocalSegmentCount'));
|
||||
eval(extractFunc('_lineageReportNeedsFetch'));
|
||||
const backendOnly = {{session_id:'tip', _lineage_key:'root', _compression_segment_count:25}};
|
||||
const localFull = {{
|
||||
session_id:'tip',
|
||||
_lineage_key:'root',
|
||||
_compression_segment_count:2,
|
||||
_lineage_segments:[{{session_id:'tip'}}, {{session_id:'root'}}],
|
||||
}};
|
||||
const before = _lineageReportNeedsFetch(backendOnly, 'root', 25);
|
||||
_lineageReportCache.set('root', {{segments:[{{session_id:'tip'}}, {{session_id:'root'}}]}});
|
||||
const afterCache = _lineageReportNeedsFetch(backendOnly, 'root', 25);
|
||||
const fullLocal = _lineageReportNeedsFetch(localFull, 'root', 2);
|
||||
console.log(JSON.stringify({{before, afterCache, fullLocal}}));
|
||||
"""
|
||||
assert json.loads(_run_node(source)) == {"before": True, "afterCache": False, "fullLocal": False}
|
||||
|
||||
|
||||
def test_cached_lineage_report_segments_merge_with_materialized_segments_without_duplicates_or_children():
|
||||
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
||||
source = f"""
|
||||
const src = {js!r};
|
||||
function extractFunc(name) {{
|
||||
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
const _lineageReportCache = new Map();
|
||||
eval(extractFunc('_lineageReportCacheKey'));
|
||||
eval(extractFunc('_lineageSegmentsForRender'));
|
||||
_lineageReportCache.set('root', {{
|
||||
segments:[
|
||||
{{session_id:'tip', title:'Tip', role:'tip', started_at:30}},
|
||||
{{session_id:'root', title:'Root', role:'hidden_segment', started_at:20}},
|
||||
{{session_id:'older', title:'Older', role:'hidden_segment', started_at:10}},
|
||||
{{session_id:'child', title:'Child', role:'child_session', started_at:40}},
|
||||
],
|
||||
children:[{{session_id:'child', title:'Child', role:'child_session'}}],
|
||||
}});
|
||||
const row = {{
|
||||
session_id:'tip',
|
||||
_lineage_key:'root',
|
||||
_lineage_segments:[{{session_id:'tip', title:'Tip'}}, {{session_id:'root', title:'Root'}}],
|
||||
}};
|
||||
const segments = _lineageSegmentsForRender(row, 'root').map(seg => seg.session_id);
|
||||
console.log(JSON.stringify(segments));
|
||||
"""
|
||||
assert json.loads(_run_node(source)) == ["root", "older"]
|
||||
|
||||
|
||||
def test_lineage_report_fetch_uses_endpoint_once_and_caches_result():
|
||||
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
||||
source = f"""
|
||||
const src = {js!r};
|
||||
function extractFunc(name) {{
|
||||
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
const _lineageReportCache = new Map();
|
||||
const _lineageReportInflight = new Map();
|
||||
let _lineageReportCacheGeneration = 0;
|
||||
const calls = [];
|
||||
function api(path) {{
|
||||
calls.push(path);
|
||||
return Promise.resolve({{found:true, segments:[{{session_id:'tip'}}, {{session_id:'root'}}]}});
|
||||
}}
|
||||
eval(extractFunc('_lineageReportCacheKey'));
|
||||
eval(extractFunc('_fetchLineageReportForRow'));
|
||||
(async()=>{{
|
||||
const row = {{session_id:'tip', _lineage_key:'root'}};
|
||||
const [first, second] = await Promise.all([
|
||||
_fetchLineageReportForRow(row, 'root'),
|
||||
_fetchLineageReportForRow(row, 'root'),
|
||||
]);
|
||||
await _fetchLineageReportForRow(row, 'root');
|
||||
console.log(JSON.stringify({{
|
||||
calls,
|
||||
cached:_lineageReportCache.has('root'),
|
||||
same:first===second,
|
||||
}}));
|
||||
}})().catch(err=>{{console.error(err); process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(_run_node(source))
|
||||
assert result == {
|
||||
"calls": ["/api/session/lineage/report?session_id=tip"],
|
||||
"cached": True,
|
||||
"same": True,
|
||||
}
|
||||
|
||||
|
||||
def test_active_hidden_lineage_segment_auto_expands_parent():
|
||||
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
||||
source = f"""
|
||||
|
||||
+298
-33
@@ -6,6 +6,8 @@ import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
|
||||
from api.models import Session
|
||||
@@ -59,39 +61,9 @@ class _FakeAgent:
|
||||
_FakeAgent.last_instance = self
|
||||
|
||||
|
||||
def _make_session(messages=None):
|
||||
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
messages = messages or [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
{"role": "user", "content": "three"},
|
||||
{"role": "assistant", "content": "four"},
|
||||
]
|
||||
s = Session(
|
||||
session_id="compress_test_001",
|
||||
title="Untitled",
|
||||
workspace="/tmp/hermes-webui-test",
|
||||
model="openai/gpt-5.4-mini",
|
||||
messages=messages,
|
||||
)
|
||||
s.save(touch_updated_at=False)
|
||||
return s.session_id
|
||||
|
||||
|
||||
def test_session_compress_requires_session_id(cleanup_test_sessions):
|
||||
handler = _FakeHandler()
|
||||
_handle_session_compress(handler, {})
|
||||
assert handler.status == 400
|
||||
assert handler.payload()["error"] == "Missing required field(s): session_id"
|
||||
|
||||
|
||||
def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions):
|
||||
created = cleanup_test_sessions
|
||||
sid = _make_session()
|
||||
created.append(sid)
|
||||
|
||||
def _install_fake_compression_runtime(monkeypatch, agent_cls):
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = _FakeAgent
|
||||
fake_run_agent.AIAgent = agent_cls
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
|
||||
import api.config as _cfg
|
||||
@@ -128,6 +100,40 @@ def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions):
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _make_session(messages=None):
|
||||
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
messages = messages or [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
{"role": "user", "content": "three"},
|
||||
{"role": "assistant", "content": "four"},
|
||||
]
|
||||
s = Session(
|
||||
session_id=f"compress_test_{time.time_ns()}",
|
||||
title="Untitled",
|
||||
workspace="/tmp/hermes-webui-test",
|
||||
model="openai/gpt-5.4-mini",
|
||||
messages=messages,
|
||||
)
|
||||
s.save(touch_updated_at=False)
|
||||
return s.session_id
|
||||
|
||||
|
||||
def test_session_compress_requires_session_id(cleanup_test_sessions):
|
||||
handler = _FakeHandler()
|
||||
_handle_session_compress(handler, {})
|
||||
assert handler.status == 400
|
||||
assert handler.payload()["error"] == "Missing required field(s): session_id"
|
||||
|
||||
|
||||
def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions):
|
||||
created = cleanup_test_sessions
|
||||
sid = _make_session()
|
||||
created.append(sid)
|
||||
|
||||
_install_fake_compression_runtime(monkeypatch, _FakeAgent)
|
||||
|
||||
handler = _FakeHandler()
|
||||
_handle_session_compress(handler, {"session_id": sid, "focus_topic": "database schema"})
|
||||
|
||||
@@ -153,6 +159,253 @@ def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions):
|
||||
assert _FakeAgent.last_instance.context_compressor.calls[0]["focus_topic"] == "database schema"
|
||||
|
||||
|
||||
def test_session_compress_start_is_async_and_reuses_running_job(monkeypatch, cleanup_test_sessions):
|
||||
import api.routes as routes
|
||||
|
||||
assert hasattr(routes, "_handle_session_compress_start")
|
||||
assert hasattr(routes, "_handle_session_compress_status")
|
||||
|
||||
class BlockingCompressor:
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
calls = []
|
||||
|
||||
def compress(self, messages, current_tokens=None, focus_topic=None):
|
||||
self.calls.append({"messages": list(messages), "focus_topic": focus_topic})
|
||||
self.entered.set()
|
||||
assert self.release.wait(timeout=5), "test timed out waiting to release compression"
|
||||
return [messages[0], messages[-1]]
|
||||
|
||||
class BlockingAgent:
|
||||
instances = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.context_compressor = BlockingCompressor()
|
||||
self.instances.append(self)
|
||||
|
||||
created = cleanup_test_sessions
|
||||
sid = _make_session()
|
||||
created.append(sid)
|
||||
_install_fake_compression_runtime(monkeypatch, BlockingAgent)
|
||||
try:
|
||||
first = _FakeHandler()
|
||||
routes._handle_session_compress_start(first, {"session_id": sid, "focus_topic": "slow"})
|
||||
assert first.status == 200
|
||||
first_payload = first.payload()
|
||||
assert first_payload["ok"] is True
|
||||
assert first_payload["status"] == "running"
|
||||
assert first_payload["session_id"] == sid
|
||||
assert first_payload["focus_topic"] == "slow"
|
||||
assert BlockingCompressor.entered.wait(timeout=2)
|
||||
|
||||
second = _FakeHandler()
|
||||
routes._handle_session_compress_start(second, {"session_id": sid, "focus_topic": "slow"})
|
||||
assert second.status == 200
|
||||
second_payload = second.payload()
|
||||
assert second_payload["status"] == "running"
|
||||
assert len(BlockingAgent.instances) == 1
|
||||
|
||||
running = _FakeHandler()
|
||||
routes._handle_session_compress_status(running, sid)
|
||||
assert running.status == 200
|
||||
assert running.payload()["status"] == "running"
|
||||
finally:
|
||||
BlockingCompressor.release.set()
|
||||
|
||||
deadline = time.time() + 5
|
||||
done_payload = None
|
||||
while time.time() < deadline:
|
||||
done = _FakeHandler()
|
||||
routes._handle_session_compress_status(done, sid)
|
||||
payload = done.payload()
|
||||
if payload["status"] == "done":
|
||||
done_payload = payload
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert done_payload is not None
|
||||
assert done_payload["summary"]["headline"] == "Compressed: 4 → 2 messages"
|
||||
assert done_payload["session"]["messages"] == [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "four"},
|
||||
]
|
||||
|
||||
|
||||
def test_session_compress_status_reports_worker_error_without_raw_paths(monkeypatch, cleanup_test_sessions):
|
||||
import api.routes as routes
|
||||
|
||||
assert hasattr(routes, "_handle_session_compress_start")
|
||||
assert hasattr(routes, "_handle_session_compress_status")
|
||||
|
||||
class FailingCompressor:
|
||||
entered = threading.Event()
|
||||
|
||||
def compress(self, messages, current_tokens=None, focus_topic=None):
|
||||
self.entered.set()
|
||||
raise RuntimeError("provider log at /Users/alice/.hermes/secrets/token.txt failed")
|
||||
|
||||
class FailingAgent:
|
||||
def __init__(self, **kwargs):
|
||||
self.context_compressor = FailingCompressor()
|
||||
|
||||
created = cleanup_test_sessions
|
||||
sid = _make_session()
|
||||
created.append(sid)
|
||||
_install_fake_compression_runtime(monkeypatch, FailingAgent)
|
||||
|
||||
start = _FakeHandler()
|
||||
routes._handle_session_compress_start(start, {"session_id": sid})
|
||||
assert start.status == 200
|
||||
assert FailingCompressor.entered.wait(timeout=2)
|
||||
|
||||
deadline = time.time() + 5
|
||||
error_payload = None
|
||||
while time.time() < deadline:
|
||||
status = _FakeHandler()
|
||||
routes._handle_session_compress_status(status, sid)
|
||||
payload = status.payload()
|
||||
if payload["status"] == "error":
|
||||
error_payload = payload
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert error_payload is not None
|
||||
assert error_payload["ok"] is False
|
||||
assert error_payload["error_status"] == 400
|
||||
assert "<path>" in error_payload["error"]
|
||||
assert "/Users/alice" not in error_payload["error"]
|
||||
|
||||
|
||||
def test_session_compress_start_retries_after_terminal_error(monkeypatch, cleanup_test_sessions):
|
||||
import api.routes as routes
|
||||
|
||||
class BlockingCompressor:
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def compress(self, messages, current_tokens=None, focus_topic=None):
|
||||
self.entered.set()
|
||||
assert self.release.wait(timeout=5), "test timed out waiting to release compression"
|
||||
return [messages[0], messages[-1]]
|
||||
|
||||
class BlockingAgent:
|
||||
def __init__(self, **kwargs):
|
||||
self.context_compressor = BlockingCompressor()
|
||||
|
||||
created = cleanup_test_sessions
|
||||
sid = _make_session()
|
||||
created.append(sid)
|
||||
_install_fake_compression_runtime(monkeypatch, BlockingAgent)
|
||||
|
||||
with routes._MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
routes._MANUAL_COMPRESSION_JOBS[sid] = {
|
||||
"session_id": sid,
|
||||
"focus_topic": None,
|
||||
"status": "error",
|
||||
"error": "previous failure",
|
||||
"error_status": 400,
|
||||
"started_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
|
||||
try:
|
||||
retry = _FakeHandler()
|
||||
routes._handle_session_compress_start(retry, {"session_id": sid})
|
||||
assert retry.status == 200
|
||||
retry_payload = retry.payload()
|
||||
assert retry_payload["status"] == "running"
|
||||
assert retry_payload["ok"] is True
|
||||
assert BlockingCompressor.entered.wait(timeout=2)
|
||||
finally:
|
||||
BlockingCompressor.release.set()
|
||||
|
||||
|
||||
def test_session_compress_async_reports_stale_session_guard(monkeypatch, cleanup_test_sessions):
|
||||
import api.routes as routes
|
||||
|
||||
created = cleanup_test_sessions
|
||||
sid = _make_session()
|
||||
created.append(sid)
|
||||
|
||||
class MutatingCompressor:
|
||||
entered = threading.Event()
|
||||
|
||||
def compress(self, messages, current_tokens=None, focus_topic=None):
|
||||
live = get_session(sid)
|
||||
live.messages.append({"role": "user", "content": "concurrent edit"})
|
||||
self.entered.set()
|
||||
return [messages[0], messages[-1]]
|
||||
|
||||
class MutatingAgent:
|
||||
def __init__(self, **kwargs):
|
||||
self.context_compressor = MutatingCompressor()
|
||||
|
||||
_install_fake_compression_runtime(monkeypatch, MutatingAgent)
|
||||
|
||||
start = _FakeHandler()
|
||||
routes._handle_session_compress_start(start, {"session_id": sid})
|
||||
assert start.status == 200
|
||||
assert MutatingCompressor.entered.wait(timeout=2)
|
||||
|
||||
deadline = time.time() + 5
|
||||
error_payload = None
|
||||
while time.time() < deadline:
|
||||
status = _FakeHandler()
|
||||
routes._handle_session_compress_status(status, sid)
|
||||
payload = status.payload()
|
||||
if payload["status"] == "error":
|
||||
error_payload = payload
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert error_payload is not None
|
||||
assert error_payload["ok"] is False
|
||||
assert error_payload["error_status"] == 409
|
||||
assert "modified during compression" in error_payload["error"]
|
||||
assert get_session(sid).messages[-1]["content"] == "concurrent edit"
|
||||
|
||||
|
||||
def test_session_compress_async_reports_stream_state_guard(monkeypatch, cleanup_test_sessions):
|
||||
import api.routes as routes
|
||||
|
||||
created = cleanup_test_sessions
|
||||
sid = _make_session()
|
||||
created.append(sid)
|
||||
|
||||
class StreamMutatingCompressor:
|
||||
entered = threading.Event()
|
||||
|
||||
def compress(self, messages, current_tokens=None, focus_topic=None):
|
||||
live = get_session(sid)
|
||||
live.active_stream_id = "stream-concurrent"
|
||||
self.entered.set()
|
||||
return [messages[0], messages[-1]]
|
||||
|
||||
class StreamMutatingAgent:
|
||||
def __init__(self, **kwargs):
|
||||
self.context_compressor = StreamMutatingCompressor()
|
||||
|
||||
_install_fake_compression_runtime(monkeypatch, StreamMutatingAgent)
|
||||
|
||||
start = _FakeHandler()
|
||||
routes._handle_session_compress_start(start, {"session_id": sid})
|
||||
assert start.status == 200
|
||||
assert StreamMutatingCompressor.entered.wait(timeout=2)
|
||||
|
||||
deadline = time.time() + 5
|
||||
error_payload = None
|
||||
while time.time() < deadline:
|
||||
status = _FakeHandler()
|
||||
routes._handle_session_compress_status(status, sid)
|
||||
payload = status.payload()
|
||||
if payload["status"] == "error":
|
||||
error_payload = payload
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert error_payload is not None
|
||||
assert error_payload["ok"] is False
|
||||
assert error_payload["error_status"] == 409
|
||||
assert "stream state changed" in error_payload["error"]
|
||||
assert get_session(sid).active_stream_id == "stream-concurrent"
|
||||
|
||||
|
||||
def test_static_commands_js_registers_compress_alias(cleanup_test_sessions):
|
||||
from pathlib import Path
|
||||
|
||||
@@ -160,7 +413,10 @@ def test_static_commands_js_registers_compress_alias(cleanup_test_sessions):
|
||||
src = f.read()
|
||||
assert "name:'compress'" in src
|
||||
assert "name:'compact'" in src
|
||||
assert "/api/session/compress" in src
|
||||
assert "/api/session/compress/start" in src
|
||||
assert "/api/session/compress/status" in src
|
||||
assert "await api('/api/session/compress'," not in src
|
||||
assert "beforeCount:visibleCount" in src
|
||||
assert "cmdCompress" in src
|
||||
assert "cmdCompact" in src
|
||||
|
||||
@@ -173,3 +429,12 @@ def test_static_commands_js_prefers_persisted_reference_message(cleanup_test_ses
|
||||
|
||||
assert "const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):'';" in src
|
||||
assert "const referenceText=messageRef || summaryRef;" in src
|
||||
|
||||
|
||||
def test_static_session_load_resumes_manual_compression_polling(cleanup_test_sessions):
|
||||
from pathlib import Path
|
||||
|
||||
with open(Path(__file__).resolve().parents[1] / "static" / "sessions.js", encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
|
||||
assert "resumeManualCompressionForSession" in src
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_read_turn_journal_tolerates_malformed_lines(tmp_path):
|
||||
|
||||
|
||||
def test_derive_turn_journal_states_keeps_latest_event_per_turn():
|
||||
states = derive_turn_journal_states([
|
||||
states, _ = derive_turn_journal_states([
|
||||
{"event": "submitted", "turn_id": "turn-1", "created_at": 1},
|
||||
{"event": "worker_started", "turn_id": "turn-1", "created_at": 2},
|
||||
{"event": "submitted", "turn_id": "turn-2", "created_at": 3},
|
||||
@@ -70,7 +70,7 @@ def test_derive_turn_journal_states_keeps_latest_event_per_turn():
|
||||
|
||||
|
||||
def test_derive_turn_journal_states_uses_created_at_not_file_order():
|
||||
states = derive_turn_journal_states([
|
||||
states, _ = derive_turn_journal_states([
|
||||
{"event": "completed", "turn_id": "turn-1", "created_at": 20},
|
||||
{"event": "submitted", "turn_id": "turn-1", "created_at": 10},
|
||||
])
|
||||
@@ -133,3 +133,35 @@ def test_audit_ignores_completed_or_already_materialized_turn_journal_entry(tmp_
|
||||
|
||||
assert report["status"] == "ok"
|
||||
assert report["items"] == []
|
||||
|
||||
|
||||
def test_derive_turn_journal_states_reports_terminal_collision_when_both_completed_and_interrupted():
|
||||
# A turn that recorded both completed and interrupted terminal events should
|
||||
# not silently collapse to one winner — the collision must be reported.
|
||||
events = [
|
||||
{'event': 'submitted', 'turn_id': 'turn-double-terminal', 'created_at': 1},
|
||||
{'event': 'worker_started', 'turn_id': 'turn-double-terminal', 'created_at': 2},
|
||||
{'event': 'completed', 'turn_id': 'turn-double-terminal', 'created_at': 3},
|
||||
{'event': 'interrupted', 'turn_id': 'turn-double-terminal', 'created_at': 4, 'reason': 'server_restart'},
|
||||
]
|
||||
states, collisions = derive_turn_journal_states(events)
|
||||
|
||||
# Derived state still picks the latest by timestamp (interrupted)
|
||||
assert states['turn-double-terminal']['event'] == 'interrupted'
|
||||
# But the collision is explicitly reported so callers can audit it
|
||||
assert len(collisions) == 1
|
||||
assert collisions[0]['turn_id'] == 'turn-double-terminal'
|
||||
assert [e['event'] for e in collisions[0]['events']] == ['completed', 'interrupted']
|
||||
|
||||
|
||||
def test_derive_turn_journal_states_no_collision_when_single_terminal():
|
||||
# A normal turn with only one terminal event must not produce a collision.
|
||||
events = [
|
||||
{'event': 'submitted', 'turn_id': 'turn-normal', 'created_at': 1},
|
||||
{'event': 'worker_started', 'turn_id': 'turn-normal', 'created_at': 2},
|
||||
{'event': 'completed', 'turn_id': 'turn-normal', 'created_at': 3},
|
||||
]
|
||||
states, collisions = derive_turn_journal_states(events)
|
||||
|
||||
assert states['turn-normal']['event'] == 'completed'
|
||||
assert collisions == []
|
||||
|
||||
@@ -21,7 +21,7 @@ def test_append_turn_journal_event_for_stream_reuses_submitted_turn_id(tmp_path)
|
||||
|
||||
assert submitted["turn_id"] == "turn-1"
|
||||
assert worker["turn_id"] == "turn-1"
|
||||
states = derive_turn_journal_states([submitted, worker])
|
||||
states, _ = derive_turn_journal_states([submitted, worker])
|
||||
assert states["turn-1"]["event"] == "worker_started"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from api import config as api_config
|
||||
from api import routes, workspace
|
||||
|
||||
|
||||
def test_profile_default_workspace_uses_live_config_default(monkeypatch, tmp_path):
|
||||
live_default = tmp_path / "live-default"
|
||||
live_default.mkdir()
|
||||
|
||||
monkeypatch.setattr(api_config, "DEFAULT_WORKSPACE", live_default)
|
||||
monkeypatch.setattr(api_config, "get_config", lambda: {})
|
||||
|
||||
assert workspace._profile_default_workspace() == str(live_default.resolve())
|
||||
|
||||
|
||||
def test_resolve_chat_workspace_with_recovery_repairs_missing_implicit_workspace(monkeypatch, tmp_path):
|
||||
fallback = tmp_path / "fallback"
|
||||
fallback.mkdir()
|
||||
stale = tmp_path / "deleted-workspace"
|
||||
|
||||
def fake_resolve(value):
|
||||
if value == str(stale):
|
||||
raise ValueError(f"Path does not exist: {stale}")
|
||||
return Path(value).resolve()
|
||||
|
||||
saved = {"count": 0}
|
||||
|
||||
def fake_save():
|
||||
saved["count"] += 1
|
||||
|
||||
session = SimpleNamespace(session_id="sess-1", workspace=str(stale), save=fake_save)
|
||||
|
||||
monkeypatch.setattr(routes, "resolve_trusted_workspace", fake_resolve)
|
||||
monkeypatch.setattr(routes, "get_last_workspace", lambda: str(fallback))
|
||||
|
||||
resolved = routes._resolve_chat_workspace_with_recovery(session, None)
|
||||
|
||||
assert resolved == str(fallback.resolve())
|
||||
assert session.workspace == str(fallback.resolve())
|
||||
assert saved["count"] == 1
|
||||
|
||||
|
||||
def test_resolve_chat_workspace_with_recovery_preserves_explicit_errors(monkeypatch, tmp_path):
|
||||
fallback = tmp_path / "fallback"
|
||||
fallback.mkdir()
|
||||
stale = tmp_path / "deleted-workspace"
|
||||
|
||||
def fake_resolve(value):
|
||||
if value == str(stale):
|
||||
raise ValueError(f"Path does not exist: {stale}")
|
||||
return Path(value).resolve()
|
||||
|
||||
saved = {"count": 0}
|
||||
|
||||
def fake_save():
|
||||
saved["count"] += 1
|
||||
|
||||
session = SimpleNamespace(session_id="sess-2", workspace=str(fallback), save=fake_save)
|
||||
|
||||
monkeypatch.setattr(routes, "resolve_trusted_workspace", fake_resolve)
|
||||
monkeypatch.setattr(routes, "get_last_workspace", lambda: str(fallback))
|
||||
|
||||
with pytest.raises(ValueError, match="Path does not exist"):
|
||||
routes._resolve_chat_workspace_with_recovery(session, str(stale))
|
||||
|
||||
assert session.workspace == str(fallback)
|
||||
assert saved["count"] == 0
|
||||
Reference in New Issue
Block a user